Four Verbs and a Vocabulary: The JSON-LD HTTP Surface, With Real Bodies
Everything you can do with an RDF vocabulary on the CoreModels HTTP API fits in four routes. One writes a vocabulary into a governed project, one publishes a project back out as a vocabulary, one converts statelessly through the mapping engine, and one replays a stored conversion. This article walks all four with request and response bodies we actually ran, then states the direction limits plainly — including the one that has no ledger entry to warn you about it.
Four Verbs and a Vocabulary: The JSON-LD HTTP Surface, With Real Bodies
Everything you can do with an RDF vocabulary on the CoreModels HTTP API fits in four routes. One writes a vocabulary into a governed project, one publishes a project back out as a vocabulary, one converts statelessly through the mapping engine, and one replays a stored conversion. This article walks all four with request and response bodies we actually ran, then states the direction limits plainly — including the one that has no ledger entry to warn you about it.
We are the CoreModels team at ARAMAI.
| Verb | Route (POST) | Role | JSON-LD direction |
|---|---|---|---|
| Import schema | graph/transform/schema/import/{projectId} | Admin | decode |
| Export schema | graph/transform/schema/export/{projectId} | Viewer | encode |
| Map schema | graph/transform/schema/map/{projectId} | Viewer (ai: Editor) | either end |
| Execute plan | graph/transform/plan/execute/{projectId} | Viewer | either end |
Ground rules for all of them: the base path is https://coremodels.example.com/graph/transform/...
with no api/ prefix; every request carries Authorization: Bearer $TOKEN and
Content-Type: application/json; the project id in the path scopes authorization. Every response
shares one envelope:
{
"success": true, // false only if the call could NOT proceed — then read "errors"
"lossiness": [], // what the target could not hold exactly — read this every time
"errors": [], // on failure: [{ "path": "...", "message": "..." }]
"schema": "..." // or "projectId" / "data" / "summary", by verb — plus "plan" on mapping calls
}
success: true means the call ran, not that nothing changed. The lossiness entries carry one of
four kinds — StructuralDrop, TypeApproximation, ConstraintRelaxation, SemanticNarrowing —
with a path and a plain-English explanation.
Import: a vocabulary becomes governed model
schema/import decodes the document and writes the equivalent Types and Elements into the
project through the normal profile layer. Body keys: format, schema (the document as a JSON
string), and optional spaces (empty means the project's main space). This body is small enough
to paste as-is:
{
"format": "jsonld",
"schema": "{ \"@context\": { \"schema\": \"https://schema.org/\" }, \"@graph\": [ { \"@id\": \"schema:Person\", \"@type\": \"rdfs:Class\" }, { \"@id\": \"schema:name\", \"@type\": \"rdf:Property\", \"schema:domainIncludes\": { \"@id\": \"schema:Person\" }, \"schema:rangeIncludes\": { \"@id\": \"xsd:string\" } } ] }"
}
curl -s -X POST "https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @import.json
Response: the shared envelope with this verb's payload —
{ "success": true, "lossiness": [ … ], "errors": [], "projectId": "…" }. The JSON-LD decoder
contributes nothing to that ledger (a structural property, and the subject of our deep-dive), so
anything you find in it came from the write side.
What landed: a Person type with a name element typed String — and, because every @id in a
vocabulary is an IRI, both carry a mapsTo annotation (https://schema.org/Person,
https://schema.org/name) that every other export format reads later. And schema is one of the four
prefix bindings the exporter always knows, so a later JSON-LD export compacts these IRIs back to
the same qnames. Note two decode behaviors that are not bugs: nodes with no rdfs:label take the local
name after the last : or / as their label, and every element arrives optional, because an
RDF vocabulary contains no statement that a property must be present.
A malformed payload fails honestly rather than half-importing: a document whose root is not a JSON
object comes back success: false with errors[0].path of $ and the message
The root of a JSON-LD document must be an object.
Export: a project becomes a vocabulary
The smallest useful body on the API:
{ "format": "jsonld" }
curl -s -X POST "https://coremodels.example.com/graph/transform/schema/export/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "format": "jsonld" }'
Because JSON-LD is a JSON-shaped format, the schema field of the response is a JSON object,
not a string. Types become rdfs:Class nodes; inheritance becomes rdfs:subClassOf; taxonomies
become classes subclassing schema:Enumeration with one member node per term; elements become
rdf:Property nodes wired with schema:domainIncludes (always an array — an element shared by
several types lists them all) and schema:rangeIncludes. Here is real encoder output for a
Customer model with four fields and a three-value status taxonomy, abridged to one property
and one enumeration member:
{
"@context": { "xsd": "http://www.w3.org/2001/XMLSchema#" },
"@graph": [
{ "@id": "ex:Customer", "@type": "rdfs:Class", "rdfs:label": "Customer" },
{ "@id": "Customer::status::enum", "@type": "rdfs:Class",
"rdfs:subClassOf": { "@id": "schema:Enumeration" },
"rdfs:label": "Customer::status::enum" },
{ "@id": "Customer::status::enumdraft", "@type": { "@id": "Customer::status::enum" },
"rdfs:label": "draft" },
{ "@id": "Customer::signup_date", "@type": "rdf:Property", "rdfs:label": "signup_date",
"schema:domainIncludes": [ { "@id": "ex:Customer" } ],
"schema:rangeIncludes": { "@id": "xsd:dateTime" } }
]
}
Read the identifiers carefully, because they tell you how much identity your model carries. A node
id that already looks like a qname is emitted unchanged. One that does not is minted: the type
above became ex:Customer from its PascalCased label, and enumeration members are named by
concatenating the taxonomy id with the term id. That is what an export looks like for a model that
was never authored in RDF.
Identity is recoverable, though, and the recovery rule is worth knowing: before minting anything,
the encoder looks for a mapsTo IRI on the node and compacts it back to a qname against the
prefixes it holds. A term governed as https://schema.org/name therefore comes back out as
schema:name no matter what the internal node id looks like. Straight through the coder —
vocabulary in, vocabulary out — ids and @context bindings survive verbatim, and that
decode/encode/decode cycle is what our test suite pins.
One practical note for strict consumers: the emitted @context declares the prefixes the
document's identifiers use and can resolve — here just xsd. The vocabulary keys themselves
(rdfs:Class, rdf:Property, schema:domainIncludes) rely on the four standard prefixes that
CoreModels always understands on the way back in. If you are handing the file to a general-purpose
JSON-LD processor, merge the standard rdf, rdfs, xsd, and schema bindings into the context
first. No JSON-LD-specific options exist on this endpoint: vendor belongs to sql and the
synapse* keys to synapse; both are ignored here.
Map: convert statelessly, with a hint
schema/map runs source → plan → gate → execute → target and writes nothing. The inferred
strategy matches against a target hint, and the hint is what decides how much survives. Here we
aim a five-node Person vocabulary (ex:Person, ex:Employee, and the properties schema:name,
ex:age, ex:manager) at a deliberately narrower JSON Schema hint:
cat > hint.json <<'EOF'
{ "$id": "Person", "type": "object", "title": "Person",
"properties": { "name": { "type": "string" }, "age": { "type": "integer" } } }
EOF
jq -n --rawfile vocab person.jsonld --rawfile hint hint.json '{
sourceFormat: "jsonld",
sourceSchema: $vocab,
targetFormat: "jsonschema",
targetHintFormat: "jsonschema",
targetHintSchema: $hint,
mapping: { kind: "inferred", caseInsensitive: true }
}' | curl -s -X POST "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-
The plan field of the response is the authority on what mapped:
{
"operations": [
{ "kind": "TypeMapping", "origin": "Inferred", "sourceTypeId": "ex:Person",
"targetTypeId": "Person", "targetLabel": "Person" },
{ "kind": "ElementMapping", "origin": "Inferred",
"sourceElementIds": ["schema:name"], "targetElementIds": ["Person::name"] },
{ "kind": "ElementMapping", "origin": "Inferred",
"sourceElementIds": ["ex:age"], "targetElementIds": ["Person::age"] }
]
}
Three operations, not five: ex:Employee and ex:manager had no home in the hint, so they are
absent from the plan and from the schema the call returns. That schema keeps each property's
IRI as x-maps-to — schema:name still resolves to https://schema.org/name on the far side of
the conversion. For a full-fidelity conversion instead of a narrowing one, pass the source as its
own hint, or set useProjectAsTargetHint: true to map toward the project's current schema.
Two other strategies share the same mapping object. explicit takes a mapping guide as a JSON
string in guide; unknown keys in a guide are rejected with a path-carrying error rather than
ignored, because a silently misspelled directive would execute a plan you did not intend. ai
asks a server-side Claude proposer for the plan, which then passes the identical validation gate;
it requires Editor or Admin membership plus a server-configured key, and it sends schema content
to the Anthropic API server-side.
Execute: replay a stored plan
The plan from any mapping response is a reviewable, replayable artifact. On this endpoint it
travels as a string, which is exactly what jq --rawfile gives you:
jq -n --rawfile vocab person.jsonld --rawfile plan plan.json '{
sourceFormat: "jsonld",
sourceSchema: $vocab,
plan: $plan,
targetFormat: "jsonschema"
}' | curl -s -X POST "https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-
The parsed plan goes through the same universal gate as every freshly produced plan — a stored
plan earns no shortcut — and then executes deterministically. The response is the standard
envelope with schema.
The record plane, in passing
jsonld is also one of the engine's data formats. data/import, data/export, and the
cross-schema data/map accept json | csv | jsonld | sql | avro as record serializations, so the
same key that carries your vocabulary can carry your instances — with property keys written as
IRIs rather than opaque local ids.
Direction limits, honestly
jsonld appears in both dispatch lists, so unlike odm (decode-only) or synapse (whose decode
attempt returns 'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema — decode it with the 'jsonschema' format.), it carries no directional asterisk. The honest limits are
semantic:
- Required-ness does not survive an encode, and nothing tells you. RDFS has no construct for
"this property must be present," so an element marked required in the source model is emitted as
an ordinary
rdf:Property— and the JSON-LD encoder produces no lossiness record when that happens. We checked it: take a JSON Schema carrying"required": ["customer_key", "full_name"], encode it as JSON-LD (encode ledger: empty), decode that document back, and all four elements are optional. If required-ness matters downstream, keep the governed model as the authority and publish the contract in a format that can state it. - Value constraints beyond enumerations have nowhere to go. Max-lengths, patterns, and
numeric bounds are not vocabulary constructs. Enumerations are the exception — they map both
ways through
schema:Enumeration.
Roles, in one line: schema/import is Admin, schema/export, schema/map, and plan/execute
are Viewer, and mapping.kind=ai raises the bar to Editor or Admin wherever it appears. For
ready-to-paste bodies covering every format key the dispatch speaks, see the Schema Transformation
API guide in the CoreModels docs.