Validating Schema Compatibility in CI
Run the candidate schema against every version still reachable in the log, then add the assertions a structural checker cannot make — coordinate order, CRS explicitness and precision are all type-preserving, so a generic compatibility gate passes the three changes most likely to relocate every geometry in the stream.
This guide sits under Schema Evolution & Versioning for Spatial Events, within Core Event Fundamentals & Architecture. It is the gate that makes Migrating a Spatial Stream Between Schema Versions a deliberate act rather than an accident discovered in production.
When to use this pattern
- More than one service produces to the stream, so no single review catches every schema edit.
- The schema lives in a repository and changes through pull requests, which is where a gate can sit.
- Consumers exist that you do not deploy, so a breaking change cannot be walked back quickly.
What a structural checker sees, and what it misses
Compatibility checkers reason about types and field presence. That catches a removed required field and a narrowed type, and those are worth catching. The changes that hurt a spatial stream are not those.
Complete runnable implementation
The gate has two halves: a structural check against every reachable version, and a differential replay of real payloads through the old and new readers.
"""ci/check_schema.py — run in CI; exits non-zero on an unsafe change."""
import json
import sys
from pathlib import Path
from shapely.geometry import shape
SCHEMA_DIR = Path("schemas")
CORPUS = Path("tests/corpus/payloads.jsonl")
# Every version still reachable: topic retention plus any replayable archive.
# NOT just the previous one — pairwise compatibility is not transitive.
REACHABLE = (1, 2)
class Incompatible(Exception):
pass
def structural_check(old: dict, new: dict, path: str = "") -> list[str]:
"""Required fields may not vanish, and types may not narrow."""
problems = []
for name in old.get("required", []):
if name not in new.get("required", []) and name not in new.get("properties", {}):
problems.append(f"{path}{name}: required field removed")
for name, spec in old.get("properties", {}).items():
target = new.get("properties", {}).get(name)
if target is None:
continue
if spec.get("type") != target.get("type"):
problems.append(
f"{path}{name}: type {spec.get('type')} -> {target.get('type')}"
)
if spec.get("type") == "object":
problems += structural_check(spec, target, f"{path}{name}.")
return problems
def spatial_check(new: dict) -> list[str]:
"""The assertions a structural checker cannot make.
Each of these is type-preserving, so nothing generic will ever flag it.
"""
problems = []
props = new.get("properties", {})
# 1. CRS must be explicit and required. An implicit default is a decision
# made by whichever service happened to write the payload.
if "crs" not in new.get("required", []):
problems.append("crs: must be required — an implicit CRS is not a contract")
# 2. Coordinate order must be stated, and must be lon/lat per RFC 7946.
order = props.get("geometry", {}).get("x-coordinate-order")
if order != "lon,lat":
problems.append(f"geometry: coordinate order must be 'lon,lat', got {order!r}")
# 3. Precision is part of the contract because content hashes depend on it.
if "x-coordinate-precision" not in props.get("geometry", {}):
problems.append("geometry: x-coordinate-precision must be declared")
return problems
def differential_replay(read_old, read_new) -> list[str]:
"""Feed real payloads to both readers and compare the GEOMETRY.
A change can be structurally fine and still move every feature; only
comparing the parsed geometry catches that.
"""
problems = []
for line in CORPUS.read_text().splitlines():
payload = json.loads(line)
try:
a, b = read_old(payload), read_new(payload)
except Exception as exc: # noqa: BLE001 - reported, not raised
problems.append(f"{payload.get('feature_id')}: reader raised {exc!r}")
continue
moved = shape(a.geometry).distance(shape(b.geometry))
if moved > 1e-9:
problems.append(
f"{payload.get('feature_id')}: geometry moved {moved:.6f} degrees"
)
return problems
def main() -> int:
new = json.loads((SCHEMA_DIR / "event.v3.json").read_text())
problems = spatial_check(new)
for version in REACHABLE:
old = json.loads((SCHEMA_DIR / f"event.v{version}.json").read_text())
problems += [f"v{version}: {p}" for p in structural_check(old, new)]
problems += differential_replay(read_v2, read_v3)
for problem in problems:
print(f"INCOMPATIBLE: {problem}", file=sys.stderr)
return 1 if problems else 0
if __name__ == "__main__":
raise SystemExit(main())
REACHABLE is not a nicety.Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
REACHABLE |
tuple[int, ...] |
Every version in retention plus any replayable archive | (1, 2) |
x-coordinate-order |
str |
Must be "lon,lat" per RFC 7946; the flip is type-preserving |
"lon,lat" |
x-coordinate-precision |
int |
Declared, because content hashes depend on it | 6 |
| Move tolerance | float |
Degrees; 1e-9 is far below any real change and above float noise |
1e-9 |
CORPUS |
path | Real captured payloads, biased to awkward geometries | — |
| Exit code | int |
Non-zero fails the build; overriding requires an explicit version bump | — |
Gotchas and spatial edge cases
-
A synthetic corpus proves nothing. A square in the first quadrant survives a coordinate flip looking merely translated, survives a precision change with no visible difference, and never exercises hole handling or antimeridian logic. Capture the corpus from production and include the antimeridian polygon, the multipolygon with holes, the empty geometry, the null geometry and the largest payload the stream has carried.
-
distance()on geographic coordinates is in degrees, not metres. The1e-9tolerance above is roughly a tenth of a millimetre at the equator and about half that at 60° north. That asymmetry is fine for a “did it move at all” check and useless as a distance measurement — do not reuse the number as a spatial tolerance elsewhere. -
Compare geometries, not serialised payloads. Two readers can produce byte-different JSON for the same shape — key order, float formatting, an explicit
bboxmember — and a string comparison fails on all of it while missing the case where the bytes match and the interpretation differs. -
The corpus has to be regenerated. A corpus captured two years ago does not contain the payload shapes a producer added last quarter, so the gate is checking the schema against a stream that no longer exists. Refresh it on a schedule and fail the build if it is stale.
-
Precision is part of the contract even though it is not in the type system. Reducing declared precision changes every content hash, so deduplication stops matching across the boundary exactly as described in Event Key Generation for Spatial Data. Declaring it as an extension keyword is what makes it reviewable.
Verification
The gate itself needs a test, because a compatibility checker that has never rejected anything is indistinguishable from one that always passes.
import pytest
BASE = {
"required": ["feature_id", "crs", "geometry"],
"properties": {
"feature_id": {"type": "string"},
"crs": {"type": "string"},
"geometry": {"type": "object", "x-coordinate-order": "lon,lat",
"x-coordinate-precision": 6},
},
}
def test_gate_accepts_a_safe_addition():
"""A new optional field must not trip the gate."""
new = {**BASE, "properties": {**BASE["properties"], "confidence": {"type": "number"}}}
assert structural_check(BASE, new) == []
assert spatial_check(new) == []
def test_gate_rejects_a_coordinate_flip():
"""The change no generic checker sees."""
geom = {**BASE["properties"]["geometry"], "x-coordinate-order": "lat,lon"}
new = {**BASE, "properties": {**BASE["properties"], "geometry": geom}}
assert structural_check(BASE, new) == [] # structurally identical
assert any("coordinate order" in p for p in spatial_check(new))
def test_gate_rejects_an_implicit_crs():
"""Removing crs from required is how an implicit default gets in."""
new = {**BASE, "required": ["feature_id", "geometry"]}
assert any("crs" in p for p in spatial_check(new))
The middle test is the one to keep in front of a reviewer: structural_check returns an empty list on a change that relocates every feature in the stream, and that is not a bug in the structural checker — it is the reason the spatial one exists.
Related
- Schema Evolution & Versioning for Spatial Events — the topic this guide belongs to
- Migrating a Spatial Stream Between Schema Versions — what to do when the gate correctly says the change is breaking
- Best Practices for Spatial Event Payload Schemas — the schema conventions these assertions enforce
- Geometry Validation Pipelines — the runtime counterpart to a build-time gate