ODCS logoAutomation

The Contract Pipeline Is a Plan File: Automating ODCS With Replay

Automation is where schema tooling usually stops being honest. A conversion that a human runs once gets its output eyeballed; the same conversion in a nightly job gets trusted. So the two properties that matter most for automating data contracts are not features, they are guarantees: CoreModels transforms are **deterministic** — the same contract in produces byte-identical output every run, which makes diffs a real review surface — and every mapping executes from a **plan** that comes back in the response as JSON you can commit, review, and replay. This article builds an ODCS pipeline on those guarantees: a published subset of an internal contract, regenerated on every change, with drift caught by the engine instead of by a consumer.

The Contract Pipeline Is a Plan File: Automating ODCS With Replay

Automation is where schema tooling usually stops being honest. A conversion that a human runs once gets its output eyeballed; the same conversion in a nightly job gets trusted. So the two properties that matter most for automating data contracts are not features, they are guarantees: CoreModels transforms are deterministic — the same contract in produces byte-identical output every run, which makes diffs a real review surface — and every mapping executes from a plan that comes back in the response as JSON you can commit, review, and replay. This article builds an ODCS pipeline on those guarantees: a published subset of an internal contract, regenerated on every change, with drift caught by the engine instead of by a consumer.

The job

The internal contract, orders.odcs.yaml — six properties, of which customer_ref must never appear in the published edition:

apiVersion: v3.1.0
kind: DataContract
id: urn:datacontract:sales:orders
name: orders
version: 1.2.0
status: active
domain: sales
description:
  purpose: Governed view of confirmed customer orders.
  usage: Analytics and settlement reporting.
servers:
  - server: analytics-pg
    type: postgres
    host: db.internal.example
    port: 5432
schema:
  - name: orders
    physicalName: orders_v1
    physicalType: table
    logicalType: object
    description: One row per confirmed order.
    properties:
      - name: order_id
        logicalType: string
        physicalType: uuid
        primaryKey: true
        primaryKeyPosition: 1
        required: true
        unique: true
      - name: customer_ref
        logicalType: string
        required: true
      - name: order_total
        logicalType: number
        physicalType: numeric(12,2)
        required: true
        quality:
          - metric: nullValues
            mustBe: 0
      - name: placed_at
        logicalType: timestamp
        required: true
      - name: item_count
        logicalType: integer
      - name: gift
        logicalType: boolean

The plan is a file you can write

You can derive a plan by calling schema/map with a mapping strategy — but a plan is just JSON, and for a redaction this small the clearest artifact is one you author directly and keep in the repo. publish.plan.json:

{
  "operations": [
    { "kind": "TypeMapping", "origin": "Inferred", "sourceTypeId": "orders",
      "targetTypeId": "orders", "targetLabel": "orders" },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ordersOrderId"], "targetElementIds": ["ordersOrderId"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ordersOrderTotal"], "targetElementIds": ["ordersOrderTotal"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ordersPlacedAt"], "targetElementIds": ["ordersPlacedAt"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ordersItemCount"], "targetElementIds": ["ordersItemCount"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ordersGift"], "targetElementIds": ["ordersGift"] },
    { "kind": "Drop", "origin": "Explicit", "sourceNodeId": "ordersCustomerRef",
      "sourceKind": "Element",
      "reason": "customer_ref is not published to the analytics contract." }
  ]
}

The ids are the engine's element ids — the object name plus the camelCased property name (ordersOrderId for orders.order_id). Every mapping is explicit; the one omission is a Drop operation carrying its reason inside the artifact. A reviewer of this file in a pull request sees what will happen to every property before it happens to any of them. That is the entire point: human judgment lands once, visibly, instead of on every run, invisibly.

Replay is the pipeline

plan/execute runs a stored plan — the plan travels as a string, hence --rawfile:

jq -n --rawfile s orders.odcs.yaml --rawfile p publish.plan.json '{
  sourceFormat: "odcs",
  sourceSchema: $s,
  plan: $p,
  targetFormat: "odcs"
}' \
| curl -sS -X POST \
    "https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    --data-binary @- > publish-result.json

The produced contract, jq -r '.schema' publish-result.json:

apiVersion: v3.1.0
kind: DataContract
id: urn:datacontract:sales:orders
name: orders
version: 1.2.0
status: active
domain: sales
description:
  purpose: Governed view of confirmed customer orders.
  usage: Analytics and settlement reporting.
schema:
  - name: orders
    logicalType: object
    physicalName: orders_v1
    physicalType: table
    description: One row per confirmed order.
    properties:
      - name: order_id
        logicalType: string
        physicalType: uuid
        required: true
        unique: true
        primaryKey: true
        primaryKeyPosition: 1
      - name: order_total
        logicalType: number
        physicalType: numeric(12,2)
        required: true
        quality: [{"metric":"nullValues","mustBe":0}]
      - name: placed_at
        logicalType: timestamp
        required: true
      - name: item_count
        logicalType: integer
      - name: gift
        logicalType: boolean
servers: [{"server":"analytics-pg","type":"postgres","host":"db.internal.example","port":5432}]

No customer_ref; everything else intact — head, physical facts, the quality check, the servers block. Run it twice and the outputs are identical. The ledger carries the decode-side entries (servers and quality preserved verbatim, declared as SemanticNarrowing); the drop itself is not a surprise to look for in the ledger, because it is the second thing anyone reading the committed plan sees. Two artifacts, two jobs: the plan states intent, the ledger states cost.

Replay stays safe because a stored plan earns no shortcut: every execution passes the same universal validation gate as a freshly derived plan. If someone renames customer_ref upstream, the next run fails with the operation index and the id that stopped resolving — a drift alarm delivered by the same call that does the work. And an unmapped property is never silently lost on inferred plans either: a source property no operation maps or drops produces StructuralDrop — "Element is a member of the mapped type but no operation maps or drops it" — with the type and element named.

Choosing a strategy when you do derive plans

schema/map's mapping.kind (and the MCP tool's mappingKind) offers three authoring routes, and ODCS changes the calculus a little:

  • inferred — label/type matching against a target hint; caseInsensitive defaults true. It requires a hint (without one: The inference resolver requires a target IR to match against.), and for contract-to-contract narrowing — legacy shape onto governed shape — it is usually all you need.
  • explicit — an authored guide: autoMatchByMapsTo, fieldMappings ({sourceElementIds, targetElementIds, transformName}), taxonomyDirectives, drops. One ODCS-specific caveat: autoMatchByMapsTo aligns constructs that share a semantic IRI, and the ODCS decoder lifts no field into that channel (authoritativeDefinitions are preserved verbatim, not read as mapping IRIs) — so for ODCS sources, matching comes from labels and authored entries, not IRIs.
  • ai — a server-side Claude proposer drafts the plan; the identical gate accepts or rejects it, one repair attempt maximum. It needs a server-held Anthropic key and Editor/Admin membership, and it sends the schema content to the Anthropic API. Use it to draft a gnarly first mapping, then freeze the returned plan and never call the proposer in the pipeline.

Whichever route authored it, the plan is what you commit. Strategies are for authoring; pipelines replay.

The gate reads your guide before your pipeline does

Three rejections worth meeting in development rather than in CI, each arriving in the envelope with a path:

  • A typo'd guide key is refused, not skipped: guide.dropz: Unknown mapping-guide key 'dropz'. Known keys: autoMatchByMapsTo, fieldMappings, taxonomyDirectives, drops. A silently ignored typo would execute a plan you did not intend.
  • A fieldMappings entry requires a registered transformName — leave it off and the gate answers Field transform '' is not registered. The registered vocabulary is SplitName, JoinName, CoerceStringToDateTime, CoerceDateTimeToString, CoerceIntegerToDouble, and CoerceDoubleToInteger; for plain 1:1 renames against a hint, prefer inferred matching or a reviewed hand-written plan.
  • Transforms are type-checked against the target, not taken on faith: point CoerceIntegerToDouble at an integer target and the gate answers Transform 'CoerceIntegerToDouble' produces output 0 of type Double, but target element 'ordersItemCount' is Integer.

Fan-out, gated on the ledger

The same determinism makes downstream artifacts regenerable and diffable. One loop produces the JSON Schema and DDL editions of every contract in a directory:

mkdir -p out
for f in contracts/*.odcs.yaml; do
  name=$(basename "$f" .odcs.yaml)
  for target in jsonschema sql; do
    jq -n --rawfile s "$f" --arg t "$target" '{
      sourceFormat: "odcs", sourceSchema: $s,
      targetFormat: $t, vendor: "postgres",
      targetHintFormat: "odcs", targetHintSchema: $s,
      mapping: { kind: "inferred" }
    }' \
    | curl -sS -X POST \
        "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
        -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        --data-binary @- > "out/$name.$target.result.json"
  done
done

Then gate on lossiness kinds, not on successsuccess: true only means the transform ran. For contract fan-out, SemanticNarrowing entries for preserved servers/quality sections are expected steady state; a StructuralDrop or TypeApproximation is news:

fail=0
for r in out/*.result.json; do
  bad=$(jq '[.lossiness[] | select(.kind == "StructuralDrop" or .kind == "TypeApproximation")] | length' "$r")
  if [ "$bad" -gt 0 ]; then
    echo "REVIEW: $r"
    jq -r '.lossiness[] | "  [\(.kind)] \(.path): \(.explanation)"' "$r"
    fail=1
  fi
done
exit $fail

A property someone types as time (approximated as DateTime, declared) or an out-of-enum logicalType trips this gate the day it lands in the contract, not the day a consumer notices.

The steady state

  1. Author once — derive or write the plan; read it; every heuristic is stamped with an origin, every intended omission is a Drop with a reason.
  2. Commit the artifacts — plan and guide live next to the contract they govern.
  3. Replay on change — CI calls plan/execute; the gate catches drift; determinism keeps diffs meaningful; the ledger keeps residual cost auditable.
  4. Regenerate deliberately — when the contract truly evolves, re-derive and diff the plan: a smaller, more honest object than a diff of everything generated from it.

The transform API documentation specifies the schema/map and plan/execute request shapes, the guide grammar, and the validation gate's contract.