Versioning Spatial Data Contracts Without Breaking Consumers
A data contract is only useful while consumers believe it. The fastest way to destroy that belief is to change the contract in place: someone tightens a coverage clause, three downstream jobs that were passing yesterday start failing today, and the next team to propose a contract simply declines. Spatial contracts change more often than most — a new projection is adopted, an administrative boundary is redrawn, a coded domain gains classes — so the versioning discipline has to be part of the design rather than an afterthought. This guide covers what counts as a breaking change to a spatial contract, how to run two versions concurrently while consumers migrate, and how to retire the old one without a flag day. It belongs to spatial data contracts and SLO design under geospatial observability architecture fundamentals.
Problem framing: which spatial changes actually break consumers
Not every contract edit needs a version. Distinguishing the three categories keeps the process from becoming ceremony.
Loosening is non-breaking. Widening a value domain to permit an additional land-use code, relaxing a freshness bound from four hours to six, permitting an extra geometry type — none of these can cause a consumer that was passing to start failing. They ship as a minor revision, with a note.
Tightening is breaking for the producer, not the consumer. Narrowing a domain, reducing a freshness bound, raising a coverage floor: existing consumers keep working, but batches that used to be admitted may now be rejected. The risk here is availability rather than correctness, and the mitigation is a soft-launch — evaluate the new clause and emit its compliance signal without acting on it until you have seen a month of data.
Semantic change is breaking for everyone. Changing the projection, redefining what a coded value means, redrawing the extent, or altering the meaning of a column changes what the data is. A consumer that keeps reading successfully now gets wrong answers, which is worse than an error. These need a real version and a migration.
Projection change is the archetype and deserves care: it is the change most likely to be treated as cosmetic and most likely to silently invalidate every downstream spatial join. Any change to the srid or axis_order clause is a major version, without exception.
Implementation: run both versions during the overlap
The mechanism is simpler than it sounds. Keep contracts as versioned files, let the gate load every currently-active version for a layer, and evaluate all of them against each batch. Every version emits its own compliance signal; only the versions marked enforcing can reject.
# versioned_gate.py — evaluate all active versions, enforce only the enforcing ones.
from opentelemetry import metrics
meter = metrics.get_meter("gis.contract")
clause_eval = meter.create_counter("gis.contract.clause_evaluations_total")
def enforce(batch, contracts) -> bool:
"""contracts: every active version for this layer, newest first."""
admitted = True
for contract in contracts:
for clause in contract.clauses:
held = clause.evaluate(batch)
clause_eval.add(1, {
"layer": contract.layer,
"version": contract.version, # the label that makes overlap legible
"clause": clause.name,
"result": "pass" if held else "break",
"mode": contract.mode, # enforcing | observing
})
# An observing version measures without ever rejecting a batch,
# which is what makes a soft launch safe.
if contract.mode == "enforcing" and not held and clause.on_break == "halt":
admitted = False
return admitted
The mode field is the whole soft-launch mechanism. A new version ships as observing, its compliance is measured for a full cycle of the layer’s cadence — a month for a nightly layer, a quarter for a seasonal one — and only then is it promoted to enforcing. Promotion is a one-word change with a month of evidence behind it, which is a far easier conversation than “we tightened it and the loads started failing”.
Tracking migration needs one more signal, emitted by consumers rather than by the pipeline: which contract version each reader believes it is reading against.
# Consumers declare the contract version they were built against.
# gis_consumer_contract_version{consumer, layer, version} == 1
count by (layer, version) (gis_consumer_contract_version{layer="parcels_authoritative"})
# Retirement gate: zero consumers left on the old version.
count by (layer) (gis_consumer_contract_version{layer="parcels_authoritative", version="v7"}) == 0
A consumer that never declares a version is the hardest case, and the right default is to assume it is on the oldest active version. That assumption is conservative, keeps the old contract alive until someone claims otherwise, and creates the useful pressure of a visible unattributed count.
Verification: prove the overlap actually overlaps
Two checks confirm the mechanism works before you rely on it for a real migration.
Publish a new version identical to the current one, in observing mode, and confirm both compliance signals appear with matching attainment. Divergence between two identical contracts means the evaluator is not truly running both against the same batch — usually because one version was loaded from a cached path.
Then publish a deliberately stricter observing version and confirm it records breaches while batches continue to be admitted. This is the property that makes soft launches safe, and it is worth demonstrating once rather than assuming.
For the consumer side, confirm that a reader which declares no version is counted against the oldest active one. The failure mode here is a retirement gate that reads zero because unattributed consumers were never counted, retiring a contract that half the platform still depends on.
Gotchas
Editing a contract in place and calling it a fix. Even a “correction” changes the meaning of historical attainment. Version it, and let the old numbers keep referring to the old contract.
Retiring on a date. A calendar deadline retires the contract whether or not consumers migrated, which is precisely the flag day the process exists to avoid. Retire when the counter reaches zero, and escalate the stragglers by name.
Projection changes treated as minor. The single most damaging misclassification available. Any change to projection or axis order is major, because consumers keep working and start being wrong — the cascade described in the CRS cascade failure runbook.
Overlap without a deadline pressure. Indefinite overlap accumulates versions and doubles the enforcement cost forever. Publish the migration counter where the owning teams see it, and treat a version older than two cycles as an escalation rather than a fact of life.
Version label omitted from the compliance metric. Without it, attainment during an overlap is a meaningless blend of two contracts. The label is what makes the whole scheme legible.
FAQ
How many versions should be active at once?
Two, except transiently. Three means a migration stalled, and the correct response is to finish one before starting another rather than to build tooling for arbitrary depth.
What if a consumer cannot migrate — a vendor system, say?
Keep the old version enforcing for that consumer’s read path if your serving layer can partition by version; otherwise treat the consumer as a hard constraint and fold its requirements into the new version. What you must not do is retire the contract and let it silently break, since that consumer is exactly the one least likely to notice.
Does the overlap consume extra error budget?
No, because each version has its own objective and its own budget. That is another argument for the version label on the compliance metric — without it, an observing version’s breaches would pollute the enforcing version’s attainment and spend budget that was never actually breached.
How does this interact with schema fingerprints?
The fingerprint is one clause inside the contract, so a fingerprint change follows the same classification: adding a nullable column is a loosening, removing a column is semantic, and changing a column’s type is usually semantic too. The mechanics of detecting the change are covered in schema and attribute drift detection.
How should a contract change be reviewed?
Like a code change, because it is one. The proposed version goes through the same review as the loader it governs, with two additional requirements: the classification — major, tightening, or minor — must be stated in the description and agreed by the reviewer, and a tightening must link to the observing-mode evidence that justifies enforcing it. Reviews that skip the classification are how a projection change ships as a one-line edit.
Should the contract version appear in the published data?
Yes, as metadata on the layer or in the status document. A consumer that can read the version it actually received can assert against the one it was built for, which turns a silent semantic change into a clean, early failure.
Related
- Spatial data contracts and SLO design — the parent topic defining clause structure and objectives.
- Setting error budgets for spatial freshness — the budget each version carries independently.
- Defining spatial data trust boundaries — where the versioned gate is enforced.