Compatibility
A carmaker that integrates once ships that integration into vehicles that stay on the road for fifteen years and are updated by nobody. We do not get to break them.
The whole contract in one line: additive only, permissive on read, strict on write.
What we promise
A field is never renamed, never re-typed, never removed. New fields are always optional, and their default reproduces the previous behaviour.
Controlled vocabularies only grow. type, subtype, status, severity,
source_kind and verification gain values; existing values never change meaning.
subtype is the extreme case — it is designed to grow without notice, which is why the
rule for it is refine, never filter. See Event types.
A change in the meaning of an existing field is a new URL version, not a silent
edit. /v1 and /v2 would be served side by side, and /v1 would keep its old meaning
until its last consumer is gone.
What you must do
Two things. If you skip either, our promise is worth nothing to you.
Ignore unknown fields
Do not fail, warn or drop an event because it carried a key you did not expect. Most JSON parsers do this by default; strict deserialisers in typed languages do not. Check yours.
// fine — extra keys are simply not read
const { event_id, type, lat, lon, expires_at } = event;If your language needs it told (@JsonIgnoreProperties(ignoreUnknown = true),
#[serde(default)] without deny_unknown_fields, a non-strict Pydantic model), tell it
now rather than after a release adds a field.
Skip unknown values
Never crash on one, and never remap it. This is the rule people get wrong, because it is not a parsing concern.
const KNOWN = new Set(await fetch(`${RELAY}/v1/meta/event-types`).then(r => r.json()));
function handle(event) {
if (!KNOWN.has(event.type)) return; // opaque to us — skip, and that is the whole rule
render(event);
}An enum you do not recognise means the event is opaque to you. Three specific things not to do:
- Do not crash. A
switchwith nodefault, an exhaustive match, aEnum.valueOf(...)that throws — each of these turns our additive change into your outage. This is not a hypothetical:typewent from eight values to thirteen on 2026-08-11, andsubtypegains values without any announcement at all. - Do not remap onto a value you know. Serving a hazard type you do not recognise as
an
accidentroutes traffic around the wrong kind of hazard, and the mistake is invisible to whoever made it. A lie is worse than opacity. - Do not hardcode the list.
GET /v1/meta/event-typesserves the live vocabulary precisely so nobody does. Fetch it at startup, cache it, refresh it occasionally.
We hold ourselves to the same rule first, on the read path: an event whose type we
cannot interpret is served, not skipped. Dropping unrecognised rows would make the
network look empty during a rolling deploy — no error, no log, and a subscriber cannot tell
an empty road from a broken reader. An opaque event is at least visible.
The changelog records every change to the wire contract, the vocabulary and
the platform, dated. It is written for humans; do not build a migration process that
parses it — the machine-readable vocabulary is /v1/meta/event-types, at runtime.
Strict on the way in
The asymmetry is deliberate: strict where a human can still fix it, permissive where the code is already in the field.
| Direction | Rule |
|---|---|
| You → Relay | An unknown field on POST /v1/events is a 422. An unknown type is a 422. |
| Relay → you | Unknown fields and unknown values must be tolerated. |
A publisher that learns nothing from a typo ships the same bug into a million vehicles. That is why the publish path refuses rather than ignores.
schema_version
Every event carries schema_version (currently 2). It records which envelope rules
produced this row.
- It is not bumped when a field is added — additive changes are invisible to old readers by construction. A version that changes on every release is a gate people remove.
- It is bumped when the meaning of an existing field changes.
Do not gate on it. A version stamp documents a semantic break; it cannot prevent
one. Code compiled in 2027 does not grow a new branch because a field arrives with a 2 in
it — it keeps applying the 2027 meaning to 2029 data, silently and confidently. Nothing on
the wire can make old code understand a new meaning; that is what URL versioning is for.
schema_version is an audit and migration aid inside one URL version.
What already changed, before you
All of it was done before the first integration, because that was the last moment any of it was possible. It is recorded here so nothing in older material misleads you:
geometryships as a structured GeoJSON object, not as a JSON string. NeverJSON.parseit. Text on the wire would have made you parse JSON out of a JSON document, and the field could never have become an object afterwards.org_idwas removed from published events, first replaced by thepublisher_idpseudonym and then, inschema_version2, removed entirely along with it. Removing a published field breaks the additive rule, which is exactly why it happened before anyone had integrated. See Privacy.confirm_countanddeny_countcount VEHICLES, not organisations (schema_version2), and the three organisation-granular tallies —org_count,confirm_org_count,deny_org_count— were removed with the quorum that fed them. The same road reality now produces a much larger number, so any threshold set on the old meaning became looser. This is whyschema_versionexists: it records that the break happened, it does not make it safe. Threshold onverificationorconfidence, which mean what they always meant.
Nothing of this kind will happen again inside /v1.
Stability by field
| Treat as | Fields |
|---|---|
| Stable forever | every field name and type in the event object; the SSE frame names and their payload shapes; the change values created, updated and expired (the set may grow). |
| Growing sets — handle the unknown | type, subtype (the one designed to grow without notice — refine on it, never filter), status, severity, source_kind, verification, environment, feed_id, bye reasons. |
| Opaque — never parse | event_id, feed_id, the API key and ticket formats. |
| Not contract — read at runtime | max_query_radius_m (from /v1/meta/limits), the event-type list (from /v1/meta/event-types), max_seconds (from the snapshot frame), TTL durations, confidence constants, the reporter quorum thresholds. |
The last row is the one worth re-reading. Every number in these docs that is not a field name is a current value, not a promise — fetch it where an endpoint serves it.