ODCS logoQuickstart

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.