Spatial Data Contracts and SLO Design
Every spatial pipeline has a contract; most of them are undocumented, and the undocumented ones are enforced by whichever downstream consumer breaks first. A data contract makes the promise explicit — this layer is projected in EPSG:27700, refreshed within four hours, complete over this extent, with these columns and these value domains — and a service level objective makes it measurable: the promise holds for 99.5% of hours in a rolling thirty-day window. Together they turn “the parcels feed is usually fine” into a number that can be reported, budgeted against, and used to decide whether the next change is safe to ship.
This topic covers how to express a spatial contract in a form a pipeline can enforce, how to choose objectives that mean something for geographic data, and how to run an error budget when the thing being budgeted is correctness rather than availability. It belongs to geospatial observability architecture fundamentals and supplies the per-layer targets that tracking spatial data freshness SLAs and alert routing and on-call design both read from.
What a spatial contract has to declare
A general data contract covers schema and freshness. A spatial one needs three more clauses, because spatial data has three additional ways to be wrong while looking right.
Projection. The declared spatial reference identifier of the geometry column, and whether axis order is longitude-first or latitude-first. This clause exists because a feature in the wrong projection is not slightly wrong — it is somewhere else on the planet, and every spatial predicate downstream returns a confidently incorrect answer. The clause must be asserted against the actual geometry, not against the metadata that claims to describe it, for the reasons set out in coordinate reference system validation.
Coverage. The extent the layer is expected to cover and the minimum fraction of it that must be populated. Without this clause a truncated export passes every structural check: correct columns, valid geometry, plausible row count, half the country missing.
Geometry validity and type. The permitted geometry types and the requirement that geometries be topologically valid. A contract that permits GEOMETRY and says nothing about validity has effectively promised nothing, since a self-intersecting polygon satisfies it.
Alongside these sit the conventional clauses: the structural fingerprint of the column set, the freshness bound, and the value domains for coded attributes. Together they form a declaration that is short enough to read in a minute and specific enough to enforce mechanically.
# contracts/parcels_authoritative.yaml — checked in beside the loader.
layer: parcels_authoritative
owner: geo-platform
steward: land-records
pipeline_class: batch
geometry:
srid: 27700 # asserted against ST_SRID, not against metadata
axis_order: easting_northing
types: [MULTIPOLYGON]
must_be_valid: true # ST_IsValid on every feature
max_vertices: 250000 # rejects pathological geometries at the boundary
coverage:
extent_ref: registry://extents/gb_national
min_extent_ratio: 0.99 # of the registered extent's area
min_feature_count_ratio: 0.95 # against the previous accepted batch
freshness:
max_age: 4h
measured_from: source_generated_at # not ingestion time
structure:
fingerprint_ref: registry://fingerprints/parcels_v7
on_break: halt # never "warn" for a structural clause
domains:
land_use:
allowed_ref: registry://domains/land_use_v3
on_unmapped_code: halt
Two conventions in that file are worth adopting generally. Freshness is measured from source_generated_at rather than from ingestion, because a pipeline that ingests stale data promptly is fresh by the wrong measure and stale by the one consumers care about. And every clause names its behaviour on breach explicitly, so nobody has to infer whether a projection mismatch halts or warns.
Turning clauses into objectives
A contract clause is a per-batch predicate. An objective is a statement about how often that predicate holds over a window. The translation is mechanical, and the decision that carries all the weight is the choice of window and target.
| Clause | Objective statement | Typical target | Window |
|---|---|---|---|
| Projection | Fraction of hours with zero projection mismatches | 100% | 30 d |
| Structure | Fraction of batches passing the fingerprint | 100% | 30 d |
| Freshness | Fraction of minutes with age under the bound | 99.5% | 30 d |
| Coverage | Fraction of hours with extent ratio above the floor | 99.9% | 30 d |
| Validity | Fraction of features passing validity per batch | 99.99% | 30 d |
| Domains | Fraction of batches with no unmapped codes | 99.9% | 30 d |
Correctness clauses take a target of exactly 100% and availability-like clauses do not. That asymmetry is deliberate and is the single most useful idea in spatial objective design. A projection mismatch is not a small amount of unavailability — it is data that is wrong, and there is no defensible budget of hours per month during which a layer may be in the wrong coordinate system. Freshness, by contrast, degrades gracefully: a layer three minutes past its bound is very nearly as useful as one inside it, so a fractional target with a budget is exactly right.
The practical consequence is that correctness clauses produce a count of breaches rather than a budget, and each breach is an incident with a review. Freshness and coverage produce a budget you spend, and spending it is normal.
Objective attainment is computed from the same compliance series the gate emits, which is what keeps the numbers honest. The evaluator never re-derives compliance from raw data; it reads what the gate decided, so the reported attainment is exactly the enforcement history.
where is attainment for clause over window , is the target, and is the fraction of the error budget remaining. means the budget is exhausted and the layer is out of objective.
Enforcing the contract at the boundary
The gate belongs at the trust boundary — the point where data moves from a source you do not control into a store your consumers do trust. Enforcing anywhere later means the wrong data is already inside, and the discussion becomes remediation rather than prevention. The boundary itself is the subject of defining spatial data trust boundaries; the contract is what that boundary checks.
The gate’s job is to evaluate every clause, emit a compliance signal per clause, and then act according to each clause’s declared behaviour. Emitting the signal even when the clause passes is essential: attainment cannot be computed from breach events alone, because the denominator is unobservable if you only record failures.
# contract_gate.py — evaluate every clause, emit every result, act on breaches.
from opentelemetry import metrics
meter = metrics.get_meter("gis.contract")
clause_ok = meter.create_counter("gis.contract.clause_evaluations_total")
def enforce(batch, contract) -> bool:
admitted = True
for clause in contract.clauses:
held = clause.evaluate(batch)
# Emit on BOTH outcomes — attainment needs the denominator.
clause_ok.add(1, {
"layer": contract.layer,
"clause": clause.name,
"result": "pass" if held else "break",
})
if not held and clause.on_break == "halt":
admitted = False
return admitted
Three operational rules make the gate durable. Version the contract and record the version on every evaluation, so a change in attainment can be attributed to a contract edit rather than to the data. Fail closed on evaluation errors — a clause that could not be evaluated is not a clause that passed. And keep the gate’s decision authoritative: if a batch was admitted despite a breach because someone overrode it, record the override as its own signal rather than rewriting the compliance history.
Choosing what the objective is measured over
Two layers can have identical clauses and completely different objectives, because the unit being measured differs. Getting the unit right is the difference between a number that reflects consumer experience and one that reflects pipeline mechanics.
Time-based units measure a fraction of minutes or hours during which a condition held. They suit continuously-consumed layers — a tile service, a live feed, a layer backing an interactive map — because a consumer can arrive at any instant and what matters is what they find. Freshness and coverage are almost always time-based.
Batch-based units measure a fraction of loads that satisfied a clause. They suit layers consumed by scheduled jobs, where nobody reads between runs and the only question is whether each delivered batch was sound. Structure and domain clauses are naturally batch-based: a fingerprint either matched for that load or it did not, and there is no meaningful notion of it being broken for forty minutes.
Feature-based units measure a fraction of features satisfying a clause within a batch. Validity is the clear case: a batch of twelve million parcels containing four self-intersections is not a failed batch, and expressing the objective as 99.99% of features rather than 100% of batches produces a target that is both achievable and informative. The risk is that a feature-based unit hides a concentrated failure — four hundred invalid geometries all in one district is a coverage problem wearing a validity costume — so pair it with a per-region breakdown wherever regions are meaningful.
Mixing units within one objective is the error to avoid. An objective that says “99.5% of batches are fresh” for a layer that is read continuously will report health during a two-day gap between successful loads, because a batch that never ran is a batch that never failed. Choose the unit from how the layer is consumed, and state it in the objective’s own text so the number cannot be misread later.
There is one further unit worth naming because spatial platforms hit it regularly: the region-time unit, which measures a fraction of region-minutes in bound rather than a fraction of minutes globally. For a layer built or served per region, this is the only unit under which a single failed region shows up at all, for exactly the arithmetic reason described in alerting on partial-region failures. The cost is that a rare region with little traffic weighs the same as a dense one, which is usually the correct trade for a correctness objective and the wrong one for a cost model — another reason to keep objectives and capacity planning as separate numbers.
Finally, be explicit about what happens to the measurement when the layer is deliberately unavailable. A declared reload window, a source that only publishes on weekdays, or a seasonal feed that pauses over winter all produce intervals during which the clause cannot meaningfully hold. Excluding them from the denominator is correct, but it must be excluded by a declared signal rather than by a hand-edited exception, or attainment quietly becomes whatever the last person to edit the query wanted it to be.
Publishing the objective to consumers
An objective that only the owning team can see is an internal metric, not a contract. The value of the contract comes from consumers being able to check it, which means publishing current attainment, remaining budget, and the timestamp of the last breach in a form a downstream job can read before it runs.
The pattern that works is a small status document per layer, served alongside the data and updated by the evaluator. A downstream analytics job that reads the status first can decline to run against a layer whose coverage objective is currently breached, which converts a silent wrong answer into a clean skip. A dashboard consumer can display a staleness banner instead of a confidently wrong map. Neither is possible if attainment lives only in a monitoring system.
Publishing also changes the internal conversation productively. Once a consumer can see that the freshness objective is at 98.9% against a 99.5% target, the discussion moves from “the feed feels unreliable” to a specific, bounded gap with a budget attached — and the corrective work can be prioritised against it rather than against impressions.
Failure modes of contract and objective design
Targets set at 100% for degradable clauses. A freshness objective of 100% is permanently breached by the first slow night and rapidly ignored. Reserve 100% for clauses where any breach is genuinely an incident.
Attainment computed from raw data rather than gate decisions. Re-deriving compliance after the fact drifts from what the gate actually did, and the two numbers disagreeing destroys trust in both. Compute from the gate’s emitted results.
Contracts that describe the current data rather than the requirement. A fingerprint generated from whatever arrived today pins accidental structure and breaks on the first legitimate change. Write the clause from what consumers need, then check that today’s data satisfies it.
No versioning. A silently edited contract makes historical attainment meaningless. Version it, record the version on each evaluation, and treat a contract change as a change with a review.
Objectives without an owner. A budget nobody owns is a number nobody acts on. Every objective needs a named owner who is expected to respond when the budget crosses its policy threshold.
A contract written for a layer nobody consumes. Contracts are expensive to maintain and their value comes entirely from downstream reliance. Writing one for every intermediate table produces a large body of clauses that nobody reads and that fail for reasons nobody cares about, which trains the team to override breaches. Start with the layers that have named external consumers and expand only as reliance grows.
Clauses that duplicate what the store already guarantees. A NOT NULL constraint enforced by the database does not need a contract clause asserting non-nullness; the clause adds evaluation cost and a second place to change. Reserve contract clauses for properties the store cannot enforce — projection of the actual coordinates, coverage against a registered extent, freshness relative to source generation, and the semantic domains of coded values. This keeps the contract short enough that people read it, which is the property that makes it work at all.
A last failure mode is organisational rather than technical: treating the contract as the producer’s promise alone. A contract that consumers never read, never assert against, and never notice breaking is a unilateral declaration, and it will drift out of correspondence with what consumers actually need within a couple of quarters. The mechanism that keeps it honest is consumers declaring which version they depend on and checking status before they run, so that a clause nobody relies on becomes visibly unused and a need nobody wrote down becomes visibly unmet.
Design checklist
- Write the five spatial clauses — projection, structure, freshness, coverage, validity — plus value domains, in a version-controlled file beside the loader.
- Declare the breach behaviour of every clause explicitly; never leave it implied.
- Measure freshness from source generation time, not from ingestion.
- Give correctness clauses a target of 100% and no budget; give degradable clauses a fractional target and a budget.
- Emit a compliance signal on both pass and break so attainment has a denominator.
- Fail closed when a clause cannot be evaluated.
- Publish attainment, remaining budget and last breach where consumers can read them before running.
- Set a policy threshold on the budget and name the owner who acts when it is crossed.
Related
- Geospatial observability architecture fundamentals — the parent section and its metric conventions.
- Defining spatial data trust boundaries — where the enforcement gate sits.
- Tracking spatial data freshness SLAs — the freshness clause in operational depth.
- Alert routing and on-call design for spatial pipelines — how a breach becomes a page.