MACH ODM over HTTP: One Converter, Two Importers, and an Honest "No Export"
Every schema format on the CoreModels transform surface declares its direction, and we hold ourselves to those declarations in public. The `odm` format — MACH Alliance Open Data Model entity documents — is **decode-only**: ODM entities are authored documentation, not a generated artifact, so there is no encode back to prose. Rather than paper over that, the HTTP surface is built around it. This article walks the complete set of routes that touch ODM, with real request and response bodies, the role each route requires, and exactly what happens when you try the direction that does not exist.
MACH ODM over HTTP: One Converter, Two Importers, and an Honest "No Export"
Every schema format on the CoreModels transform surface declares its direction, and we hold ourselves to those declarations in public. The odm format — MACH Alliance Open Data Model entity documents — is decode-only: ODM entities are authored documentation, not a generated artifact, so there is no encode back to prose. Rather than paper over that, the HTTP surface is built around it. This article walks the complete set of routes that touch ODM, with real request and response bodies, the role each route requires, and exactly what happens when you try the direction that does not exist.
The routes
All transform routes live under /graph/transform/... on your API host, require Authorization: Bearer $TOKEN, and are project-scoped. The ODM-relevant subset:
| Purpose | Method and path | Role |
|---|---|---|
| Convert ODM Markdown to JSON Schema 2020-12 (stateless) | POST /graph/transform/odm/convert/{projectId} | Viewer |
| Import ODM into the project (dry-run first) | POST /graph/transform/odm/import/{projectId} | Admin (dry-run: Viewer) |
Import any decodable format — including odm | POST /graph/transform/schema/import/{projectId} | Admin |
Export the project to a format — odm refused | POST /graph/transform/schema/export/{projectId} | Viewer |
| Map a schema source-to-target (stateless) | POST /graph/transform/schema/map/{projectId} | Viewer (ai mapping: Editor) |
| Replay a stored plan (stateless) | POST /graph/transform/plan/execute/{projectId} | Viewer |
Every response shares one envelope: success, lossiness (the honest list of what changed), errors (only when the call could not proceed), plus a payload key — schema, schemas, summary, or projectId depending on the route. success: true means "it ran," not "nothing changed"; the ledger is where the truth lives.
The running example is a small Product entity document saved as product.md:
# MACH Alliance Open Data Model: `Product`
## Entity purpose
A sellable good or service with the commerce attributes needed to present and trade it.
## Object: Product
| Field | Description | Practice |
|-------|-------------|----------|
| `sku` | Stock-keeping unit | MUST |
| `name` | Display name | MUST |
| `status` | Lifecycle status | SHOULD |
| `media` | Primary media asset | COULD |
## YAML Schema Definition
```yaml
Product:
type: object
required: [sku, name]
properties:
sku:
type: string
description: Stock-keeping unit
name:
type: string
status:
type: string
enum: [draft, live, retired]
media:
$ref: "#/components/schemas/Media"
Media:
type: object
properties:
url:
type: string
alt:
type: string
```
1. Convert: Markdown in, JSON Schema out
The converter is stateless — the {projectId} scopes authorization only, and Viewer suffices. The body is entities, an array of { name, markdown }:
jq -n --rawfile md product.md \
'{ entities: [ { name: "product", markdown: $md } ] }' \
| curl -s -X POST \
"https://coremodels.example.com/graph/transform/odm/convert/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @-
One entity returns "schema" — a JSON Schema 2020-12 document with the supporting Media schema under $defs, the practice levels as per-property x-odm-practice, and the entity-purpose paragraph as the root description. Several entities return "schemas" instead: a bundle keyed by kebab-cased entity title, in which a file that fails to convert is reported as a StructuralDrop in lossiness while the rest of the bundle proceeds. Send an empty body and you get a precise refusal: Body must include 'entities' — one or more { name, markdown } ODM entity documents.
2. Import: dry-run first, then write
odm/import takes the Markdown straight into a CoreModels project — Markdown to formal JSON Schema to intermediate representation to Types and Elements. The contract we recommend for any partner-facing standard: run the dry-run, read the lossiness, then import. The dry-run needs only Viewer; the write needs Admin.
jq -n --rawfile md product.md '{ markdown: $md, dryRun: true }' \
| curl -s -X POST \
"https://coremodels.example.com/graph/transform/odm/import/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @-
The dry-run response tells you what would be written, and what would not survive, without touching the graph:
{
"success": true,
"lossiness": [],
"errors": [],
"summary": { "types": 2, "elements": 6, "taxonomies": 1 },
"dryRun": true
}
Two Types (Product, Media), six Elements, and one taxonomy — the status enum becomes a controlled list. Flip to "dryRun": false (the default) with an Admin token and the same body writes the schema into the project, stamps its provenance as odm, and the response adds "projectId" with "dryRun": false. The optional spaces array targets specific space ids; empty means the project's main space.
The generic importer accepts ODM too — POST /graph/transform/schema/import/{projectId} with { "format": "odm", "schema": "<the markdown>" } behaves the same way, minus the dry-run ergonomics. Use the dedicated route when you want the summary-before-write contract; use the generic one when ODM is just one case in format-agnostic tooling.
3. Export: the direction that does not exist
POST /graph/transform/schema/export/{projectId} with { "format": "odm" } is refused — by design, with success: false and an Unknown schema format 'odm' error listing the formats that do encode: jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse. There is no generator that writes MACH entity documentation prose, and we would rather return a clear error than a fake document. If you want the ODM-derived model back out, export the project to any of those twelve — the Types and Elements your ODM import created come along like everything else.
4. Map: ODM as a source, with a target hint
The stateless mapping endpoint is where odm earns its keep as a source format. schema/map decodes the source, produces a plan (here by inference against a target hint), validates it through the universal gate, executes deterministically, and encodes the result. Suppose your catalog service already has a JSON Schema and you want the ODM Product mapped toward it:
jq -n --rawfile md product.md '{
sourceFormat: "odm",
sourceSchema: $md,
targetFormat: "jsonschema",
targetHintFormat: "jsonschema",
targetHintSchema: "{ \"title\": \"CatalogItem\", \"type\": \"object\", \"properties\": { \"sku\": { \"type\": \"string\" }, \"name\": { \"type\": \"string\" } }, \"required\": [\"sku\"] }",
mapping: { kind: "inferred", caseInsensitive: true }
}' \
| curl -s -X POST \
"https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @-
The response carries the transformed schema, the aggregated ledger (decode, plan, gate, execute, encode), and — the part to keep — the executed plan:
{
"success": true,
"lossiness": [ ... ],
"errors": [],
"schema": { ... },
"plan": { "operations": [ ... ] }
}
Nothing is written to the project; every schema/map call is inherently a dry run. The mapping object takes three kinds: inferred (label/type matching against the hint, caseInsensitive defaulting to true), explicit (an authored SIA mapping-guide JSON, where unknown keys are rejected with a path-carrying error), and ai (a server-side proposal validated by the exact same gate — it requires Editor or Admin membership because it spends the server's Anthropic budget, and it sends schema content to the Anthropic API server-side).
5. Replay: the plan is the artifact
Store the plan from a mapping call and you can replay it later without re-deriving anything. plan/execute takes the same source document, the plan as a JSON string, and the target format:
jq -n --rawfile md product.md --rawfile plan plan.json '{
sourceFormat: "odm",
sourceSchema: $md,
plan: $plan,
targetFormat: "jsonschema"
}' \
| curl -s -X POST \
"https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @-
The parsed plan passes through the same universal validation gate as every strategy — a stored plan earns no shortcut — then executes deterministically: same plan plus same source produces the same output, which is what makes the pair a reviewable, committable artifact.
The shape of the surface
Put together, the ODM story over HTTP is deliberately asymmetric: two ways in (odm/import with its dry-run contract, schema/import for format-agnostic tooling), one stateless converter for when you want the JSON Schema itself, full membership in the mapping engine as a source, and a flat, documented refusal on export. Directionality is a property of the format, not a gap in the product — and stating it plainly is part of the contract.
Ready-to-paste bodies for every format, the Postman walkthrough, and the full endpoint reference are in the CoreModels transform API guide in the product docs.