General logo

Data Contracts That Mean Something: ODCS In and Out

A data contract is a promise from a producer to its consumers: this dataset will have these properties, with these types, and these ones will always be present. The Open Data Contract Standard (ODCS) gives that promise an open, versioned YAML form — and CoreModels gives it something to be *true of*: a governed model that exports the contract, imports it, and audits the estate against it. Here is the full loop, with an honest ledger of what each hop can and cannot carry.

Data Contracts That Mean Something: ODCS In and Out

A data contract is a promise from a producer to its consumers: this dataset will have these properties, with these types, and these ones will always be present. The Open Data Contract Standard (ODCS) gives that promise an open, versioned YAML form — and CoreModels gives it something to be true of: a governed model that exports the contract, imports it, and audits the estate against it. Here is the full loop, with an honest ledger of what each hop can and cannot carry.

A contract nobody governs is a wish

ODCS — the Open Data Contract Standard, developed in the open by the Bitol project — formalizes the producer/consumer agreement as a versioned v3 YAML document: an identified, statused contract head, then a schema section that spells out every property's logical type and requiredness. It is a good spec, and to be clear about our position: CoreModels implements it. We read the published standard and built a coder that honors it; we are not speaking for the project or claiming a seat anywhere.

But a YAML file sitting in a repository enforces nothing. If the contract is not connected to a model of record — something with owners, roles, and change control — and not checked against the physical estate it describes, then it is not an agreement. It is documentation, aging quietly. In CoreModels, an ODCS contract is a projection of a governed model: it flows out of governance, back into it, onward into other formats, and the same governed model is what your warehouse and pipeline artifacts get audited against. That closed loop is what makes the promise mean something.

Out: a governed model as a spec-valid contract

Exporting a contract is one authenticated call to the transform surface. Export is a Viewer-role operation — reading a projection of the model requires no special authority:

curl -s "https://coremodels.example.com/graph/transform/schema/export/<project-id>" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "format": "odcs" }'

Replace coremodels.example.com with your CoreModels host; <project-id> is the 32-character hex project id. The response envelope is { success, lossiness, errors, schema }, and the schema field carries the contract. For a Customer model — an integer key and a name that are required, a signup timestamp, and a status field constrained to a three-value taxonomy — this is the actual coder output (the contract id falls back to the schema's internal id when the model preserves no contract id; a project export uses your project's id there):

apiVersion: v3.1.0
kind: DataContract
id: sia-schema
name: Customer
version: 1.0.0
status: active
schema:
  - name: Customer
    properties:
      - name: customer_key
        logicalType: integer
        required: true
      - name: full_name
        logicalType: string
        required: true
      - name: signup_date
        logicalType: timestamp
      - name: status
        logicalType: string

The head is the spec's required head, emitted in a deterministic order: apiVersion, kind, id, name when known, version (default 1.0.0), status (default active), with optional domain, tenant, dataProduct (deprecated since v3.1.0), and a description block carrying purpose, limitations, and usage. Two of those fields are worth pausing on:

  • apiVersion is pinned to v3.1.0, tracked against the published odcs-json-schema-v3.1.0.json in the bitol-io/open-data-contract-standard repository. This is not a "whatever YAML we felt like" version string; it names the exact spec revision the output conforms to.
  • kind is always DataContract — the only valid kind for an ODCS document, per the spec's enum.

The output YAML is hand-written by the encoder with deterministic key order, and it round-trips through our own decoder.

Now the honest part. That three-value status taxonomy from the governed model? It is not in the contract, because ODCS has no construct for it. The export comes back with two lossiness records saying exactly that: the taxonomy itself is not representable in ODCS, and the taxonomy reference on status was approximated as a plain string. Our documentation puts the rule bluntly: success: true does not mean "nothing changed" — it means "it ran." Always read lossiness. Constructs ODCS cannot express — taxonomies, inheritance, relations, components, standalone elements — are reported as lossiness rather than smuggled into the wire format as invented keys. A spec-valid contract stays spec-valid.

In: an existing contract into governance

Most teams do not start from zero — they already have contracts. Suppose one arrives with the sections real contracts have: quality rules, server bindings, a team block.

# customer-contract.odcs.yaml
apiVersion: v3.1.0
kind: DataContract
id: customer-contract
name: Customer
version: 1.0.0
status: active
description:
  purpose: Governed shape of the Customer dataset shared with analytics consumers.
schema:
  - name: Customer
    properties:
      - name: customer_key
        logicalType: integer
        required: true
      - name: full_name
        logicalType: string
        required: true
      - name: signup_date
        logicalType: timestamp
      - name: status
        logicalType: string
    quality:
      - type: sql
        query: SELECT COUNT(*) FROM Customer WHERE customer_key IS NULL
        mustBe: 0
servers:
  - server: analytics-prod
    type: postgres
    host: db.example.com
    database: analytics
team:
  - username: data-owner@example.com
    role: owner

Importing it is one call — but note the role. Import writes to the governed graph, so it is Admin-gated. Changing what a model means is a deliberate act by someone with the authority to make it, not a side effect of a pipeline:

jq -n --rawfile contract customer-contract.odcs.yaml \
  '{format: "odcs", schema: $contract}' \
| curl -s "https://coremodels.example.com/graph/transform/schema/import/<project-id>" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @-

The response is the same envelope with the projectId of the graph that now governs the contract's semantics: the Customer schema object with its typed, requirement-carrying properties is now a governed type you can audit against, export to eleven other formats, and change only through role-gated operations.

What survives structurally, what rides along, what gets reported

Three different things happen to a contract's content on the way in, and we think you should know which is which.

Survives structurally. The contract head and the schema section land as first-class model semantics. Property types map through the spec's logicalType enum (string, date, timestamp, time, number, integer, object, array, boolean): integer becomes Integer, number becomes Double, boolean becomes Boolean, and date/timestamp/time become DateTime — with time an approximation, and recorded as one. On the way back out, Integer emits integer, Double number, Boolean boolean, DateTime timestamp; rich text degrades to string, and that degradation is recorded too.

Rides along. Everything the coder does not model as graph semantics — the quality rules, the servers binding, the team block, and any other key the spec or your tooling adds — is preserved verbatim in namespaced odcs.* extras and re-emitted, content-intact, when you export again. Unknown ODCS keys can never break parsing. This is deliberate honesty in both directions: your quality rules are not lost, and we do not pretend the graph is governing them. They are cargo, carried carefully, and we tell you which is which rather than letting you assume everything in the file became enforced meaning.

Reported. Anything that had to bend gets a lossiness record — a { kind, path, explanation } triple, one of exactly four kinds: a structural drop, a type approximation, a constraint relaxation, or a semantic narrowing. One more example of the discipline: if a contract arrives carrying a non-v3 apiVersion, the exporter replaces it with v3.1.0 — and files a semantic-narrowing record saying so, instead of silently rewriting your document's claim about itself.

Transit proofs: the same contract as DDL and JSON Schema

A contract that can only be read by the tool that wrote it is another silo. So here is the transit test: take the exported ODCS contract and push it through the transformation engine into something a database or a validator can execute. Over MCP, this is one tool call. Connect any MCP client to the OAuth-protected endpoint — for Claude Code:

claude mcp add --transport http coremodels https://go.coremodels.io/mcp

(replace the host with your CoreModels host; spec-compliant clients discover the authorization server and run the PKCE flow automatically). Then call the transform_schema tool — a stateless, read-only Viewer tool, so the public /mcp endpoint is all you need. The default inferred mapping aligns source constructs against a target hint; for a pure format transit, the contract itself is the hint:

{
  "graphProjectId": "0123456789abcdef0123456789abcdef",
  "sourceFormat": "odcs",
  "sourceSchema": "apiVersion: v3.1.0\nkind: DataContract\nid: sia-schema\nname: Customer\nversion: 1.0.0\nstatus: active\nschema:\n  - name: Customer\n    properties:\n      - name: customer_key\n        logicalType: integer\n        required: true\n      - name: full_name\n        logicalType: string\n        required: true\n      - name: signup_date\n        logicalType: timestamp\n      - name: status\n        logicalType: string\n",
  "targetFormat": "sql",
  "vendor": "postgres",
  "targetHintFormat": "odcs",
  "targetHintSchema": "apiVersion: v3.1.0\nkind: DataContract\nid: sia-schema\nname: Customer\nversion: 1.0.0\nstatus: active\nschema:\n  - name: Customer\n    properties:\n      - name: customer_key\n        logicalType: integer\n        required: true\n      - name: full_name\n        logicalType: string\n        required: true\n      - name: signup_date\n        logicalType: timestamp\n      - name: status\n        logicalType: string\n"
}

The engine decodes the contract and re-encodes it as Postgres DDL:

CREATE TABLE "Customer" (
  "customer_key" INTEGER NOT NULL,
  "full_name" VARCHAR(255) NOT NULL,
  "signup_date" TIMESTAMP,
  "status" VARCHAR(255)
);

The contract's required: true became NOT NULL; its timestamp logical type became TIMESTAMP. Notice what did not need a lossiness record here: status arrives as a plain VARCHAR without an enum-relaxation entry, because the allowed-value constraint had already been honestly surrendered — and recorded — at the ODCS boundary. Ledgers compose; nothing gets lost twice in silence.

Change targetFormat to "jsonschema" (dropping vendor) and the same call returns the contract as a JSON Schema document: customer_key and full_name in the required array, and signup_date and status as plain strings — because the contract itself, not the engine, is now the source of truth about what survived.

Every one of these calls returns three things: the target schema, the lossiness ledger, and a replayable plan — a stored artifact that, given the same source, provably produces the same output. The identical engine sits behind the HTTP surface at POST graph/transform/schema/map/{projectId} if you would rather script it than say it: odcs is a first-class key among the engine's transform formats, both directions.

When the estate disagrees: drift surfaces in audits

Here is where governance stops being a metaphor. The governed model that projected your ODCS contract is the same model CoreModels audits the physical estate against — dbt manifests, warehouse information schemas, registry subjects. So when the warehouse quietly diverges from what the contract promises, it does not stay quiet. From CI, the audit is one API-key call (read-only — a Viewer key suffices, because the audit never writes):

dbt parse   # writes target/manifest.json; the audit needs no warehouse connection
jq -n --rawfile m target/manifest.json \
  '{artifacts: {manifest: $m}, recordHistory: true}' \
| curl -s "https://coremodels.example.com/v1/<project-id>/integrations/dbt/audit" \
    -H "Authorization: Bearer $API_KEY" \
    -H "Content-Type: application/json" \
    -d @-

The response nests counts under data.*, and the gate is one line: data.errorCount > 0 means the change violates governed meaning — fail the build. Drift findings name exactly what moved:

{ "section": "Drift", "severity": "Error", "code": "field-type-drift",
  "subject": "model.jaffle_shop.orders.order_total",
  "message": "Field type drifted from the governed model.",
  "detail": "governed: Double, artifact: VARCHAR" }

The drift family covers the ways contracts rot in practice: contract-drift, field-type-drift, field-removed, dataset-removed, enum-narrowed, enum-widened. And divergence runs both ways: the reaudit verb re-checks the estate snapshot stored at import time against the current governed model, so when the contract side changes, you find out what the estate no longer satisfies — no fresh artifacts required. Recorded runs feed a history trail and an embeddable SVG status badge, so "are we honoring our contracts?" has an answer you can see from a README.

A contract you can export, import, transform, and continuously audit is an agreement. Everything less is a wish.

To try it against your own contracts, start with the CoreModels docs and the per-vendor quickstarts.