Skip to main content

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 version

This 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.

1.2.0 added output and did not move this number, deliberately

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.

Source of truth

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​

BumpChangeConsumer impact
MAJORa field removed or renamed; a type narrowed; an enum member removed; an optional field made required; ids.NAMESPACE changedbreaks readers; needs a migration entry below and a coordinated deployment
MINORan optional field added; an enum member added; a payload model registered; validation relaxedold readers keep working, old data keeps validating
PATCHdescriptions, error-message wording, docsnone

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​

  1. Edit the Pydantic model. It is the single source; the files in /schemas are a publication.
  2. Bump version.SCHEMA_VERSION per the table above.
  3. Re-export: python -m synapse_cdm.schemas --out schemas. tests/test_cdm_schemas.py fails the build if you forget, and --check is the CI form. Then regenerate this site's reference pages: cd docs && npm run gen:schemas, which npm run check:schemas gates in the same way.
  4. Add an entry below, naming the reason — not just the change.
  5. 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 through importlib.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.
  6. If a documented gap in FORMAT_COVERAGE.md is now closed, close it there too. tests/test_cdm_format_coverage.py::test_the_documented_gaps_are_still_gaps fails 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_ids moved from Entity to CDMBase, 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_strength became signal_strength_dbm. A bare signal_strength has been read as dBW, dBm and a 0–100 bar by three different consumers; the unit belongs in the name, as it already does in speed_mps, alt_m and accuracy_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.py 1.0.0 — Cursor-on-Target, bidirectional. Implements every row of the CoT table in FORMAT_COVERAGE.md at 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 for point/@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: attributes for the unmapped values, TRANSFORMS for the nine paths whose value legitimately changes, and UNKNOWN as an enum member for the three CoT affiliation letters the CDM does not carry.

  • adapters/ais.py 1.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_ids is 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. And Position.accuracy_m stays null for every AIS fix: AIS states accuracy as one bit, better or worse than 10 m, and writing 10.0 into 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.py 1.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: Position requiring both coordinates, which made "a position within a zone" unspellable as a partial fix; Kinematics treating absent as unknown, which is what nine zero-sentinel fields need; and PositionSource being 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_m documents, 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 into accuracy_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_m lives on Position, Position requires 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.py 1.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 follows paging.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 CDM Track. The entity and track endpoints return byte-identical schemas and a track location's foreign key is named entity_id: a Legion Track is an Entity whose category is TRACK, 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.version demonstrably 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: crs is optional and defaults to EPSG:4978, geocentric X/Y/Z in metres, while the position object is shaped like GeoJSON. An adapter reading coordinates as [lon, lat] would place every contact somewhere impossible while emitting perfectly well-formed CDM objects. So Position is a derived one-way view, the source coordinates are re-emitted verbatim beside it, and attributes.position_basis records 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.

  • adapters/stanag4676.py 1.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 Track per TrackData: a TrackSegment is 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, ProcessedTrack and 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 TRAVELER and ZOMBIE as 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.py 1.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 P1 quoted, 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 Entity and one DETECTION Event whose key ends in two positional ordinals — and no target Track is 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 one Track: P3 + P8 is 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. P7 is Mandatory on every packet; a pure-simulated value against a real declaration refuses, a pure-real value against a synthetic one refuses, and synthesized — "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.10 mapping is a lookup and never arithmetic (128 + n mirrors n only for n = 0…13; 144–148 mirror 14–18 at +130) and FACILITY appears nowhere in it. Reserved and extension segment types are skip-and-record: exact, because S2 gives their length, and never silent. TRANSFORMS is 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.py put it in Event.geometry, asterix_cat021.py and adsb.py leave it None. Both are 1.1.0 questions with both arguments on the record.

  • adapters/asterix_cat048.py 1.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 a DETECTION rather than a TRACK_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 and Entity.position is None — and because Position requires 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 to RHO and THETA within 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_to is not set by the track-end bit, because TRE ends "a track record within a particular track file" and not the airframe the entity_id names. 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 with cat021, 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.py 1.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_type is SENSOR from the category rather than read off any item, the SAC/SIC that asterix_cat048.py parks as a sensor identifier is a SourceId here because the station is the object, and Kinematics is None on every object — the one bearing the category carries is the antenna's, and writing a sector number into course_deg would say the radar head is travelling on that heading.

    I034/120 carries the station position cat048 has 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 a Position on 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 own attributes.position_basis rather than in a comment.

    Table 2 decided two rulings that looked like preferences. No Geometry is 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, at STATUS_CHANGE / ADVISORY: an undefined type is not a malformed record, and neither INFO nor WARNING is 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 ALERT at WARNING with 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.py 1.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_m or Entity.confidence under 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 ICAO24 source-id namespace means a CAT021 record and a 1090ES frame of one airframe derive the same entity_id without the two adapters coordinating — asserted by a fixture that carries the ADS-B set's own address. And attributes accepting anything is what lets the wire octets of every item be parked verbatim beside the converted values, which is why TRANSFORMS is empty: a declared transform is an exemption from the never-drop check, and this adapter needs none. The harness reports lossless: PASS on 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.py made 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 in attributes, 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_m is horizontal only, so CoT's @le has no home. It matters for air tracks, where a 300 m vertical error decides whether two aircraft are deconflicted — and the TAK adapter's air_track_due_north fixture is exactly that case: le="120.0" on a track at 7 620 m, parked in attributes where no consumer will look for it.
  • Kinematics.heading_deg and Kinematics.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. Entity has attributes, Event has payload, Track has nowhere to park anything. The Legion adapter makes it concrete: a Track from 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 the Entity today, so a consumer holding only the Track cannot read them.
  • Position.baro_alt_m, or Entity.baro_alt_m — where it hangs is part of the work. alt_m is 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 off Position inherits 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.