JSON-LD logoAutomation

Boring on Purpose: JSON-LD Conversion Pipelines That Produce the Same Bytes Every Time

A generated artifact is only trustworthy if it is boring. If regenerating last week's JSON Schema from the same vocabulary reshuffles keys, renames anonymous constructs, or quietly drops a constraint, then the diff in your pull request is noise and nobody reads it. This article is our recipe for the opposite: JSON-LD conversions that are byte-stable, gated by a reviewed plan, and loud when meaning is lost.

Boring on Purpose: JSON-LD Conversion Pipelines That Produce the Same Bytes Every Time

A generated artifact is only trustworthy if it is boring. If regenerating last week's JSON Schema from the same vocabulary reshuffles keys, renames anonymous constructs, or quietly drops a constraint, then the diff in your pull request is noise and nobody reads it. This article is our recipe for the opposite: JSON-LD conversions that are byte-stable, gated by a reviewed plan, and loud when meaning is lost.

We are the CoreModels team at ARAMAI, and every artifact below is engine output we generated while writing this.

Two guarantees the pipeline stands on

The engine is deterministic by construction. Every mapping strategy — inferred, explicit, or AI-proposed — produces a plan; every plan passes one universal validation gate, with no strategy earning a shortcut; execution then runs the plan. Same plan plus same source yields the same output, and that is a tested property, not a README promise. A stored plan replayed later goes through the identical gate.

The JSON-LD coder is stable in both directions. On encode, the @context declares only the prefixes the document's identifiers actually use, in fixed sorted order; the @graph is emitted in a fixed sequence — classes first, then enumerations with their member nodes, then properties; and node ids follow a fixed rule (keep an id that is already a qname, else compact a mapsTo IRI against the declared prefixes, else mint one from the label). We checked the practical consequence: decode a vocabulary, encode it, decode that, encode again — the second and third documents are byte-identical. Round trips converge instead of drifting, which is what makes a generated vocabulary safe to commit.

Step 1: mint the plan once, by hand

schema/map is stateless and Viewer-role; it never writes to a project. Run it once, interactively, when you set the pipeline up, and keep the plan it returns. For a conversion with no reshaping intent, hand the vocabulary to itself as the target hint — inference needs something to match against, and matching a document against itself yields clean identity operations:

jq -n --rawfile doc vocab/person.jsonld '{
  sourceFormat: "jsonld",
  sourceSchema: $doc,
  targetFormat: "jsonschema",
  targetHintFormat: "jsonld",
  targetHintSchema: $doc,
  mapping: { kind: "inferred" }
}' | curl -sf -X POST "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @- \
  | jq '.plan' > plans/person-to-jsonschema.plan.json

For a five-node Person/Employee vocabulary, the entire artifact is this:

{
  "operations": [
    { "kind": "TypeMapping", "origin": "Inferred",
      "sourceTypeId": "ex:Person", "targetTypeId": "ex:Person", "targetLabel": "Person" },
    { "kind": "TypeMapping", "origin": "Inferred",
      "sourceTypeId": "ex:Employee", "targetTypeId": "ex:Employee", "targetLabel": "Employee" },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["schema:name"], "targetElementIds": ["schema:name"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ex:age"], "targetElementIds": ["ex:age"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ex:manager"], "targetElementIds": ["ex:manager"] }
  ]
}

Review it the way you would review a migration: every operation names its source and target constructs, and every operation declares its origin. JSON-LD makes this unusually pleasant to read, because the ids are qnames — schema:name, ex:manager — rather than positional handles. Commit the file next to the vocabulary. From that moment the mapping is a versioned decision rather than whatever inference happened to produce on the day the build ran.

Step 2: replay the plan in the build

The build never re-infers. It replays, through plan/execute, where the plan travels as a string — precisely what jq --rawfile produces:

#!/usr/bin/env bash
set -euo pipefail
API="https://coremodels.example.com"

jq -n --rawfile vocab vocab/person.jsonld \
      --rawfile plan plans/person-to-jsonschema.plan.json '{
  sourceFormat: "jsonld",
  sourceSchema: $vocab,
  plan: $plan,
  targetFormat: "jsonschema"
}' > /tmp/replay.json

curl -sf -X POST "$API/graph/transform/plan/execute/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @/tmp/replay.json > /tmp/result.json

count=$(jq '.lossiness | length' /tmp/result.json)
if [ "$count" -ne 0 ]; then
  echo "conversion reported $count lossiness record(s):" >&2
  jq -r '.lossiness[] | "\(.kind) at \(.path): \(.explanation)"' /tmp/result.json >&2
  exit 1
fi

jq '.schema' /tmp/result.json > dist/person.schema.json

The lossiness check is the step teams skip and then regret. success: true only means the call ran; the ledger is the honest statement of what the target could not hold. Failing the build on a non-empty ledger converts silent semantic drift into a red run.

When a conversion has known and accepted losses — JSON-LD into SQL will always relax an enumeration, for example — do not weaken the check; baseline it. Store the expected ledger next to the plan and compare:

jq -S '.lossiness' /tmp/result.json > /tmp/ledger.json
diff <(jq -S . baselines/person-to-sql.ledger.json) /tmp/ledger.json \
  || { echo "lossiness changed — review before merging" >&2; exit 1; }

Now the build fails on new loss, which is the thing you actually care about. Vocabulary additions flow through a replay untouched — plans address constructs by id — so you regenerate and re-review the plan only when you intend the mapping decision itself to change.

Fan-out: one vocabulary, several consumers

Because schema/map is stateless, batch conversion is a loop, and the cost of adding a consumer format is one array element:

for vocab in vocab/*.jsonld; do
  name=$(basename "$vocab" .jsonld)
  for target in jsonschema linkml owl sql; do
    jq -n --rawfile doc "$vocab" --arg t "$target" '{
      sourceFormat: "jsonld", sourceSchema: $doc, targetFormat: $t,
      targetHintFormat: "jsonld", targetHintSchema: $doc,
      mapping: { kind: "inferred" }
    }' | curl -sf -X POST "$API/graph/transform/schema/map/$PROJECT_ID" \
          -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @- \
      | jq '{schema, lossiness}' > "dist/$name.$target.json"
  done
done

What lands is not a lossy sketch. The LinkML output for that Person vocabulary, verbatim, with an empty ledger:

id: https://coremodels.example.com/ns/jsonldschema
name: jsonld-schema
prefixes:
  linkml: https://w3id.org/linkml/
  schema: https://schema.org/
imports:
  - linkml:types
default_range: string

classes:
  Person:
    class_uri: https://example.org/Person
    attributes:
      name:
        slot_uri: schema:name
      age:
        slot_uri: https://example.org/age
        range: integer

  Employee:
    is_a: Person
    class_uri: https://example.org/Employee
    attributes:
      manager:
        slot_uri: https://example.org/manager
        range: Person

The subclass edge became is_a, and — the reason we care about JSON-LD as a source — every IRI from the @ids reappears as class_uri and slot_uri. The same run against owl produces a Turtle ontology with the same identities:

@prefix ex: <https://example.org/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix schema: <https://schema.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<https://example.org/JsonldSchema> a owl:Ontology ;
    rdfs:label "jsonld-schema" .

ex:Person a owl:Class ;
    rdfs:label "Person" ;
    rdfs:subClassOf [ a owl:Restriction ; owl:onProperty schema:name ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ] ;
    rdfs:subClassOf [ a owl:Restriction ; owl:onProperty ex:age ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ] .

ex:Employee a owl:Class ;
    rdfs:label "Employee" ;
    rdfs:subClassOf ex:Person ;
    rdfs:subClassOf [ a owl:Restriction ; owl:onProperty ex:manager ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ] .

schema:name a owl:DatatypeProperty ;
    rdfs:label "name" ;
    rdfs:domain ex:Person ;
    rdfs:range xsd:string .

ex:age a owl:DatatypeProperty ;
    rdfs:label "age" ;
    rdfs:domain ex:Person ;
    rdfs:range xsd:integer .

ex:manager a owl:ObjectProperty ;
    rdfs:label "manager" ;
    rdfs:domain ex:Employee ;
    rdfs:range ex:Person .

One naming detail to plan for: a JSON-LD document carries no schema-level name, so the decoder labels the decoded schema jsonld-schema, and target formats that need a document-level id derive one from it — that is where name: jsonld-schema and the minted LinkML id: above come from. If your consumers care about that header, map toward a hint from a format that names the schema, or rewrite the header in the pipeline; the class and property identities are unaffected either way.

The three mapping kinds, under automation

inferred is scaffolding. Label and type matching against the hint, case-insensitive by default (caseInsensitive: true). Use it to bootstrap a plan, then pin the plan. A pipeline that re-infers on every run is a pipeline whose mapping can change without a code review.

explicit is the production kind for anything that is not identity, and it is where JSON-LD quietly outclasses formats without identifiers: every element decoded from a vocabulary is born with an IRI, so autoMatchByMapsTo can pair source and target by identity even when the labels differ completely. A guide that matches by IRI, splits one field, and drops a retired one:

{
  "autoMatchByMapsTo": true,
  "fieldMappings": [
    { "sourceElementIds": ["ex:fullName"],
      "targetElementIds": ["ex:givenName", "ex:familyName"],
      "transformName": "SplitName" }
  ],
  "drops": ["ex:legacyCode"]
}

The guide travels as a string in mapping.guide with "kind": "explicit". Guides are validated strictly — an unknown key is rejected with a path-carrying error instead of being ignored — because a silently misspelled directive would execute a plan you did not intend. In CI that strictness is a feature: typos fail the build rather than shipping a wrong mapping.

ai asks a server-side Claude proposer for a plan. Treat it as an authoring step, not a build step: let it draft, review the operations, commit the plan, then replay the committed artifact forever after. The proposal faces the same gate as everything else, with at most one repair attempt — a rejected repair is a rejection. It requires Editor or Admin project membership and a server-configured key, and it sends schema content to the Anthropic API server-side. A proposal may carry a self-reported confidence value; the gate never trusts it, and neither should your pipeline.

Two operational notes to close. Everything in this article runs with Viewer access and writes nothing to any project, so a CI token scoped for these routes carries read-only risk. And the same engine is exposed to agents as the transform_schema MCP tool, returning the same plans and the same ledgers — so an agent and a build can share one set of reviewed plan artifacts rather than each inventing its own. For the endpoint reference and the full mapping-guide grammar, see the Schema Transformation API guide in the CoreModels docs.