# MACH ODM — Deep dive: 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:

1. **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.
2. **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 `## Object` table's third column is normative vocabulary - the coder recognizes `MUST NOT`, `SHOULD NOT`, `MUST`, `SHOULD`, `RECOMMENDED`, `COULD`, and `OPTIONAL` (case-insensitively, normalized to uppercase) and lands each on its property as `x-odm-practice`. A property absent from the table simply carries no practice annotation. Note that practice and `required` are independent channels: `required` comes from the YAML schema, `x-odm-practice` from the table, and the coder does not conflate them.
- **Source marking.** Every converted document carries `x-odm-source: "machalliance/standards"` and an `$id` of the form `https://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 `odm` and records the entity name under the dotted extras key `odm.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:

```yaml
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:

```json
{
  "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 Definition` section - 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`, `-3` suffixes 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 Schema` and `### Supporting Type Definitions` inside the schema section are fine; the coder collects every `yaml` fence 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.
