Anatomy of the ODCS Coder: What Maps, What Rides, What's Declared
Where does `servers:` go?
Anatomy of the ODCS Coder: What Maps, What Rides, What's Declared
Where does servers: go?
It is the right first question for any tool that claims to convert data contracts. An ODCS document is two things braided together: a schema (objects, properties, types, keys) and a contract around it (servers, quality, SLAs, team, pricing). CoreModels converts every format through one neutral intermediate representation (IR), and the IR models schemas — what SQL, Avro, LinkML, and JSON Schema can agree on. So the coder splits three ways: constructs the IR models structurally, facts that ride alongside for exact re-emit, and everything that gets declared in the lossiness ledger. This is the complete anatomy, in that order.
The structural mapping
Import is decode (ODCS YAML or JSON → IR; JSON is a YAML subset, so one parser serves both). Export is encode (IR → ODCS YAML). The spine:
| ODCS construct | IR construct | Notes |
|---|---|---|
| the contract document | the schema node | head fields ride schema-level extras |
schema[] object | Type (label = name) | ids sanitized to camelCase alphanumerics |
| property | Element (id = objectId + PropertyName) | label keeps the wire name |
logicalType: string | String | |
logicalType: number | Double | |
logicalType: integer | Integer | |
logicalType: boolean | Boolean | |
date / timestamp | DateTime | |
time | DateTime + declared approximation | the IR has no time-of-day primitive |
required: true | Required | ODCS's own flag is authoritative |
logicalType: array | collection cardinality | bounds from logicalTypeOptions.minItems/maxItems |
| object property / object array items | child Type + type reference | re-nested on encode |
primaryKey, unique, partitioned | extras, not structure | see below |
Two rows deserve fine print. Names: CoreModels node ids must be alphanumeric, so txn_ref_dt on
object tbl becomes element id tblTxnRefDt while the label stays txn_ref_dt and the emitted
contract uses the original name; id collisions (two objects both named a) are suffixed (a,
a2) rather than merged. Collection bounds: minItems/maxItems become real IR cardinality
bounds — they travel into formats that can hold them (JSON Schema output gets "minItems": 1, "maxItems": 100) — and a nonsensical pair (minItems above maxItems) is ignored rather than
propagated.
And the opening question: servers — with team, roles, slaProperties, support, price,
and any key the coder does not recognize at all — is captured verbatim as JSON under
odcs.raw.<key>, declared once in the ledger, and re-emitted exactly when the target is ODCS. A
decode never hard-fails on a construct it can preserve.
The extras channel
Every IR node carries an annotation bag; the ODCS coder writes the facts the IR cannot express
into dotted odcs.* keys. The namespace, grouped:
| Keys | Where | What they hold |
|---|---|---|
odcs.apiVersion, odcs.id, odcs.name, odcs.version, odcs.status, odcs.domain, odcs.tenant, odcs.dataProduct | schema | the contract head |
odcs.description.purpose / .limitations / .usage, odcs.description.raw.* | schema | the description block, unknown description keys verbatim |
odcs.stableId, odcs.businessName, odcs.physicalName, odcs.physicalType, odcs.description, odcs.dataGranularityDescription | type/element | identity and physical facts |
odcs.logicalType, odcs.logicalTypeOptions, odcs.itemsLogicalType, odcs.items.raw.* | element (odcs.logicalType also on type) | the verbatim wire types and options |
odcs.primaryKey, odcs.primaryKeyPosition, odcs.unique, odcs.partitioned, odcs.partitionKeyPosition, odcs.classification, odcs.encryptedName, odcs.criticalDataElement | element | key, partition, and sensitivity markers |
odcs.transformLogic, odcs.transformDescription, odcs.transformSourceObjects, odcs.examples | element | provenance and samples |
odcs.tags, odcs.customProperties, odcs.authoritativeDefinitions, odcs.quality, odcs.relationships | type/element (at the contract root these keys ride odcs.raw.*) | preserved verbatim as JSON |
odcs.parent | type | marks a nested type's enclosing object |
odcs.raw.* | any | any unmodeled wire key, verbatim JSON |
The encoder reads all of it back in a fixed order, emitting preserved JSON payloads in YAML flow
style — a round-tripped contract shows quality: [{"metric":"rowCount",...}] where the original
had block YAML: same data, spec-valid, deterministic. Determinism is total: fixed key order,
two-space indent, and a quoting discipline under which a string that would re-parse as a number
or boolean gets quoted while varchar(18) stays plain — so encode after the first round trip is
a byte-for-byte fixed point, and diffs of generated contracts are meaningful.
primaryKey does not imply required. ODCS carries its own required flag, so the coder
believes it: primaryKey: true with required: false decodes as an optional element,
odcs.primaryKey riding in extras. (Our OSI import, whose format lacks a separate flag, treats
a primary key as required; the ODCS coder deliberately does not.) Convert such a contract to SQL
and the key column comes out nullable — the contract said so.
The head, minted or preserved
The spec requires id, version, and status on every contract, so the encoder resolves each
head field in order: the preserved wire value if the IR came from ODCS, else a deterministic
default — apiVersion: v3.1.0 (the current release), kind: DataContract (the only valid
kind), the IR schema's id, version: 1.0.0, status: active. Output is always spec-valid, even
from an IR that never saw a contract; an empty model encodes as a head plus schema: []. A
contract missing required head fields decodes anyway, with a SemanticNarrowing noting that a
re-encode will mint defaults.
apiVersion gets a symmetric policy: a non-v3 version (say v2.2.1) is decoded with v3
semantics and declared; on encode it is replaced with v3.1.0 and declared again. Both records
appear on a full trip — the modernization happened in two places, so it is visible twice.
Verbatim types beat mapped types on the way out
For scalars, the encoder prefers the preserved wire logicalType over re-deriving one from the
IR primitive:
datedecodes as DateTime but re-emits asdate, nottimestamp. Likewisetime— an approximation on decode, restored verbatim on encode.- An out-of-enum type like
geometrydecodes as String with aTypeApproximationand re-emits asgeometry: textual fidelity wins on the round trip, and the decode-side ledger entry is the record that the IR held it coarsely in between. - IR that never saw ODCS gets defaults: Integer →
integer, Double →number, Boolean →boolean, DateTime →timestamp, everything else →string(RichText degrades, declared).
Collections and type references override all of that: they always emit logicalType: array and
logicalType: object respectively.
Nesting, both directions
An object-typed property, or an array of objects, becomes a child Type marked with
odcs.parent — the treatment we use for Protobuf and Avro nested types. Ids compose from the
element: tbl.line_items with object items produces child type tblLineItemsItem, whose
properties become tblLineItemsItemSku and so on. On encode, a type carrying odcs.parent that
some element references is re-nested inside that property rather than emitted top-level — for an
array of objects, as an items: block with logicalType: object and the child's properties; for
an object-typed property, as inline properties under logicalType: object. Two safety valves: an orphaned
nested type (parent marker, nothing referencing it) falls back to top-level emission so it is
never dropped; a shared reference (two elements pointing at one type — ODCS has no $ref) is
flattened to an inline object with a SemanticNarrowing naming the flattening, while the target
also emits as its own schema object.
One round-tripped property shows the whole story — child type re-nested, bounds restored, preserved options in JSON flow style:
- name: line_items
logicalType: array
logicalTypeOptions: {"minItems":1,"maxItems":100}
items:
logicalType: object
properties:
- name: sku
logicalType: string
required: true
- name: quantity
logicalType: integer
The lossiness inventory
Decode side — everything is a warning, nothing is fatal:
| Trigger | Kind | Behavior |
|---|---|---|
| unknown key anywhere | SemanticNarrowing | preserved under odcs.raw.*, one summarized record per node |
quality / relationships | SemanticNarrowing | preserved verbatim, declared per occurrence |
logicalType: time | TypeApproximation | DateTime, verbatim type preserved |
out-of-enum logicalType | TypeApproximation | String, verbatim type preserved |
property with no logicalType | TypeApproximation | String — one summarized record for the whole contract |
array with no items | TypeApproximation | item type approximated as String |
array items of type array or out-of-enum | TypeApproximation | String items, item definition preserved verbatim |
object property with no properties | TypeApproximation | String, verbatim logicalType: object preserved |
items/properties on a scalar property | SemanticNarrowing | preserved under odcs.raw.* |
schema object with non-object logicalType | SemanticNarrowing | preserved verbatim |
non-v3 apiVersion | SemanticNarrowing | decoded with v3 semantics |
| missing required head fields | SemanticNarrowing | re-encode will mint defaults |
Encode side — IR constructs ODCS cannot express, declared rather than smuggled:
| IR construct | Kind | Behavior |
|---|---|---|
| taxonomy (controlled list) | ConstraintRelaxation | ODCS has no enum construct; not represented |
| element referencing a taxonomy | ConstraintRelaxation | approximated as string |
| type inheritance | SemanticNarrowing | no inheritance in ODCS |
| free-standing relations | SemanticNarrowing | only verbatim-preserved odcs.relationships re-emit |
| components/projections | StructuralDrop | no counterpart |
| element owned by no type | StructuralDrop | ODCS has no free-standing properties |
| RichText primitive | TypeApproximation | degrades to string |
| nullable flag | ConstraintRelaxation | required models nullability only; null-vs-absent is not representable |
| unresolvable type reference | TypeApproximation | emitted without nested properties (or string items) |
The enum story is ODCS's sharpest structural limit, so here it is concretely. A model imported from this table:
CREATE TABLE Customer (
customer_key INTEGER NOT NULL,
full_name VARCHAR(255) NOT NULL,
signup_date TIMESTAMP,
status ENUM('draft','active','closed')
);
encodes to a contract whose schema body (head omitted) is:
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
with two ConstraintRelaxation entries: one for the taxonomy ("ODCS has no enum/controlled-list
construct"), one for the element referencing it. The three allowed values exist in your source,
not in the contract — and the ledger names the taxonomy that was relaxed.
Round-trip edge cases, pinned
An itemless array gains items. logicalType: array with no items decodes as a String
collection (declared); the re-encode emits an explicit items with logicalType: string. The
first pass changes the text; every pass after it is a fixed point.
An array of arrays keeps its text. The IR cannot hold nested collections, so the items
approximate as String (declared) — but odcs.itemsLogicalType: array rides along and the
re-encode emits logicalType: array items verbatim. Textual fidelity survives a structural
approximation.
Rejection is reserved for non-contracts. Empty input; a scalar or sequence at the root;
unparseable YAML; a kind other than DataContract; a mapping with none of kind,
apiVersion, or schema. These five fail with a path and message; everything else decodes.
The fixed-point invariant, stated precisely. For a contract built from modeled constructs — head, description, physical facts, key markers, quality, relationships, custom properties, tags, nested objects, arrays with bounds, unknown sections — decode → encode reproduces an equivalent spec-valid document, decode → encode → decode yields a structurally identical IR, and one more encode is byte-identical. Our test suite asserts exactly that, and it is what makes the coder safe in a pipeline: the contract is not merely parseable but reconstructible, with every deviation carried as a ledger entry instead of a surprise.
The companion articles cover the HTTP surface, the MCP tool, and pipeline patterns; the transform documentation indexes them all.