The Canonical Data Model
Nineteen integration adapters are shipped and harness-verified — PNTMAP GNSS alerts, TAK / Cursor-on-Target (bidirectional), AIS / NMEA 0183 AIVDM (bidirectional), ADS-B 1090ES extended squitter (bidirectional), Picogrid Legion Platform API v3, ASTERIX category 021 ADS-B target reports (bidirectional), STANAG 4676 / AEDP-12 Edition B NITS tracks (bidirectional), STANAG 4607 / AEDP-4607 Edition A GMTI radar detections (bidirectional, byte-exact), ASTERIX category 048 monoradar target reports (bidirectional, byte-exact), and ASTERIX category 034 monoradar service messages (bidirectional, byte-exact), ASTERIX category 062 SDPS system track messages (bidirectional, byte-exact), ASTERIX category 023 CNS/ATM ground station and service status reports (bidirectional, byte-exact), and STANAG 4609 / MISP-2019.1 UAS Datalink Local Set KLV metadata (bidirectional, byte-exact), STANAG 4586 Edition 3 DLI air-vehicle telemetry (ingest), GeoJSON / RFC 7946 (bidirectional), OGC GeoPackage 1.4.0 (ingest), SISO-STD-019/020 C2SIM (bidirectional), AIXM 5.1.1 with the Digital NOTAM Event Schema 2.0 (ingest) and AIXM 5.2 (ingest).
Without a canonical model in the middle, N adapters means N(N−1)/2 translations and N private notions of what "a contact" is — one hundred and seventy-one and nineteen as of today — and the integration layer becomes the place where meaning is quietly lost. With one, an adapter is a thin translator and nothing else.
external format ──▶ Adapter.to_cdm() ──▶ Entity | Event | Track | PlanObject ──▶ consumer
consumer ──▶ Adapter.from_cdm() ─▶ external format (egress, e.g. TAK)
Where this sits
The CDM is one of five things, and each answers a different question. Keeping them apart is what makes any of them usable on its own.
| Layer | Answers | Where |
|---|---|---|
| Canonical Data Model | what shape is this record | this page, and the four objects |
| Operational Ontology | what does this term mean | Operational Ontology |
| SC-OES | what kind of operational assertion is this | SC-OES |
| Adapters | how does a source format become a canonical record | Writing an Adapter |
| SynapseCommand private runtime | what should be done about it | not published — the public boundary |
SC-OES and the Operational Ontology are both v0.1.0 drafts, published from this repository and not submitted to or approved by any external standards body.
Together, the four public layers are Part 1 of the Synapse Open Interoperability Framework
(SOIF), which is what the abbreviation means wherever these pages use it. The framework
specification itself is a private document, and the SOIF §N sections cited on these pages and
across the repository are its numbering, not a section of anything published here.
The four objects
Everything an adapter emits is one of four kinds. The split is by what a thing is, not by which system sent it, which is why one source payload legitimately becomes several objects.
| Object | Means | Read |
|---|---|---|
Entity | something that exists | a unit, a jammer, an evacuee group |
Event | something that happened | a detection, an interference alert |
Track | an entity's position history | STANAG 4676's shape |
PlanObject | something we push out | a COA sketch, a route |
A PNTMAP alert is an INTERFERENCE_SOURCE entity and a GNSS_INTERFERENCE event, so
to_cdm() returns a list. Forcing that into one object would mean inventing a container
nothing else uses, and losing whichever half the container was not shaped for.
The rules, and where each one is enforced
None of these is a convention. Each is checked by something that fails a build.
1. Adapters never drop data
A field with no canonical home goes into Entity.attributes or Event.payload, parked under
source_extras. Enforced: where an adapter declares MAPPINGS, the harness recomputes every
declared source-path-to-destination mapping and fails the adapter on a leaf that is LOST — missing,
mismatched in value or type, or on the wrong object; where it declares none, the harness falls
back to harvesting every scalar in the source payload and every scalar in the output and fails on
a value that appears nowhere, and the report's basis says heuristic, because an empty result
on value presence alone is not proof (a value that also occurs elsewhere in the output is
"present" whether or not it reached the right path — corrected 2026-09-19 by the audit's F02).
Values that legitimately change — a unit conversion, a re-rendered timestamp — are declared in
the adapter's TRANSFORMS with a reason, and the harness prints every declaration on every
run. An exemption is a visible line in the report, not a silent skip. An adapter that wanted
to hide a dropped field would have to write down that it was dropping it.
2. Adapters are pure translation
No filtering, no enrichment, no thresholds. Each of those is a decision, and a decision made inside a translator is invisible to the audit trail and unattributable.
The reference adapter demonstrates the rule where it is most tempting: a GNSS jamming emitter
gets affiliation: UNKNOWN unless the payload states an attribution. Inferring HOSTILE
would be an intelligence judgement — and it would be wrong the first time the "jammer" turns
out to be a friendly EW exercise.
3. An unknown position is null, never (0, 0)
Structural, not conventional: Position requires
lat and lon, so "unknown" cannot be spelled as zeros — it is spelled by the absence of a
Position. Coordinate zero is a real point in the Gulf of Guinea, and a contact painted there
is a contact that does not exist.
0.0 is a real coordinate, so if not lat is as wrong as null-to-zero — it silently
discards a real position on the Greenwich meridian or the equator. The test is for absence
(lat is None), never for falsiness. Both directions have a fixture and a test.
4. An unknown scalar is null, never 0
0 kt is measured stillness, 0° is due north, confidence 0 is certainty-that-not. All three are real measurements, so none can double as "no data". A source's "value not available" sentinel — AIS sends 102.3 for unknown speed, CoT sends 9999999, ADS-B sends 0 in a field that is otherwise offset by one — is translated, never passed through.
5. Every object states whether it is exercise data
source.synthetic is required and has no default. Mislabelling exercise data as live can
reach an operational picture; mislabelling live data as exercise hides it from an operator.
Neither direction is safe to guess, so the format makes someone state it.
6. Identity is derived, never drawn
entity_id is uuid5(namespace, system|external_id), so the tenth report about one emitter
updates one entity instead of creating a tenth. It is also what makes golden-output tests
possible at all: a derived id is deterministic, a drawn one makes every run differ from every
other run.
An adapter with no stable upstream identifier cannot fake one — it must record what it keyed on, and the harness prints that basis.
7. Time has one serialised form
RFC 3339 UTC, exactly three decimals, always Z. Two timestamps meaning the same instant must
compare equal as strings, because that is how they are compared in golden diffs and chain
hashes: ...:44Z, ...:44.0Z and ...:44.000000Z are one instant and three strings.
received_at comes from an injected clock; adapter code never calls datetime.now().
Where to go next
- Current contracts — the entry page for an implementer: version policy, validation levels and the semantic corpus, parser limits and the deployment envelope, evidence definitions and the support matrix, each routed to its normative text and its gate, with the figures rendered from the package.
- The four objects — one page each, with the reasoning behind the fields that look odd.
- JSON Schema Reference — generated from the published schemas, which is what a non-Python consumer validates against.
- Writing an Adapter — the tutorial, built on the PNTMAP reference adapter, with a real fixture and its real golden output side by side.
- SC-OES — the draft event-semantics layer that applies after translation, its thirteen governed types and its five conformance dimensions.
- The public boundary — what is published here, what is not, and the tests that hold the line.
- Changelog — what
schema_versionmeans and what has changed.
Installing it
The distribution is synapse-cdm — the import name is synapse_cdm, because a Python
identifier cannot carry a hyphen. It is on PyPI:
pip install synapse-cdm
1.0.0 was published on 2026-08-25 — ledger entry 5 of
PUBLICATION.md
— and every upload since is measured there as an entry of its own; the
changelog states which version this tree is at, and a test holds that sentence
to the package's own constant. Working on the CDM rather than with it is a different
command from a clone of the repository — pip install -e "packages/cdm[test]", which is a
contributor's install and is documented in
CONTRIBUTING.md.
A consumer never needs a clone.
Everything it needs comes with it. The nineteen shipped adapters' fixtures are part of the package, so the harness runs against the payloads those adapters are verified on without a repository anywhere:
python -m synapse_cdm.harness --adapter pntmap # no --fixtures: they came with the package
python -m synapse_cdm.schemas --out ./schemas # the six JSON Schemas, written on demand
The package depends on pydantic and jsonschema and nothing else. It contains no crypto:
the integrity field is designed and deliberately unpopulated, because a signature computed
inside a translator is held by nothing that audits it.