JSON Schema logoDeep dive

The Fidelity Contract: JSON Schema to IR and Back, Construct by Construct

Every format coder in CoreModels has to answer the same awkward question: what do you do with the parts of a document you do not model? There are three bad answers — drop them, guess at them, or refuse the document — and one good one, which is to carry them untouched and be explicit about the handful of cases where carrying them is not enough.

The Fidelity Contract: JSON Schema to IR and Back, Construct by Construct

Every format coder in CoreModels has to answer the same awkward question: what do you do with the parts of a document you do not model? There are three bad answers — drop them, guess at them, or refuse the document — and one good one, which is to carry them untouched and be explicit about the handful of cases where carrying them is not enough.

This article is the full accounting for the JSON Schema coder (format key jsonschema, alias sia): what becomes structure, what rides along verbatim, every condition that produces a lossiness record, and the edges we have found and pinned down.

Three buckets and one exception

Everything in an incoming JSON Schema document lands in exactly one of three places.

Structure. A fixed list of keywords is consumed and rebuilt as model constructs: type, properties, items, enum, $ref, required, allOf, minItems, maxItems, $defs, definitions, plus the four annotation keywords below. These are not copied anywhere else, because the encoder reconstructs them from the structure they produced.

Annotations. x-sia-role, x-sia-priority, x-sia-instruction, and x-maps-to are lifted into the neutral annotation bag. x-maps-to is the important one: it is the cross-standard equivalence carrier the LinkML, OWL, SQL, and vendor coders also read, so an assertion recorded once in a JSON Schema surfaces in every other export.

Everything else, verbatim. Any other keyword — description, pattern, minLength, minimum, default, examples, deprecated, additionalProperties, $schema, $id, a vendor's own x- extension — is preserved exactly as JSON on the node it appeared on, and re-emitted on the way out. The coder does not need to understand a keyword to keep it.

The exception is the conditional combinators — anyOf, oneOf, if, then, else, not. They are preserved verbatim like anything else, and they produce a SemanticNarrowing lossiness record, because the model cannot represent "one of several shapes" as structure. Preserved is not the same as understood, and the ledger says which one you got.

The mapping table

JSON Schema constructModel constructNotes
root objectType, id = title (else Root)the root's identity is remembered on the schema
$defs / definitions entryType, id = the keyboth keywords are read; $defs is written
propertyElement, id Type::property, label = the property namedeclaration order preserved
required arrayRequired on the elementcollected from the object and from allOf members
type: "array" + itemscollection cardinality; the item schema supplies the value typeminItems / maxItems become bounds
$refreference to a type, id = the last path segmentemitted as #/$defs/{id}
enumcontrolled list, id Type::property::enumterms are the enum values
inline type: "object" propertyanonymous type Type::property::type, marked inlinere-emitted inline, never hoisted into $defs
allOf: [{"$ref": …}, {object}]parent type + the inline object's properties as native onesemitted back as the same allOf pair
type: "string"String; format: "date" or "date-time" makes it DateTime
type: "integer" / "number" / "boolean"Integer / Double / Booleanencoded back as integer / number / boolean
x-sia-*, x-maps-toannotation baground-trip in both directions
any other keywordpreserved verbatimre-emitted unchanged

Two encoding rules complete the picture. On the way out, one type becomes the root document and the others go under $defs — the root is the type the source marked as root, or the first one when the model came from somewhere else. And a type with a parent is emitted as allOf: [{ "$ref": "#/$defs/Parent" }, { …its own properties… }], with inherited properties not repeated.

Verbatim preservation, demonstrated

Take a document with nothing structural about it beyond the basics:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/invoice.json",
  "title": "Invoice",
  "type": "object",
  "additionalProperties": false,
  "description": "A customer invoice.",
  "properties": {
    "invoiceNumber": { "type": "string", "minLength": 3, "pattern": "^INV-[0-9]{4}$" },
    "amountDue": { "type": "number", "minimum": 0, "default": 0 },
    "currency": { "type": "string", "enum": ["EUR", "USD"], "deprecated": false }
  },
  "required": ["invoiceNumber", "amountDue"]
}

Decoded and re-encoded, with an empty ledger both ways:

{
  "type": "object",
  "properties": {
    "invoiceNumber": { "type": "string", "minLength": 3, "pattern": "^INV-[0-9]{4}$" },
    "amountDue": { "type": "number", "minimum": 0, "default": 0 },
    "currency": { "enum": ["EUR", "USD"], "deprecated": false }
  },
  "required": ["invoiceNumber", "amountDue"],
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/invoice.json",
  "title": "Invoice",
  "additionalProperties": false,
  "description": "A customer invoice."
}

Every keyword survived: minLength, pattern, minimum, default, deprecated, additionalProperties, description, $id, $schema, title.

Two differences are worth naming, because a byte-level diff will show them. Key order changes: structural keywords are written first, then annotations, then preserved keywords. And currency lost its "type": "string" — an enum becomes a controlled list, and a controlled list encodes as enum alone. Both documents validate the same instances; they are not the same bytes. Our round-trip test compares JSON structurally for exactly this reason — object keys order-insensitive, arrays order-sensitive — and on a fully annotated schema with $defs, $ref, an enum, an array, and the whole x-sia-* family, that comparison is exact with zero lossiness in both directions.

The complete lossiness inventory

This coder emits lossiness in exactly four situations. There is no fifth.

On decode:

  1. A property with no recognized type — because it declares none, or declares one this coder does not map — becomes a String, with TypeApproximation: "Property has no recognized 'type' (none); approximated as String."
  2. A conditional combinator on any node produces SemanticNarrowing: "'anyOf' conditional logic cannot be modelled as IR structure; preserved verbatim for re-emit." One record per combinator per node, with the path pointing at it, such as #/properties/contact.anyOf.
  3. An allOf member that is neither a $ref nor an object with properties produces SemanticNarrowing: "An allOf member is neither a $ref nor an object with properties; not modelled structurally."

On encode:

  1. A rich-text value has no JSON Schema equivalent and is written as a plain string, with TypeApproximation: "RichText has no JSON Schema equivalent; emitted as a plain string." This only arises when the model came from a format that distinguishes rich text; a JSON Schema round trip never produces it.

Everything else is either structural or preserved. Note what is absent from the list: pattern, minLength, minimum, maximum, format, additionalProperties, and every other constraint keyword produce no lossiness on a JSON-Schema-to-JSON-Schema conversion, because they come back exactly. They may well produce records when the target is another format — a Postgres export of an enum reports ConstraintRelaxation — but that is the target coder's ledger entry, not this one's.

Two conditions are hard errors rather than lossiness, because there is no sensible partial answer:

  • $: The root of a JSON Schema must be an object. — the payload parsed but was a string, number, or array.
  • The schema has no root type to encode. — the model presented for encoding has no type to serve as the root document.

Edges we have pinned down

No title. The root type is named Root, and the document re-encodes without a title — nothing is invented to fill the gap.

definitions is read, $defs is written. A draft-07 document with definitions and #/definitions/Child references decodes cleanly and re-encodes with $defs and #/$defs/Child. The $schema value you sent is preserved verbatim, so the document keeps declaring the dialect it declared. Reference resolution takes the last path segment, which is why both spellings land on the same type.

Inline objects stay inline. A nested type: "object" property becomes an anonymous type carrying an internal marker, and the encoder writes it back in place rather than hoisting it into $defs. Its properties get ids built from the chain — Ticket::address::type::city — which is how you address a nested field in a mapping guide.

Arrays are cardinality, not a type. {"type": "array", "items": {"$ref": "#/$defs/Agent"}, "maxItems": 5} produces one element whose value type is a reference to Agent and whose cardinality is a bounded collection. The bound round-trips.

Controlled lists have ids, not labels. An enum becomes a list identified as Type::property::enum with no display label. That is invisible on a straight conversion, and decisive in a mapping: label-based inference has nothing to match on, so an enum needs a taxonomyDirective in an explicit guide. Without one, execution fails loudly — Referenced taxonomy 'Order::status::enum' does not resolve. — rather than dropping the constraint.

DateTime is a string on the way out. A governed date-time encodes as {"type": "string"}. The format: "date-time" keyword comes back only when it rode in from a JSON Schema source, because that is where preserved keywords live. Converting SQL to JSON Schema therefore gives you {"type": "string"} for a TIMESTAMP column, correctly and without a format hint.

Foreign namespaced data is ignored, not re-emitted. Other coders park their round-trip data under dotted keys such as shex.predicate. This coder writes bare JSON Schema keywords, which never contain a dot, so it skips dotted keys entirely. Internal markers, which begin with an underscore, are never emitted at all.

Renaming the root type breaks the encode. The identity of the root travels with the schema. If a mapping renames the root type — Order becomes SalesOrder — and the target format is JSON Schema, the encoder looks for the type the source named as root, does not find it, and returns The schema has no root type to encode. Map the root type to itself when JSON Schema is the target, or send that mapping to a format without a single-root document model, such as SQL.

Why it is arranged this way

The reason the coder consumes a short list of keywords and preserves everything else is that JSON Schema is not one language — it is a base with a decade of accumulated extensions, and any coder claiming to understand all of it is claiming something false. Structure is where meaning lives and where conversion happens. The rest is data that belongs to you, and the coder's job is to hand it back intact. Where neither is possible, there is a ledger entry with your construct's path on it.

For request shapes, roles, and the routes that carry this coder, see the schema transformation guide in the CoreModels documentation.