# Query with SPARQL and validate writes with SHACL

Source: https://docs.littlebigbrain.com/guides/sparql-and-shacl/

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](https://docs.littlebigbrain.com/start/quickstart/), and [core concepts](https://docs.littlebigbrain.com/start/concepts/)
explains the names.

## Choose a surface

| You want | Use |
| --- | --- |
| Joins, paths, filters and aggregates in standard SPARQL | [SPARQL 1.1 text](#sparql-11-text): `POST /v1/query/sparql-text` |
| A query that app code builds as JSON, with typed fields and no query text | [Structured query](#structured-query): `POST /v1/query/sparql` |
| YASGUI, Protégé, RDFLib or another SPARQL client | [The `/sparql` endpoint](#external-sparql-tools) |
| Rules that check your records and every write | [SHACL](#published-shacl-conformance): `POST /v1/schema/publish` |
| Facts that follow from your schema | [Reasoning & inference](https://docs.littlebigbrain.com/guides/reasoning/) |

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

## Example data

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.

### TypeScript

```ts
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);
```

### Python

```python
import os
import time

from lbb import LbbClient

lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support")

lbb.ontology.evolve({
    "ops": [
        {"op": "add_property", "name": "severity", "value_type": "keyword"},
        {"op": "add_property", "name": "opened_at", "value_type": "date_time"},
    ],
})
fields = 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"}},
    ],
}, idempotency_key="sparql-guide-fields-1")
lbb.wait_for_published(fields["commit_seq"])
```

### cURL

```sh
curl --fail-with-body -sS -X POST "$LBB_URL/v1/ontology/evolve?graph=support" \
  -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
  -d '{"ops": [
    {"op": "add_property", "name": "severity", "value_type": "keyword"},
    {"op": "add_property", "name": "opened_at", "value_type": "date_time"}
  ]}'

curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \
  -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: sparql-guide-fields-1" \
  -d '{"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"}}
  ]}'

until curl -sS "$LBB_URL/v1/graph/publication-status?graph=support" \
  -H "Authorization: Bearer $LBB_API_KEY" | grep -q '"state": *"current"'; do sleep 1; done
```

## SPARQL basics

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:

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

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

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

```sparql
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](#filter-comparisons-and-datatypes) before you compare dates.
[Core concepts](https://docs.littlebigbrain.com/start/concepts/#graph-queries-with-sparql) maps Cypher
patterns to SPARQL.

## Structured query

`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](#sparql-11-text).

This body counts the tickets per severity:

```json
{
  "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](#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](https://docs.littlebigbrain.com/guides/query-profiles/). |

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

### TypeScript

```ts
// 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");
```

### Python

```python
# Tickets opened on or after 3 September, with the release that fixed them.
recent = lbb.entities.filter_by_attributes(
    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": {"date_time": "2026-09-03T00:00:00Z"}}],
    select=["t", "r"],
)
for row in recent["solutions"]:
    print(row["bindings"]["t"]["name"], "->", row["bindings"]["r"]["name"])

# The full structured request: tickets per severity.
by_severity = lbb.sparql_select({
    "patterns": [{"subject": {"var": "t"}, "predicate": "REPORTED_AS", "object": {"var": "b"}}],
    "group_keys": [{"property": {"var": "t", "field": "severity", "as": "severity"}}],
    "aggregates": [{"func": "count", "as": "tickets"}],
})
print(len(by_severity["groups"]), "groups")
```

### cURL

```sh
curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql?graph=support" \
  -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
  -d '{"patterns": [
         {"subject": {"var": "t"}, "predicate": "REPORTED_AS", "object": {"var": "b"}},
         {"subject": {"var": "b"}, "predicate": "FIXED_IN", "object": {"var": "r"}}],
       "filters": [{"compare": {"op": "ge",
         "left": {"property": {"var": "t", "field": "opened_at"}},
         "right": {"value": {"date_time": "2026-09-03T00:00:00Z"}}}}],
       "select": ["t", "r"]}' \
  | jq -r '.solutions[] | "\(.bindings.t.name) -> \(.bindings.r.name)"'
```

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.

## SPARQL 1.1 text

`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`](#external-sparql-tools).

| 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](https://docs.littlebigbrain.com/guides/query-profiles/). |

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

### TypeScript

```ts
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);
```

### Python

```python
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"""

results = lbb.sparql(query)
for row in results:
    print(row["ticket"], row["severity"])

print(lbb.sparql("ASK { ?b a <https://littlebigbrain.com/class/bug> }").boolean)
```

### cURL

```sh
curl --fail-with-body -sS -X POST "$LBB_URL/v1/query/sparql-text?graph=support" \
  -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": "SELECT ?ticket ?severity WHERE { ?t a <https://littlebigbrain.com/class/ticket> ; <http://www.w3.org/2000/01/rdf-schema#label> ?ticket ; <https://littlebigbrain.com/p/severity> ?severity } ORDER BY ?ticket"}' \
  | jq -r '.results | fromjson | .results.bindings[] | "\(.ticket.value) \(.severity.value)"'
```

### MCP

```text
lbb_query mode=sparql graph=support
  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"
```

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

:::caution[Lowercase local names]
Types, links and fields you declare have IRIs of the form
`<https://littlebigbrain.com/{class,r,p}/name>`. The local name is
**lowercase**. An uppercase one, such as `<…/r/REPORTED_AS>`, is another IRI.
It matches nothing and reports no error.

The structured query is case-insensitive. The MCP `sparql` mode lowercases
these local names and adds a note that says so. Data loaded through
[RDF import](https://docs.littlebigbrain.com/guides/load-rdf/) keeps its **source IRIs**, so query those as
they are.
:::

### Read a link's evidence

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:

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

### Read a link's properties

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.

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

### Reasoning, history and paging

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](https://docs.littlebigbrain.com/guides/reasoning/).

`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](https://docs.littlebigbrain.com/guides/time-travel-audit/) 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.

### Strong and eventual consistency

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.

### Profile a query in the console

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](https://docs.littlebigbrain.com/guides/query-profiles/).

**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.

### Query limits and errors

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

### External SPARQL tools

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:

```sh
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](https://docs.littlebigbrain.com/sdks/http-api/#native-sparql-11-protocol) has the full protocol
reference.

### FILTER comparisons and datatypes

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

```sparql
# 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.

## Published SHACL conformance

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:

```turtle
@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:

### TypeScript

```ts
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);
```

### Python

```python
with open("shapes.ttl") as f:
    shapes_turtle = f.read()
shapes = {"source": shapes_turtle, "format": "turtle"}

published = lbb.schema.publish({"desired_mode": "warn", "shapes": shapes})
report = lbb.ontology.conformance(consistency="strong")
print(published["shapes_version"], report.conforms, report.result_count)
for result in report.results or []:
    print(result.focus_node, result.message)
```

### cURL

```sh
jq -n --rawfile src shapes.ttl '{desired_mode: "warn", shapes: {format: "turtle", source: $src}}' \
  | curl --fail-with-body -sS -X POST "$LBB_URL/v1/schema/publish?graph=support" \
      -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
      --data-binary @-

curl --retry 10 -fsS \
  "$LBB_URL/v1/ontology/conformance?graph=support&consistency=strong" \
  -H "Authorization: Bearer $LBB_API_KEY"
```

### MCP

```text
lbb_inspect action=ontology_conformance graph=support
```

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:

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

### Read the conformance report

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

### Supported SHACL

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.

### Choose an enforcement mode

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:

### TypeScript

```ts
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 });
}
```

### Python

```python
repair = lbb.graph("support").facts.create({
    "entity_properties": [{"type": "Ticket", "key": "4823", "name": "CSV export times out",
                           "properties": {"severity": "low"}}],
}, idempotency_key="sparql-guide-repair-4823")
while True:
    time.sleep(1)
    report = lbb.ontology.conformance(consistency="strong")
    if report.validated_at_seq and report.validated_at_seq.root >= repair["commit_seq"]:
        break

if report.conforms:
    lbb.schema.publish({"desired_mode": "reject", "shapes": shapes})
```

### cURL

```sh
curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support" \
  -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: sparql-guide-repair-4823" \
  -d '{"entity_properties": [{"type": "Ticket", "key": "4823", "name": "CSV export times out",
                              "properties": {"severity": "low"}}]}'

sleep 2
curl --retry 10 -fsS \
  "$LBB_URL/v1/ontology/conformance?graph=support&consistency=strong" \
  -H "Authorization: Bearer $LBB_API_KEY" | jq '{conforms, validated_at_seq}'

jq -n --rawfile src shapes.ttl '{desired_mode: "reject", shapes: {format: "turtle", source: $src}}' \
  | curl --fail-with-body -sS -X POST "$LBB_URL/v1/schema/publish?graph=support" \
      -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
      --data-binary @- | jq '.write_enforcement.timing'
```

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

### 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.

### Rejected writes and dry runs

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

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

### TypeScript

```ts
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);
}
```

### Python

```python
new_ticket = {"type": "Ticket", "key": "4824", "name": "password reset email never arrives"}
preflight = lbb.commit_dry_run({
    "triplets": [{"source": new_ticket, "relation": "REPORTED_AS",
                  "target": {"type": "Bug", "key": "AUTH-113", "name": "mail queue drops resets"}}],
    "entity_properties": [{**new_ticket, "properties": {"severity": "urgent"}}],
})
for result in preflight["schema_validation"]["results"]:
    print(result["component"], result["message"])
```

### cURL

```sh
curl --fail-with-body -sS -X POST "$LBB_URL/v1/graph/commit?graph=support&dry_run=true" \
  -H "Authorization: Bearer $LBB_API_KEY" -H "Content-Type: application/json" \
  -d '{"triplets": [{"source": {"type": "Ticket", "key": "4824", "name": "password reset email never arrives"},
                     "relation": "REPORTED_AS",
                     "target": {"type": "Bug", "key": "AUTH-113", "name": "mail queue drops resets"}}],
       "entity_properties": [{"type": "Ticket", "key": "4824", "name": "password reset email never arrives",
                              "properties": {"severity": "urgent"}}]}' \
  | jq '.schema_validation'
```

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.

### Write-gate limits

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](https://docs.littlebigbrain.com/guides/load-rdf/).

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

## Why RDF & SHACL suit AI agents

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](https://docs.littlebigbrain.com/guides/reasoning/).
- **Schema changes have a preview.** `POST /v1/ontology/evolve?dry_run=true`
  reports conflicts with stored records before anything changes.

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