Versioning a Spatial Data Contract Without Breaking Consumers

The contract needs to change — a new attribute, a wider envelope, a tighter tolerance — and every consumer validates against it. Changing it in place breaks whoever is slowest to notice; never changing it means the pipeline is frozen.

Part of Scoping Rules & Data Contracts: that page covers what a contract declares and where each clause is enforced. This one is about changing it afterwards, which is a different problem and the one that actually recurs.

Root Cause: A Contract Has Two Audiences Pulling Opposite Ways

A data contract is read by two parties with incompatible interests in change. The producer wants the contract to describe what the pipeline currently does, so a change to the pipeline is a change to the contract. The consumer wants the contract to be a stable promise, so a change is a breach.

Both are right, and the resolution is not a compromise on either side. It is to recognise that contract clauses fall into three kinds with different change rules, and that a change which is safe for one kind is a breach for another.

  • Widening clauses can grow without breaking anybody. A larger envelope, a new optional attribute, a longer list of permitted geometry types: a consumer written against the old contract still validates, because everything it expected is still permitted.
  • Narrowing clauses can only tighten safely. A stricter tolerance, a smaller permitted null fraction, a shorter list of permitted CRSs: a consumer relying on the loose version may now fail, but a consumer written against the tight version is unaffected by loosening — so the safe direction is the opposite of the widening ones.
  • Fixed clauses cannot change at all without a major version. The CRS, the units, the meaning of an attribute: any change is a breach regardless of direction, because there is no version of a consumer that survives it.
Three contract clause kinds with their safe direction of change Three cards. Widening clauses — the declared envelope, the permitted geometry types, the optional attribute set — may grow safely, because a consumer written against the smaller version still validates against everything it expected; shrinking one is what breaks a consumer. Narrowing clauses — the maximum null fraction, the metric tolerances, the permitted CRS list — may tighten safely, because a consumer written against the tight version is unaffected by a tighter one; loosening one is what breaks a consumer relying on the guarantee. Fixed clauses — the CRS itself, the units, an attribute's meaning, the required attribute set — cannot change in either direction, because there is no version of a consumer that survives it. A banner underneath draws the conclusion that motivates the whole classification: the safe direction for the first two kinds is opposite, so any single rule about what counts as a compatible change is wrong for one of them, and the classification has to be per clause and written down before it is needed. The safe direction is opposite for two of the three kinds WIDENING may grow shrinking breaks a consumer EXAMPLES declared envelope permitted geometry types optional attributes supported output formats NARROWING may tighten loosening breaks a consumer EXAMPLES max null fraction metric tolerances permitted CRS list max imputed fraction FIXED cannot change either direction breaks a consumer EXAMPLES the CRS itself units attribute semantics required attributes No single rule about "compatible changes" can be right for both of the first two. Which is why the classification is per clause, and why it has to be written down before a change is proposed rather than argued when one is. A clause added without a kind is a clause whose change rule gets decided under pressure by whoever is shipping.
Each clause kind has a safe direction of change, and two of the three are opposites — which is why a single "compatible change" rule does not work.

Prerequisite Check: Classify Every Clause Before the First Change

python
from dataclasses import dataclass
from enum import Enum


class Kind(Enum):
    WIDENING = "widening"      # may grow; shrinking is breaking
    NARROWING = "narrowing"    # may tighten; loosening is breaking
    FIXED = "fixed"            # any change is breaking


CLAUSE_KINDS = {
    "envelope":            Kind.WIDENING,
    "permitted_geometry":  Kind.WIDENING,
    "optional_attributes": Kind.WIDENING,
    "max_null_fraction":   Kind.NARROWING,
    "metric_tolerances":   Kind.NARROWING,
    "permitted_crs":       Kind.NARROWING,
    "crs":                 Kind.FIXED,
    "units":               Kind.FIXED,
    "attribute_semantics": Kind.FIXED,
    "required_attributes": Kind.FIXED,
}

Writing this table down is the whole prerequisite, and it takes an afternoon. Without it every proposed change becomes an argument, because the two audiences reach for different intuitions about what “compatible” means and neither is wrong in general.

Note the asymmetry between permitted_crs and crs. The list of CRSs a consumer may receive can safely shrink, because a consumer handling three will handle two. The declared CRS of a given release cannot change at all, because a consumer handling one will not handle a different one.

Fix: Version the Contract, and Publish Two of Them for a While

1 — Give the contract its own version, separate from the release

python
@dataclass(frozen=True)
class Contract:
    version: str                # the contract's own semantic version
    supersedes: str | None
    clauses: dict
    valid_from: str
    sunset: str | None          # when the previous version stops being published

The contract version is not the release version. Several releases share a contract; a contract change is a distinct event with its own timeline, and conflating the two forces a major release bump for a contract change that touched nothing in the data.

2 — Validate a candidate change against the clause kinds

python
def check_change(old: Contract, new: Contract) -> list[str]:
    breaks = []
    for name, kind in CLAUSE_KINDS.items():
        a, b = old.clauses.get(name), new.clauses.get(name)
        if a == b:
            continue
        if kind is Kind.FIXED:
            breaks.append(f"{name}: fixed clause changed ({a!r}{b!r})")
        elif kind is Kind.WIDENING and not contains(b, a):
            breaks.append(f"{name}: widening clause narrowed ({a!r}{b!r})")
        elif kind is Kind.NARROWING and not contains(a, b):
            breaks.append(f"{name}: narrowing clause loosened ({a!r}{b!r})")
    return breaks

contains is per-clause: for an envelope it is geometric containment, for a list it is a superset test, for a tolerance it is a numeric comparison. Each is a few lines and each has to be written once.

3 — Publish both contracts during the deprecation window

The mechanism that actually protects consumers is not the versioning; it is publishing the old and the new contract simultaneously, and validating each release against both for the duration of the window.

python
def validate_against_window(release, contracts: list[Contract]) -> dict:
    """A release in a deprecation window must satisfy every live contract."""
    results = {c.version: validate(release, c) for c in contracts}
    failing = [v for v, ok in results.items() if not ok]
    if failing:
        raise SystemExit(f"release does not satisfy live contract(s): {failing}")
    return results

That constraint is stronger than it first appears, and it is the point. During the window the producer cannot make a change that satisfies only the new contract — which means the window is not just time for consumers to migrate, it is a period during which the change is proved to be compatible by every release that ships.

A contract deprecation window, with dual validation and consumer migration A horizontal timeline runs across six months. The old contract version begins at the left and continues to a marked sunset; the new version begins when it is published and continues past the right edge. Between publication and sunset both are live, and that overlap is the deprecation window. Release markers along the timeline sit inside the window, and each is annotated as validated against both contracts — which is the constraint that matters, because it means the producer cannot ship a change during the window that satisfies only the new contract. The window is therefore not only migration time for consumers; it is a period during which the compatibility claim is proved by every release that ships. Underneath, a second track shows consumers migrating one at a time, with a count remaining at each point, and the last two migrating only once the sunset is imminent — which is typical and is why the sunset needs the consumption record behind it to be enforceable at all. The window proves the claim, one release at a time Jan Feb Mar Apr May Jun deprecation window — both live contract 2.x contract 3.0 sunset each release validated against both contracts CONSUMERS STILL ON 2.x 7 6 4 3 3 1 0 The last consumers migrate only once the sunset is imminent, which is typical — and it is why the sunset needs the consumption record behind it, so the remaining teams are a list rather than a hope.
The window is not only migration time: every release inside it is validated against both contracts, which is what turns a compatibility claim into evidence.

4 — Sunset on a date, and notify by consumption record

python
def consumers_still_on(registry, contract_version: str) -> list[str]:
    """Who has not migrated — answerable only from the consumption log."""
    return sorted({e["consumer"] for e in registry.consumption_by_contract(contract_version)})

A sunset date nobody can enforce is a suggestion. The consumption record turns it into a list of specific teams, and a list of specific teams is something that can be worked through.

Verification Step: Gate the Contract Change Itself

python
def test_contract_change_is_classified(old_contract, new_contract):
    breaks = check_change(old_contract, new_contract)
    if breaks:
        assert new_contract.version.split(".")[0] != old_contract.version.split(".")[0], (
            "breaking contract change without a major bump:\n  " + "\n  ".join(breaks)
        )


def test_every_clause_has_a_kind(new_contract):
    unclassified = sorted(set(new_contract.clauses) - set(CLAUSE_KINDS))
    assert not unclassified, f"clauses with no change rule: {unclassified}"


def test_window_releases_satisfy_both(release, live_contracts):
    assert len(live_contracts) <= 2, "more than two live contracts is not a window, it is drift"
    validate_against_window(release, live_contracts)

The second test is the one that keeps the system honest over time. A clause added without a kind is a clause whose change rule will be decided in the moment, under pressure, by whoever is shipping — which is exactly the situation the table exists to prevent.

Edge Cases & Gotchas

A change that is widening for one consumer and narrowing for another. Adding a permitted geometry type widens the contract and breaks a consumer whose reader handles only points. This is a genuine conflict rather than a classification error, and the resolution is that the permitted list is widening and the emitted list is fixed: declare both, and let the release say which types it actually contains.

Six fields of a contract change record and what each one settles Six rows. The clause names what changed, which is the only field anybody ever records without prompting. The kind — widening, narrowing or fixed — settles which change rule applies, and without it the classification is re-argued every time the same clause moves. The direction states whether the clause grew or shrank, which combined with the kind gives the verdict mechanically rather than by judgement. The verdict — compatible or breaking — is derived from the previous two rather than asserted, and recording it alongside them lets a reviewer check the derivation instead of trusting it. The window records when both contracts are live and when the old one sunsets; without it the deprecation has no end and the pipeline accumulates live contracts. And the rationale, one sentence on why the change was wanted, is the field that is obvious at the time and unrecoverable later — it is what lets somebody a year on tell a deliberate tightening from an accident. A footer records the pattern shared with the rest of this area: the fields that cost nothing to write down are the ones that cannot be reconstructed afterwards. The field that is obvious now is the one that is unrecoverable later clause what changed without it: nothing — this is the one everybody records kind which change rule applies without it: the classification is re-argued every time direction did it grow or shrink without it: the verdict becomes a judgement call verdict compatible or breaking without it: a reviewer has to trust it rather than check it window when both are live, when the old one sunsets without it: the deprecation has no end rationale why the change was wanted without it: a deliberate tightening is indistinguishable from an accident Six fields, one row per change, appended to the contract's own history — and the two that are hardest to reconstruct later are the two that take the least effort to write now.
Six fields per change, appended to the contract's history — and the two hardest to reconstruct later take the least effort to write now.

Tolerances that need to loosen. Occasionally a metric tolerance turns out to have been unrealistically tight and has to be relaxed. That is a breaking change under the rules above and it is worth treating as one, because a consumer who built a check on the tight value will now pass data they would have rejected.

The envelope shrinks because a region was withdrawn. Formally breaking. In practice consumers of the withdrawn region need to be told regardless, so the version bump is not the mechanism that protects them — the consumption record is.

Two windows overlapping. Three live contracts is not a deprecation window, it is a pipeline that has stopped retiring anything. The assertion above caps it at two deliberately.

The Change Nobody Classifies: Adding a Clause

The three kinds cover changes to clauses that already exist. Adding a new clause is a fourth case, and it is the one that quietly breaks the system.

A new clause is additive from the producer’s side — nothing that was permitted has been forbidden, so no consumer’s data suddenly fails. But a new clause is also a new obligation, and whether it breaks anybody depends entirely on how the validator treats a contract field it has never seen. A validator that ignores unknown clauses is unaffected. One that rejects them fails immediately, and one that treats a missing value as a violation fails on every release generated before the clause existed.

The resolution is to decide the validator’s behaviour once and state it in the contract itself: unknown clauses are ignored, missing values default to permissive, and a clause only becomes enforced in the version that introduces it. That makes adding a clause genuinely additive, and it makes the contract forward-compatible in the same way a well-designed wire format is.

The second half of the resolution is a grace period on the clause rather than on the contract. A new clause can be published as advisory in one version and enforced in the next, which gives the producer a release or two of evidence about how often it would have fired before it starts failing anything. A clause that would have failed forty per cent of releases is a clause that was mis-specified, and finding that out from an advisory period costs nothing.