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

python
# 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:

bash
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

python
# 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]

Four changes that look small in a diff and break a consumerFour changes against how they appear in a pull request and what they actually do. A field renamed and re-added reads as two lines and removes a field every reader depends on. An equivalent-looking CRS swap reads as a one-word change and alters every coordinate declared axis order. A precision reduction reads as a number and changes what equality means. An extent narrowed at the edges reads as four numbers and turns working queries into empty results. All four classify as major from the diff alone.Looks likeActuallyrename a fieldtwo linesreaders lose a fieldCRS 4326 → CRS84one wordaxis order changesprecision 8 → 6a numberequality changes meaningnarrow the extentfour numbersqueries return empty

3. Fail the release when the declared bump disagrees

python
# 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)")

Diff, classify, compare against the declared bump, block if understatedA candidate contract is diffed structurally against the current version, the diff is classified by a fixed rule, and the required classification is compared against the bump the author declared. A declared bump equal to or above the requirement proceeds; one below it blocks the release with every contributing change named. The rail records what the gate replaces: a human reviewer, who catches what they think to look for and reliably misses the changes that look small.Structural diffclause by clauseClassifyfixed ruleCompare declaredagainst requiredRelease proceedsbump is honestchangesrequiredconsistentdeclared below requiredBlocked, reasons listednot left to a reviewer

Verify the gate blocks an understated bump, which is the case it exists for:

bash
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.

When a breaking change is caught, and what it costs at each pointFour points at which a breaking change can be discovered, and the relative cost of each, against a one-unit baseline. Caught by the diff in CI it costs one unit — the author edits the change. Caught by a consumer contract test before promotion it costs about four. Caught in a consumer staging environment it costs around 30. Caught by a consumer in production it costs roughly 240 and is discovered by someone acting on wrong data. The gate exists to move the discovery leftward.diff in CIconsumer contract testconsumer’s staging30×consumer production240×found by wrong databaseline

bash
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.