Skip to content

CRUD

How a client reads, writes and modifies RDF over plain HTTP. Two layers over one store: the LOD layer, which addresses a resource at its canonical IRI and refuses to bend HTTP; and the system layer, which addresses a property slot or a single edge and is honest about the places where RDF set semantics make it deviate.

Status: descriptive. See ../../GOVERNANCE.md.

Profiles: RFC 9110, RFC 7396 (JSON Merge Patch), RFC 7232 (conditional requests), RFC 4648 §5 (base64url), JSON-LD 1.1, and the Linked Data principles. Error codes are index.md §5; contexts are contexts.md; who may do what is grants.md.

1. Two layers, one resource

SPS-CRUD-001 — A resource's identity is its LOD IRI. The system-layer URL is an operations address for the same resource and MUST NOT be treated as a second identity.

identity:    https://example.org/alice/contacts/bob-smith
operations:  https://example.org/alice/_system/resources/aHR0cHM6Ly9leGFtcGxlL…

SPS-CRUD-002 — For a resource inside the pod namespace, the two addresses MUST behave identically: the same body, the same entity tag, the same context rules, the same conditional-write semantics. An implementation MUST NOT implement them twice.

SPS-CRUD-003 — For an IRI outside the pod namespace there is no LOD address. The system layer MUST be the route that exists, and it MUST accept any IRI scheme — did:, urn:, mailto:, a foreign https: — without special-casing.

SPS-CRUD-004 — A resource path equal to _system or beginning _system/ MUST NOT be addressable through the LOD layer.

2. Embedding an IRI in a path

SPS-CRUD-005 — An IRI embedded in a system-layer path segment MUST be encoded as base64url without padding (RFC 4648 §5).

SPS-CRUD-006 — An implementation MUST NOT inspect an embedded IRI to decide how to decode it. There is no :// detection, no scheme allow-list and no container-specific rule; to the path parser an embedded IRI is an opaque blob.

Percent-encoding was the obvious alternative and does not survive contact with reverse proxies and servlet containers, which disagree about %2F, %23 and %3F. base64url has Web precedent — JWT, WebAuthn credential ids, PKCE challenges — and is a one-liner in every mainstream language.

{pod}/_system/resources/{b64url(iri)}                                       whole resource
{pod}/_system/resources/{b64url(subject)}/{b64url(predicate)}               slot
{pod}/_system/resources/{b64url(subject)}/{b64url(predicate)}/{b64url(iri)} single edge

3. The context rule

?context= selects the named graph an operation touches. It means different things on a write and on a read, and both meanings hold on both layers.

Writes

SPS-CRUD-007 — Every write MUST carry exactly one ?context=. A write with none MUST be 400; a write with the parameter repeated MUST be 400.

SPS-CRUD-008 — The repetition rule MUST be applied to the parameter's occurrence count, not to its non-blank values. ?context=valid&context= is a repeated parameter and MUST be 400.

SPS-CRUD-009 — An implementation MUST accept a canonical context IRI, and MAY additionally accept a pod-relative context path which it resolves to the canonical IRI.

SPS-CRUD-010 — A ?context= value naming a context that is not registered MUST be 404; a registered context the caller may not write MUST be 403 (SPS-CORE-018).

SPS-CRUD-011 — The resource IRI and the target context are independent dimensions. An implementation MUST allow a write whose resource IRI lies outside the target context's path, and MUST NOT derive one from the other.

This is the requirement that makes the model work, and the one an implementation is most tempted to "tighten". A pod may hold statements about did:web:bob.example, about another pod's resources, and about its own control-plane IRIs. What decides where they land is the writable context; the subject never does.

SPS-CRUD-012 — Any @graph or context member inside a request body MUST be treated as advisory. Statements MUST be persisted in the context named by ?context=.

SPS-CRUD-013 — There is no atomic multi-context write, at any layer. An implementation MUST NOT offer one, and MUST NOT offer a write path that spans contexts in one operation.

Reads

SPS-CRUD-014 — On a read, ?context= is an OPTIONAL downscope filter. With no value the result MUST be the union of every context the caller may read. With one or more values, repeated as separate parameters, the result MUST be the intersection of the requested set with the readable set.

SPS-CRUD-015 — A requested context the caller cannot read MUST be excluded silently (SPS-CORE-017).

SPS-CRUD-016 — A comma-separated list MUST NOT be accepted. Repetition is the only form.

A context IRI may legally contain a comma under RFC 3986, so a comma-separated list is ambiguous by construction.

SPS-CRUD-017 — Where the resulting set contains no statement for the resource, the response MUST be 404 — whether that is because the resource has none, because the requested contexts were unreadable, or because they do not exist. The three MUST be indistinguishable.

4. LOD layer

SPS-CRUD-018 — An implementation MUST serve GET, HEAD, OPTIONS, PUT, PATCH and DELETE at {pod}/{resourcePath}.

SPS-CRUD-019POST MUST NOT be offered on a resource IRI. Creation is PUT on the target IRI, which is idempotent and leaves the identifier choice with the client.

GET

SPS-CRUD-020GET MUST return every statement whose subject is the resource IRI and which is visible in the selected contexts.

SPS-CRUD-021 — The default JSON-LD representation MUST be the merged resource object, with named-graph provenance collapsed.

SPS-CRUD-022include_contexts=true MUST switch the representation to a JSON-LD named-graph array grouped by RDF context. It MUST NOT change which contexts are readable — ?context= remains the only downscope.

The canonical representation

SPS-CRUD-023 — The canonical JSON-LD shape is what GET returns with Accept: application/ld+json and without include_contexts=true. It MUST be:

  • a top-level object whose @id is the resource IRI;
  • @type present where the resource has rdf:type values;
  • predicate keys as absolute IRIs only;
  • values as arrays of JSON-LD value objects — {"@id": …} for an IRI, {"@value": …} with an optional @language or @type for a literal.

SPS-CRUD-024 — An implementation MUST NOT emit a top-level @context, and MUST NOT emit compact terms or CURIE-like keys.

SPS-CRUD-025 — An implementation MUST NOT expand CURIEs or resolve prefixes on any request body. Prefix resolution is the client's business.

Content negotiation

SPS-CRUD-026application/ld+json MUST be supported and MUST be the default. application/json MUST be answered with application/ld+json. application/n-quads MUST be supported.

SPS-CRUD-027 — An Accept header that cannot be satisfied MUST be 406. A negotiated response MUST carry Vary: Accept.

SPS-CRUD-028application/n-quads is graph-aware by format and MUST NOT be altered by include_contexts.

Entity tags

SPS-CRUD-029 — Every GET and HEAD response MUST carry a strong ETag, and If-None-Match MUST be honoured per RFC 7232.

SPS-CRUD-030 — A successful write MUST NOT claim an entity tag for the representation it just stored on the LOD resource path.

JSON-LD → RDF → JSON-LD is a transformation, so the stored representation is not byte-identical to the request body, and RFC 9110 §10.2.3 does not permit claiming a tag for it. A client that needs the new tag issues a GET.

PUT

SPS-CRUD-031PUT MUST replace all outgoing statements of the resource in the target context. Incoming statements from other resources MUST be untouched, and statements in other contexts MUST be untouched.

SPS-CRUD-032 — A request body MUST contain statements for the target resource only. A body carrying statements about other subjects MUST be 400.

SPS-CRUD-033 — Creation MUST answer 201 with a Location header. Replacement MUST answer 200 or 204.

SPS-CRUD-034If-Match MUST be honoured, and a mismatch MUST be 412. If-None-Match: * MUST be honoured for create-or-fail, and an existing representation MUST be 412.

PATCH

SPS-CRUD-035PATCH MUST accept application/merge-patch+json and MUST apply RFC 7396 strictly to the canonical JSON-LD representation in the target context.

SPS-CRUD-036 — The accepted top-level members MUST be exactly: @id — OPTIONAL, and where present it MUST equal the request's resource IRI or the request MUST be 400; @type; and absolute IRI predicate keys.

SPS-CRUD-037 — Every other JSON-LD keyword — including @context, @graph, @reverse, @nest and @included — MUST be 400. Compact terms and CURIE-like keys MUST be 400.

application/merge-patch+json is JSON Merge Patch, not a JSON-LD processing mode. Accepting a @context here would make the same document mean different things depending on a member the patch format has no opinion about.

SPS-CRUD-038 — RFC 7396 array semantics MUST be preserved exactly: a multivalued property is replaced wholesale. An implementation MUST NOT add a semantic RDF-aware merge.

Adding or removing one value from a multivalued property is what the system layer is for. This is a property of RFC 7396, not a defect to work around — and working around it silently is how two implementations stop agreeing about what a patch did.

DELETE

SPS-CRUD-039DELETE MUST remove the resource's outgoing statements in the target context and MUST leave incoming statements untouched. Success MUST be 204; nothing to delete MUST be 404. If-Match MUST be honoured.

5. System layer

A slot is the container for all values of (subject, predicate) within one context. It is a real HTTP resource: it has content, it answers GET, and it takes the standard verbs.

SPS-CRUD-040 — The resource-node route MUST offer GET, HEAD, OPTIONS, PUT, PATCH and DELETE with the semantics of §4, for an IRI at any scheme.

SPS-CRUD-041 — The slot route MUST offer GET, PUT, POST and DELETE:

Verb Semantics
GET all values as a JSON-LD array
PUT replace the slot's contents; an empty array clears it
POST add the given value or values
DELETE empty the slot

SPS-CRUD-057 — A slot GET MUST support include_contexts with the same meaning it has on a resource read (SPS-CRUD-022): default is the merged value array, and true switches to the named-graph form grouped by source context. It MUST change the representation only.

Without this the parameter is offered on a route that has nothing to do with it. A slot read spans the same union of readable contexts a resource read does, so the same question — which context did this value come from — is the same question here.

SPS-CRUD-042 — The single-edge route MUST offer DELETE, removing exactly (subject, predicate, target) and leaving the slot's other values in place. It MUST be available only where target is an IRI.

SPS-CRUD-043 — A Location header on a system-layer create MUST point at the system-layer route, because the canonical path does not exist for an external IRI.

The outcome representation

SPS-CRUD-044 — The three idempotent slot mutations MUST answer with a body naming the outcome:

Route Outcomes
slot POST created (201, with Location) · already_present (200)
slot DELETE cleared · already_empty (both 200)
edge DELETE removed · already_absent (both 200)

The distinction these report is precisely the one an idempotent status code cannot carry. A bare 204 on a slot DELETE makes "there was nothing to clear" and "the slot is now empty" indistinguishable to every caller outside the server. It is a body rather than a custom header so a browser fetch can read it without an Access-Control-Expose-Headers entry.

Deviations from HTTP, named

SPS-CRUD-045POST on a slot is idempotent in practice, because adding a statement that already exists is a no-op under RDF set semantics. A client MAY retry it without a conditional header. The verb stays POST because it expresses extend, not replace.

SPS-CRUD-046GET on a slot returns an unordered set. Array order MUST NOT be treated as meaningful, and MAY differ between calls.

SPS-CRUD-047POST MUST answer 201 with Location when a new edge was inserted and 200 without Location when the value was already present. Location is the unambiguous signal that a new edge exists.

These three are the complete list of deviations. Any other behaviour that surprises a competent HTTP client is a defect.

Literals

SPS-CRUD-048 — A literal MUST NOT be addressable as a path segment. Removing or modifying one literal value is GET, modify, PUT on the slot; DELETE on a slot holding literals empties the whole slot.

SPS-CRUD-049POST MUST accept a literal, carrying @value and an optional @language or @type.

This is the only structural asymmetry between IRI-valued and literal-valued slots. It is a URL-encoding limit — a literal plus datatype plus language tag has no stable path encoding — and not a design choice worth reproducing elsewhere.

Conditional requests on slots

SPS-CRUD-050 — A slot GET resolving to exactly one context MUST emit an ETag when the slot holds at least one statement there.

SPS-CRUD-051 — A slot GET spanning more than one context MUST NOT emit an ETag. The representation is a union of snapshots that no single tag can validate.

SPS-CRUD-052PUT, POST and slot DELETE MUST echo the slot's new ETag, so a client can chain conditional writes without an intervening GET.

SPS-CRUD-053If-None-Match: * on a slot MUST mean the slot holds no statement for (subject, predicate) in the write context. It MUST NOT be interpreted as "the subject does not exist".

The subject interpretation breaks both of the cases this route exists for: an external did:web: subject that certainly "exists" elsewhere, and a local subject that already has values for other predicates.

SPS-CRUD-054PUT MUST honour If-Match, with 412 on mismatch. POST MUST honour it where provided and MUST NOT require it. Single-edge DELETE MUST ignore it — the operation names a statement by identity, so the outcome is the same whether or not it was there.

6. What this layer does not do

SPS-CRUD-055 — An implementation MUST NOT canonicalise predicate IRIs. A predicate is stored exactly as written, and http://schema.org/name and https://schema.org/name MUST remain distinct.

Silently unifying them would make two implementations disagree about what a pod contains, and the disagreement would surface as data that vanishes on migration. A pod picks one canonical form and says so; clients normalise on the way in.

SPS-CRUD-056 — An implementation MUST NOT accept SPARQL Update, JSON Patch or N3 Patch bodies on either layer.

Not defined here, and named so a reader does not go looking: SHACL validation of cardinality, datatype and value range — until that layer exists, both layers accept any structurally valid write; bulk writes across several subjects in one call; and the atomic multi-context write ruled out by SPS-CRUD-013.

One consequence of that last one is worth stating plainly rather than leaving to be discovered: splitting a compound write per context does not reconstruct all-or-nothing semantics. The intermediate state is externally visible, and a compensating write can fail in turn. What is left is best-effort recovery. A caller who cannot tolerate that gap should not spread the data across contexts in the first place.