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;caseInsensitivedefaultstrue. 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:autoMatchByMapsToaligns constructs that share a semantic IRI, and the ODCS decoder lifts no field into that channel (authoritativeDefinitionsare 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
fieldMappingsentry requires a registeredtransformName— leave it off and the gate answersField transform '' is not registered.The registered vocabulary isSplitName,JoinName,CoerceStringToDateTime,CoerceDateTimeToString,CoerceIntegerToDouble, andCoerceDoubleToInteger; 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
CoerceIntegerToDoubleat an integer target and the gate answersTransform '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 success — success: 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
- Author once — derive or write the plan; read it; every heuristic is stamped with an
origin, every intended omission is aDropwith a reason. - Commit the artifacts — plan and guide live next to the contract they govern.
- Replay on change — CI calls
plan/execute; the gate catches drift; determinism keeps diffs meaningful; the ledger keeps residual cost auditable. - 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.