Skip to content

Query with SPARQL and validate writes with SHACL

View Markdown

This page is the reference for exact reads and checked writes. Use SPARQL to count, filter and follow the links between records. Use SHACL rules to check what gets written. The examples extend the support graph from the quickstart, and core concepts explains the names.

You want Use
Joins, paths, filters and aggregates in standard SPARQL SPARQL 1.1 text: POST /v1/query/sparql-text
A query that app code builds as JSON, with typed fields and no query text Structured query: POST /v1/query/sparql
YASGUI, Protégé, RDFLib or another SPARQL client The /sparql endpoint
Rules that check your records and every write SHACL: POST /v1/schema/publish
Facts that follow from your schema Reasoning & inference

The three query surfaces read the same data. They follow the same rules for which commit they serve.

The examples use the quickstart’s support graph with two more ticket fields. severity is a keyword, and opened_at is a date and time. Ticket 4823 gets no severity yet. The conformance report in the SHACL section lists it.

Run the quickstart through step 3 first. Then add the fields. The TypeScript and Python snippets on this page continue one script.

import { LbbClient } from "@littlebigbrain/client";
const lbb = new LbbClient({
baseUrl: process.env.LBB_URL!,
apiKey: process.env.LBB_API_KEY!,
graph: "support",
});
await lbb.ontology.evolve({
ops: [
{ op: "add_property", name: "severity", value_type: "keyword" },
{ op: "add_property", name: "opened_at", value_type: "date_time" },
],
});
const fields = await lbb.graph("support").facts.create(
{
entity_properties: [
{ type: "Ticket", key: "4821", name: "login fails after 3.1",
properties: { severity: "high", opened_at: "2026-09-02T09:15:00Z" } },
{ type: "Ticket", key: "4822", name: "invoice PDF is blank",
properties: { severity: "medium", opened_at: "2026-09-03T14:40:00Z" } },
{ type: "Ticket", key: "4823", name: "CSV export times out",
properties: { opened_at: "2026-09-04T08:05:00Z" } },
],
},
{ idempotencyKey: "sparql-guide-fields-1" },
);
await lbb.waitForPublished(fields.commit_seq);

SPARQL is the W3C query language for graphs. A query is a list of patterns. Each pattern names a record, then a link or field, then a record or value.

Names that start with ? are variables. The query returns every set of values that fits all the patterns at once.

Each type, link and field you declare has a global name, an IRI. A query shortens IRIs with prefixes:

PREFIX dclass: <https://littlebigbrain.com/class/>
PREFIX dr: <https://littlebigbrain.com/r/>
PREFIX dp: <https://littlebigbrain.com/p/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
You declared In SPARQL
Type Ticket dclass:ticket
Link REPORTED_AS dr:reported_as
Field severity dp:severity
A record’s name rdfs:label

The local names are lowercase. Each query below needs the prefix lines above. The examples leave them out.

This query lists each ticket with its fields. a means “has type”, and ; starts another pattern about the same record. OPTIONAL keeps ticket 4823, which has no severity:

SELECT ?ticket ?severity ?opened WHERE {
?t a dclass:ticket ; rdfs:label ?ticket ; dp:opened_at ?opened .
OPTIONAL { ?t dp:severity ?severity }
}
ORDER BY ?opened
ticket severity opened
login fails after 3.1 high 2026-09-02T09:15:00+00:00
invoice PDF is blank medium 2026-09-03T14:40:00+00:00
CSV export times out 2026-09-04T08:05:00+00:00

A path follows several links in one pattern. dr:reported_as/dr:fixed_in goes from a ticket to its bug, then to the release. GROUP BY and COUNT count the tickets per release:

SELECT ?release (COUNT(?t) AS ?tickets) WHERE {
?t a dclass:ticket ; dr:reported_as/dr:fixed_in ?r .
?r rdfs:label ?release .
}
GROUP BY ?release
ORDER BY ?release

Release 3.1.1 and Release 3.2 each count one ticket. Ticket 4823 has no release, so it drops out. A + after a link follows it one or more times, as in dr:fixed_in+. A ^ before a link follows it backwards.

FILTER NOT EXISTS keeps the rows that lack a pattern. This query finds the bugs with no fix yet. It returns export runs on one thread:

SELECT ?bug WHERE {
?b a dclass:bug ; rdfs:label ?bug .
FILTER NOT EXISTS { ?b dr:fixed_in ?r }
}

FILTER also compares values, as in FILTER(?opened >= "2026-09-03T00:00:00Z"^^xsd:dateTime). The ^^ suffix gives a value its datatype. Read FILTER comparisons and datatypes before you compare dates. Core concepts maps Cypher patterns to SPARQL.

POST /v1/query/sparql takes the query as JSON. Use it when app code builds a query from parts, such as a filter form. You write no query text and no IRIs. Link names are case-insensitive.

The structured form covers a subset of SPARQL. Each pattern links two records, and each variable binds a record. Fields appear in filters, group keys and aggregates. For paths, OPTIONAL, UNION or negation, use SPARQL text.

This body counts the tickets per severity:

{
"patterns": [{ "subject": { "var": "t" }, "predicate": "REPORTED_AS", "object": { "var": "b" } }],
"group_keys": [{ "property": { "var": "t", "field": "severity", "as": "severity" } }],
"aggregates": [{ "func": "count", "as": "tickets" }],
"order_by": [{ "var": "severity" }]
}

The response has one entry in groups per severity, with value_keys.severity and aggregates.tickets. Ticket 4823 has no severity, so its group key is null.

Field Meaning
patterns The links to match: subject, predicate (a link name) and object. A side is a variable, { "var": "t" }, or one record, { "entity": { "entity_id": "…" } }.
filters Conditions on each row, joined with AND.
group_by Variables whose records form the groups. They come back in groups[].keys.
group_keys Group by a field, { "property": { "var", "field", "as" } }, or by a date bucket, { "date_bucket": { "var", "field", "granularity", "as" } }. granularity is year, month, week, day or hour, in UTC. The keys come back in groups[].value_keys.
aggregates count, sum, avg, min or max, each with an as name. operand names a number, such as { "property": { "var", "field" } }. count needs none, and takes distinct.
having Conditions on the groups.
order_by Sort keys: { "var": "…", "descending": false }.
select, distinct The variables to return, and whether to drop duplicate rows. Without select, every variable comes back.
ask true returns only boolean: whether any row matches.
limit, offset Page the rows. row_page reports the page.
max_solutions Cap the rows. Past the cap, the response sets truncated: true.
max_object_reads, max_fetched_bytes Accepted. The server’s own read budget applies, so truncated_by_read_budget stays false.
consistency, min_indexed_seq, as_of_commit_seq Which commit to read. See strong and eventual consistency.
reason true returns 400. Stored rules already run when a commit is published.
profile true adds a profile with the time, reads and join order of this request. See See how a query ran.
Filter part Shape
Comparison { "compare": { "op", "left", "right" } }. op is eq, ne, lt, le, gt or ge.
Logic { "and": […] }, { "or": […] } or { "not": {…} }
Operand A record { "var": "t" }, a field { "property": { "var", "field" } }, or a value { "value": { "<type>": … } }
Value types str, i64, f64, bool, date_time or entity

A comparison with an unbound or mismatched operand is false. GET /v1/ontology lists the field names in property_defs.

The response holds vars, and solutions for rows or groups for aggregates. A row binds each variable to a record with its id, name and type. boolean answers ask, and snapshot names the commit read.

The SDKs add filterByAttributes / filter_by_attributes. It takes the patterns plus simple field comparisons:

// Tickets opened on or after 3 September, with the release that fixed them.
const recent = await lbb.entities.filterByAttributes({
patterns: [
{ subject: { var: "t" }, predicate: "REPORTED_AS", object: { var: "b" } },
{ subject: { var: "b" }, predicate: "FIXED_IN", object: { var: "r" } },
],
where: [{ var: "t", field: "opened_at", op: "ge", value: { dateTime: "2026-09-03T00:00:00Z" } }],
select: ["t", "r"],
});
for (const row of recent.solutions) {
console.log(row.bindings.t.name, "->", row.bindings.r.name);
}
// The full structured request: tickets per severity.
const bySeverity = await lbb.sparql({
patterns: [{ subject: { var: "t" }, predicate: "REPORTED_AS", object: { var: "b" } }],
group_keys: [{ property: { var: "t", field: "severity", as: "severity" } }],
aggregates: [{ func: "count", as: "tickets" }],
});
console.log(bySeverity.groups.length, "groups");

The filter returns one row, invoice PDF is blank -> Release 3.1.1. Ticket 4823 is newer too, but no release fixes its bug yet. The severity query returns three groups.

POST /v1/query/sparql-text runs a SPARQL 1.1 SELECT or ASK query. The response carries the SPARQL Results JSON document as a string, in results. CONSTRUCT and DESCRIBE return RDF, so send them to /sparql.

Body field Meaning
query The query text.
entailment none (the default), subclass, rdfs or owl.
as_of_commit_seq Run the query on the graph as it was at this commit.
limit, offset Page the rows after the query runs. row_page reports the page.
cursor Page a long ordered listing on one commit.
request The user’s words behind the query. The server records an eval trace and returns its trace_id.
reason true returns 400. Stored rules already run when a commit is published.
profile true adds a profile with the time, reads and join order of this request. See See how a query ran.

consistency and min_indexed_seq go on the URL. The SDKs parse the result into rows:

const query = `
PREFIX dclass: <https://littlebigbrain.com/class/>
PREFIX dp: <https://littlebigbrain.com/p/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?ticket ?severity WHERE {
?t a dclass:ticket ; rdfs:label ?ticket ; dp:severity ?severity .
}
ORDER BY ?ticket`;
const { vars, rows } = await lbb.sparqlRows({ query });
for (const row of rows) console.log(row.ticket, row.severity);
const anyBug = await lbb.sparqlRows({ query: "ASK { ?b a <https://littlebigbrain.com/class/bug> }" });
console.log(anyBug.boolean);

Each returns two rows. invoice PDF is blank has medium, and login fails after 3.1 has high. The ASK query returns true.

Each piece of evidence on a link is one statement node. It uses the standard RDF reification terms (rdf:subject, rdf:predicate, rdf:object) and LBB terms in https://littlebigbrain.com/ns#: lbb:evidenceText, lbb:evidenceSource, lbb:observation, lbb:confidence, lbb:commit, and lbb:evidenceRegion for a page region. A link without evidence still matches the outer pattern:

PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX lbb: <https://littlebigbrain.com/ns#>
SELECT ?bug ?text ?source WHERE {
?ticket <https://littlebigbrain.com/r/reported_as> ?bug .
OPTIONAL {
?st rdf:subject ?ticket ; rdf:predicate <https://littlebigbrain.com/r/reported_as> ;
rdf:object ?bug ; lbb:evidenceText ?text ; lbb:evidenceSource ?source .
}
}

The text keeps at most 4,096 bytes; a cut text carries lbb:evidenceTextTruncated true. A write may not name a statement node or a predicate in the lbb: namespace: it returns 400 reserved_namespace.

Evidence that a document workflow reads from a file names the file and the place in it. Its lbb:evidenceSource is the file’s Document IRI with a fragment, such as https://littlebigbrain.com/e/41ab…#page=3 for page 3 of a PDF. IRI(STRBEFORE(?source, "#")) is the Document, whose p:name is the file’s name.

A link’s own fields are a statement node too. The values use https://littlebigbrain.com/ep/<field>, with the field names of your schema. They are typed like record fields. p:status matches records only, and ep:status matches links only.

PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX ep: <https://littlebigbrain.com/ep/>
SELECT ?ticket ?bug ?status WHERE {
?ticket <https://littlebigbrain.com/r/reported_as> ?bug .
?st rdf:subject ?ticket ; rdf:predicate <https://littlebigbrain.com/r/reported_as> ;
rdf:object ?bug ; ep:status ?status .
}

In append mode each statement of a link with properties is its own node. Order them by lbb:commit to pick the newest. A write may not name a predicate in the ep: namespace: it returns 400 reserved_namespace.

A query reads the facts as written by default (entailment: "none"). The values subclass, rdfs and owl apply those rules at query time. They take the schema from the commit the query reads. See Reasoning & inference.

as_of_commit_seq runs the query on the graph as it was at that commit. The response echoes the pin in snapshot.as_of_commit_seq. A pin past the latest commit returns 400.

A commit that is no longer retained returns 400 historical_generation_unavailable. The server never answers from a nearby commit instead. Replay an answer covers retention.

Only as_of_commit_seq pins a read. Valid time is not queryable. /sparql?as_of_valid_time= returns 400 historical_generation_unavailable. The text route rejects an as_of_valid_time field with 400.

cursor pages a long listing on one commit. Send "" for the first page. Then send each next_cursor back with the same query. The query must order by LCASE(STR(?label)) STR(?id). It needs one LIMIT from 1 to 10,000, and no OFFSET.

Other query shapes return 400 invalid_pagination_cursor. Page those with limit and offset. A cursor whose commit is no longer retained returns 410 pagination_cursor_expired. Then start again from the first page.

A write becomes part of the published data a second or two after its commit. The consistency option sets which data a read covers:

Mode What it reads Use it when
eventual (default) The latest published commit. You want the fast default, or you record the commit you read.
strong The latest published commit plus every newer write. It is exact right after each acknowledged write. You must read your own write at once.
Surface Where you set it Default
Structured POST /v1/query/sparql consistency in the JSON body eventual
Text POST /v1/query/sparql-text ?consistency= on the URL eventual
Native /sparql ?consistency= on the URL. An unknown value returns 400. eventual

An eventual response names the commit it read in snapshot.served_at_seq. When newer writes exist, it also sets stale: true and stale_reason: "eventual_consistency". A strong text response has no snapshot block.

To read one write, pass its commit_seq as min_indexed_seq. It is a body field on the structured route and a URL parameter elsewhere. A floor with no consistency makes the read strong.

With consistency=eventual, a floor returns a retryable 429 read_your_writes_pending until publication covers it. Retry after Retry-After, or call waitForPublished / wait_for_published first. A floor above the newest commit returns the same 429 in every mode.

Reasoning works the same in both modes. It uses the schema of the latest published commit.

In the console Query view, a selector sets the mode per run. The default is strong, so a query sees your latest writes. An eventual result shows an “as of @N” badge. When a strong run still returns strong_read_pending after its retries, the view polls publication and runs the query again. A button runs the eventual read once.

In Query, turn on Profile and run the query. A profile shows under the results:

  • The total time, and the time of each stage as a bar.
  • The blocks and bytes the query read, and the cache hits and misses.
  • Whether the trigram index served a text filter, and the reason when it did not.
  • The operators that ran, with their input and output rows.
  • The join order: the patterns in the order the planner chose, with its estimated row count for each.

A profiled run skips the result cache, so it always executes. The fields are described in See how a query ran.

Query planner, under Graph, shows what the planner reads for the graph: the triple, subject and object counts of each predicate, and the statistics and indexes of the published generation. It lists the profiles you ran in the browser tab.

Each query has its own read budget, deadline and cancellation. A query that hits its limit does not affect other queries on the graph.

Past the read budget or the deadline, a query fails with a typed error. It never returns part of a result as a success. Joins check cancellation as they build their results and stop at the existing intermediate-row limit while those rows form.

The read budget counts every byte a query reads from the index. Bytes served from cache count too. The budget does not measure network transfer or billed storage.

Code Status Meaning What to do
query_deadline_exceeded 503 The query ran past its 25-second deadline. Retry after Retry-After, or narrow the query.
scan_too_wide 400 The query needs more index reads than one request may make. Narrow the pattern, such as with a type or a fixed record.
result_too_large 400 The result is over 1,000,000 rows or 256 MiB, or one join step builds over 16,000,000 rows. Add LIMIT, tighten the pattern, or page. Join patterns through a shared variable.
strong_read_pending 429 The exact view for a strong read is not ready. Retry after Retry-After, or read eventual.
read_your_writes_pending 429 Publication does not cover the min_indexed_seq floor yet, or the floor is above the newest commit. Retry after Retry-After.
ingest_busy 429 An eventual read found nothing published yet. Retry after Retry-After.
historical_generation_unavailable 400 The pinned commit is no longer retained. Read a retained commit.
pagination_cursor_expired 410 The cursor’s commit is no longer retained. Start from the first page.

A query that nests too deeply fails with 400 invalid_request. The message starts with query too deeply nested. A retry of the same query fails again.

Brackets nest at most 48 levels deep. Graph patterns nest at most 128 levels, and expressions at most 64. A group, a UNION or an || list holds at most 2,048 items. Use VALUES or IN for long lists of alternatives.

An error body is {"error": {…}} with type, code, message, param, request_id and doc_url. A retryable error also has retryable: true and retry_after_seconds.

Each stack serves the SPARQL 1.1 Protocol at /sparql. YASGUI, Protégé and RDFLib’s SPARQLWrapper connect to it directly. They send the stack API key as a bearer token. The endpoint accepts three request forms:

  • GET /sparql?query=<url-encoded query>
  • POST /sparql with a form body, query=…
  • POST /sparql with the query as an application/sparql-query body

SELECT and ASK return SPARQL Results JSON, XML, CSV or TSV, chosen by Accept. CONSTRUCT and DESCRIBE return Turtle or N-Triples. ?format= overrides Accept with json, xml, csv, tsv, turtle or nt.

URL parameter Meaning
consistency, min_indexed_seq Which commit to read, as above.
as_of_commit_seq Run the query at this commit.
entailment none, subclass, rdfs or owl. An unknown value returns 400.
reason true returns 400, as on the text route.
max_graph_triples Lower the triple cap for one CONSTRUCT or DESCRIBE.
format Override Accept.

A CONSTRUCT or DESCRIBE answer holds at most 1,000,000 triples by default. A larger answer returns 400 before the server sends any byte. So a 200 answer is always complete. max_graph_triples can lower the cap, and a value above the cap returns 400.

This request returns the ticket names as CSV:

Terminal window
curl --fail-with-body -sS -G "$LBB_URL/sparql?graph=support" \
-H "Authorization: Bearer $LBB_API_KEY" -H "Accept: text/csv" \
--data-urlencode "query=
PREFIX dclass: <https://littlebigbrain.com/class/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?ticket WHERE { ?t a dclass:ticket ; rdfs:label ?ticket } ORDER BY ?ticket"

The same endpoint accepts SPARQL Update, as an update= form field or an application/sparql-update body. So does /update. Each graph serves one default RDF graph. A write into a named graph returns 400, so keep datasets apart with separate graphs.

A CONSTRUCT query over the whole graph returns it as Turtle. The HTTP API has the full protocol reference.

In SPARQL, a comparison that is a type error drops the row and reports no error. A comparison of datatypes the engine does not order returns an empty answer.

The engine orders these values in <, <=, >, >=, =, MIN, MAX and ORDER BY:

  • Numbers across the XSD numeric types, strings in code point order, and booleans.
  • Dates and times of one datatype: xsd:dateTime, xsd:date, xsd:time and the Gregorian types. These are gYear, gYearMonth, gMonthDay, gMonth and gDay.
  • Durations of one datatype: xsd:yearMonthDuration and xsd:dayTimeDuration in a total order. xsd:duration uses the XSD partial order.

The Gregorian types go beyond the SPARQL 1.1 operator table. They use the XSD 1.1 order, because real data often types years and months this way. In the xsd:duration partial order, some pairs stay type errors, such as P30D and P1M.

A value without a timezone compared with a zoned value takes UTC, as SPARQL prescribes. A stored "2020-06-01T12:00:00"^^xsd:dateTime passes FILTER(?when > "2020-06-01T11:59:59Z"^^xsd:dateTime). It also equals "2020-06-01T14:00:00+02:00"^^xsd:dateTime.

Pairs of different datatypes stay type errors. Examples are xsd:date with xsd:dateTime, and gYear with gYearMonth. So give a bound the same datatype as the data. The opened_at field is an xsd:dateTime:

# Two tickets: 4822 and 4823.
SELECT ?ticket WHERE {
?t rdfs:label ?ticket ; dp:opened_at ?opened .
FILTER(?opened >= "2026-09-03T00:00:00Z"^^xsd:dateTime)
}
# No rows: an xsd:date bound against xsd:dateTime values is a type error.
SELECT ?ticket WHERE {
?t rdfs:label ?ticket ; dp:opened_at ?opened .
FILTER(?opened >= "2026-09-03"^^xsd:date)
}

The response does not count the rows that type errors dropped. The server records the count as filter_type_errors in its query log. It also counts them in the metric lbb_sparql_filter_type_errors_total.

The date functions follow the same rule:

Function Accepts
YEAR() xsd:dateTime, xsd:date, xsd:gYear, xsd:gYearMonth
MONTH() xsd:dateTime, xsd:date, xsd:gYearMonth
DAY() xsd:dateTime, xsd:date

So SUM(YEAR(?o)) over values typed gYear returns the sum. DBLP types years this way. A missing part is a type error: MONTH() on a gYear, or DAY() on a gYearMonth.

To compare a string with a date, cast the string first. The date casts follow the XPath casting table:

Cast Accepts
xsd:dateTime() a string, xsd:dateTime, xsd:date
xsd:date() a string, xsd:date, xsd:dateTime
xsd:time() a string, xsd:time, xsd:dateTime

A date casts to the start of its day. An invalid string, such as "2026-13-01", is a type error, so a BIND of it stays unbound.

A SUM, AVG, MIN or MAX whose every input is a type error comes back unbound. A group with no rows still sums to 0, as the standard says.

SHACL is the W3C standard for rules about your records. A rule set is called a set of shapes. A shape selects records, such as every ticket, and states what each one must have.

You publish shapes for a graph. The platform then checks every record in the background and publishes a conformance report. In reject mode it also checks each write and refuses one that breaks a rule.

These shapes state three rules for tickets. Each ticket has a severity. The severity is low, medium or high. Each ticket names the bug it was reported as:

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix dclass: <https://littlebigbrain.com/class/> .
@prefix dr: <https://littlebigbrain.com/r/> .
@prefix dp: <https://littlebigbrain.com/p/> .
<https://example.org/shapes/Ticket> a sh:NodeShape ;
sh:targetClass dclass:ticket ;
sh:property [ sh:path dp:severity ; sh:minCount 1 ;
sh:message "A ticket has a severity" ] ;
sh:property [ sh:path dp:severity ; sh:in ( "low" "medium" "high" ) ;
sh:message "Severity {$value} is not low, medium or high" ] ;
sh:property [ sh:path dr:reported_as ; sh:minCount 1 ;
sh:message "A ticket names the bug it was reported as" ] .

sh:targetClass selects the records of a type. sh:path names the field or link that a rule checks. sh:minCount and sh:in state the rules, and sh:message gives each one a message.

Save the shapes as shapes.ttl. Publish them in warn mode, then read the report:

import { readFileSync } from "node:fs";
const shapesTurtle = readFileSync("shapes.ttl", "utf8");
const shapes = { source: shapesTurtle, format: "turtle" };
const published = await lbb.schema.publish({ desired_mode: "warn", shapes });
let report = await lbb.ontology.conformance({ consistency: "strong" });
console.log(published.shapes_version, report.conforms, report.result_count);
for (const result of report.results ?? []) console.log(result.focus_node, result.message);

The SDKs and curl --retry repeat the strong read until the new report exists. The report does not conform. It has one result, for ticket 4823:

{
"conforms": false,
"ontology_version": 3,
"result_count": 1,
"results": [
{
"component": "http://www.w3.org/ns/shacl#MinCountConstraintComponent",
"focus_node": "<https://littlebigbrain.com/e/1077df79bbd316129e6547ae6d855426>",
"message": "A ticket has a severity",
"result_path": "<https://littlebigbrain.com/p/severity>",
"severity": "violation",
"source_node_shape": "<https://example.org/shapes/Ticket>",
"source_shape": "_:d0ebc6f9ff3a2bc554bf310bcf643615",
"value": null
}
],
"shapes_version": 1,
"validated_at_seq": 2
}

GET /v1/ontology/conformance returns the report. Reading it never runs a validation. A publish changes metadata only. Its response means the shapes are active. It says nothing about the current data.

The default read returns the latest report. While new commits are being validated, that report can be for an earlier commit. validated_at_seq names the commit it checked. Add consistency=strong to wait for the report of the latest commit.

Field Meaning
conforms true when no record breaks a rule.
result_count The exact number of results.
validated_at_seq The commit the report checked.
ontology_version, shapes_version The schema and shapes the report checked.
results One entry per result, up to ?limit= (default 200, at most 2,000).
truncated true when results holds fewer entries than result_count.

Each result has a focus_node (the record) and a component (the rule that failed). It also has result_path, value, severity and message. details lists the inner results of a nested failure.

source_node_shape is the shape that selected the record. source_shape is the rule inside it, often a blank node.

An eventual read returns the report for the latest published commit. A strong read returns 429 strong_read_pending until a report for the latest commit exists. Check shapes_version and validated_at_seq before you act on a report. An older report can describe older shapes.

Validation does not delay queries. Suppose you evolve the ontology while shapes are active. The platform then restates the same shapes for the new ontology version. No reader sees a new ontology with stale shapes.

The platform checks common rules with fast scans of the index. These rules check one direct link or field: counts, datatypes, classes and similar checks. The platform checks sh:or over such rules the same way.

Other rules use the general validator. Examples are SPARQL constraints, recursive paths, nested logical rules and closed shapes. That validator has a memory budget for background work. A validation that does not fit fails visibly. It never exceeds the budget or skips the check.

Each result keeps its severity, source shape and message. A publish parses every shape and returns 400 for what the validator cannot run:

  • a malformed SPARQL constraint,
  • an sh:ask validator,
  • a SPARQL-based target (sh:target),
  • an sh:path expression the engine does not recognize.

A shape set that publishes can run. The current data may still break it. SPARQL constraints declare prefixes the standard way. sh:prefixes points at sh:declare entries, which apply to the sh:select text.

sh:message accepts templates. {$this} is the record, {$value} the offending value and {$path} the path. A value that fails an sh:node shape lists the inner failure in details.

The publish response says how the shapes will be checked. write_enforcement has a timing and lists each root shape with write_enforceable:

timing When
write_time reject mode. Each write checks the records it can affect, at a cost that follows the size of the write.
write_time_full_scan reject mode on a graph written as JSON records, with a rule that has no bounded set of affected records. Each commit checks the whole graph.
after_publication warn or off mode. Writes are accepted, and the conformance report lists the results.

An sh:sparql constraint or a recursive shape reference has no bounded set of affected records. Its shape shows write_enforceable: false. A blockers entry names the shape and the constraint.

Each publish sets a desired_mode. The same source with another mode creates a new shapes_version. So to change the mode, send the shapes again.

Mode Records already stored Later writes
off Kept as they are. The conformance report is still produced. Not checked.
warn Kept as they are, and listed in the conformance report. Accepted. A JSON commit reports results in commit.schema_validation. SPARQL Update and Graph Store writes return no body, so only the report lists theirs.
reject Kept and listed in the report. A publish never deletes or rewrites them. A write that leaves a rule broken returns 400. Nothing from it is stored.

POST /v1/schema/publish?dry_run=true checks a publish without applying it. It parses the shapes and returns write_enforcement with activated: false. It does not validate the stored records.

Continue the example. Fix ticket 4823, wait for a clean report, then publish the same shapes in reject mode:

const repair = await lbb.graph("support").facts.create(
{ entity_properties: [{ type: "Ticket", key: "4823", name: "CSV export times out",
properties: { severity: "low" } }] },
{ idempotencyKey: "sparql-guide-repair-4823" },
);
do {
await new Promise((resolve) => setTimeout(resolve, 1_000));
report = await lbb.ontology.conformance({ consistency: "strong" });
} while ((report.validated_at_seq ?? 0) < repair.commit_seq);
if (report.conforms) {
await lbb.schema.publish({ desired_mode: "reject", shapes });
}

The report now conforms at the repair commit. The reject publish reports timing: "write_time".

Tighten validation without stopping ingestion

Section titled “Tighten validation without stopping ingestion”

When new shapes do not fit the records in a graph, change it in steps:

  1. Publish the new shapes with desired_mode: "warn".
  2. Wait for the strong report for that shapes_version, and read its results.
  3. Add, update or retract the affected data while writes are still accepted.
  4. Wait until the strong report says conforms: true at the latest commit.
  5. Publish the same shapes source with desired_mode: "reject".
  6. Treat a later 400 from a write as feedback to act on.

A rule such as sh:minCount needs care. Say a record and its required link arrive in two commits. In reject mode, the first commit fails. Put facts that require each other in one write, or load them under warn first.

Publishing reject onto data that breaks a rule keeps the data safe. Old records stay readable, and a refused write stores nothing. It can block repairs, though.

With write_time timing, a write checks only the records it can affect. Unrelated writes go through. A write that touches a broken record must leave it valid.

With write_time_full_scan timing, each commit checks the whole graph. One existing violation then refuses every write, until one write repairs all of them. /v1/schema/publish has no dry run that checks stored data against new shapes. confirm_restrictive does not add one.

In reject mode, a write that breaks a rule returns 400 and stores nothing. The message quotes the first violations:

schema validation failed in reject mode with 1 SHACL violation(s):
http://www.w3.org/ns/shacl#InConstraintComponent
on '<https://littlebigbrain.com/e/5c2db74be0bf1d95420c8d759178405a>'
path '<https://littlebigbrain.com/p/severity>': Severity "urgent" is not low, medium or high.
Preflight with dry_run=true to get the full structured report (schema_validation.results)

A dry run returns the same check as data and stores nothing. Send the write to POST /v1/graph/commit?dry_run=true, or call commitDryRun / commit_dry_run. The response has schema_validation.conforms and schema_validation.results.

const newTicket = { type: "Ticket", key: "4824", name: "password reset email never arrives" };
const preflight = await lbb.commitDryRun({
triplets: [{ source: newTicket, relation: "REPORTED_AS",
target: { type: "Bug", key: "AUTH-113", name: "mail queue drops resets" } }],
entity_properties: [{ ...newTicket, properties: { severity: "urgent" } }],
});
for (const result of preflight.schema_validation?.results ?? []) {
console.log(result.component, result.message);
}

The result is one sh:InConstraintComponent violation. Its message is Severity "urgent" is not low, medium or high. An agent can read the list, correct its write and send it again.

The check covers what a write removes. A commit whose remove_fields drops a required field returns 400. POST /v1/graph/retract is not checked. A retraction that breaks a rule is stored, and the conformance report lists it.

RDF import, SPARQL Update and Graph Store writes keep the source RDF terms. So a shape with sh:targetClass <https://example.com/ActiveStudySite> checks the records of that class by their own IRIs. It needs no internal ids.

A graph whose first write is RDF checks each reject-mode write before it applies it. The check reads the records the write can affect, including records the write does not name. Its cost follows the size of the write. See Load RDF data.

Refusal When What to do
400 shapes_not_write_enforceable A reject publish or a write meets an sh:sparql constraint or a recursive shape reference. The active shapes stay unchanged. Publish the set in warn mode and read the report, or remove the constraint.
400 shacl_write_validation_budget One write affects more than 250,000 records, or needs more than 2,000,000 reverse lookups. Split the write, or load it under warn.
429 strong_read_pending More than 64 MiB of statements wait for publication, or a read behind the check failed. Retry after Retry-After.

A graph written as JSON records never returns shapes_not_write_enforceable. There, a rule with no bounded set of affected records gets write_time_full_scan timing.

An agent writes facts one claim at a time, and some claims are wrong. RDF and SHACL fit that pattern:

  • RDF stores one fact at a time. A triple is a subject, a predicate and an object. A new kind of link needs no table design and no migration.
  • Global names merge facts. A record has one IRI everywhere. Facts from two agents, or from turn 1 and turn 900, attach to the same record.
  • A missing fact means unknown. A new fact does not rewrite others. An agent can record what it learns before the schema is complete.
  • Values keep their types. Numbers, dates and booleans compare and aggregate as values, without string matching.
  • SHACL checks a write before it is stored. In reject mode, a write that breaks a shape fails. The result names the record, the rule and a message.
  • Reasoning is repeatable. entailment derives facts from the schema the same way on every run. See Reasoning & inference.
  • Schema changes have a preview. POST /v1/ontology/evolve?dry_run=true reports conflicts with stored records before anything changes.
propose fact ──▶ SHACL check ──▶ conforms? ──yes──▶ commit
│ │
└── report ◀── no┘
│
agent reads the result
(record, rule, message)
and corrects the fact ──▶ retry

Standard RDF tools read the same graph, so an agent’s memory stays in an open format. Each fact keeps its evidence and its commit history.