The four canonical objects
Everything an adapter emits is an Entity, an Event, a Track or a PlanObject. The split
is by what a thing is — not by which system sent it, and not by which consumer wants it.
| Object | Means | Key fields |
|---|---|---|
Entity | something that exists | entity_id, entity_type, affiliation, position, kinematics, valid_from/valid_to, confidence, attributes |
Event | something that happened | event_id, event_type, severity, related_entities, geometry, payload, observed_at, received_at |
Track | an entity's position history | track_id, entity_id, samples[], track_quality |
PlanObject | something we push out | object_id, object_type, geometry, style, label, expires_at |
What all four carry
Every canonical object inherits CDMBase, and none of these fields is decoration.
schema_version, source, source_ids[] and integrity were the four. schema_version
2.1.0 adds three more — quality, status and residual — and all three are OPTIONAL, so
an object written against 2.0.0 is still a valid object and a 2.0.0 reader still reads a
2.1.0 one. What each of them means, and the rule that keeps residual from being trusted,
are on the CDM Policies page.
Corrected 2026-09-19 by the audit's F01 (the sentence above is left as written). The first
half holds and the second does not: a 2.0.0 reader does NOT read a 2.1.0 object, because every
published schema carries additionalProperties: false and refuses the three properties 2.1.0
introduced, populated or null. version.assess("2.1.0", read_by="2.0.0") answers REFUSED,
WRITER_NEWER, and the frozen 2.0.0 schemas under tests/frozen/cdm/ are the evidence. See
Compatibility is not equality.
schema_version
Semver, in every serialised object. A consumer reading an object off a queue has no other way to know which shape it is holding, and "we will add versioning when we need it" means adding it at the moment two incompatible producers are already in the field.
Compared with version.compatible(), not by equality — see the Changelog.
source
{system, adapter, adapter_version, synthetic} — which translator produced this object.
synthetic is required and has no default. Mislabelling exercise data as live can reach an
operational picture; mislabelling live data as exercise hides it from an operator. There is no
safe default, so there is no default.
source_ids[]
Every external identifier the object is known by, at least one, on every kind.
source_ids was specified on Entity alone. The lossless check found the gap within a minute
of first running: a PNTMAP alert whose emitter carries its own id produced an entity keyed on
the emitter and an event keyed on nothing, so the alert's own identifier —
PNTMAP-2026-04-29-0117 — appeared nowhere in the output.
Three consequences, none acceptable: a redelivery could not be recognised as a duplicate, because nothing held the identifier the source deduplicates on; an auditor holding the event could not get back to the source record, which is the one question an audit trail exists to answer; and the loss was silent — every other check passed.
It is a list rather than a single value because the same object arrives from several systems: one vessel is an MMSI to AIS, a track number to STANAG 4676 and a UID to TAK. Fusion joins them later; an adapter records the name its own system used and never overwrites another system's entry.
integrity
The post-quantum signature block: designed, deliberately not implemented.
The field exists from day one so that turning signing on is a value change rather than a schema change — a schema change would be a MAJOR bump rippling through every store and every consumer, arriving exactly when the signing work is already late.
No crypto happens in this package, and that is enforced by AST rather than promised: an import
of hashlib or cryptography here would mean somebody had started signing objects inside the
translation layer, where the key material has no business being and where nothing audits it.
All three of its fields or none. A block holding a signature with no algorithm is unverifiable, and an unverifiable signature that looks present is worse than an absent one: it reads as assurance to everything downstream that does not check.
quality
{source_quality, confidence, accuracy_m, uncertainty} — how good the source says its own
data is. Absent means the source said nothing about quality, which is not the same as saying it
is poor. uncertainty is a named dict rather than one number, because an error ellipse is not a
radius and flattening it loses the orientation that made it worth sending.
status
{state, namespace, since, attributes} — the source's own operational state, in the source's own
words. There is no enum and there will not be one. "SERVICEABLE", "MISSION CAPABLE", "RED" and
"U/S" come from different domains and mean different things; a closed CDM vocabulary would have to
map each onto a member, which is a judgement about somebody else's operational language made
inside a translator. namespace says whose vocabulary state belongs to, and a consumer must not
compare state across namespaces.
residual
{namespace, data} — source information the CDM does not model, kept under the name of the format
it came from. It identifies its origin and it is never semantically trusted: it records
that the source said something the CDM has no home for, which is a fact about the source record
and not an assertion about the world. See CDM Policies.
Fourteen of the nineteen adapters shipped today do not use it — they keep parking leftovers in
attributes.source_extras and declare residual: legacy, because the information is already
preserved and moving the placement would cost every golden file and every consumer for nothing a
reader can use. The five of the adapter expansion (geojson, geopackage, c2sim,
aixm511, aixm52) declare residual: structured and carry their leftovers in it.
Why additionalProperties: false everywhere
A canonical model whose objects accept unknown keys is not canonical — it is a dict with a
docstring. The strictness is safe only because it is paired with a declared escape hatch:
Entity.attributes and Event.payload accept anything, so an adapter never has to choose
between dropping a field and failing validation.
Strict where the meaning is fixed, open where it is not, and the boundary between the two
written down. The alternative — extra="allow" on the objects themselves — puts
source-specific fields at the same level as canonical ones, and six months later nobody can
tell which fields the model guarantees and which one adapter happens to send.