SPARQL, GraphQL, WebSocket, and REST Endpoints¶
All endpoints described on this page are available on port 7001. Authentication is required on all endpoints in production deployments; see the API Overview for details.
Trial image
The trial image (ghcr.io/indentiaplatform/indentiadb-trial) runs without authentication by default — all endpoints are accessible without credentials. The -u root:changeme flags shown in examples are included for consistency with production usage and are ignored in trial mode.
SPARQL Query — GET / POST /sparql¶
Execute SPARQL 1.2 SELECT, ASK, CONSTRUCT, and DESCRIBE queries against the RDF triple store.
GET Request¶
URL-encode the query parameter and send it as a query string:
curl -u root:changeme \
"http://localhost:7001/sparql?query=SELECT%20%3Fs%20%3Fp%20%3Fo%20WHERE%20%7B%20%3Fs%20%3Fp%20%3Fo%20%7D%20LIMIT%2010"
Optional dataset parameters:
| Parameter | Description |
|---|---|
default-graph-uri |
Restrict the query to a specific default graph |
named-graph-uri |
Include an additional named graph in the dataset |
POST Request — application/sparql-query¶
Send the raw SPARQL query string as the request body:
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/sparql-query" \
-H "Accept: application/sparql-results+json" \
-d "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10"
POST Request — application/x-www-form-urlencoded¶
Send the query as a form-encoded query field:
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "query=SELECT ?s WHERE { ?s a <http://example.org/Person> }"
Response Formats¶
The Accept header controls the serialization format:
| Accept Header | Format | Use Case |
|---|---|---|
application/sparql-results+json |
JSON | SELECT, ASK, CONSTRUCT, DESCRIBE |
application/sparql-results+xml |
XML | SELECT, ASK |
text/csv |
CSV | SELECT (tabular export) |
text/tab-separated-values |
TSV | SELECT (tabular export) |
application/x-ndjson |
NDJSON | SELECT (streaming) |
text/turtle |
Turtle | CONSTRUCT, DESCRIBE |
application/n-triples |
N-Triples | CONSTRUCT, DESCRIBE |
application/n-triples;profile="http://www.w3.org/ns/rdf-canon#c14n" |
N-Triples Canonical (C14N) | CONSTRUCT, DESCRIBE — deterministic output |
The format query parameter (json, xml, csv, tsv, turtle, ntriples, ntriples-c14n) is an alternative to the Accept header.
TSV and CSV¶
Both tabular formats list their columns in the query's SELECT projection order (SELECT ?s ?p ?o gives the columns s, p, o), on the buffered and the streaming path alike.
- TSV follows the SPARQL 1.1 Query Results TSV format: the header row carries
?-prefixed variable names, and every cell is a complete RDF term in Turtle/N-Triples syntax — IRIs in<…>, quoted literals with their datatype or language tag,_:blank nodes and RDF 1.2 triple terms (<<( … )>>). Tabs, newlines and carriage returns inside a term are written as\t,\nand\r. TSV is the lossless tabular format: a literal can never be mistaken for an IRI. - CSV follows the SPARQL 1.1 CSV format: plain variable names in the header, bare term values without datatype or language tag, and RFC 4180 quoting. Use TSV or JSON when term types matter.
?s ?p ?o
<http://example.org/alice> "42"^^<http://www.w3.org/2001/XMLSchema#integer> "hallo"@nl
_:b0 <<( <http://example.org/alice> <http://example.org/name> "Alice" )>>
Changed behavior
Earlier releases wrote TSV cells as bare values (like CSV), omitted the ? header prefix and sorted columns alphabetically. Update TSV consumers that relied on that shape.
Canonical N-Triples (RDF 1.2 C14N)
The canonical N-Triples variant implements the escaping rules from the RDF 1.2 Candidate Recommendation (7 April 2026) and the related RDF Dataset Canonicalization spec. It differs from plain N-Triples in two ways:
- Canonical character escaping —
\b,\t,\n,\f,\r,\",\\as named escapes; all other C0/C1 controls, DEL, and XML 1.1 invalid characters as\uXXXX. - Language tag canonicalization — language tags are lowercased
per BCP47 (
"chat"@EN-GB→"chat"@en-gb), including the direction component of directional language-tagged strings ("مرحبا"@AR--RTL→"مرحبا"@ar--rtl).
Select it via either:
- Accept header with profile parameter:
- Query parameter:
Use canonical form when you need byte-for-byte reproducible output for hashing, signing, or diff-based change detection.
CONSTRUCT and DESCRIBE output format
CONSTRUCT and DESCRIBE queries return a JSON array of N-Triples strings when Accept: application/sparql-results+json is used:
[
"<http://example.org/alice> <http://xmlns.com/foaf/0.1/name> \"Alice\"",
"<http://example.org/bob> <http://xmlns.com/foaf/0.1/name> \"Bob\""
]
Accept: text/turtle or Accept: application/n-triples — the response will be one triple per line in N-Triples format (which is a valid Turtle subset).
SELECT Example¶
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/sparql-query" \
-H "Accept: application/sparql-results+json" \
-d '
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT ?person ?name
WHERE {
?person a foaf:Person ;
foaf:name ?name .
}
ORDER BY ?name
'
Response:
{
"head": { "vars": ["person", "name"] },
"results": {
"bindings": [
{
"person": { "type": "uri", "value": "http://example.org/alice" },
"name": { "type": "literal", "value": "Alice" }
}
]
}
}
ASK Example¶
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/sparql-query" \
-H "Accept: application/sparql-results+json" \
-d '
PREFIX ex: <http://example.org/>
ASK { ex:alice a ex:Person }
'
Response:
CONSTRUCT Example¶
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/sparql-query" \
-H "Accept: text/turtle" \
-d '
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
PREFIX ex: <http://example.org/>
CONSTRUCT { ?s foaf:name ?name }
WHERE { ?s foaf:name ?name }
'
DESCRIBE Example¶
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/sparql-query" \
-H "Accept: text/turtle" \
-d 'DESCRIBE <http://example.org/alice>'
Named Dataset Parameters¶
Restrict a query to a specific named graph:
curl -u root:changeme \
-X POST http://localhost:7001/sparql \
-H "Content-Type: application/sparql-query" \
-H "Accept: application/sparql-results+json" \
-d 'SELECT * WHERE { ?s ?p ?o }' \
"http://localhost:7001/sparql?default-graph-uri=http://example.org/employees"
Read Consistency — consistency=strong¶
In an HA cluster, default reads are answered locally (fast, eventually consistent). For linearizable reads, opt in per request with the consistency=strong query parameter or the X-Indentia-Consistency: strong header:
curl -X POST "http://localhost:7001/sparql" \
-H "X-Indentia-Consistency: strong" \
-H "Content-Type: application/sparql-query" \
-d 'SELECT * WHERE { ?s ?p ?o } LIMIT 10'
A strong read executes a Raft read-index barrier before the query. A follower answers with a 307 redirect to the leader; a node that cannot confirm quorum answers 503 — never stale data. See High Availability for details.
Streaming Results¶
Large result sets are delivered with chunked transfer encoding once they exceed the configured streaming threshold (default 1,000 results). Streaming responses include X-Rows-Sent and X-Duration-Ms trailer headers. Send X-No-Streaming: true to force a fully buffered response.
With the server flag IG_SPARQL_LAZY_NDJSON=1, SELECT results additionally stream lazily — rows are serialized as they are produced, without materializing the full result set in memory:
| Query form | Lazy streaming format |
|---|---|
| SELECT | NDJSON (Accept: application/x-ndjson), CSV, TSV, or SPARQL Results XML |
| CONSTRUCT / DESCRIBE | N-Triples |
| SELECT (JSON), ASK, COUNT | Always buffered (the SPARQL JSON format requires a full pre-scan) |
Lazy streams are governed by the same execution limits and timeout as buffered queries — the timeout bounds the entire evaluation — and a client disconnect hard-cancels the running query, releasing its resources.
SPARQL Update — POST /update¶
Execute SPARQL 1.1 Update operations to modify the triple store.
Request¶
curl -u root:changeme \
-X POST http://localhost:7001/update \
-H "Content-Type: application/sparql-update" \
-d '
PREFIX ex: <http://example.org/>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
INSERT DATA {
ex:alice a ex:Person ;
foaf:name "Alice" ;
foaf:age 30 .
}
'
A successful update returns HTTP 200 with a JSON confirmation body:
{
"success": true,
"message": "Update applied successfully",
"snapshot_index": 1,
"affected_triples": 7
}
Delta Overlay Limit — HTTP 413¶
Live updates land in an in-memory delta overlay that is bounded by the [limits] configuration (max_delta_quads, default 5,000,000 quads). An update that would push the overlay past the cap is rejected fail-closed with HTTP 413 and error code delta_overlay_limit_exceeded instead of risking an out-of-memory crash:
{
"error": "delta_overlay_limit_exceeded",
"message": "Delta overlay limit exceeded (current=4980000, attempted=60000, max=5000000). Use bulk index build for large datasets."
}
Batch bulk writes at ≤ ~2,000 triples per UPDATE and use the offline bulk index build for large datasets — see Scale & Sharding.
Request Limits for UPDATE¶
The WHERE part of an update is evaluated under the same [limits] as a query (max_results, query_timeout_secs, query_memory_mb, plus per-actor and per-role overrides), counted across the whole request rather than per operation. For updates the limits are always hard: an update is never applied to a truncated set of bindings.
| Limit exceeded | Status | error |
|---|---|---|
More WHERE solutions than max_results |
400 | result_limit_exceeded |
| Request memory budget | 413 | memory_limit_exceeded |
| Query timeout | 408 | query_timeout |
Template expansion above the per-request ceiling — min(max_delta_quads, max_results) quads, or max_results when max_delta_quads = 0 |
400 | invalid_request |
CLEAR or DROP over NAMED or ALL graphs is rejected with 403 before evaluation. Clear or drop individual graphs instead (DROP GRAPH <iri>).
Write Rate Limiting — [limits.write_qos]¶
An optional per-actor token bucket protects the write path (and the reads that share its locks) from a single chatty writer. It is disabled by default:
[limits.write_qos]
enabled = true
updates_per_sec = 10 # sustained rate per actor; 0 = unlimited
burst = 50 # bucket capacity per actor
[limits.write_qos.per_actor."ingest-service"]
updates_per_sec = 5 # unset fields inherit the defaults above
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch |
updates_per_sec |
10 |
Sustained refill rate per actor (updates/second); 0 means unlimited |
burst |
50 |
Bucket capacity per actor; must be greater than 0 when a rate is set |
per_actor."<actor id>" |
— | Overrides updates_per_sec and/or burst for one actor id |
A request over budget receives HTTP 429 (too_many_requests) with a Retry-After header (seconds until the next token). The gate applies to POST /update only; internal replication and projection work is never throttled. Rejections are counted in ig_write_qos_rejections_total.
Idempotent Retries — X-Indentia-Mutation-Id¶
Send a client-generated canonical UUIDv7 in X-Indentia-Mutation-Id to make a write safe to retry. The header works on every /update request — it no longer requires X-Indentia-Valid-From — and the server echoes it on the response.
curl -u root:changeme \
-X POST http://localhost:7001/update \
-H "Content-Type: application/sparql-update" \
-H "X-Indentia-Mutation-Id: 01991a2b-3c4d-7e5f-8a6b-7c8d9e0f1a2b" \
-d 'INSERT DATA { <http://example.org/alice> <http://example.org/status> "active" }'
| Situation | Result |
|---|---|
| Exact retry of an applied write | 200 with the original snapshot_index; the data is not applied again (affected_triples may report 0) |
| Same ID reused for a different write | 409 |
| Not a canonical UUIDv7 | 400 |
| ID older than the retry horizon (5 minutes after its UUIDv7 timestamp) | 400 — issue a new UUIDv7 |
Generate a new UUIDv7 for every logical write and keep the request body and headers byte-identical when retrying it. For valid-time writes, see Bitemporal — Temporal Writes via HTTP Headers.
Conditional Publication¶
A cluster-mode /update can be guarded by a version precondition on a publication control subject (compare-and-set). The update commits only if the control subject still holds the expected version, and it must move the control to the next version in the same request. Both parts are required together — otherwise the request fails with 400:
Content-Type: application/vnd.indentiagraph.publication-update+sparql(the body is the unchanged SPARQL update);- exactly one
X-Indentia-Publication-Preconditionheader containing a JSON object of at most 4,096 bytes.
curl -X POST http://localhost:7001/update \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.indentiagraph.publication-update+sparql" \
-H "X-Indentia-Mutation-Id: 01991a2b-3c4d-7e5f-8a6b-7c8d9e0f1a2b" \
-H 'X-Indentia-Publication-Precondition: {"graph":"https://id.indentia.ai/sources/tenant-a/docs","subject":"https://id.indentia.ai/sources/tenant-a/docs/doc-1/publication","predicate":"https://id.indentia.ai/publication/version","expected_version":"01991000-0000-7000-8000-000000000001","next_version":"01991000-0000-7000-8000-000000000002"}' \
--data-binary @publish.ru
| Field | Required | Meaning |
|---|---|---|
graph |
yes | Control graph — under https://id.indentia.ai/sources/<tenant>/ or exactly urn:indentia:rag:<tenant> |
subject |
yes | Control subject — under <graph>/ or https://id.indentia.ai/document/Document/<tenant>/ |
predicate |
yes | Version predicate in the https://id.indentia.ai/publication/ namespace |
expected_version |
yes | Current version — a canonical UUIDv7 that must equal the single plain-literal value of predicate — or explicit null to bootstrap a control subject that does not exist yet |
next_version |
yes | Canonical UUIDv7 greater than expected_version; the update must leave exactly this value on the control subject |
dependencies |
no | Up to 8 read-only {subject, predicate, expected_version} gates in the same graph that must still hold their version |
Rules and outcomes:
- The caller needs a tenant claim, read and write permission on the graph, and a configured authorization service; otherwise 403.
- The control state and each dependency are limited to 256 quads.
- Standalone mode, temporal headers (
X-Indentia-Valid-From) and tenant-routed writes do not support this request form (400). - 200 returns the ordinary update body. 409 means the precondition was lost (version mismatch, control already exists on bootstrap, or a concurrent publication won) — no data was written by that attempt.
- 503 (with
Retry-After) until every replica runs a release that supports cluster command format v9 and the cluster has activated it. See High Availability for the upgrade procedure.
Generate a new X-Indentia-Mutation-Id for each publication attempt and keep its exact body and header bytes; if the outcome is unknown, resend that attempt unchanged. Read the control state back with a consistency=strong query.
INSERT DATA¶
PREFIX ex: <http://example.org/>
INSERT DATA {
ex:bob a ex:Person ;
<http://xmlns.com/foaf/0.1/name> "Bob" .
}
DELETE DATA¶
DELETE / INSERT WHERE¶
Conditional update based on a pattern match:
PREFIX ex: <http://example.org/>
DELETE { ex:alice ex:status "inactive" }
INSERT { ex:alice ex:status "active" }
WHERE { ex:alice ex:status "inactive" }
Graph Management¶
Create a named graph:
Drop a named graph and all its triples:
CLEAR/DROP of NAMED or ALL graphs is not accepted on /update (403); name the graphs individually.
Copy all triples from one graph to another:
Move triples (copy then drop source):
Graph Store Protocol — GET / PUT / POST / DELETE /data¶
The SPARQL 1.1 Graph Store HTTP Protocol allows reading and writing entire graphs via HTTP.
GET — Read a Graph¶
Retrieve a named graph in the desired RDF serialization:
# N-Triples (default)
curl -u root:changeme \
-H "Accept: application/n-triples" \
"http://localhost:7001/data?graph=http://example.org/employees"
# Turtle
curl -u root:changeme \
-H "Accept: text/turtle" \
"http://localhost:7001/data?graph=http://example.org/employees"
# TriG (includes GRAPH <iri> { ... } wrapper for named graphs)
curl -u root:changeme \
-H "Accept: application/trig" \
"http://localhost:7001/data?graph=http://example.org/employees"
Retrieve the default graph:
Supported Accept headers for GET:
| Accept Header | Response Content-Type |
|---|---|
application/n-triples (default) |
application/n-triples; version=1.2 |
application/n-triples-star |
application/n-triples-star; version=1.2 |
text/turtle |
text/turtle; version=1.2 |
application/trig |
application/trig; version=1.2 |
application/rdf+xml |
application/rdf+xml; version=1.2 |
application/ld+json |
application/ld+json; version=1.2 |
The version=1.2 parameter signals RDF 1.2 conformance — clients that understand quoted triples and rdf:JSON can use this to opt in to RDF-star-aware parsing.
GET /data?all=true, or a GET /data without graph/default that sends Accept: application/n-quads, returns the whole dataset instead — see Dataset Export.
PUT — Replace a Graph¶
Replace the entire content of a named graph (or the default graph) with the uploaded RDF:
curl -u root:changeme \
-X PUT \
-H "Content-Type: text/turtle" \
"http://localhost:7001/data?graph=http://example.org/employees" \
--data-binary @employees.ttl
Replace the default graph:
curl -u root:changeme \
-X PUT \
-H "Content-Type: text/turtle" \
http://localhost:7001/data \
--data-binary @data.ttl
POST — Merge Into a Graph¶
Add triples to an existing named graph without removing existing triples:
curl -u root:changeme \
-X POST \
-H "Content-Type: text/turtle" \
"http://localhost:7001/data?graph=http://example.org/employees" \
-d '
@prefix ex: <http://example.org/> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
ex:carol a ex:Person ; foaf:name "Carol" .
'
DELETE — Remove a Graph¶
Delete all triples from a named graph:
Delete the default graph:
Supported Content Types¶
| Content-Type | Format |
|---|---|
text/turtle |
Turtle |
application/n-triples |
N-Triples |
application/n-quads |
N-Quads (includes graph name) |
application/trig |
TriG (Turtle with named graphs) |
application/rdf+xml |
RDF/XML |
application/ld+json |
JSON-LD |
Dataset Export¶
Two Admin-only endpoints stream the complete dataset — every named graph plus the default graph — as N-Quads. Both read the raw dataset without ACL filtering (an export that silently dropped hidden data would be useless as a backup), stream without a result cap, and abort the response body mid-stream on an internal error instead of ending a partial export with a clean 200. Neither export is a point-in-time snapshot: writes applied while an export runs may or may not be included.
GET /data?all=true — Full N-Quads Dump¶
The response is application/n-quads; version=1.2. Blank-node labels are consistent across the whole dump, so a blank node shared between graphs keeps a single label. A bare GET /data with Accept: application/n-quads (and no graph or default parameter) returns the same dump. Requires Admin permission (403 otherwise).
GET /admin/export/nquads — RDF 1.2 Export¶
A batched RDF 1.2 N-Quads export (base directions, triple terms <<( s p o )>>) intended as input for rebuilding or attaching an index. It takes the dataset read lock once per batch, never for the whole export.
curl -u root:changeme -D headers.txt \
"http://localhost:7001/admin/export/nquads?min_snapshot_index=1200" \
-o export.nq
| Parameter | Default | Meaning |
|---|---|---|
min_snapshot_index |
— | Refuse with 409 (export_behind_requested_snapshot, retryable: true) unless this node has applied at least this log index |
batch |
4096 |
Quads per lock acquisition (clamped to 64–65,536) |
skip_invalid |
false |
Leave out quads that N-Quads 1.2 cannot express (for example a literal in subject position) instead of aborting the export |
The response header X-IG-Export-Snapshot-Index reports the snapshot index at which the export started. With skip_invalid=true, a complete export ends with a comment line that states the counts, including how many quads were left out:
With skip_invalid=true, a body that lacks this trailing comment is incomplete. Without skip_invalid, the first unrepresentable quad aborts the stream.
Vector Top-K — POST /vector/topk¶
A native top-k cosine-similarity scan over chunk embeddings stored in one named graph, bypassing the SPARQL layer. The scan is scoped to a caller-supplied set of documents: only chunks linked to one of doc_iris are scored.
curl -u root:changeme \
-X POST http://localhost:7001/vector/topk \
-H "Content-Type: application/json" \
-d '{
"graph": "urn:indentia:rag:tenant-a",
"query_vector": [0.012, -0.034, 0.051],
"doc_iris": ["urn:indentia:doc:tenant-a/report-2026.pdf"],
"k": 20
}'
| Field | Required | Default / limits |
|---|---|---|
graph |
yes | Named graph holding the chunks (IRI) |
query_vector |
yes | Non-empty array of finite numbers |
doc_iris |
yes | 1–200 document IRIs scoping the scan |
k |
yes | 1–1000 |
field |
no | Embedding predicate, default urn:indentia:rag:embedding |
text_field |
no | Chunk text predicate, default urn:indentia:rag:chunkText |
headings_field |
no | Heading trail predicate, default urn:indentia:rag:headings |
belongs_to_field |
no | Chunk → document predicate, default urn:indentia:rag:belongsTo |
Embeddings are read from literals of comma- or space-separated numbers, optionally wrapped in […]. A chunk whose embedding does not parse or has a different dimension than the query vector is skipped.
{
"hits": [
{
"chunk": "urn:indentia:chunk:tenant-a/17",
"doc": "urn:indentia:doc:tenant-a/report-2026.pdf",
"text": "…",
"headings": "Results > Revenue",
"score": 0.91
}
]
}
Hits are sorted by score (descending), at most k. text and headings are null when absent.
| Status | Cause |
|---|---|
| 400 | Invalid field value or IRI |
| 401 / 403 | Missing authentication, or no Read permission |
| 403 | The requested graph is not visible to the caller |
| 429 | Query admission queue is full |
| 503 | The node is shedding load under memory pressure |
The endpoint enforces Read permission and graph-level visibility. Document-level filtering inside a visible graph is the caller's responsibility: pass only document IRIs the user may see.
Stream Ingest — POST /stream/ingest, GET /stream/status¶
Offset-ordered ingestion for event producers with exactly-once semantics. Each batch carries a per-producer offset; the batch's triples and the new offset are written in one update, so data and offset commit atomically and survive restarts.
curl -u root:changeme \
-X POST http://localhost:7001/stream/ingest \
-H "Content-Type: application/json" \
-d '{
"producer": "sensor-gateway-1",
"offset": 42,
"events": [
{
"event_id": "evt-9001",
"graph": "https://example.org/graphs/sensors",
"subject": "https://example.org/sensor/17",
"predicate": "https://example.org/status",
"object": "online",
"op": "upsert"
}
]
}'
{ "producer": "sensor-gateway-1", "offset": 42, "accepted": 1, "duplicate": false, "last_offset": 42 }
| Field | Meaning |
|---|---|
producer |
Producer name; the key of the offset sequence (no spaces, <, >, " or \) |
offset |
Offset of this batch, starting at 1 |
events |
Up to 1,000 events; an empty list is a valid offset-only heartbeat |
events[].graph, subject, predicate |
Absolute IRIs |
events[].object |
Value, written as a plain string literal |
events[].op |
upsert (default) replaces every existing value of subject/predicate in that graph; delete removes them |
events[].event_id |
Optional, for tracing only |
Offset handling:
| Offset sent | Result |
|---|---|
last_offset + 1 |
Applied — 200 with accepted = number of events |
≤ last_offset |
Duplicate — 200 with duplicate: true and accepted: 0; nothing changes, so producers can safely redeliver |
> last_offset + 1 |
Gap — 409; the producer must resend the missing offsets first |
More than 1,000 events or an invalid IRI/producer name returns 400. At most IG_STREAM_MAX_INFLIGHT batches (default 8) run concurrently; further batches get 429 and should be retried with backoff. POST /stream/ingest requires Write permission.
GET /stream/status (Read permission) returns the offsets as known to the answering node:
{
"producers": [ { "producer": "sensor-gateway-1", "last_offset": 42, "events": 1337 } ],
"max_inflight": 8,
"offset_graph": "https://id.indentia.ai/sources/_/stream-offsets"
}
Offsets are stored as ordinary triples in the reserved named graph shown in offset_graph.
Workspace Expansion — POST /workspaces/{id}/expand¶
When the workspaces API is enabled (IG_WORKSPACES_ENABLED=true or a IG_WORKSPACES_CONFIG file; otherwise the routes return 404), expand resolves a workspace into the document IRIs and tables the caller may see. A workspace the caller has no role on answers 404, exactly like a missing one.
A collection that references a tenant source graph (https://id.indentia.ai/sources/<tenant>/<source>) expands to the documents in that graph only — subjects typed https://id.indentia.ai/document/Document or minted under https://id.indentia.ai/document/Document/ — not to chunk or entity subjects.
Unpaged (empty body):
{ "workspace": "…", "iris": ["…"], "tables": [ { "catalog": "…", "namespace": "…", "table": "…" } ] }
Paged (send limit and/or cursor):
curl -X POST http://localhost:7001/workspaces/q3-review/expand \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"limit": 1000, "cursor": "<next_cursor from the previous page>"}'
limitis 1–5,000 (default 1,000 when only acursoris sent).irisis one page of the ascending, de-duplicated set;next_cursorisnullon the last page.- Paging is keyset-based: no duplicates or reordering while the scope changes, and every page is cut from the caller's own ACL view, so a cursor cannot widen access.
stampchanges when the scope or the caller's access changes.
| Setting | Default | Effect |
|---|---|---|
IG_WORKSPACE_EXPAND_UNPAGED_MAX (TOML expand_unpaged_max) |
1000 |
Maximum visible references one collection may contribute to an unpaged expand |
IG_WORKSPACE_EXPAND_MAX_IRIS (TOML expand_max_iris) |
100000 |
Absolute ceiling for any expansion, paged or not |
Errors:
| Status | error |
Cause |
|---|---|---|
| 400 | collection_too_large |
An unpaged expand exceeds IG_WORKSPACE_EXPAND_UNPAGED_MAX for one collection, or any expansion exceeds IG_WORKSPACE_EXPAND_MAX_IRIS. The body adds collection and limit. |
| 400 | invalid_cursor |
Malformed cursor, or a cursor issued for another workspace |
| 400 | bad_request |
limit out of range or an unparseable body |
{
"error": "collection_too_large",
"message": "collection exceeds 1000 document references; page the expansion or narrow the workspace collection",
"collection": "https://id.indentia.ai/sources/tenant-a/sharepoint",
"limit": 1000
}
Projection-Backed Endpoints — /sql, /lpg/query, /algo/{name}, /locy/query¶
POST /sql, POST /lpg/query, POST /algo/{name} and POST /locy/query read the embedded SurrealKV projection of the RDF data. All four require Admin permission. While that projection is being (re)built, or after its repair has been parked, they fail closed with 503 instead of reading a half-built mirror:
{
"error": "projection_not_ready",
"message": "projection mirror not ready: the SurrealKV projection is being (re)built from the authoritative dataset; retry later",
"projection_repair_parked": false,
"retryable": true
}
In cluster mode a follower redirects POST /sql to the leader (307) before checking its own projection, so a certified leader serves the request even while the follower is still rebuilding. (/sql wraps the JSON above in the message of a standard service_unavailable error.)
After a restart the projection resumes from a certified watermark and replays only the missed changes; a full rebuild is the fallback. If IG_PROJECTION_MAX_FULL_REBUILDS consecutive full rebuilds fail (default 3; 0 never parks), the repair is parked: the projection stays unavailable (projection_repair_parked: true, retryable: false) until an administrator fixes the cause and resumes it, or the node restarts.
POST /admin/projection/resume¶
Requires Admin permission. The next repair cycle runs a full pass with a fresh failure budget; projection_ready turns true once it is certified. Nodes without an embedded projection answer 503 embedded_surreal_not_configured.
Production profile
With IG_PRODUCTION=true, /sql is disabled (403) and /lpg/query, /algo/{name} and /locy/query are refused (409), because these local projections cannot yet prove they are up to date with the replicated log.
GraphQL — POST /graphql¶
Execute GraphQL queries and mutations against the IndentiaDB schema.
Query Example¶
curl -u root:changeme \
-X POST http://localhost:7001/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "{ article { id title author } }"
}'
Response:
{
"data": {
"article": [
{ "id": "article:1", "title": "Introduction to Graph Databases", "author": "Alice" }
]
}
}
Mutation Example¶
curl -u root:changeme \
-X POST http://localhost:7001/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "mutation CreateArticle($title: String!, $author: String!) { createArticle(data: {title: $title, author: $author}) { id title } }",
"variables": { "title": "GraphQL in IndentiaDB", "author": "Bob" }
}'
Variable Substitution¶
Variables are passed in the variables field of the request body:
curl -u root:changeme \
-X POST http://localhost:7001/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query GetArticle($id: ID!) { article(id: $id) { title content } }",
"variables": { "id": "article:1" }
}'
Schema Introspection¶
curl -u root:changeme \
-X POST http://localhost:7001/graphql \
-H "Content-Type: application/json" \
-d '{ "query": "{ __schema { types { name } } }" }'
Error Format¶
GraphQL errors are returned alongside partial data using the standard GraphQL error structure:
{
"data": null,
"errors": [
{
"message": "Field 'nonexistent' does not exist on type 'article'",
"locations": [{ "line": 1, "column": 3 }],
"path": ["article", "nonexistent"]
}
]
}
SHACL Validation — POST /shacl/validate¶
Validate RDF data against SHACL (Shapes Constraint Language) shapes.
Request¶
Send a JSON body with a shapes field containing the SHACL shapes as a Turtle string. The validation is run against the current contents of the triple store.
curl -u root:changeme \
-X POST http://localhost:7001/shacl/validate \
-H "Content-Type: application/json" \
-d '{
"shapes": "@prefix sh: <http://www.w3.org/ns/shacl#> .\n@prefix ex: <http://example.org/> .\n@prefix foaf: <http://xmlns.com/foaf/0.1/> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n\nex:PersonShape\n a sh:NodeShape ;\n sh:targetClass ex:Person ;\n sh:property [ sh:path foaf:name ; sh:minCount 1 ; sh:datatype xsd:string ] ."
}'
Response — Conforms¶
When the data satisfies all shapes:
{
"conforms": true,
"results": [],
"stats": {
"focus_nodes_validated": 2,
"constraints_checked": 4,
"violations": 0,
"warnings": 0,
"infos": 0,
"duration_ms": 3
}
}
Response — Violation¶
When constraints are violated, each violation is reported with its focus node, path, severity, and message:
{
"conforms": false,
"results": [
{
"focusNode": "http://example.org/alice",
"resultPath": "http://xmlns.com/foaf/0.1/name",
"severity": "http://www.w3.org/ns/shacl#Violation",
"message": "Every Person must have exactly one foaf:name of type xsd:string",
"sourceShape": "http://example.org/PersonShape",
"sourceConstraintComponent": "http://www.w3.org/ns/shacl#MinCountConstraintComponent"
}
]
}
Severity Levels¶
| Severity IRI | Meaning |
|---|---|
sh:Violation |
Hard constraint failure |
sh:Warning |
Advisory constraint |
sh:Info |
Informational constraint |
WebSocket — /ws¶
The WebSocket endpoint provides real-time streaming for LIVE SELECT queries. Connect over ws:// (or wss:// for TLS) and send JSON messages.
Connection¶
Authentication credentials must be included as query parameters or in the initial handshake message:
LIVE SELECT Subscription¶
Send a JSON message to subscribe to live changes on a table or query:
{
"id": "sub-001",
"method": "query",
"params": ["LIVE SELECT * FROM article WHERE status = 'published'"]
}
The server responds with a subscription UUID:
CREATE Notification¶
When a new record is created that matches the live query:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"action": "CREATE",
"result": {
"id": "article:5",
"title": "New Article",
"status": "published"
}
}
UPDATE Notification¶
When a matching record is updated:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"action": "UPDATE",
"result": {
"id": "article:5",
"title": "Updated Title",
"status": "published"
}
}
DELETE Notification¶
When a matching record is deleted:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"action": "DELETE",
"result": {
"id": "article:5"
}
}
KILL — Unsubscribe¶
Send a KILL message with the subscription UUID to stop receiving notifications:
JavaScript Example¶
const ws = new WebSocket('ws://localhost:7001/ws');
ws.onopen = () => {
// Subscribe to live changes
ws.send(JSON.stringify({
id: 'sub-1',
method: 'query',
params: ['LIVE SELECT * FROM sensor_reading WHERE value > 90']
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.id === 'sub-1') {
// Store the subscription UUID for later use
const subscriptionId = msg.result;
console.log('Subscribed:', subscriptionId);
return;
}
// Handle live notifications
console.log('Live event:', msg.action, msg.result);
// Unsubscribe after receiving 10 events
if (eventCount++ >= 10) {
ws.send(JSON.stringify({
id: 'kill-1',
method: 'kill',
params: [msg.id]
}));
}
};
ws.onerror = (err) => console.error('WebSocket error:', err);
ws.onclose = () => console.log('Disconnected');
Python Example¶
import asyncio
import json
import websockets
async def live_select():
uri = "ws://localhost:7001/ws"
async with websockets.connect(uri) as ws:
# Subscribe to live changes
await ws.send(json.dumps({
"id": "sub-1",
"method": "query",
"params": ["LIVE SELECT * FROM orders WHERE status = 'pending'"]
}))
# Get subscription UUID
response = json.loads(await ws.recv())
subscription_id = response["result"]
print(f"Subscribed with ID: {subscription_id}")
# Process live events
async for message in ws:
event = json.loads(message)
print(f"Action: {event['action']}, Data: {event['result']}")
# Kill subscription after first event
await ws.send(json.dumps({
"id": "kill-1",
"method": "kill",
"params": [subscription_id]
}))
break
asyncio.run(live_select())
Health Endpoints¶
IndentiaDB exposes four health endpoints with a deliberate split: the public probes reveal only an aggregate status, while the full cluster evidence lives behind authentication.
| Endpoint | Auth | Purpose |
|---|---|---|
GET /live |
public | Process liveness — always {"status": "alive"} (200) while the process accepts connections. Kubernetes livenessProbe / startupProbe. |
GET /ready |
public | Traffic readiness — {"status": "ready"} (200) or {"status": "not_ready"} (503). Kubernetes readinessProbe. |
GET /health |
public | Minimal status probe. |
GET /health/details |
Admin permission | Full node and cluster evidence. |
GET /health — minimal public probe¶
HTTP 200 when healthy, 503 otherwise. status is healthy, starting, or unhealthy. Node identities, addresses, Raft terms, replication progress, and shard topology are deliberately not exposed on the public probe.
Changed behavior
Older releases returned the full cluster document on /health. That detail — cluster members, replication lag, ha_ready, sharding layout — has moved to the authenticated /health/details endpoint. Unauthenticated callers of /health/details receive 401.
GET /health/details — authenticated HA evidence¶
{
"status": "healthy",
"role": "leader",
"node_id": 1,
"snapshot_index": 120,
"current_term": 7,
"cluster_members": [
{ "node_id": 1, "address": "node-1:7001", "role": "leader", "reachable": true },
{ "node_id": 2, "address": "node-2:7001", "role": "follower", "reachable": true },
{ "node_id": 3, "address": "node-3:7001", "role": "follower", "reachable": true }
],
"replication_lag": null,
"index_loaded": true,
"uptime_seconds": 86400,
"cluster_enabled": true,
"ha_ready": true,
"meta_voters": 3,
"meta_failure_margin": 1,
"sharding": {
"num_shards": 1,
"routing_num_shards": 1,
"routing_factor": 1,
"replicas_per_shard": 2,
"meta_replicas": 3
}
}
Notable fields:
| Field | Meaning |
|---|---|
role |
leader, follower, candidate, or standalone |
cluster_enabled |
true when clustered; false standalone (no replication) |
ha_ready |
Leader-local HA proof — true only when this node itself holds fresh quorum-acknowledged leadership evidence. Followers do not echo their leader's claim; standalone nodes always report false. |
ha_reason |
When not HA-ready: the first failed invariant, e.g. insufficient_voters, no_current_leader, local_node_not_leader, stale_quorum_ack |
meta_voters / meta_failure_margin |
Exact Raft voter count and how many voter failures still leave a strict majority |
replication_lag |
Follower lag behind the leader in log entries (null on leader/standalone) |
sharding |
Shard layout — omitted entirely in standalone mode |
Metrics — GET /metrics¶
Returns Prometheus-compatible metrics in the text exposition format.
Sample Response¶
# HELP indentiagraph_indentiagraph_queries_total Total number of queries executed
# TYPE indentiagraph_indentiagraph_queries_total counter
indentiagraph_indentiagraph_queries_total 15234
# HELP indentiagraph_indentiagraph_query_duration_seconds Query execution time
# TYPE indentiagraph_indentiagraph_query_duration_seconds histogram
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.005"} 12000
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.01"} 14500
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.025"} 15100
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.05"} 15200
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="+Inf"} 15234
indentiagraph_indentiagraph_query_duration_seconds_sum 45.2
indentiagraph_indentiagraph_query_duration_seconds_count 15234
# HELP indentiagraph_indentiagraph_cluster_size Number of cluster members
# TYPE indentiagraph_indentiagraph_cluster_size gauge
indentiagraph_indentiagraph_cluster_size 1
# HELP indentiagraph_indentiagraph_is_leader Whether this node is the Raft leader
# TYPE indentiagraph_indentiagraph_is_leader gauge
indentiagraph_indentiagraph_is_leader 1
Key Metrics¶
| Metric | Type | Description |
|---|---|---|
indentiagraph_indentiagraph_queries_total |
Counter | Total queries executed |
indentiagraph_indentiagraph_query_duration_seconds |
Histogram | Query execution latency distribution |
indentiagraph_indentiagraph_cluster_size |
Gauge | Number of cluster members |
indentiagraph_indentiagraph_is_leader |
Gauge | 1 if this node is the Raft leader, 0 otherwise |
indentiagraph_indentiagraph_leader_elections_total |
Counter | Total Raft leader elections |
Geospatial — POST /geospatial/evaluate¶
Evaluate GeoSPARQL functions for geographic data.
curl -u root:changeme \
-X POST http://localhost:7001/geospatial/evaluate \
-H "Content-Type: application/json" \
-d '{
"function": "geof:distance",
"args": [
"POINT(4.9041 52.3676)",
"POINT(13.4050 52.5200)",
"http://www.opengis.net/def/uom/OGC/1.0/kilometre"
]
}'
Alerts — GET / POST / DELETE /alerts¶
Manage threshold-based alerts on query results.
List Alerts¶
Create Alert¶
curl -u root:changeme \
-X POST http://localhost:7001/alerts \
-H "Content-Type: application/json" \
-d '{
"name": "high-latency",
"query": "SELECT count() FROM requests WHERE latency_ms > 1000 GROUP ALL",
"condition": "result > 0",
"interval_seconds": 60,
"webhook": "https://hooks.example.com/alert"
}'
Delete Alert¶
Holonic Four-Graph — /holonic/*¶
Native implementation of Cagle's holonic model on named graphs.
The router is mounted only when the server is started with
[holonic] enabled=true (see the Holonic feature page).
/holonic/health is always mounted and is in the authentication
bypass list so orchestrators and probes can poll it without
credentials.
Readiness — GET /holonic/health¶
Response shapes:
| Status | Body | Meaning |
|---|---|---|
200 |
{"available": true, "default_tenant": "acme"} |
Service attached, ready to accept CRUD |
503 |
Holonic service not configured (enable via AppState::enable_holonic) |
Feature not enabled on this server |
Holon CRUD — POST/GET /holonic/holons¶
# Declare a holon in the tenant registry graph
curl -u root:changeme \
-X POST "http://localhost:7001/holonic/holons?tenant=acme" \
-H 'Content-Type: application/json' \
-d '{"iri":"https://id.indentia.ai/identity/person/acme/leon",
"label":"Leon de Vries",
"member_of":"https://id.indentia.ai/identity/org/acme/indentia"}'
# List every holon in the tenant
curl -u root:changeme \
"http://localhost:7001/holonic/holons?tenant=acme"
# Get one holon's detail including every layer
curl -u root:changeme \
"http://localhost:7001/holonic/holons/<url-encoded-iri>?tenant=acme"
Layer writes¶
Four layer endpoints share the same body shape:
{"turtle": "<valid Turtle>", "graph_slug": "optional-slug"}.
The server parses @prefix directives via oxttl and emits
canonical N-Triples into the target named graph.
| Layer | Endpoint | Typical use |
|---|---|---|
| Interior | POST /holonic/holons/{iri}/interior |
A-Box facts from a specific source (CRM, LDAP, WhatsApp) |
| Boundary | POST /holonic/holons/{iri}/boundary |
SHACL shape acting as membrane |
| Context | POST /holonic/holons/{iri}/context |
Membership + PROV-O events |
| Projection | POST /holonic/holons/{iri}/projection |
Derived views |
Portals — POST/GET /holonic/portals¶
Portals encode cross-holon transforms as RDF rather than code.
A cga:TransformPortal triple in the source holon's boundary
graph declares target + a SPARQL CONSTRUCT that translates the
source into the target's schema.
# Register a portal
curl -u root:changeme \
-X POST "http://localhost:7001/holonic/portals?tenant=acme" \
-H 'Content-Type: application/json' \
-d '{
"iri": "urn:portal:leon-to-crm",
"source": "https://id.indentia.ai/identity/person/acme/leon",
"target": "https://id.indentia.ai/identity/person/crm/leon",
"construct_query": "CONSTRUCT { ?s <http://crm/worksAt> ?o } WHERE { ?s <http://indentia.ai/core#worksAt> ?o }"
}'
# List outbound portals from a holon
curl -u root:changeme \
"http://localhost:7001/holonic/portals?tenant=acme&source=<url-encoded-source-iri>"
Authentication + Authorization¶
When an AuthenticationService is attached to the server:
| Method | Required | Status when missing |
|---|---|---|
Anonymous on any /holonic/* |
Authentication | 401 Unauthorized |
GET, HEAD |
Permission::Read |
403 Forbidden |
POST, PUT, PATCH, DELETE |
Permission::Write |
403 Forbidden |
Write without AuthorizationService (but authn enabled) |
Configured authz | 403 Forbidden |
Cross-tenant ?tenant= values that do not match the actor's
org_scope.org_path slug are logged under the holonic.authz
tracing target but currently not blocked — blocking requires a
canonical tenant claim on Actor which is a follow-up ADR.
Caveats¶
- Cluster mode is refused at startup — holonic writes
bypass the Raft-routed
UpdateExecutorpath, which would diverge from followers. Configure standalone mode for holonic ingest. - SPARQL
INSERT DATAcannot carry@prefix— the store's customparse_intodecodes Turtle to N-Triples first. - The
/holonic/healthprobe always responds, even when holonic is disabled (503). Rely on status codes, not the presence of the route, to distinguish "not mounted" from "not configured".
See Holonic Four-Graph Layer for the conceptual model and deployment pattern.