Skip to content

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 switch with no default, an exhaustive match, a Enum.valueOf(...) that throws — each of these turns our additive change into your outage. This is not a hypothetical: type went from eight values to thirteen on 2026-08-11, and subtype gains values without any announcement at all.
  • Do not remap onto a value you know. Serving a hazard type you do not recognise as an accident routes 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-types serves 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.

DirectionRule
You → RelayAn unknown field on POST /v1/events is a 422. An unknown type is a 422.
Relay → youUnknown 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:

  • geometry ships as a structured GeoJSON object, not as a JSON string. Never JSON.parse it. 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_id was removed from published events, first replaced by the publisher_id pseudonym and then, in schema_version 2, 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_count and deny_count count VEHICLES, not organisations (schema_version 2), 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 why schema_version exists: it records that the break happened, it does not make it safe. Threshold on verification or confidence, which mean what they always meant.

Nothing of this kind will happen again inside /v1.

Stability by field

Treat asFields
Stable foreverevery 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 unknowntype, 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 parseevent_id, feed_id, the API key and ticket formats.
Not contract — read at runtimemax_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.