MACH ODM logoQuickstart

Your First MACH ODM Transform: Markdown In, JSON Schema Out

The MACH Alliance publishes its Open Data Model (ODM) as entity documentation: Markdown files with prose, a field table, embedded YAML schema definitions, and sample objects. That is a great format for humans and a frustrating one for machines — you cannot validate a payload against a Markdown page.

Your First MACH ODM Transform: Markdown In, JSON Schema Out

The MACH Alliance publishes its Open Data Model (ODM) as entity documentation: Markdown files with prose, a field table, embedded YAML schema definitions, and sample objects. That is a great format for humans and a frustrating one for machines — you cannot validate a payload against a Markdown page.

CoreModels closes that gap with one HTTP call. The odm schema format decodes a MACH ODM entity document exactly as published, and the dedicated conversion endpoint turns it into a formal JSON Schema 2020-12 document — with every constraint carried over, the prose promoted into the schema, and an explicit ledger of anything that could not be represented. This quickstart takes you from a raw entity document to a usable JSON Schema in about five minutes.

What you need

  • A CoreModels API host (we use https://coremodels.example.com as the placeholder throughout).
  • A bearer token in $TOKEN for a user with at least Viewer access to a project.
  • A project id in $PROJECT_ID. The conversion endpoint is stateless — the project only scopes authorization; nothing is read from or written to the graph.

That is the whole setup. Viewer is enough because we are converting, not importing.

The input: a real ODM entity document

Save the following as customer.md. It has the structure every ODM entity document shares: an H1 naming the entity, an ## Entity purpose section, an ## Object table with MUST/SHOULD/COULD practice levels, a ## YAML Schema Definition section with fenced YAML blocks, and a ## Sample Object.

# MACH Alliance Open Data Model: `Customer`

## Entity purpose

A unified customer entity representing an individual or organization interacting with the business.

## Object: Customer

| Field | Description | Practice |
|-------|-------------|----------|
| `id` | Unique customer identifier | MUST |
| `status` | Lifecycle status | SHOULD |
| `person` | Person data for individual customers | COULD |

## YAML Schema Definition

### Customer Schema

```yaml
Customer:
  type: object
  required:
    - id
    - status
  properties:
    id:
      type: string
      description: Unique customer identifier
    status:
      type: string
      enum: [active, inactive, archived]
    person:
      $ref: "#/components/schemas/PersonData"
    addresses:
      type: array
      items:
        $ref: "#/components/schemas/Address"
```

### Supporting Type Definitions

```yaml
PersonData:
  type: object
  required: [first_name]
  properties:
    first_name:
      type: string
    last_name:
      type: string
Address:
  type: object
  additionalProperties: false
  properties:
    line1:
      type: string
    country:
      type: string
      pattern: "^[A-Z]{2}$"
      maxLength: 2
```

## Sample Object: Minimal customer

```json
{
  "id": "cust-001",
  "status": "active"
}
```

The call

The endpoint is POST /graph/transform/odm/convert/{projectId}. The body is entities — one or more { name, markdown } documents. Because the Markdown has to travel as a JSON string, the cleanest way to build the body is jq --rawfile:

jq -n --rawfile md customer.md \
  '{ entities: [ { name: "customer", 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 in means one schema out, under the schema key. (Send several entities and you get schemas instead — a bundle keyed by kebab-cased entity name. That is the batch story, and it deserves its own article.)

The output

{
  "success": true,
  "lossiness": [],
  "errors": [],
  "schema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://machalliance.org/odm/customer.schema.json",
    "title": "Customer",
    "description": "A unified customer entity representing an individual or organization interacting with the business.",
    "type": "object",
    "required": ["id", "status"],
    "properties": {
      "id": {
        "type": "string",
        "description": "Unique customer identifier",
        "x-odm-practice": "MUST"
      },
      "status": {
        "type": "string",
        "enum": ["active", "inactive", "archived"],
        "x-odm-practice": "SHOULD"
      },
      "person": {
        "$ref": "#/$defs/PersonData",
        "x-odm-practice": "COULD"
      },
      "addresses": {
        "type": "array",
        "items": { "$ref": "#/$defs/Address" }
      }
    },
    "x-odm-source": "machalliance/standards",
    "$defs": {
      "PersonData": {
        "type": "object",
        "required": ["first_name"],
        "properties": {
          "first_name": { "type": "string" },
          "last_name": { "type": "string" }
        }
      },
      "Address": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "line1": { "type": "string" },
          "country": {
            "type": "string",
            "pattern": "^[A-Z]{2}$",
            "maxLength": 2
          }
        }
      }
    },
    "examples": [
      { "id": "cust-001", "status": "active" }
    ]
  }
}

Walk through what happened:

  • The entity schema became the document root. The converter picks the YAML schema whose name matches the entity named in the H1 (Customer), and the supporting schemas (PersonData, Address) moved under $defs.
  • $ref pointers were rewired. #/components/schemas/PersonData became #/$defs/PersonData, so the document resolves against itself. A reference back to the entity itself would become #.
  • Every constraint survived verbatim. required, the enum, pattern: "^[A-Z]{2}$", maxLength: 2, additionalProperties: false — the translator is a structural rewriter, not a whitelist, so keywords it has never heard of are carried over too.
  • The prose was promoted into the schema. The first paragraph of ## Entity purpose became the root description. The practice column of the ## Object table landed per-property as x-odm-practice (MUST, SHOULD, COULDaddresses has none because it is not in the table). The first ## Sample Object JSON block became examples.
  • Provenance is stamped. x-odm-source: "machalliance/standards" and the $id derived from the kebab-cased entity name mark where this schema came from.

Read the lossiness ledger — every time

The lossiness array is the part of the response we most want you to internalize. success: true means the conversion ran; it does not mean nothing changed. Anything the output could not carry exactly is recorded as { kind, path, explanation }.

This document converts clean, so the ledger is empty. Here is what would populate it:

  • A $ref pointing outside the document (an external URL, or a target no YAML block defines) is preserved verbatim but reported as SemanticNarrowing — the pointer dangles, and the ledger says so at the exact path.
  • A ## Sample Object block that is not valid JSON is skipped, with a SemanticNarrowing record explaining that no examples were attached.

Hard failures are different: a document with no ## YAML Schema Definition section at all, or with malformed YAML inside it, comes back as success: false with a path-carrying entry in errors. Broken input never silently produces an empty schema.

Where this goes next

odm is a first-class source format across the whole transform surface, with one honest limit: it is decode-only. ODM entities are authored documentation, so there is nothing meaningful to encode back to — you will never see odm as an export target. But as a source, the same document you just converted can go anywhere the engine goes: POST /graph/transform/schema/import/{projectId} with "format": "odm" writes it into a CoreModels project as Types and Elements, POST /graph/transform/odm/import/{projectId} does the same with a dry-run-first contract, and the stateless mapping endpoint accepts "sourceFormat": "odm" on the way to SQL DDL, Avro, LinkML, or any other encode format — always with the same lossiness ledger attached.

For the full endpoint catalog, request bodies for every format, and the rest of the quickstarts, see the CoreModels transform API guide in the product docs.