Versioning Spatial Data Contracts with SemVer

Semantic versioning gives a spatial data contract a rule a machine can enforce: the version number itself tells a consumer whether an upgrade is safe. This operation sits under Data Contracts for Spatial Products in the Geospatial Data Mesh Fundamentals reference, and it answers one narrow question precisely — given an old contract and a candidate revision, what version bump is correct, and how do you prove the bump before publishing. The stakes are spatial-specific: a change from EPSG:4326 to EPSG:3857, a polygon-to-multipolygon switch, or the removal of a required attribute silently corrupts every consumer’s spatial predicates, so those changes are always MAJOR. The convention this site uses, v1.2.0-crs:EPSG:4326-res:10m, embeds the CRS and resolution in the stamp so a mismatched consumer fails loudly at pin time rather than after a bad join.

Figure — A decision tree that classifies a spatial contract change into a MAJOR, MINOR, or PATCH version bump.

SemVer classification decision tree for spatial contract changes A contract change enters at the top. The first question asks whether the CRS or geometry type changed or a required field was removed; if yes the change is a MAJOR bump to 2.0.0. If no, a second question asks whether a new optional field was added or a constraint relaxed; if yes it is a MINOR bump to 1.3.0. If no, the change is a PATCH bump to 1.2.1 for documentation or metadata only. yes no yes no Contract change old vs candidate CRS / geometry-type change? or required field removed? MAJOR 1.2.0 → 2.0.0 New optional field added? or constraint relaxed? MINOR 1.2.0 → 1.3.0 PATCH 1.2.0 → 1.2.1 · docs/meta

Prerequisites

Requirement Value / Assumption Notes
Runtime Python >= 3.10 with pyyaml and deepdiff Computes the structural contract diff
Policy engine OPA >= 0.60 (opa eval) Runs the consumer-driven compatibility rule
Contract store Immutable object store or Git tag namespace Each version published once, never mutated
CRS contract Canonical storage CRS EPSG:4326; changes are always MAJOR Axis order lon_lat fixed in the stamp
Version convention vMAJOR.MINOR.PATCH-crs:EPSG:<code>-res:<r> e.g. v1.2.0-crs:EPSG:4326-res:10m
Access role contract-publisher (RBAC) Required to cut and publish a new version
Environment CONTRACT_DIR, CONSUMER_PACT_DIR Injected by CI; never hard-coded

Step-by-Step Implementation

Classification comes first, the version bump second, and publication only after consumer-driven tests pass. Each step is verifiable before you proceed.

1. Compute the structural diff between old and candidate contracts

Load both contract documents and produce a typed diff of the fields that carry compatibility meaning — crs_epsg, geometry_type, required schema_fields, and constraints. The diff is the evidence the classifier consumes.

python
# diff_contract.py — emit a compatibility-relevant diff as JSON
import sys, json, yaml
from deepdiff import DeepDiff

old = yaml.safe_load(open(sys.argv[1]))
new = yaml.safe_load(open(sys.argv[2]))

def required(fields):
    return {f["name"] for f in fields if not f.get("nullable", False)}

diff = {
    "crs_changed": old["crs_epsg"] != new["crs_epsg"],
    "geometry_type_changed": old["geometry_type"] != new["geometry_type"],
    "required_removed": sorted(required(old["schema_fields"]) - required(new["schema_fields"])),
    "optional_added": sorted({f["name"] for f in new["schema_fields"]}
                             - {f["name"] for f in old["schema_fields"]}),
    "raw": json.loads(DeepDiff(old, new, ignore_order=True).to_json()),
}
json.dump(diff, sys.stdout, indent=2)

Verify the diff runs and flags the expected surfaces:

bash
python diff_contract.py contract-v1.2.0.yaml candidate.yaml > diff.json && \
  jq '{crs_changed, geometry_type_changed, required_removed}' diff.json

2. Classify the bump with a consumer-driven compatibility rule

Feed the diff to an OPA policy that encodes the SemVer rules: any CRS change, geometry-type change, or required-field removal is MAJOR; a new optional field or relaxed constraint is MINOR; anything else is PATCH. Running it as policy-as-code keeps the rule identical in CI and at publish time.

rego
package contract.semver

import rego.v1

bump := "MAJOR" if {
    some breaking in [input.crs_changed, input.geometry_type_changed]
    breaking == true
} else := "MAJOR" if {
    count(input.required_removed) > 0
} else := "MINOR" if {
    count(input.optional_added) > 0
} else := "PATCH"

Verify the classifier returns the expected level for the diff:

bash
opa eval -i diff.json -d semver.rego -f raw "data.contract.semver.bump"
# expected for a CRS change: "MAJOR"

3. Stamp and publish the new version immutably

Apply the bump to the version triple, rebuild the stamp with the current CRS and resolution, and publish the contract under a path that can never be overwritten. Publishing is idempotent: re-running with an unchanged candidate resolves to the same path and is a no-op.

bash
#!/usr/bin/env bash
set -euo pipefail
BUMP=$(opa eval -i diff.json -d semver.rego -f raw "data.contract.semver.bump")
IFS=. read -r MAJ MIN PAT <<< "$(yq '.contract_version' candidate.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')"

case "$BUMP" in
  MAJOR) MAJ=$((MAJ+1)); MIN=0; PAT=0 ;;
  MINOR) MIN=$((MIN+1)); PAT=0 ;;
  PATCH) PAT=$((PAT+1)) ;;
esac

CRS=$(yq '.crs_epsg' candidate.yaml)
RES=$(yq '.coordinate_precision' candidate.yaml)
STAMP="v${MAJ}.${MIN}.${PAT}-crs:EPSG:${CRS}-res:10m"
yq -i ".contract_version = \"${STAMP}\"" candidate.yaml

# Immutable publish: fails if the version already exists (no overwrite)
aws s3 cp candidate.yaml "s3://contracts/${STAMP}.yaml" \
  --if-none-match '*' && echo "published ${STAMP}"

Verify the version was published and is immutable:

bash
aws s3api head-object --bucket contracts --key "${STAMP}.yaml" \
  --query 'Metadata' && echo "immutable copy present"

4. Run consumer-driven contract tests before promotion

For a MINOR or PATCH, prove that every registered consumer pact still passes against the new contract; for a MAJOR, confirm consumers are pinned to the old version and are not auto-upgraded. This is the gate that turns a version number into an enforceable promise.

bash
# Replay each consumer pact against the candidate contract
for pact in "${CONSUMER_PACT_DIR}"/*.json; do
  python verify_pact.py --contract candidate.yaml --pact "$pact" \
    || { echo "BREAK: $(basename "$pact") fails candidate"; exit 1; }
done
echo "all consumer pacts satisfied"

Verify the exit status is zero for a compatible bump; a non-zero status on a MINOR/PATCH means the diff was misclassified and must be re-run as MAJOR.

Configuration Reference

Parameter Scope Default / Example Effect
version_convention Stamp vMAJOR.MINOR.PATCH-crs:EPSG:<c>-res:<r> Canonical contract identity
crs_change_policy Compatibility MAJOR Any crs_epsg change forces a major bump
geometry_type_change_policy Compatibility MAJOR Type widening/narrowing is breaking
required_removal_policy Compatibility MAJOR Dropping a required field is breaking
optional_add_policy Compatibility MINOR Additive, backward-compatible
retention_days Governance 90 Superseded versions stay queryable
auto_upgrade Consumer MINOR,PATCH MAJOR requires explicit migration
immutable_publish Store true --if-none-match '*' blocks overwrite

Common Failure Modes & Fixes

A MINOR bump breaks a consumer. Symptom: a consumer pact fails after a change classified MINOR. Root cause: the change removed or tightened something the diff missed — often a narrowed enum or a nullability flip counted as “optional.” Fix: re-run Step 1 with the constraint check, reclassify as MAJOR, and cut a new major version.

CRS change published as PATCH. Symptom: downstream joins return empty or shifted geometry after a “docs-only” release. Root cause: crs_epsg changed but the classifier input omitted crs_changed. Fix: always derive crs_changed from the parsed contract, never from the changelog; treat any EPSG delta as MAJOR per CRS Governance and Reprojection Standards.

Overwritten version. Symptom: a consumer pinned to v1.2.0 observes changed behavior. Root cause: the publish step overwrote an existing key. Fix: enforce --if-none-match '*' so a re-publish of an existing version fails; never mutate a released contract.

Stamp and body disagree. Symptom: contract_version says EPSG:4326 but crs_epsg is 3857. Root cause: the stamp was hand-edited. Fix: rebuild the stamp from crs_epsg in Step 3 so the two can never diverge.

FAQ

Why is a CRS change always MAJOR even if every attribute stays the same?

Because coordinates are the payload’s meaning, not a formatting detail. A consumer that stored EPSG:4326 degrees and receives EPSG:3857 metres will compute wrong distances, wrong intersections, and empty joins while every schema field still validates. There is no backward-compatible way to change the coordinate space, so the only correct signal is a major bump that stops auto-upgrade and forces an explicit, tested migration.

Is adding a new required field MINOR or MAJOR?

Adding a required field is MAJOR, because existing producers that omit it now fail validation and existing consumers may assume it was always present. Adding an optional (nullable) field is MINOR. If you need the field to become required eventually, ship it optional in a MINOR, let producers populate it, then flip it to required in a later MAJOR.

How does the -res:10m suffix interact with SemVer?

The resolution suffix is part of the identity, not a fourth version component. Coarsening resolution below what consumers depend on is a compatibility break and gets a MAJOR bump with the suffix updated; refining resolution while keeping the old guarantee is MINOR. Keeping the suffix in the stamp lets a consumer reject a mismatched contract at pin time instead of after a degraded result.

What proves a MINOR bump is actually safe to auto-upgrade?

The consumer-driven pact replay in Step 4. Every consumer publishes a pact describing the fields and constraints it relies on; a MINOR or PATCH is only promotable when every stored pact still passes against the candidate. If any pact fails, the bump was misclassified and must be reissued as MAJOR.