# MACH ODM — API: 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`:

````markdown
# 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 }`:

```bash
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.

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

```json
{
  "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:

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

```jsonc
{
  "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:

```bash
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.
