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.