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.
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.
# 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:
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.
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:
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.
#!/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:
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.
# 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.
Related
- Data Contracts for Spatial Products — the parent contract model this versions
- CRS Governance and Reprojection Standards — why any CRS change is a breaking bump
- Spatial Product Lifecycle Management — deprecation windows and retention for superseded versions
- Schema Contracts for Vector Tile Data — versioning the tile-layer schema in step with the contract