Detecting Breaking Changes in Spatial Contracts
The most expensive contract changes are the ones that look additive. A field renamed and re-added, a CRS swapped for an equivalent-looking one, an extent narrowed at the edges — each reads as a small diff and breaks a working consumer. This guide computes the structural diff between two contract versions, classifies it against a consumer-driven compatibility rule rather than a judgement call, and fails a release whose declared version does not match what the diff shows. It automates the classification step in Data Contracts for Spatial Products within Geospatial Data Mesh Fundamentals, extending Versioning Spatial Data Contracts with SemVer.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | python3 ≥ 3.11, jq, the contract registry API |
The diff runs in CI, not by eye |
| Contracts | Machine-readable JSON, one per version, immutable | A prose contract cannot be diffed |
| Registry | Previous version retrievable by identifier | The comparison baseline |
| Access roles | domain-owner proposes; CI enforces |
Classification is not a judgement call |
| Environment | CONTRACT_API, PRODUCT, CANDIDATE |
Exported before running |
Step-by-Step Implementation
1. Diff the two contracts structurally
# contract_diff.py — a structural diff over the clauses that can break a consumer.
import json
def index_attributes(contract: dict) -> dict:
return {a["name"]: a for a in contract.get("attributes", [])}
def diff(old: dict, new: dict) -> list[dict]:
"""Every difference that can affect a conforming consumer, as a typed change.
Cosmetic differences — description text, ordering — are deliberately ignored."""
changes = []
o_attrs, n_attrs = index_attributes(old), index_attributes(new)
for name in n_attrs.keys() - o_attrs.keys():
changes.append({"kind": "attribute_added", "name": name,
"required": n_attrs[name].get("required", False)})
for name in o_attrs.keys() - n_attrs.keys():
changes.append({"kind": "attribute_removed", "name": name})
for name in o_attrs.keys() & n_attrs.keys():
o, n = o_attrs[name], n_attrs[name]
if o.get("type") != n.get("type"):
changes.append({"kind": "attribute_type_changed", "name": name,
"from": o.get("type"), "to": n.get("type")})
if not o.get("required", False) and n.get("required", False):
changes.append({"kind": "attribute_became_required", "name": name})
for field in ("crs", "geometry_type", "coordinate_precision"):
if old.get(field) != new.get(field):
changes.append({"kind": f"{field}_changed",
"from": old.get(field), "to": new.get(field)})
o_ext, n_ext = old.get("extent"), new.get("extent")
if o_ext != n_ext:
# Narrowing removes coverage a consumer may be querying; widening does not.
changes.append({"kind": "extent_narrowed" if _narrows(o_ext, n_ext)
else "extent_widened", "from": o_ext, "to": n_ext})
return changes
def _narrows(old_bbox, new_bbox) -> bool:
"""True when the new extent fails to contain the old one on any side."""
if not old_bbox or not new_bbox:
return True
return (new_bbox[0] > old_bbox[0] or new_bbox[1] > old_bbox[1]
or new_bbox[2] < old_bbox[2] or new_bbox[3] < old_bbox[3])
Verify the diff is symmetric-aware — a rename must appear as a removal plus an addition, not as a benign pair:
python3 -c "
import contract_diff, json
old = {'attributes': [{'name': 'owner_ref', 'type': 'string'}], 'crs': 'EPSG:4326'}
new = {'attributes': [{'name': 'owner_reference', 'type': 'string'}], 'crs': 'EPSG:4326'}
print(json.dumps(contract_diff.diff(old, new), indent=2))"
# Both an attribute_removed and an attribute_added — which classifies as MAJOR.
2. Classify from the diff, not from intent
# classify.py — the rule is: if any conforming consumer can break, it is MAJOR.
MAJOR = {
"attribute_removed", "attribute_type_changed", "attribute_became_required",
"crs_changed", "geometry_type_changed", "extent_narrowed",
"coordinate_precision_changed",
}
MINOR = {"attribute_added", "extent_widened"}
def classify(changes: list[dict]) -> str:
kinds = {c["kind"] for c in changes}
if kinds & MAJOR:
return "MAJOR"
# An added REQUIRED attribute breaks writers even though addition is usually minor.
if any(c["kind"] == "attribute_added" and c.get("required") for c in changes):
return "MAJOR"
if kinds & MINOR:
return "MINOR"
return "PATCH"
def justify(changes: list[dict]) -> list[str]:
"""Human-readable reasons, so a MAJOR classification is arguable with evidence."""
return [f"{c['kind']}: {c.get('name') or f'{c.get(chr(39)+chr(39))}'}" if False
else f"{c['kind']}" + (f" ({c['name']})" if "name" in c else
f" ({c.get('from')} -> {c.get('to')})")
for c in changes]
3. Fail the release when the declared bump disagrees
# gate.py — CI refuses a version that understates its own diff.
import sys
def gate(declared: str, previous: str, changes: list[dict]) -> None:
required = classify(changes)
order = {"PATCH": 0, "MINOR": 1, "MAJOR": 2}
actual = bump_kind(previous, declared) # from the two version strings
if order[actual] < order[required]:
print(f"BLOCKED: declared {actual}, diff requires {required}", file=sys.stderr)
for reason in justify(changes):
print(f" - {reason}", file=sys.stderr)
raise SystemExit(1)
print(f"{actual} bump is consistent with the diff ({required} required)")
Verify the gate blocks an understated bump, which is the case it exists for:
curl -sS "$CONTRACT_API/products/$PRODUCT/versions/latest" > /tmp/old.json
python3 gate.py --old /tmp/old.json --new "$CANDIDATE" --declared v1.3.0
# A CRS change declared as MINOR must exit 1 and name the clause.
4. Publish the diff alongside the version
A consumer facing a MAJOR bump needs the list of what changed, not the instruction to migrate.
python3 contract_diff.py /tmp/old.json "$CANDIDATE" \
| jq '{from: env.PREVIOUS, to: env.DECLARED, changes: .}' \
| curl -sS -X PUT "$CONTRACT_API/products/$PRODUCT/versions/$DECLARED/diff" \
-H 'content-type: application/json' -d @-
Configuration Reference
| Change kind | Classification | Why |
|---|---|---|
attribute_added (optional) |
MINOR |
No conforming consumer reads it yet |
attribute_added (required) |
MAJOR |
Writers now fail validation |
attribute_removed |
MAJOR |
Readers lose a field |
attribute_type_changed |
MAJOR |
Parsers break |
attribute_became_required |
MAJOR |
Writers break |
crs_changed |
MAJOR |
Every coordinate changes meaning |
geometry_type_changed |
MAJOR |
Spatial predicates change behaviour |
coordinate_precision_changed |
MAJOR |
Equality comparisons change |
extent_widened |
MINOR |
Additive coverage |
extent_narrowed |
MAJOR |
Working queries return empty |
Common Failure Modes & Fixes
A rename classifies as two unrelated changes.
Root cause: correct, and deliberately so. A structural diff cannot know a rename was intended, and treating it as benign would let a field vanish under a MINOR bump. Fix: none needed — the MAJOR classification is right.
An “equivalent” CRS change is flagged.
Root cause: also correct. EPSG:4326 and CRS84 differ in declared axis order, and consumers that trusted one will misread the other. Fix: accept the MAJOR, or leave the CRS alone.
A precision reduction passes as PATCH.
Root cause: coordinate_precision absent from the contract, so the diff cannot see it. Fix: make it a required contract field; an unstated precision is a clause consumers depend on and nobody wrote down.
The gate blocks a change everyone agrees is safe. Root cause: usually a genuinely additive change to a field no consumer uses — which the diff cannot know. Fix: consumer-driven contract tests, which can demonstrate that no registered consumer breaks; that evidence, not an assertion, is what should override the classification.
The diff is empty and consumers still broke. Root cause: a behaviour change outside the contract — feature ordering, empty-result semantics, response size limits. Fix: add the negative clauses to the contract so they are diffable; a behaviour nobody declared is a behaviour nobody can version.
FAQ
Why not let the author declare the bump and review it?
Because review catches what a reviewer thinks to look for, and the changes that hurt are the ones that look small. A CRS swap between two codes that both mean “WGS 84 lat/lon” reads as a no-op in a pull request and changes every coordinate’s declared axis order. A structural diff has no intuition to fool: it compares clauses and applies a fixed rule. Human review remains valuable for whether the change is desirable; it is unreliable for whether the change is breaking.
Should the classification ever be overridden?
Only with evidence, and the evidence is a consumer contract test. If every registered consumer’s test passes against the candidate, the practical claim that nothing breaks is demonstrated rather than asserted, and downgrading the bump is defensible. Overriding on the grounds that a change “should be fine” reintroduces exactly the judgement the gate replaced, and the first time it is wrong it is wrong for a consumer who had no warning.
How does this interact with the compound version identifier?
The identifier carries the CRS and resolution inline precisely because they are MAJOR-triggering clauses, so a change to either produces a visibly different identifier rather than a silent mutation. The diff still classifies them, because the identifier records what changed and the classification decides what consumers are owed — a migration window, a published diff, a parallel-running predecessor. The two mechanisms are complementary: one makes the change visible, the other makes it survivable.
What about changes to the SLO rather than the schema?
Treat a loosened SLO as MAJOR and a tightened one as MINOR, on the same reasoning. A consumer who designed their own availability target around a product’s 99.95% commitment is broken by a move to 99.9% just as surely as by a removed field, and the change is far less visible. Including the SLO clauses in the diff costs nothing and closes a gap most contract tooling leaves open.
Where should the diff run — in CI or at registration?
Both, for different reasons. Running it in CI gives the author feedback while the change is still cheap to alter, which is where most misclassifications are caught. Running it again at registration makes it a gate the registry enforces rather than a check a pipeline could be configured around, which matters because the consequence of an understated bump lands on consumers rather than on the producing team. The two use the same code and the same rule, so the second run is nearly free and closes the gap where a repository’s CI configuration diverges from what the platform assumes.
Related
- Data Contracts for Spatial Products — the parent topic and what belongs in a contract
- Versioning Spatial Data Contracts with SemVer — the promotion path this gate sits in
- Deprecating a Spatial Data Product Safely — what a
MAJORbump obliges the producer to do