Changelog and versioning
Every serialised object carries schema_version. 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.
schema_version is not the package's versionThis page is about the wire contract — the number in every object, bumped by the table below.
The synapse-cdm distribution carries its own version, ordinary semver over the Python surface,
and the two are allowed to diverge: thirteen adapters have shipped so far without a single
change to schema_version, and each of them would have been a release of the package. They were
both 1.0.0 at first release, by coincidence of two first releases, and they parted at 1.1.0 and
stayed apart through eight package releases, the last of which — 1.8.0 — is what the index serves
today. version.py in the package is where the distinction is argued.
Updated 2026-09-06. The wire contract moved for the first time. On the working branch
the schema stays at 2.0.0 from the SC-OES model round onward, while the package was still at
1.8.0 and no release carried the change. The two numbers differed in the other
direction — the contract a major ahead of where a mechanical derivation from the package number
would put it — which is the same argument this note has always made, arriving from the opposite
side.
Updated 2026-09-07, and the two numbers are now EQUAL again. On the working branch the
package is at 2.0.0 and the schema stays at 2.0.0, and that is a coincidence and not a
derivation: two independently justified major changes that happen to land on one number. The
schema is major because a 1.x strict reader rejects an object carrying the new keys; the package
is major because a third party's consumer written against 1.8.0 does not work against this
distribution. They were equal at 1.0.0 once before, for exactly this kind of reason, and that
equality did not survive the eleventh adapter. Still no release carries either change: the
index serves 1.8.0, and pip install synapse-cdm resolves it.
Updated 2026-09-08, and they are UNEQUAL again — on the schema's side this time. On the
working branch the package is at 2.0.0 and the schema stays at 2.1.0: round P3 moved the wire
contract a MINOR for the CDM foundation primitives (multi-geometry, vertical position with a unit
and a datum, temporal validity, route, area, quality, the Rule 5 provenance fields, operational
status and the structured residual container) and moved the package by nothing at all, because a
package release is a later round's business. Every addition is an optional field or a model
reached only through one, so a 2.0.0 object still validates and a 2.0.0 reader still reads a 2.1.0
object — the fourteen examples under examples/ are the witness: they still declare
schema_version: "2.0.0" and they still validate against the 2.1.0 models. The paragraph above is
kept as written; the equality it recorded lasted a day, which is the shortest life any of these
coincidences has had. Still no release carries any of it.
Updated 2026-09-09, the 2.1.0 release, and the two numbers are LEVEL again. On this tree the
package is at 2.1.0 and the schema stays at 2.1.0: the SOIF Part 1 release pays the MINOR round
P3's schema MINOR obliged, because a schema bump always creates that debt and never settles it
itself. Level is not derived — nothing in the package computes one number from the other, and
tests/test_cdm_packaging.py sweeps for an assignment that would — and this is the fourth time the
two have coincided for two separate reasons. What the index serves is a separate fact from what
this tree says, measured after an upload rather than asserted before one: PUBLICATION.md's
ledger is where it is recorded, and the paragraphs above that state it in the present tense are
dated readings and are left as written.
Updated 2026-09-10, the 2.1.1 corrective release, and they are UNEQUAL again after one day.
On this tree the package is at 2.1.1 and the schema stays at 2.1.0, and the schema had no part
in the move: v2.1.0 was tagged and its own pip-audit --strict release gate refused to publish
it — the audit resolves every installed distribution against the index and could not resolve the
release candidate itself, which is a gate requiring the publication it gates. The tag stays where
it is, permanently, naming a commit that never reached PyPI, and 2.1.1 carries the identical wire
contract one PATCH higher. The paragraph above is kept as written: the level it recorded was real
and lasted a day, which is the second-shortest life any of these coincidences has had. What the
index serves is still a separate fact from what this tree says, and PUBLICATION.md's ledger is
still where it is measured.
Updated 2026-09-12, the 2.1.2 corrective release, and the gap is now two PATCHes wide. On this
tree the package is at 2.1.2 and the schema stays at 2.1.0, and the schema had no part in this
move either: v2.1.1 was tagged in turn and the release pipeline's CodeQL gate refused to publish
it, because that step asked for the code-scanning analyses of the REF and a tag ref can carry none
in this repository. Two release tags now name commits that never reached PyPI, both stay where they
are, and 2.1.2 carries the identical wire contract two PATCHes above the level of 2026-09-09. The
paragraph above is kept as written. What the index serves is still a separate fact from what this
tree says — at the time of writing it is still 2.0.0, which is the whole point of measuring it
after an upload rather than asserting it before one, and PUBLICATION.md's ledger is still where
it is measured.
Corrected 2026-09-12, later the same day, and it is the measurement above that moved rather than
the argument. The paragraph above says that what the index serves "at the time of writing is still
2.0.0". That was true when the 2.1.2 transition was written and it stopped being true at
10:49:11Z, when the second of the two artefacts finished uploading: the index serves 2.1.2,
and pip install synapse-cdm resolves it. The sentence is kept as written, because every
present-tense reading on this page is a dated reading and none of them is edited after the fact —
which is exactly why this correction is appended beside it rather than made inside it. The wire
contract took no part in the move and is unchanged by it. PUBLICATION.md's ledger entry 19 is the
measurement, taken after the upload rather than asserted before it, and it is also where the two
tagged commits that never reached the index are recorded as permanent.
Updated 2026-09-17, the 2.2.0 release, and the gap is now a MINOR wide — for the ordinary reason
at last. On this tree the package is at 2.2.0 and the schema stays at 2.1.0, and the number
moved for what the distribution carries rather than for what a pipeline refused: the audit arc
since v2.1.2 added importable names — a depth bound in the adapter base class, the round-trip
tolerance declarations that moved the Adapter API to 2.1.0, one semver pattern for every version
field, a canonical serialiser written once — and gates/bump_derivation.py derived MINOR over it
with nothing unruled. No wire field and no published schema moved, and no golden but the ten KLV
ones whose VMTI identity ruling string quoted a private document by path, so a 2.1.x reader reads
a 2.2.0 object unchanged. The paragraphs above are kept as written. What the index serves
is still a separate fact from what this tree says — at the time of writing it is 2.1.2 — and
PUBLICATION.md's ledger is still where it is measured.
Corrected 2026-09-17, later the same day, and again it is the measurement above that moved rather
than the argument. The paragraph above says that what the index serves "at the time of writing is
2.1.2". That was true when the 2.2.0 transition was written and it stopped being true at
15:05:41Z, when the second of the two artefacts finished uploading: the index serves 2.2.0,
and pip install synapse-cdm resolves it. The sentence is kept as written, for the reason the
2026-09-12 correction gives — every present-tense reading on this page is a dated reading and none
of them is edited after the fact. The wire contract took no part in the move and is unchanged by it.
PUBLICATION.md's ledger entry 20 is the measurement, taken after the upload rather than asserted
before it, and it is also where the pipeline's own witness record is recorded as refused and the
committed one as built by hand.
Updated 2026-09-20, the 3.0.0 release, and the two numbers are LEVEL again — by two majors
argued apart. On this tree the package is at 3.0.0 and the schema stays at 3.0.0 from this
commit on. The wire contract took a MAJOR because the 2026-09-19 audit's finding F04 narrowed the
PUBLISHED schema — a pattern on every schema_version and adapter_version, a pattern on
Entity.symbol, uniqueItems on Entity.ontology_types — and the table above puts "a type
narrowed" on the MAJOR row whatever the reference models already enforced: a document that
validated under the published 2.1.0 schema with "adapter_version": "banana" is refused by
3.0.0, so accepted documents do become invalid for a consumer validating with the schema alone.
The package took a MAJOR on its own table: lossless.unrepresented is removed without an alias
and fourteen ruled units changed meaning. No path was removed, no required list grew and no
enum member went, and every golden in the package moved by its schema_version stamp alone. The
fourteen examples under examples/ — the witness of 2026-09-08 above, which declared 2.0.0
through two minors — now declare 3.0.0, because a reader of one major is promised nothing
about another and the conformance tool's structural dimension says so for a 2.0.0 document.
version.compatible("2.1.0", "3.0.0") is False in both directions by the different-major rule,
and the 2.1.0 contract stays frozen beside 3.0.0 under tests/frozen/cdm/. The paragraphs above
are kept as written. What the index serves is still a separate fact from what this tree says
— at the time of writing it is 2.2.0 — and PUBLICATION.md's ledger is where it is measured.
Updated 2026-09-20, the 3.0.1 corrective release, and the two numbers are UNEQUAL again after
the hours between a tag and its release run. v3.0.0 was tagged and its own release workflow
refused it in the build job, at a second run of the suite in an interpreter that job had first
loaded with release tooling — the gate job's run of the same suite on the same commit was green —
so the package takes a corrective PATCH the wire contract has no part in: on this tree the
package is at 3.0.1 and the schema stays at 3.0.0. Nothing installable changed between the
two: the arc is the release workflow, MIGRATIONS.md and version.py, and MIGRATIONS.md's
3.0.1 section is the record. Three of the six partings this note has recorded are now
release-pipeline defects rather than contract decisions. What the index serves is still a
separate fact from what this tree says — at the time of writing it is 2.2.0 — and
PUBLICATION.md's ledger is where it is measured.
Measured 2026-09-20 after the upload: the index serves 3.0.1. PUBLICATION.md entry 21 is the
measurement — the wheel and sdist digests read off the index agree with the gate's, the served bytes
re-hash to them, and the pipeline's own witness record is committed for the first time as
releases/witness/3.0.1.json. The sentence above about what the index serves stopped being true at
14:43:35Z that day and is kept as written.
Updated 2026-09-21, the 3.1.0 release, and the gap is a MINOR wide — for the ordinary reason
again. On this tree the package is at 3.1.0 and the schema stays at 3.0.0, and the number
moved for what the distribution carries: the adapter expansion since v3.0.1 added five adapter
modules — adapters/geojson.py, adapters/geopackage.py, adapters/c2sim.py,
adapters/aixm511.py and adapters/aixm52.py, the roster fourteen to nineteen — with three
shared modules, five fixture sets and the optional validate extra, and
gates/bump_derivation.py derived MINOR over it with nothing unruled once the nine rulings in
MIGRATIONS.md's 3.1.0 section are read. No wire field, no published schema and no golden of
the fourteen that predate the arc moved, so a 3.0.1 reader reads a 3.1.0 object unchanged; the
five new typed blocks are payload contracts under fields the 3.0.0 schema already has. The
paragraphs above are kept as written. What the index serves is still a separate fact from what
this tree says — at the time of writing it is 3.0.1 — and PUBLICATION.md's ledger is where
it is measured.
Updated 2026-09-21, the 3.1.1 corrective release, and the gap is a MINOR and a PATCH wide
after the hours between a tag and its release run. v3.1.0 was tagged and its own release
workflow refused it in the build job, at the package test — the conformance sweep run from the
installed wheel with check J still required, which geojson and geopackage declare
inapplicable, while the gate job's sweep on the same commit had held J per adapter and passed —
so the package takes a corrective PATCH the wire contract has no part in: on this tree the
package is at 3.1.1 and the schema stays at 3.0.0. Nothing installable changed between the
two: the arc is the release workflow, MIGRATIONS.md and version.py, and MIGRATIONS.md's
3.1.1 section is the record. Four of the eight partings this note has recorded are now
release-pipeline defects rather than contract decisions. What the index serves is still a
separate fact from what this tree says — at the time of writing it is 3.0.1 — and
PUBLICATION.md's ledger is where it is measured.
Measured 2026-09-22 after the upload: the index serves 3.1.1. PUBLICATION.md entry 23 is the
measurement — the wheel and sdist digests read off the index agree with the gate's, the served bytes
re-hash to them, and the witness record is committed as releases/witness/3.1.1.json, built by the
witness round over the run's inputs because the pipeline's own was refused on an empty approval
comment. The sentence above about what the index serves stopped being true at 09:06:25Z that day and
is kept as written.
The 1.2.0 release ships a new kind of output — a structured defect annotation, written by the
stanag4609 adapter when a KLV item's octet count contradicts its own standard's stated Required
Length. New output surface is the shape that ought to move a contract version, so the question was
put explicitly and answered from the published schemas rather than from judgement.
Nothing changes for a reader of CDM objects, and there is no migration. The annotation lives
entirely inside Entity.attributes and Event.payload, which the schemas declare
additionalProperties: true, while the objects that carry them are additionalProperties: false.
No object gained a top-level field; all six published schemas regenerate byte-identical. A 1.0.0
reader validates a 1.2.0 object unchanged, because the keys it does not recognise are in the place
this contract has always said it may ignore. MIGRATIONS.md's 1.2.0 section carries the evidence
with file and line.
packages/cdm/synapse_cdm/MIGRATIONS.md is the source, and this page is a curated summary of
it rather than a copy of it. It carries the schema history and the named field proposals, reworded
and abridged for a reader of the published contract, and it leaves out what is repository-internal:
the Phase 1 row sets that ship no adapter code, and the 1.1.0 items that are still design questions
rather than named fields. It also adds what the file does not carry, such as the compatibility
example below.
What it never does is make a claim the file does not. Every adapter and every proposed field on this
page is one that file names too, and tests/test_cdm_changelog_claim.py asserts exactly that
direction. The reverse is deliberately not asserted — the file is allowed to run ahead of this
page, and it does. When the two disagree, the file in the package wins: it sits next to the
version.py it describes.
What each bump means
| Bump | Change | Consumer impact |
|---|---|---|
| MAJOR | a field removed or renamed; a type narrowed; an enum member removed; an optional field made required; ids.NAMESPACE changed | breaks readers; needs a migration entry below and a coordinated deployment |
| MINOR | an optional field added; an enum member added; a payload model registered; validation relaxed | old readers keep working, old data keeps validating |
| PATCH | descriptions, error-message wording, docs | none |
Compatibility is not equality
version.compatible(written_with="2.0.0", read_by="2.1.0") # True — new reader, old writer
version.compatible(written_with="2.1.0", read_by="2.0.0") # False — old reader, new writer
version.compatible(written_with="2.9.0", read_by="2.1.0") # False — nobody has published 2.9.0
version.compatible(written_with="2.0.0", read_by="1.0.0") # False — a major apart
version.compatible(written_with="2.1.0", read_by="3.0.0") # False — a major apart, either way
version.assess(written_with="2.1.0", read_by="2.0.0") # REFUSED, WRITER_NEWER, and the evidence
compatible() is directional and evidence-based (corrected by the 2026-09-19 audit; it read
"accepts the same major including a minor from the future" before). A newer reader accepts an
older object because the frozen historical schemas show it validating. An older reader does NOT
accept a newer object: every published schema carries additionalProperties: false, so a
property it does not know — populated or explicitly null — is refused. A minor nobody has
published is unknown, not safe. A different major is refused outright. version.assess() returns
the verdict, the direction and the evidence it rests on; a supported verdict is version
eligibility, and the document is still validated on its own.
Renaming a field is two releases, never one: add the new name in a MINOR, populate both, then remove the old one in the next MAJOR. One release that renames is an outage for every consumer that has not been redeployed in the same hour.
Changing the schema — the procedure
- Edit the Pydantic model. It is the single source; the files in
/schemasare a publication. - Bump
version.SCHEMA_VERSIONper the table above. - Re-export:
python -m synapse_cdm.schemas --out schemas.tests/test_cdm_schemas.pyfails the build if you forget, and--checkis the CI form. Then regenerate this site's reference pages:cd docs && npm run gen:schemas, whichnpm run check:schemasgates in the same way. - Add an entry below, naming the reason — not just the change.
- Re-run every adapter's golden files and read the diffs:
python -m synapse_cdm.harness --adapter <name> --update-golden, once per shipped adapter. No--fixtures: a shipped adapter declares its own directory and the harness resolves it throughimportlib.resources, so the command is the same from a clone and from an install. A golden file updated without being read is how a defect becomes the expectation. - If a documented gap in
FORMAT_COVERAGE.mdis now closed, close it there too.tests/test_cdm_format_coverage.py::test_the_documented_gaps_are_still_gapsfails deliberately when a gap field appears, so the document cannot silently disagree with the code.
History
1.0.0 — initial contract
The four objects (Entity, Event, Track, PlanObject), Position, Kinematics,
SourceId, SourceRef, Integrity, TrackSample, and one registered payload model
(GnssInterferencePayload for GNSS_INTERFERENCE).
Two decisions in this release depart from the original specification, both because building the reference adapter surfaced the reason:
source_idsmoved fromEntitytoCDMBase, required on every kind. The harness's lossless check found the gap on its first run: 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 appeared nowhere in the output. A redelivery could not be recognised as a duplicate and an auditor holding the event could not get back to the source record.signal_strengthbecamesignal_strength_dbm. A baresignal_strengthhas been read as dBW, dBm and a 0–100 bar by three different consumers; the unit belongs in the name, as it already does inspeed_mps,alt_mandaccuracy_m.
Adapters that landed with no schema change
Recorded because "no entry" and "nobody wrote an entry" look identical from here, and the first is worth stating.
-
adapters/tak.py1.0.0 — Cursor-on-Target, bidirectional. Implements every row of the CoT table inFORMAT_COVERAGE.mdat schema version 1.0.0, with no field added, removed or retyped. Two temptations were declined and are listed below as 1.1.0 candidates instead: a canonical home for the CoT callsign, and one forpoint/@le. Both would have been MINOR, and both would have been added in passing — which is how a canonical model acquires two fields that mean nearly the same thing.What it needed instead already existed:
attributesfor the unmapped values,TRANSFORMSfor the nine paths whose value legitimately changes, andUNKNOWNas an enum member for the three CoT affiliation letters the CDM does not carry. -
adapters/ais.py1.0.0 — AIS / NMEA 0183 AIVDM, bidirectional. Message types 1, 2, 3, 4, 5, 18, 19 and 21 at schema version 1.0.0, with no field added, removed or retyped.AIS is the format the CDM's sentinel rule was written about before any AIS adapter existed —
Kinematics's docstring names the 102.3-knot case by number. Ten of them turned up: position 91/181, speed 102.3, course 360, heading 511, rate of turn −128, UTC second 60–63, IMO, ETA and dimension 0, and draught 0.0, which is the one worth naming because it is the only sentinel that is also a plausible reading. An adapter that correctly nulls the other nine can still report that a laden tanker draws nothing.Two existing decisions earned their keep here specifically.
source_idsis a list, so a type 5 message's MMSI and IMO number are both emitted rather than one displacing the other — an MMSI is reassigned when a vessel changes flag, an IMO number is fixed for the life of the hull. AndPosition.accuracy_mstays null for every AIS fix: AIS states accuracy as one bit, better or worse than 10 m, and writing10.0into a 1-sigma metre field would state an error nobody measured. No new field is proposed for that flag — a threshold and a measurement are different kinds of claim, and giving the threshold a numeric home is how it would quietly become one. -
adapters/adsb.py1.0.0 — ADS-B 1090ES extended squitter (Mode S DF17/DF18), bidirectional. Type codes 1–4, 5–8, 0 and 9–18, 19 subtypes 1–4, 20–22, 28 subtype 1 and 31 subtype 0, at schema version 1.0.0, with no field added, removed or retyped.The fourth adapter is the first whose silences cost something structural, so it opens two gaps (9, barometric altitude; 10, air-data speeds) and sharpens two that were already open. Three existing decisions carried it:
Positionrequiring both coordinates, which made "a position within a zone" unspellable as a partial fix;Kinematicstreating absent as unknown, which is what nine zero-sentinel fields need; andPositionSourcebeing a real vocabulary, so a rebroadcast surveillance track can be ESTIMATED rather than borrowing the aircraft's GNSS.Two design decisions are worth reading even if you never touch this format:
- Pairing two frames for a position is fusion, not translation. ADS-B encodes latitude and longitude as 17-bit Compact Position Reporting values — a position within a zone. Resolving it globally needs a second frame of the opposite parity, joined on the aircraft address across time, which means either a half-populated object or a cache. A cache in a translator is fusion done where nothing audits it, so this is the AIS type-24 argument reaching the same conclusion for the third time. Local decoding is supported, because its reference position is the receiver's own surveyed location supplied at construction — configuration, like the clock, not state. With no reference configured there is no position and the CPR fields are parked, so the default is the conservative one.
- Two altitudes are two measurements — and two encodings. Type codes 20–22 state a GNSS
height, which is what
Position.alt_mdocuments, as the plain decimal value of all twelve bits in metres: no Q bit, no offset, no 25-foot step, and a field that saturates at 4095 m. Type codes 9–18 — most of an air picture — state a pressure altitude against the 1013.25 hPa datum in 25-foot steps behind a Q bit, differing from the first by hundreds of metres in ordinary weather. Collapsing them would be the same class of false statement as writing AIS's ten-metre accuracy threshold intoaccuracy_m, so the barometric one is parked and gap 9 records what that costs. The datum is a further caveat rather than a detail: a DO-260 v0 transmitter measures that height from mean sea level and a DO-260A/B one from the ellipsoid, and the version is in a different frame — gap 7's magnetic-versus-true problem in the vertical.
Two defects are recorded here because they say what the gates are for. The byte-exact round trip found a GNSS altitude silently dropped on every frame whose position could not be decoded:
alt_mlives onPosition,Positionrequires a coordinate, and an altitude without a horizontal fix therefore had nowhere to go. Every other check passed.The second no gate here could have caught. The type code 20–22 altitude field was decoded with the barometric arithmetic when it is plain metres; because the fixture was encoded the same wrong way, the round trip stayed byte-exact and the goldens agreed with themselves while the frame did not mean what the adapter said. Only reading the reference found it — which is why the citation now sits beside the row in
FORMAT_COVERAGE.md. -
adapters/legion.py1.0.0 — Picogrid Legion Platform API v3, ingest. Entity, Track, Entity/Track Location, Locations list and Event at schema version 1.0.0, with no field added, removed or retyped.The first adapter whose upstream is a REST API, and the boundary sits where it sat for the wire formats:
to_cdm()takes one already-fetched JSON document and owns no HTTP, no auth, no retries and no pagination. Transport is where state lives, and an adapter holding state is a fusion layer nothing audits.Three decisions are worth reading even if you never touch this API:
- Pagination is framing; correlation is fusion. One page becomes one
Track— a partial history, labelled as one — and the adapter never followspaging.next. That is the AIS fragment-buffer argument and the ADS-B CPR-pairing argument reaching the same conclusion a third time. What it does read is data the payload already embeds, which is reading rather than correlating. - A Legion "Track" is a CDM
Entity, not a CDMTrack. The entity and track endpoints return byte-identical schemas and a track location's foreign key is namedentity_id: a Legion Track is an Entity whose category isTRACK, and the history lives in its Locations collection. The resource names point the wrong way. - A vendor API needs a pinned spec. Unlike a ratified standard it can move between two
deploys, and its
info.versiondemonstrably does not move when it does — so the row set is pinned to a document by SHA-256, with a field-by-field inventory, and a test fails the build on a field that has no row.
The largest hazard is a coordinate:
crsis optional and defaults toEPSG:4978, geocentric X/Y/Z in metres, while the position object is shaped like GeoJSON. An adapter readingcoordinatesas[lon, lat]would place every contact somewhere impossible while emitting perfectly well-formed CDM objects. SoPositionis a derived one-way view, the source coordinates are re-emitted verbatim beside it, andattributes.position_basisrecords which of two incompatible readings was applied to three bare numbers.Four corrections happened during implementation and all four came from a gate rather than a review: the never-drop check caught the list path pruning every sample's metadata, the pinned inventory caught a hand-read claiming six omitted fields where the spec says five, a TRANSFORMS audit caught six exemptions with no subject, and the harness caught the spec pin being replayed as a payload.
- Pagination is framing; correlation is fusion. One page becomes one
-
adapters/stanag4676.py1.0.0 — STANAG 4676 / AEDP-12 Edition B Version 2 NITS tracks, bidirectional. The full UML model — 48 classes and 273 attributes — at schema_version 1.0.0, with no field added, removed or retyped.Pinned to AEDP-12 Edition B Version 2 (March 2022) by SHA-256, alongside the AEDP-12.1 Implementation Guide and the STANAG 4676 Edition 2 ratification wrapper. Edition A is refused by name — the standard says the two are incompatible and that the model was re-architected "from scratch", so a 2014 feed is a separate adapter and not a mode.
One
TrackperTrackData: aTrackSegmentis a temporal and administrative subdivision of a track, not an identity boundary, so segments park against the range of sample indices they cover. Points out of time order are refused quoting both instants, and segments overlapping in time — the multi-hypothesis structure the standard's own text describes — are refused quoting the hypothesis count and confidences, never reassembled and never silently resolved to the best-scoring branch.Three of its six coordinate systems cannot produce a position and each says why. The mandatory STANAG 4774 confidentiality label is carried as the exact fragment that arrived and egress refuses rather than inventing one.
TrackLinkage,ProcessedTrackand the standard's own normative cross-stream consolidation rule are all carried and none is performed.The XML element binding is provisional and labelled as such: the normative XSD is distributed through NATO national representatives and is not pinned here, so element names bind through one empty table. Every fixture is an XML/parsed twin and a test asserts the two produce byte-identical CDM.
Four gaps opened: 16 no per-sample extension, 17 no state-vector uncertainty, 18 no confidence provenance and no retraction, 19 no relation object. Gap 2 gained
TRAVELERandZOMBIEas evidence and a stated divergence: three adapters map FAKER and JOKER and this one disagrees with the other two, deliberately and on the record.The XML element binding is provisional and every row's status column says so — the marker is
nits 1.0.0 · provisional, and the declines-and-blockers table states the five steps that remove it, beginning with obtaining and pinning the XSD. -
adapters/gmtif.py1.0.0 — STANAG 4607 / AEDP-4607 Edition A Version 1 GMTI, bidirectional. The packet header, the segment header and all ten defined segments — 212 fields — at schema_version 1.0.0, with no field added, removed or retyped.Pinned to AEDP-4607 Edition A Version 1 (February 2024) by SHA-256, alongside AEDP-4607.1 and the STANAG 4607 Edition 4 ratification wrapper. Edition 3 is refused with
P1quoted, and not because the layout changed — it did not. Three enumeration tables did, so an Edition 3 packet decoded as Edition A misclassifies targets with no structural symptom: every length checks out and the targets are the wrong kind of object.The first non-text wire format, so the Annex C codec is a layer of its own with its own suite. Seven numeric encodings, two sign-magnitude rather than two's complement and two binary angles whose signed and unsigned forms differ in both signedness and exponent — every one a place where a wrong answer is a plausible number rather than an exception. The two strongest tests are the worked examples the standard itself prints:
BA16 0101100100011100= 125.31006° and −34.876099° =SA16 1100111001100110.Targets here are detections, not tracks. Nothing in the core segments identifies a real target, so each target report becomes one
Entityand oneDETECTIONEventwhose key ends in two positional ordinals — and no targetTrackis ever emitted, because the standard's own implementation guide sends the reader to the sensor manufacturer for the association rule. The platform is the exception and gets the oneTrack:P3+P8is the only identity the format guarantees.Two payload declarations of whether the data are real, and neither writes
source.synthetic— in any direction, agreement included.P7is Mandatory on every packet; a pure-simulated value against a real declaration refuses, a pure-real value against a synthetic one refuses, andsynthesized— "a mix of real and simulated data" — contradicts neither pure declaration and parks visibly without a refusal. A simulated target inside a purely-real packet is a separate refusal, payload against payload.A reference date on the wire, in a different segment from the times it resolves, and the first adapter here whose date comes from neither the clock nor the object that needs it. Three paths with provenance on every emitted instant, and a Mission Segment contradicting the caller's argument is a refusal quoting both — neither silently wins.
The
D32.10mapping is a lookup and never arithmetic (128 + nmirrorsnonly for n = 0…13; 144–148 mirror 14–18 at +130) andFACILITYappears nowhere in it. Reserved and extension segment types are skip-and-record: exact, becauseS2gives their length, and never silent.TRANSFORMSis empty, and the round trip is byte-exact on all sixteen fixtures.Four gaps opened: 20 no detection-versus-track distinction, 21 no home for a radar measurable — and specifically no way to state one component of a velocity, 22 no negative information (a dwell that found nothing is this format's primary product and the CDM cannot say so), 23 no way to carry an observation whose source states no time. Gap 20 also carries two stated divergences: where a person maps, and where a detection's fix lives — this adapter and
stanag4676.pyput it inEvent.geometry,asterix_cat021.pyandadsb.pyleave itNone. Both are 1.1.0 questions with both arguments on the record. -
adapters/asterix_cat048.py1.0.0 — ASTERIX category 048 monoradar target reports, bidirectional. All 28 UAP data items of EUROCONTROL-SPEC-0149-4 Edition 1.32, SP and RE included, at schema_version 1.0.0, with no field added, removed or retyped.The sensor-side complement of
cat021, and the first adapter whose ordinary case is aDETECTIONrather than aTRACK_UPDATE— a radar detects where AIS, ADS-B and CAT021 receive self-reports. Which inverts three of CAT021's easy problems: one time item rather than seven, a target that may not be an object at all (the format has codes for a reflection, an angel, a bird and a wind turbine), and a position that is slant range and azimuth from a station whose location the format never carries.Geometry is derived only when the caller injects a
sensor_position. That is the injected-clock precedent applied to geometry: I048/140 carries no date and the adapter supplies one from configuration, I048/040 carries no site and the site is the same class of deployment fact. What stays forbidden is the adapter obtaining either for itself. With no site, the polar measurement is parked andEntity.positionisNone— and becausePositionrequires both coordinates, that absence is unspellable as anything else.The geodesy is not in the specification at all, and the row set says so rather than implying otherwise. §4.3.2.1 gives only the radar-plane identities and §4.3.2.2 names the WGS-84 ellipsoid before deferring the projection to "a suitable projection technique". So the derived latitude and longitude are the adapter's arithmetic, declared in
attributes.position_basis, and audited two ways: an inversion back toRHOandTHETAwithin the items' own least significant bits, and — because a round trip proves only self-consistency — an independent pin of the ellipsoid against three published geodesic distances.Three rulings reversed under review, all away from reusing an existing field.
Entity.valid_tois not set by the track-end bit, becauseTREends "a track record within a particular track file" and not the airframe theentity_idnames. The 12-bit track number is a carried claim and never an identity key, because keying on a recycled number merges two airframes into one entity. And the Reserved Expansion Field is parked for a procedural reason — the appendix that defines it is a public download that was identified and not acquired — which is a weaker park than the two it resembles, and is recorded as one.Byte-exact both ways, on its own tested codec layer (
cat048_codec): the FSPEC re-derived from Category 048's own Table 2 rather than shared withcat021, bounds computed from the standard's printed maxima, and egress that re-encodes from the parsed items and then checks the result against the octets parked on ingest — so byte-exactness is a proven property of the decoder/encoder pair rather than a consequence of copying the input back out. -
adapters/asterix_cat034.py1.0.0 — ASTERIX category 034 monoradar service messages, bidirectional. All 14 UAP FRNs and all 12 data items of EUROCONTROL-SPEC-0149-2b Edition 1.29, RE and SP included, at schema_version 1.0.0, with no field added, removed or retyped.The first adapter whose primary object is the sensor itself. Every record in Part 2b describes the radar station rather than a target, and the two documents say so in one sentence with the parenthesis moved: CAT048 §4.1 puts service messages outside itself, CAT034 §4.1 puts target reports outside itself. So
Entity.entity_typeisSENSORfrom the category rather than read off any item, the SAC/SIC thatasterix_cat048.pyparks as a sensor identifier is aSourceIdhere because the station is the object, andKinematicsisNoneon every object — the one bearing the category carries is the antenna's, and writing a sector number intocourse_degwould say the radar head is travelling on that heading.I034/120carries the station positioncat048has to be handed, and it is handed to nobody. Reading it out of one category's record to resolve another's range and azimuth is cross-payload state, so it becomes aPositionon the CAT034 object that carries it and stops there. The gap that records the missing CAT048 geodesy does not close, and every such object says so in its ownattributes.position_basisrather than in a comment.Table 2 decided two rulings that looked like preferences. No
Geometryis ever derived from the Generic Polar Window, because the item and the station's own position are mutually exclusive across all seven message types — the position could only ever come from a different record. And a record whose message type this edition does not define is translated rather than refused, atSTATUS_CHANGE/ADVISORY: an undefined type is not a malformed record, and neitherINFOnorWARNINGis a claim the record supports.A radar jamming strobe is not GNSS interference, and the CDM says so out loud. Three of the seven message types are jamming reports, the model's only interference vocabulary is paired with a GNSS payload built for PNTMAP, and reusing it would put radar jamming in the field a consumer filters on to find GNSS threats. They become
ALERTatWARNINGwith the strobe geometry parked, and a gap is opened rather than the payload model widened in passing.Byte-exact both ways, on its own tested codec layer (
cat034_codec) — the FSPEC derived from Category 034's own Table 3, which numbers fourteen FRNs in two octets where Category 048's numbers twenty-eight in four, so sharing the sibling's reader would have given this category the wrong ceiling and a refusal message quoting the wrong count. -
adapters/asterix_cat021.py1.0.0 — ASTERIX category 021 ADS-B target reports, bidirectional. All 42 data items plus the RE and SP fields and the whole Reserved Expansion Field, at schema_version 1.0.0, with no field added, removed or retyped.Pinned to EUROCONTROL-SPEC-0149-12 Edition 2.6 and its Appendix A Reserved Expansion Field Edition 1.5, both by SHA-256. Ed 2.6 states on its own cover that it is not backwards compatible with Ed 2.1 or earlier, so the edition is part of the mapping and not a footnote.
Three things this format has that no earlier one did, and each is a decision rather than a translation:
- Seven time items and not one date. Every CAT021 time is elapsed time since last midnight UTC at 1/128 s. The reference date comes from the injected clock and the instant chosen is the one bearing the stated time of day NEAREST the receipt instant — one rule that handles both midnight-rollover directions with no special case, and the AIS second-of-minute construction generalised. A value at or beyond 86 400 s is REFUSED with the raw integer quoted, never taken modulo a day.
- A quality vocabulary that needs another item to say what it means. I021/090's primary
subfield holds "NUCr or NACv" and "NUCp or NIC", decided by the MOPS version in I021/210 —
which is optional. Where it is absent the reading is recorded as UNDETERMINED rather than
guessed. Nothing in that item reaches
Position.accuracy_morEntity.confidenceunder either reading, PIC included: it states a containment bound in nautical miles and a bound is still not a 1-sigma error. - A ground station that has already judged. Range checks, CPR validation, an independent
position check and a black-list lookup all arrive as flags. They are carried and never
re-decided — and
RCF's own note in the specification says an operational user will SUPPRESS such a target, which this adapter does not: filtering is a decision, and a decision made inside a translator is invisible in the CDM output.
What the CDM already had was enough, and two existing decisions earned their keep. The
ICAO24source-id namespace means a CAT021 record and a 1090ES frame of one airframe derive the sameentity_idwithout the two adapters coordinating — asserted by a fixture that carries the ADS-B set's own address. Andattributesaccepting anything is what lets the wire octets of every item be parked verbatim beside the converted values, which is whyTRANSFORMSis empty: a declared transform is an exemption from the never-drop check, and this adapter needs none. The harness reportslossless: PASSon every parsed twin with nothing excused.Three gaps opened, each evidenced by something this format states and the CDM cannot hold: 13 no per-measurement time (two applicability instants in one record, plus twenty-three per-item ages in I021/295), 14 no producing sensor (the ground station is named in every single record), and 15 no intent (selected altitudes, trajectory intent, navigation mode — the deferral
adsb.pymade at type code 29, which this format does not allow).One decision changed during implementation and a gate found it:
from_cdm()originally took a single emittable object, which failed the two-record round trip. A data block holds N records and the byte-exact claim is about a BLOCK, so it now emits many Entities as many records in block order.
Proposed for the next MINOR — not yet implemented
This heading named 1.1.0 until 1.1.0 shipped without any of it. The number is dropped rather than moved, because "the next MINOR" is what these items mean and a version number here goes stale on the very event that makes a reader open this section.
These come from FORMAT_COVERAGE.md's gap list, and each is deliberately deferred rather than
added in passing. Each is now confirmed by a shipped adapter rather than anticipated: an
adapter parks a real value for it on every fixture it translates, which is the evidence that
was missing when they were first written down.
Entity.label— a canonical human-readable name. A CoT callsign and a STANAG 4676 track number are the strings an operator reads, and today they land inattributes, so every consumer that wants to label a contact needs private knowledge of which adapter's key to look under. Deferred because it needs one owner naming its precedence rules across sources, not a field added in passing.Position.alt_accuracy_m— vertical accuracy.accuracy_mis horizontal only, so CoT's@lehas no home. It matters for air tracks, where a 300 m vertical error decides whether two aircraft are deconflicted — and the TAK adapter'sair_track_due_northfixture is exactly that case:le="120.0"on a track at 7 620 m, parked inattributeswhere no consumer will look for it.Kinematics.heading_degandKinematics.turn_rate_dpm— together, with one owner. AIS carries course over ground, true heading and rate of turn as three separate measurements; the CDM carries the first and parks the other two. The difference between the first two is the interesting fact: a vessel making good 095 while its bow points 070 is being set by wind or current, or is not going where it is pointing on purpose. They are proposed as a pair because a gap opened twice for one concept gets closed twice differently. ADS-B added a requirement rather than just a third vote: its heading is referenced to magnetic north unless a type 31 frame says otherwise, while an AIS heading is true — so the field needs a stated datum, and ADS-B cannot supply that datum from the same frame as the heading.Track.attributes— an extension bag on the one canonical object without one.Entityhasattributes,Eventhaspayload,Trackhas nowhere to park anything. The Legion adapter makes it concrete: aTrackfrom one page of a paginated history is a fragment, and how much of the history it holds must be machine-readable or a consumer computes a speed across a gap it cannot see. Those figures ride on theEntitytoday, so a consumer holding only theTrackcannot read them.Position.baro_alt_m, orEntity.baro_alt_m— where it hangs is part of the work.alt_mis metres above the ellipsoid; a barometric altitude is a pressure reading against a fixed datum, and the CDM has no home for it. Most air tracks therefore carry no altitude at all today. Hanging it offPositioninherits the requirement of a coordinate, which leaves an altitude with no horizontal fix homeless — a case ADS-B produces constantly and a Mode C reply produces always. And like gap 7, it needs a datum carried beside it rather than assumed.
Two further gaps — extent (gap 8) and air-data speeds (gap 10) — are recorded in
FORMAT_COVERAGE.md and deliberately NOT proposed as fields. Gap 10 is ADS-B's: a type 19
subtype 3/4 frame states an indicated or true airspeed, which is not a speed over the ground,
so speed_mps is left null rather than filled with a number every consumer would misread. It
stays unproposed because indicated airspeed, true airspeed and Mach are three quantities and a
consumer that wants wind needs a heading and its datum too — adding one airspeed_mps would
close a third of a question.
On extent: AIS states four dimensions from the position reference point plus a draught, and all of it is parked; but a bounding extent, an offset reference point and a draught are three different ideas, and STANAG 4676's own object-size fields should be read before any of them is added. A gap with no proposal is a decision too, and for both of these the decision is "not yet understood well enough to name a field for".
Until then, every one of these values is carried in Entity.attributes by the adapters that receive it —
which is lossless but not canonical, and that difference is the whole reason these are listed as
gaps rather than as decisions.