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.
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.
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.
from dataclasses import dataclass
from enum import Enum
classKind(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.
@dataclass(frozen=True)classContract:
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.
defcheck_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:continueif kind is Kind.FIXED:
breaks.append(f"{name}: fixed clause changed ({a!r} → {b!r})")elif kind is Kind.WIDENING andnot contains(b, a):
breaks.append(f"{name}: widening clause narrowed ({a!r} → {b!r})")elif kind is Kind.NARROWING andnot 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.
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
defvalidate_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()ifnot 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.
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.
defconsumers_still_on(registry, contract_version:str)->list[str]:"""Who has not migrated — answerable only from the consumption log."""returnsorted({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.
deftest_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))deftest_every_clause_has_a_kind(new_contract):
unclassified =sorted(set(new_contract.clauses)-set(CLAUSE_KINDS))assertnot unclassified,f"clauses with no change rule: {unclassified}"deftest_window_releases_satisfy_both(release, live_contracts):assertlen(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.
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 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 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.