ODCS Over HTTP: The Transform Routes for Data Contracts
Direction first, because we publish it per format and it decides what you can build. The format key `odcs` appears in both of CoreModels' transform lists — decode (`jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm`) and encode (`jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse`). ODCS round-trips. Its neighbors do not all manage that: `odm` decodes only (ODM entities are authored documentation, and we do not generate prose), and `synapse` encodes only (its output is plain draft-07 JSON Schema — re-import it as `jsonschema`). What `odcs` means here is a Bitol Open Data Contract Standard v3 document — `apiVersion: v3.1.0`, `kind: DataContract` — as YAML or JSON.
ODCS Over HTTP: The Transform Routes for Data Contracts
Direction first, because we publish it per format and it decides what you can build. The format
key odcs appears in both of CoreModels' transform lists — decode (jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm) and encode (jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse). ODCS
round-trips. Its neighbors do not all manage that: odm decodes only (ODM entities are authored
documentation, and we do not generate prose), and synapse encodes only (its output is plain
draft-07 JSON Schema — re-import it as jsonschema). What odcs means here is a Bitol Open Data
Contract Standard v3 document — apiVersion: v3.1.0, kind: DataContract — as YAML or JSON.
Four routes matter for the format, all under
https://coremodels.example.com/graph/transform/... (no api/ prefix), all taking
Authorization: Bearer $TOKEN and Content-Type: application/json:
| # | Route | Role | Writes? |
|---|---|---|---|
| 1 | POST graph/transform/schema/import/{projectId} | Admin | yes |
| 2 | POST graph/transform/schema/export/{projectId} | Viewer | no |
| 3 | POST graph/transform/schema/map/{projectId} | Viewer (ai: Editor) | no |
| 4 | POST graph/transform/plan/execute/{projectId} | Viewer | no |
Every response uses one envelope — success, lossiness (a list of {kind, path, explanation}
records), errors, and a payload field (projectId, schema, and on the mapping routes plan).
success: true means the call ran; the ledger is where the honesty lives. There is also
schema/mapImport, which maps an incoming schema onto the project's existing model with a
dryRun flag — run the dry-run, read the ledger, then write.
1. Import: a contract becomes a governed model
The body is { "format": "odcs", "schema": "<the contract text>" }, plus an optional spaces
array. With the orders contract from our quickstart (one schema object, six properties):
jq -n --rawfile s orders.odcs.yaml '{ format: "odcs", schema: $s }' \
| curl -sS -X POST \
"https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @-
The project gains a Type orders with six Elements. Labels keep the wire names (order_id,
placed_at); internal node ids are camelCase alphanumerics (ordersOrderId,
ordersPlacedAt), because CoreModels ids must be. Logical types land as data types — string,
number → Double, integer, boolean, timestamp → DateTime — and each property's
required: true is persisted as a fact that survives a later export.
Now the honest scope statement, and it is the one thing to internalize about this route. During a
transform, everything else in the contract — the head (id, version, status, domain), the
physical names and types, primary-key markers, quality checks, the servers block — rides a
format-specific preservation channel inside the transform layer, which is what makes the stateless
round trip in route 3 exact. The project graph persists the structural model, not that channel. So
importing a contract gives you its shape under governance; it does not turn the project into a
byte-faithful archive of the contract document. Keep the contract file as the source of record and
treat the project as the semantic model. The response ledger tells you this at import time: the
decode-side entries for servers and quality arrive with the response.
2. Export: a model becomes a contract
The reverse needs only the format key — there are no ODCS-specific request options (vendor
belongs to sql, the synapse* keys to synapse):
curl -sS -X POST \
"https://coremodels.example.com/graph/transform/schema/export/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "format": "odcs" }'
ODCS is a text format, so schema in the response is a YAML string. The interesting part is the
contract head. The spec requires id, version, and status on every contract, and a CoreModels
project that never saw ODCS has none of them — so the encoder mints a deterministic, spec-valid
head: apiVersion: v3.1.0, kind: DataContract, the model's id as id, version: 1.0.0,
status: active. A model whose types and elements were built by hand exports like this:
apiVersion: v3.1.0
kind: DataContract
id: invoice
name: Invoice
version: 1.0.0
status: active
schema:
- name: invoice
properties:
- name: number
logicalType: string
required: true
- name: total
logicalType: number
That is a valid starting draft, not a publishable contract — the minted head says so
(version: 1.0.0, status: active are defaults, not decisions). There are no request keys on
this surface to set the contract id, version, or status; edit the head in review, which is where
contract identity belongs anyway. Model constructs ODCS cannot express are declared rather than
smuggled: a taxonomy (controlled list) has no ODCS construct, so an element referencing one
exports as a plain string property with a ConstraintRelaxation in the ledger naming the
taxonomy.
3. Map: modernize a legacy contract, statelessly
schema/map is the engine surface: decode, plan against a target hint, validate through the
universal gate, execute, encode — the project is never touched. Here is a real ODCS-to-ODCS use
for it: a contract written against the v2 line, mapped onto the shape of your governed v3
contract. The legacy document, legacy.odcs.yaml:
apiVersion: v2.2.1
kind: DataContract
id: legacy-orders
name: legacy_orders
version: 0.9.0
status: draft
schema:
- name: orders
properties:
- name: order_id
logicalType: string
required: true
- name: order_total
logicalType: number
required: true
- name: placed_at
logicalType: time
- name: legacy_flag
logicalType: geometry
With the governed orders.odcs.yaml from route 1 as the hint:
jq -n --rawfile s legacy.odcs.yaml --rawfile h orders.odcs.yaml '{
sourceFormat: "odcs",
sourceSchema: $s,
targetFormat: "odcs",
targetHintFormat: "odcs",
targetHintSchema: $h,
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 @-
The produced contract:
apiVersion: v3.1.0
kind: DataContract
id: legacy-orders
name: legacy_orders
version: 0.9.0
status: draft
schema:
- name: orders
properties:
- name: order_id
logicalType: string
required: true
- name: order_total
logicalType: number
required: true
- name: placed_at
logicalType: time
Three decisions are visible, and the seven-entry ledger accounts for every one (two of the seven
belong to the governed hint, whose own servers block and quality check travel the same decoder
as any source). The apiVersion
was normalized — a non-v3 version is decoded with v3 semantics and re-emitted as v3.1.0, and
both sides of that say so (SemanticNarrowing at apiVersion on decode and again on encode).
The head otherwise follows the source contract, not the hint: legacy-orders, 0.9.0,
draft are this contract's identity and they stay. placed_at kept its verbatim time — the
neutral model has no time-of-day primitive, so it was approximated as DateTime internally
(TypeApproximation, declared), but the encoder prefers the preserved wire type on re-emit. And
legacy_flag is gone, visibly: its geometry type drew a TypeApproximation on decode, and
because the governed hint has no counterpart, the plan neither mapped nor dropped it — so the
engine declared StructuralDrop at Type[orders].ordersLegacyFlag: "Element is a member of the
mapped type but no operation maps or drops it." Nothing vanished without a line item.
4. Replay: the plan is the artifact
Every schema/map response carries the executed plan. Store it; plan/execute runs it again —
same plan, same source, same output — with the plan passed as a string:
jq '.plan' map-result.json > modernize.plan.json
jq -n --rawfile s legacy.odcs.yaml --rawfile p modernize.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 @-
The stored plan passes the same validation gate as every strategy; if the source contract drifts — a renamed property, say — the gate fails loudly with the operation index and the id that no longer resolves, instead of quietly emitting a thinner contract.
Errors, exactly as they arrive
Failures come inside the envelope with a path and a message. The ODCS decoder's own vocabulary:
- Empty input —
$:The ODCS YAML document is empty. - A YAML scalar or list at the top level —
$:The document is valid YAML but is not an ODCS data contract (expected a mapping at the top level). - Broken YAML —
$:The document is not valid YAML: …with the parser's detail. - The wrong kind —
kind:The document's kind is 'CustomResource', not 'DataContract'; it is not an ODCS data contract. - A mapping with none of
kind,apiVersion, orschema—$:The document is valid YAML but is not an ODCS data contract (none of kind, apiVersion, or schema is present).
Note what is not an error: unknown keys, out-of-enum logical types, missing required head
fields, quality checks, relationships. The decoder never hard-fails on a construct it can preserve
with declared lossiness — that is a design commitment, because contracts in the wild carry
tooling-specific extensions. One last boundary: the data routes (data/import, data/export,
data/map) move records in json | csv | jsonld | sql | avro — a contract governs records, but
an ODCS document is never itself a record format.
The transform API documentation collects all ten endpoints, roles, and ready-to-paste bodies for every format.