# JSON-LD — API: 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:

```jsonc
{
  "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:

```json
{
  "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\" } } ] }"
}
```

```bash
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:

```json
{ "format": "jsonld" }
```

```bash
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:

```json
{
  "@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:

```bash
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:

```json
{
  "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:

```bash
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.
