One Contract, Three Schemas: An ODCS Quickstart
A data contract is a promise with a schema inside it. The ODCS documents your platform team publishes — Open Data Contract Standard, the Bitol project's v3 line — carry tables and columns, but also servers, quality checks, SLAs, ownership: everything the promise needs and most target formats cannot hold. This quickstart takes one real contract through a single CoreModels call three times — to JSON Schema, to SQL, and back to ODCS — and shows you how to read what each conversion kept, what it set aside, and where the set-aside parts went.
One Contract, Three Schemas: An ODCS Quickstart
A data contract is a promise with a schema inside it. The ODCS documents your platform team publishes — Open Data Contract Standard, the Bitol project's v3 line — carry tables and columns, but also servers, quality checks, SLAs, ownership: everything the promise needs and most target formats cannot hold. This quickstart takes one real contract through a single CoreModels call three times — to JSON Schema, to SQL, and back to ODCS — and shows you how to read what each conversion kept, what it set aside, and where the set-aside parts went.
The endpoint is stateless: it decodes, maps, encodes, and returns everything in one response.
Nothing is written anywhere. You need the API host (https://coremodels.example.com throughout), a
bearer token in $TOKEN, a project id in $PROJECT_ID (32 hex characters; it scopes authorization
only, and Viewer is enough), plus curl and jq.
The contract
Save this as orders.odcs.yaml. It is a compact but honest v3.1.0 contract: a head with identity
and purpose, one schema object with six properties, a primary key, a physical type, a quality
check, and a servers block — the parts of ODCS that behave differently from a plain schema.
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 format key is odcs, and it works in both directions: CoreModels decodes ODCS and encodes
ODCS, so the same key is valid as a source and as a target. (Not every format is like that — odm
decodes only, synapse encodes only.)
The call
The default mapping strategy, inferred, matches the source against a target hint and declines
rather than guesses when there is none. For a pure format conversion, pass the source as its own
hint; that yields an identity plan and lets the target coder do the format work. The contract
travels inside JSON as a string, so build the body with jq --rawfile:
jq -n --rawfile s orders.odcs.yaml '{
sourceFormat: "odcs",
sourceSchema: $s,
targetFormat: "jsonschema",
targetHintFormat: "odcs",
targetHintSchema: $s,
mapping: { kind: "inferred" }
}' > request.json
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 @request.json > result.json
The response is one envelope: success, the produced schema, the executed plan, and the
lossiness ledger.
Reading the schema
jq '.schema' result.json — for a JSON-shaped target it is a JSON object:
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"customer_ref": { "type": "string" },
"order_total": { "type": "number" },
"placed_at": { "type": "string" },
"item_count": { "type": "integer" },
"gift": { "type": "boolean" }
},
"required": ["order_id", "customer_ref", "order_total", "placed_at"]
}
Each ODCS property became a JSON Schema property, its logicalType mapped across: string to
string, number to number, integer to integer, boolean to boolean, timestamp to a
string-carried date-time. The contract's own required: true flags became the required array —
ODCS declares requiredness explicitly per property, and that declaration is what travels. Notice
what is not here: no uuid, no numeric(12,2), no primary key, no quality check. Physical
facts and contract governance have no JSON Schema slots. Where did they go? That is the ledger's
job.
Reading the plan
jq '.plan.operations | length' result.json returns 7 — one type mapping and six element
mappings. The first:
{
"kind": "TypeMapping",
"origin": "Inferred",
"sourceTypeId": "orders",
"targetTypeId": "orders",
"targetLabel": "orders"
}
origin: "Inferred" means the match came from labels and types — a heuristic, marked as one. The
element ids are worth a glance too: ordersOrderId, ordersPlacedAt. CoreModels ids are
camelCase alphanumerics composed from the object and property names, while the original
order_id-style names are kept as labels and re-emitted on output. Keep the plan: it is a
replayable artifact, and POST /graph/transform/plan/execute/{projectId} will run it again
against the same source for the same result.
Reading the ledger
jq '.lossiness | unique' result.json — the raw list holds four records here, because the target
hint is the same contract and is decoded through the same path, so its two entries appear again;
unique collapses the duplicated pair:
[
{ "kind": "SemanticNarrowing", "path": "$",
"explanation": "Contract section(s) the IR does not model structurally not modeled by the IR: servers; preserved verbatim under odcs.raw.* for exact re-emit." },
{ "kind": "SemanticNarrowing", "path": "schema[0].properties[2].quality",
"explanation": "Quality checks are not structurally modeled by the IR; they were preserved verbatim under odcs.quality for exact re-emit." }
]
Here is the single most useful thing to understand about converting data contracts: a perfectly
clean contract still produces ledger entries, because a contract carries more than a schema. The
servers block and the quality check on order_total have no structural home in the neutral
model the engine works over, so each is preserved verbatim in a format-specific channel and
declared. Nothing was dropped silently; nothing was invented. success: true means "it ran" —
the ledger is the part you actually review. The four kinds you will meet are StructuralDrop,
TypeApproximation, ConstraintRelaxation, and SemanticNarrowing.
Second target: SQL
Change two keys in the request — targetFormat: "sql", add vendor: "postgres" — and run the
same call. The schema field is now a string of DDL:
CREATE TABLE "orders" (
"order_id" VARCHAR(255) NOT NULL,
"customer_ref" VARCHAR(255) NOT NULL,
"order_total" NUMERIC NOT NULL,
"placed_at" TIMESTAMP NOT NULL,
"item_count" INTEGER,
"gift" BOOLEAN
);
Requiredness became NOT NULL; logical types drove the column types. Look closely at
order_total: the contract says physicalType: numeric(12,2), but the column is plain NUMERIC.
The DDL is generated from the logical type — number — because the physical annotation is an
ODCS fact riding the preservation channel, not a portable type. The same ledger entries appear
again, and the quality check (nullValues mustBe 0) is one of them: the generated table enforces
NOT NULL, but the contract's quality rule is not a SQL constraint here, and the ledger is your
prompt to wire it into whatever checks that database runs.
Third target: ODCS itself
Now set targetFormat: "odcs" and run once more. This is the round trip, and it is where the
preservation channel pays out:
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: 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
servers: [{"server":"analytics-pg","type":"postgres","host":"db.internal.example","port":5432}]
Everything is back: the contract head with its id, version, and status; the physical names and
types; the primary-key markers; the quality check; the servers block, re-emitted in JSON flow
style (JSON is a YAML subset, so this is still a valid v3.1.0 contract). Key order and quoting are
deterministic — run the call five times and you get one distinct output — and feeding this output
back in reproduces it exactly. The ledger still shows the same entries, because they describe
how the trip was made, not a failure of it.
Where to go next
If the contract should become a governed CoreModels model rather than a converted file, POST /graph/transform/schema/import/{projectId} with { "format": "odcs", "schema": "<the YAML>" }
writes its objects and properties into a project — that route writes, so it needs Admin. If an AI
agent should do this instead of a script, the same engine is exposed over MCP as the
transform_schema tool with the same argument names (the mapping object flattens to a
mappingKind argument). The transform section of the CoreModels documentation lists every
endpoint, role, and format key.