JSON-LD logoDeep dive

The Coder That Never Complains: A Full Accounting of JSON-LD Fidelity in CoreModels

Here is the fact that should make you suspicious: in either direction, the CoreModels JSON-LD coder emits no lossiness records at all. Not "few" — none. Most coders in the engine keep a list of things they apologize for; this one's ledger is empty. That is either an honest structural property or a place where losses hide, so this article is the accounting: what maps to what, what rides in the extras channel, what the `@id` contract guarantees, where fidelity breaks, and what the tests pin down.

The Coder That Never Complains: A Full Accounting of JSON-LD Fidelity in CoreModels

Here is the fact that should make you suspicious: in either direction, the CoreModels JSON-LD coder emits no lossiness records at all. Not "few" — none. Most coders in the engine keep a list of things they apologize for; this one's ledger is empty. That is either an honest structural property or a place where losses hide, so this article is the accounting: what maps to what, what rides in the extras channel, what the @id contract guarantees, where fidelity breaks, and what the tests pin down.

We are the CoreModels team at ARAMAI.

Scope first. As a schema format, jsonld means a vocabulary: rdfs:Class and rdf:Property nodes inside an @context/@graph document, the schema.org publication style. Instance records go through a separate coder — the jsonld data format — where each @graph node is a record keyed by @id, property keys are the elements' IRIs, references are {"@id": "<record-id>"}, and a property the schema does not know is reported as a StructuralDrop rather than thrown. That coder keeps its own ledger. The rest of this article is the schema plane.

The structural mapping

One document that exercises the awkward corners — a shared property with two domains, a reference-typed property written with bare-string references, a date, and a node with no label:

{
  "@context": {
    "schema": "https://schema.org/",
    "xsd": "http://www.w3.org/2001/XMLSchema#",
    "ex": "https://example.org/shop#"
  },
  "@graph": [
    { "@id": "ex:Order", "@type": "rdfs:Class", "rdfs:label": "Order" },
    { "@id": "ex:Invoice", "@type": "rdfs:Class", "rdfs:label": "Invoice" },
    { "@id": "ex:placedOn", "@type": "rdf:Property", "rdfs:label": "placedOn",
      "schema:domainIncludes": [ { "@id": "ex:Order" }, { "@id": "ex:Invoice" } ],
      "schema:rangeIncludes": { "@id": "xsd:date" } },
    { "@id": "ex:reference", "@type": "rdf:Property",
      "schema:domainIncludes": "ex:Order", "schema:rangeIncludes": "ex:Invoice" }
  ]
}

Decoding runs in two passes — classes and enumerations first, then properties and enumeration members — so declaration order in the @graph is irrelevant. The mapping into our intermediate representation:

JSON-LD constructIR constructDetail
node typed rdfs:ClassTypelabel from rdfs:label, else the local name of the @id
rdfs:subClassOf another classinheritancethe first non-enumeration superclass wins
rdfs:subClassOf schema:EnumerationTaxonomylowercase schema:enumeration is accepted too
node whose @type is an enumeration classTaxonomy termterm id is the local name; the full @id is preserved
node typed rdf:PropertyElementalways decoded optional — RDF is open-world
schema:domainIncludeselement-to-type wiringevery listed type gets the element
schema:rangeIncludesthe element's value typetaxonomy ref, type ref, or primitive
@idmapsTo annotationexpanded against the context; types and elements also get provenance stamped JSON-LD with the @id as source ref
@contextschema-level extrasevery prefix binding preserved for re-emit

For the document above that yields ex:Order holding ex:placedOn and ex:reference, ex:Invoice holding ex:placedOn, a DateTime-typed placedOn, a reference typed as a pointer to ex:Invoice, and an unlabeled property taking the label reference from its local name. Bare strings, {"@id": …} objects, and arrays of either are all accepted anywhere a reference is read.

Range resolution has a precedence worth knowing: a range naming a decoded taxonomy becomes a taxonomy reference; one naming a decoded class becomes a type reference; an xsd: or schema: datatype becomes a primitive; and a range pointing at an IRI the document never defines is still a type reference — the open-world benefit of the doubt — rather than a string.

Datatypes, decoding: xsd:integer, xsd:int, xsd:long, schema:Integer → Integer; xsd:decimal, xsd:double, xsd:float, schema:Number, schema:Float → Double; xsd:boolean, schema:Boolean → Boolean; xsd:dateTime, xsd:date, schema:Date, schema:DateTime → DateTime; anything else → String. Encoding: Integer → xsd:integer, Double → xsd:decimal, Boolean → xsd:boolean, DateTime → xsd:dateTime, everything else → xsd:string. Four prefixes are pre-loaded in both directions — rdf, rdfs, xsd, schema — so a document may use them without declaring them.

The extras channel

The IR carries an open key-value bag on every node, and this coder uses three dotted jsonld.* keys to move wire detail that has no structural home:

  • jsonld.context.<prefix> (schema level) — every prefix-to-namespace binding, the document's and the defaults. This is what lets the encoder re-emit your namespaces instead of inventing its own.
  • jsonld.range (element level) — the original rangeIncludes id. Datatype decoding is many-to-one, and this is the device that undoes it: xsd:date decodes to DateTime, and on re-encode the preserved range beats the generic xsd:dateTime. Run the document above through decode and encode and ex:placedOn returns as xsd:date, exactly as authored, with both domains rebuilt.
  • jsonld.id (taxonomy-term level) — the member node's full @id, so terms keep their IRIs even though the term id inside the IR is only the local name. A term that never saw JSON-LD has no such extra; its member id is minted by concatenation instead.

The @id contract, precisely

A vocabulary's identifiers are global, and identifiers are how the engine carries meaning across standards. On decode, an @id written as a qname whose prefix is declared (or one of the four defaults) is expanded and lifted into a mapsTo annotation: schema:name becomes https://schema.org/name. That annotation reappears as x-maps-to in JSON Schema, as slot_uri in LinkML, as a column comment in DDL — and a shipped test pins exactly that: a vocabulary pushed through the engine into JSON Schema emits name with "x-maps-to": { "schema": "https://schema.org/name" }.

On encode, each node's @id is chosen by a strict preference: an IR id that already looks like a qname (a colon, no pipe, no space, no dot) is kept; otherwise the node's mapsTo IRIs are compacted against the prefix map, longest namespace first; otherwise an id is minted as ex: plus the PascalCased label.

Two consequences that matter more than they look:

Write qnames, not full IRIs. An @id of https://example.org/Device has a prefix of https, which no context declares, so nothing is lifted into mapsTo — and on the way out it fails the qname test (it contains dots), finds no mapsTo to compact, and is re-minted from its label as ex:Device. Structure survives that trip; identity does not. If your vocabulary is authored with full IRIs and you care about the identifiers, declare a prefix and use qnames.

A minted ex: id may have no binding. The emitted @context lists the prefixes used by the document's identifiers that the coder can resolve. ex is not one of the four defaults, so a model that never came from a JSON-LD document declaring it yields ex: qnames with no ex binding. The vocabulary keys are the same story: rdfs:Class, rdf:Property, and schema:domainIncludes rely on prefixes the coder always understands on the way back in, but a general-purpose JSON-LD processor will want them declared. Merge the standard bindings in before handing the file to one.

Where the honesty actually lives

The empty ledger is a structural outcome, not a cover-up: everything a vocabulary can express — classes, subclassing, enumerations, domain- and range-typed properties, IRIs — has a first-class home in the IR, and the three extras keys catch the wire details that do not. On a jsonld transform, the entries in the response come from the other stages: the target encoder, the plan, the gate.

That leaves three real limits, and one of them is quiet enough to deserve a warning:

  1. Required-ness disappears without a record. RDFS cannot say "this property must be present," so a required element encodes as an ordinary rdf:Property — and no lossiness entry is produced. We measured it: a Customer model with customer_key and full_name required, encoded to JSON-LD and decoded back, returns four optional elements and an empty encode ledger. Inside pure JSON-LD round trips this is symmetric and harmless; across formats it is the first thing to check.
  2. Constraints other than enumerations have nowhere to land. Max-lengths, patterns, numeric bounds, and cardinality bounds are not vocabulary constructs; enumerations are the exception, and travel both ways.
  3. Non-vocabulary nodes are ignored. A @graph node that is neither an rdfs:Class, an rdf:Property, nor a member of a decoded enumeration is skipped. Keep vocabulary documents to vocabulary content, and put instance data through the data plane.

A cosmetic asymmetry is also worth planning around when the model was not born in RDF. Encoding a Customer type decoded from DDL, the type becomes ex:Customer and its properties ex:CustomerKey, ex:FullName, ex:SignupDate, ex:Status — minted from the labels, because ids like CustomerFullName are not qnames — while the taxonomy keeps its raw id and its members are named by concatenation:

[
  { "@id": "CustomerStatusEnum", "@type": "rdfs:Class",
    "rdfs:subClassOf": { "@id": "schema:Enumeration" },
    "rdfs:label": "CustomerStatusEnum" },
  { "@id": "CustomerStatusEnumdraft", "@type": { "@id": "CustomerStatusEnum" },
    "rdfs:label": "draft" }
]

The document re-decodes cleanly, but those ids are not dereferenceable IRIs. If you intend to publish a vocabulary for other people to reference, give the governed model its mapsTo identities first — then the encoder compacts real IRIs instead of minting placeholders.

Round trips and edge cases

Round-trip fidelity, as measured: decode → encode → decode preserves the structural signature — types, parents, elements, value types — which is what the shipped test asserts; taxonomies with their terms make the same trip. One cycle further (decode, encode, decode, encode) and the second and third documents are byte-identical. The output converges rather than drifting, because the @context is sorted, the @graph order is fixed (classes, then enumerations with members, then properties), and references are normalized to {"@id": …} objects with domainIncludes always an array. The first hop is structurally faithful rather than byte-faithful; from there it is stable.

The edges, from the code and from running it:

  • A root that is not a JSON object fails immediately: path $, message "The root of a JSON-LD document must be an object."
  • A root object with no @graph is valid — it is treated as a single-node graph, so one free-standing class definition decodes.
  • Multiple rangeIncludes values: the first drives typing and is the one preserved; the rest are not modeled.
  • Multiple domainIncludes values: the element attaches to every named type, and on encode the domain array is rebuilt from every type that holds it — shared properties survive.
  • An element that belongs to no type encodes without a domainIncludes key; rangeIncludes is always written.
  • Missing rdfs:label degrades to the local name after the last : or /.

That is the whole accounting. The coder itself has nothing to apologize for, because identifiers — not field names — are what the engine carries between standards. What a vocabulary cannot carry is required-ness and value constraints; check those two, and the rest travels. For the surfaces that drive this coder, see the transform section of the CoreModels docs.