ODCS logoAPI

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:

#RouteRoleWrites?
1POST graph/transform/schema/import/{projectId}Adminyes
2POST graph/transform/schema/export/{projectId}Viewerno
3POST graph/transform/schema/map/{projectId}Viewer (ai: Editor)no
4POST graph/transform/plan/execute/{projectId}Viewerno

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, or schema$: 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.