Inside the MACH ODM Coder: What Maps, What Rides Along, What Gets Reported
A MACH Alliance Open Data Model entity document is five things wearing one Markdown file: an H1 that names the entity, an `## Entity purpose` section of prose, an `## Object` table with normative practice levels, a `## YAML Schema Definition` section holding the actual schema, and a `## Sample Object`. The CoreModels `odm` coder reads all five — and this article is the precise account of where each one lands, what travels as annotation rather than structure, which situations produce lossiness records, and why this format is the one place in our lineup where round-trip is deliberately not the goal.
Inside the MACH ODM Coder: What Maps, What Rides Along, What Gets Reported
A MACH Alliance Open Data Model entity document is five things wearing one Markdown file: an H1 that names the entity, an ## Entity purpose section of prose, an ## Object table with normative practice levels, a ## YAML Schema Definition section holding the actual schema, and a ## Sample Object. The CoreModels odm coder reads all five — and this article is the precise account of where each one lands, what travels as annotation rather than structure, which situations produce lossiness records, and why this format is the one place in our lineup where round-trip is deliberately not the goal.
A two-stage pipeline, and why there is no encoder
Most formats in CoreModels are symmetric coders: decode to our intermediate representation (IR), encode back out. ODM is deliberately different. The input is documentation Markdown — there is no meaningful "encode" back to prose — and the meaningful intermediate output is a JSON Schema document in its own right. So the pipeline runs in two stages:
- Markdown to JSON Schema 2020-12. A section parser extracts the fenced YAML blocks of
## YAML Schema Definition(tolerating###subsections), a structural translator rewrites them into one JSON Schema document, and the prose enriches it. - JSON Schema to IR. The produced document goes through our standard JSON Schema decoder, and the resulting IR schema is stamped with ODM provenance.
That is why odm is a decode-only format key: ODM entities are authored documentation, not a generated artifact. Export attempts are refused with a clear error listing the formats that do encode, and the intermediate JSON Schema is available on its own through the dedicated conversion endpoint.
The mapping table
| ODM document construct | Where it lands | Notes |
|---|---|---|
H1 last word (# ... `Customer`) | Entity name → schema title, root selection, $id | Backticks and punctuation stripped; falls back to the first schema name if absent |
## Entity purpose, first paragraph | Root description → IR schema description | Later paragraphs are not carried |
| YAML schema map matching the entity name | The JSON Schema root → the root IR Type | If no name matches, the first schema is the root |
| Other YAML schema maps | $defs entries → additional IR Types | e.g. PersonData, Address become Types alongside the entity |
properties | JSON Schema properties → IR Elements | Labels preserved exactly, including snake_case |
required lists | required → the Element's Required flag | Verified all the way into IR: Customer::id is required, Customer::person is not |
Inline enum | JSON Schema enum → an IR Taxonomy | Becomes a controlled list; onward encoders then decide what an enum costs (see below) |
type: array with items $ref | Collection cardinality on the Element | Customer::addresses decodes as a collection |
$ref: "#/components/schemas/X" | Rewired to #/$defs/X → an IR reference between Types | A self-reference to the entity becomes # |
pattern, maxLength, additionalProperties, format, default, bounds — and any keyword the translator does not know | Carried into the JSON Schema verbatim | The translator is a structural rewriter, not a whitelist |
## Object table practice column | Per-property x-odm-practice | Annotation, not structure — see next section |
First ## Sample Object JSON block | Root examples | Invalid JSON is skipped, with a ledger record |
Everything else (## Typical pitfalls, extra prose) | Not represented | Documentation sections with no schema content are ignored by design |
What rides as annotation
Three pieces of ODM meaning are real but not structural, and they travel as annotations rather than being forced into types:
- Practice levels. The
## Objecttable's third column is normative vocabulary — the coder recognizesMUST NOT,SHOULD NOT,MUST,SHOULD,RECOMMENDED,COULD, andOPTIONAL(case-insensitively, normalized to uppercase) and lands each on its property asx-odm-practice. A property absent from the table simply carries no practice annotation. Note that practice andrequiredare independent channels:requiredcomes from the YAML schema,x-odm-practicefrom the table, and the coder does not conflate them. - Source marking. Every converted document carries
x-odm-source: "machalliance/standards"and an$idof the formhttps://machalliance.org/odm/{kebab-cased-entity}.schema.json(Customer →customer, ProductType →product-type). - Provenance in the IR. The direct-compile path stamps the decoded schema with source format
odmand records the entity name under the dotted extras keyodm.entity— the same dotted-namespace convention every CoreModels coder uses for format-specific baggage. Downstream tooling can always answer "where did this Type come from?"
Quoting is meaning: the scalar rule
YAML is famously casual about types, and an entity document's schema blocks are hand-authored YAML. The coder's rule is conservative and worth knowing exactly: it parses through the YAML representation model — which preserves scalar quoting style — and only plain (unquoted) scalars are re-typed, and only when the value round-trips exactly (3 becomes the number 3; true becomes boolean). An explicitly quoted scalar always stays a string.
Why so careful? Consider:
status:
type: string
enum: ["1", "2", "3"]
default: "42"
An eager YAML-to-JSON conversion would re-type those as numbers — producing enum: [1, 2, 3] on a type: string property, a schema no valid instance can satisfy. The coder keeps them as the JSON strings the author wrote:
{
"type": "string",
"enum": ["1", "2", "3"],
"default": "42"
}
while a plain default: 3 on an integer property still comes through as the number 3. Fidelity here is not a nicety; it is the difference between a working schema and a self-contradictory one.
The lossiness inventory
The coder's failure philosophy has two channels, and the boundary between them is principled: errors mean the conversion could not proceed; lossiness means it proceeded and something changed. The complete inventory for ODM:
Reported as lossiness (conversion continues):
| Situation | Kind | What the record says |
|---|---|---|
$ref to an external or non-local target | SemanticNarrowing | The pointer is preserved verbatim but does not resolve within this document |
$ref to a name no YAML block defines | SemanticNarrowing | The pointer dangles — named at the exact path |
## Sample Object block that is not valid JSON | SemanticNarrowing | No examples were attached |
| In repo/batch mode: one file fails to convert | StructuralDrop | The file is named, the reason quoted, and the rest of the bundle proceeds |
Hard failures (the errors channel, success: false):
- An empty document.
- No
## YAML Schema Definitionsection — the document is not an ODM entity document, and the error says exactly that. - A section present but containing no fenced YAML blocks.
- A fenced block that is not valid YAML (the parser's message is passed through, block by block).
- YAML blocks that define no named schema maps.
Nothing is silently dropped in either channel: a construct either lands, rides as annotation, appears in the ledger, or stops the run with a path-carrying error.
Fidelity is one-way — and measurable
For a decode-only format, "round-trip" means something different: not ODM-to-ODM, but how much of the document survives into the IR and onward. The coder's tests pin this down. The decoded IR passes our IR validator clean. Required flags survive to the element level (PersonData::first_name is required three layers away from the Markdown that said so). Collections survive as cardinality. Supporting types survive as first-class Types, not inlined blobs. Constraints the IR itself does not model structurally — pattern, maxLength, additionalProperties: false — survive in the JSON Schema stage, which is itself a supported output.
Onward fidelity then becomes the target format's story, reported by the target's encoder in the same ledger. The classic example: an ODM status enum becomes an IR Taxonomy, and exporting to Postgres relaxes it to VARCHAR with a ConstraintRelaxation record — Postgres has no inline enum — while exporting to LinkML preserves it as a permissible-value enum with no record at all. Same source, different targets, and the ledger tells you the exact cost of each.
Edge cases worth knowing
These all come from the coder's test suite, and they are the behaviors you will eventually rely on:
- OpenAPI wrappers are unwrapped. An entity document that pastes a full
components: schemas:fragment works; the coder reaches inside. - First definition wins. The same schema name defined in two YAML blocks resolves to the first occurrence — later duplicates are ignored rather than merged unpredictably.
- Bundle keys are collision-safe. Batch conversion keys schemas by kebab-cased title; two entities that kebab to the same key get
-2,-3suffixes instead of overwriting each other. - Non-entity files are skipped, not failed. In batch mode, only documents containing a YAML Schema Definition section (matched case-insensitively) are treated as entities; a README passes through untouched. Only a batch where zero entities convert fails outright.
- Subsection headings are tolerated.
### Customer Schemaand### Supporting Type Definitionsinside the schema section are fine; the coder collects everyyamlfence in the section regardless of subheadings.
The design intent running through all of it: treat authored documentation with the same rigor as machine-generated schemas, promote exactly as much prose as has formal meaning, and put every judgment call on the record. That is what makes a standards document safe to build on.
For the format catalog, endpoint reference, and conversion quickstarts, see the CoreModels transform API guide in the product docs.