Quipu
The Quipu stack is built around a governed, bitemporal knowledge store: shared facts, decisions and work history your agents can query. Quipu can check writes against your rules, records where each fact came from, and answers what was true when.
Quipu is that store. Bitemporal means keeping both when a fact was recorded and, when supplied, when it was true.
A quipu is the Andean knotted-cord record: cords are entities, knots are facts.
See it
Datalinks — a 3D explorer, live in your browser, over Alpha Centauri’s technology tree: 374 entities, 329 prerequisite edges, 17 ranks. Height is longest-path depth, so position is derived and stable rather than emergent. Select a node and the lattice re-lights by personalized PageRank seeded on it. No server — the graph is a baked Quipu export.
Explore this repository’s graph — Quipu’s own code and docs as a
knowledge graph, 61k triples shipped with every release, imported and queried
by Quipu itself compiled to WebAssembly. The pack is verified against its
manifest, its shapes are adopted, and it is staged and promoted in your tab
before you can query it: the receiving half of the sharing story below, running
rather than described. Then edit it — add, change and retract facts through the real
write path — and export the result as a pack that
quipu import <archive> --db your.db stages against your local shapes. Still no
server.
What is Quipu?
An embeddable Rust library and server for building knowledge graphs with:
- Immutable bitemporal fact log — time-travel, contradiction detection, full audit trail
- RDF data model — IRIs, blank nodes, typed literals via oxrdf
- SPARQL 1.1 query engine — SELECT, CONSTRUCT, ASK, DESCRIBE with property paths, aggregates, RDFS inference
- SHACL validation — configurable write checks with structured, agent-friendly feedback
- Hybrid search — SPARQL + vector similarity in a single query
- Episode ingestion — structured write path for agent-extracted knowledge
- Bulk import + promote — load or migrate a whole dataset validated in a staging graph, then admit it in one transaction (guide)
- Graph projection — materialize subgraphs into petgraph for centrality, components, shortest-path algorithms
- “SQLite energy” — single process, no server required
Three Ways to Use It
| Interface | Use case |
|---|---|
| Rust crate | Embed in your application |
CLI (quipu) | Interactive queries and scripting |
REST API (quipu-server) | Service deployment |
Who Is This For?
This book is organized around four personas — pick the one that fits you:
| Persona | You want to… | Start here |
|---|---|---|
| Homelab Operator | Model hosts, services, and dependencies | Tutorial |
| AI Agent Builder | Let agents share structured knowledge | Tutorial |
| Code Archaeologist | Understand how a codebase evolved | Tutorial |
| Knowledge Gardener | Curate and validate an ontology | Tutorial |
New to SPARQL? Start with SPARQL from Zero — it builds up from a single triple to aggregates and temporal queries using concrete examples.
Quick Taste
# Load some facts
quipu knot data.ttl --db my.db
# Query with SPARQL
quipu read "SELECT ?name WHERE { ?s <http://example.org/name> ?name }" --db my.db
# Start the server
quipu-server --db my.db --bind 0.0.0.0:3030
# Query over HTTP
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'
Architecture at a Glance
graph TD
A[Turtle / Episodes / API] --> B[SHACL Validation]
B --> C[EAVT Fact Log]
C --> D[SPARQL Engine]
C --> E[Vector Search]
D --> F[Query Results]
E --> F
C --> G[Graph Projection]
G --> H[petgraph Algorithms]
Facts enter through validation, land in the immutable log, and are queryable through SPARQL, vector similarity, or graph algorithms. Every fact has a transaction timestamp and an optional valid-time window — you can always time-travel to see the state at any point.
Why Quipu
Quipu stores structured knowledge, validates writes against your rules, and lets agents query and share that knowledge through its CLI, REST API and MCP server.
Sharing & Federation
A Quipu store can hand its knowledge to another store, and compose another store’s knowledge, without either one having to trust the other by default. Every step is explicit, hash-verified, and labelled with where it came from — so you never absorb someone else’s knowledge by accident.
Before exporting, load an identifier-policy catalogue and the shapes governing your data. Outward sharing is the default: a missing block-tier catalogue refuses with exit 2; a matched identifier refuses with exit 1.
quipu share --output ./share # a git-native bundle: facts + shapes + lineage
quipu import ./their-share # verifies hashes, lands in QUARANTINE, not ROOT
quipu import promote <share-id> # a named operator's explicit act
quipu status ./share && quipu merge ./share # diverged? three-way; conflict exits 2
Quipu does not merge on receipt, and it does not take a peer’s word for how
trustworthy that peer is: trust is declared by the local operator, never read from
the member itself. Federated rows carry _provider, _trust and _freshness, and
a partial answer is reported as partial rather than arriving as a short one.
SPARQL SERVICE is supported, restricted to endpoints the operator has configured —
variable endpoints are refused and unconfigured hosts are unreachable. That is narrower
than SPARQL 1.1’s open federation by design, and Quipu makes no SERVICE conformance
claim.
→ Sharing & Federation — the primitive in full, every claim citing the command or symbol that proves it.
Explore this repository’s graph, in your browser
Every release ships a knowledge pack of this repository — its modules, symbols, documents and sections as RDF — and the book has a page that opens it with Quipu itself compiled to WebAssembly. GitHub cannot run scripts in a README, so here is a picture; the page is one click away.
scbrown.github.io/quipu/explore — 61k triples imported and queried in a tab. No server.
It is the receiving half of the sharing story above, running rather than described:
the manifest is verified against the exact payload bytes, the bundled shapes are
adopted as a deliberate act, and the graph is staged and then promoted — the same
share_transport → share_import → promote path quipu import takes, because it is
that code, compiled for a different target. Then you get a SPARQL box, a module and
document browser, a type distribution and a neighbourhood graph, all of it derived from
queries the page will show you. It takes any Quipu pack, not just this one.
And it is not read-only. Add, change or retract facts on any node — through the real
tool_set / tool_retract / tool_episode, the same functions the REST API exposes,
with the closed-vocabulary gate still enforcing what the sender’s shapes allow. The views
update as you go, and you can take the result with you: the edited store exports as a
genuine .pendant.tar.gz, built by the same share_payload the CLI uses and declaring the
pack it came from as its parent. Import it directly with
quipu import <archive> --db your.db to stage it against your database’s loaded shapes;
promotion is a separate step. Without --db, archive verification stays in memory. Or download
export.nt and diff it — it is line-oriented and canonically ordered, so a change is a
reviewable diff.
The bundle is a release asset (quipu-<tag>-wasm.tar.gz), so nothing here is committed
and the Pages build needs no Rust.
See It In Action
The built-in explorer at /ui — the whole graph in one request, drawn on canvas.
Run it yourself with just demo (examples/demo-graph).
$ quipu knot infrastructure.ttl --shapes aegis-schema.ttl --db ops.db
Ingested 847 triples in transaction 1 (SHACL: 0 violations)
$ quipu read "SELECT ?svc ?host WHERE {
?svc a <http://example.org/WebApplication> ;
<http://example.org/runsOn> ?host .
}" --db ops.db
| svc | host |
|-----------|--------|
| gateway | host-a |
| git | host-b |
| metrics | host-a |
3 results
$ quipu episode - --db ops.db <<'JSON'
{"name": "host-b-rebuild", "source": "ops/agent",
"nodes": [{"name": "host-b", "type": "ComputeNode",
"properties": {"status": "recovered"}}],
"edges": [{"source": "host-b", "target": "host-a", "relation": "rebuilt_on"}]}
JSON
Ingested 6 triples in transaction 2
$ quipu unravel --valid-at "2026-03-15T00:00:00Z" --db ops.db
# See the world as it was two weeks ago
$ quipu stats --db ops.db
Facts: 853 | Entities: 127 | Predicates: 34
Why Quipu?
This comparison is a self-assessment, not a measurement. Some cells are contestable (Jena, for example, ships a rule reasoner). For measured numbers, see SPARQL 1.1 conformance, where quipu and other stores are scored by the same harness.
| Jena/Stardog | Graphiti/Mem0 | Quipu | |
|---|---|---|---|
| Strict schema (SHACL) | ✅ | ❌ | ✅ |
| Bitemporal time-travel | ❌ | ✅ Graphiti | ✅ |
| SPARQL 1.1 | ✅ | ❌ | ✅ |
| Datalog reasoner | ❌ | ❌ | ✅ |
| Counterfactual queries | ❌ | ❌ | ✅ |
| Vector similarity search | ✅ Stardog | ✅ | ✅ |
| LanceDB ANN + pushdown | ❌ | ❌ | ✅ |
| Agent-friendly feedback | ❌ | ❌ | ✅ |
| Episode provenance | ❌ | ✅ | ✅ |
| Graph algorithms | ❌ | ❌ | ✅ |
| Built-in web UI | ❌ | ❌ | ✅ |
| Embeddable (no server) | ❌ | ❌ | ✅ |
| SQLite-backed | ❌ | ❌ | ✅ |
| Rust, no JVM or Python | ❌ | ❌ | ✅ |
Named products in a cell qualify the grouped column: the capability is not being attributed to every member. Graphiti documents explicit bi-temporal tracking, and Stardog’s semantic search compares vector embeddings.
Traditional RDF stores demand too much ceremony. AI-native stores have no structure. Quipu’s thesis: start strict, use agents to bear the cost of strictness.
Features
🏛️ Knowledge Graph Core
- Immutable bitemporal fact log — every fact has transaction time and valid time. Time-travel to any point. Full audit trail. Contradiction detection.
- RDF data model — IRIs, blank nodes, typed literals via oxrdf. Import/export Turtle, N-Triples, JSON-LD, RDF/XML. Exports are stably ordered and can be scoped to ROOT, one named graph, an episode provenance group, or a SPARQL CONSTRUCT result; server exports use the read pool rather than blocking writers.
- Git-native knowledge shares —
quipu sharewrites canonicalexport.nt,shapes.ttl, and a lineage-awaremanifest.json; unchanged graph state produces byte-identical files and stable hashes for meaningful git diffs. Remote consumers use read-onlyPOST /shareto receive that exact manifest and file set without access to the server filesystem. When the store carries block-tierInternalIdentifierPatternrules, the producer refuses matching outbound bytes before publishing anything and never rewrites entity IRIs. - Shape-aware reconnect —
quipu statuspreviews base/ROOT/incoming divergence, whilequipu mergeunions multi-valued RDF and emits structured decisions forsh:maxCountconflicts before any write. - SPARQL 1.1 — SELECT, ASK, CONSTRUCT, DESCRIBE. BGP, JOIN, UNION, FILTER, OPTIONAL, VALUES, ORDER BY, GROUP BY, aggregates, HAVING, property paths,
IN/NOT IN, RDFS subclass inference, and named-graph scoping (GRAPH,FROM,FROM NAMED). - SHACL validation — strict schema enforcement at write time. Structured feedback with severity, focus node, component, path, and message.
- Graph labels & kinds — graphs carry declared labels on five axes (freshness, trust, policy, durability,
dataKind); datasets compose them without ever widening, and every query answer reports the composed label. Opt-in floors can refuse queries that fall below a declared bar. - Deep freeze — relocate a graph’s full history into a verified read-only archive pack (
quipu graph freeze). The graph keeps its IRI and stays queryable — by name, via theurn:quipu:dataset:frozendataset, or byinclude_kinds: ["archive"]on a query;quipu graph thawrestores it for writes.GET /graphslists every registered graph with kind and lifecycle.
🤖 AI-Native Features
- Episode ingestion — structured write path for agent-extracted knowledge. Typed nodes, edges, and provenance tracking (
prov:wasGeneratedBy). A node name may appear only once per episode: repeated entries are rejected before any triples are written, since merging them would silently append a second description to the same entity. - Hybrid search — SPARQL filters candidates, vector similarity ranks them. Combine structured queries with semantic meaning in one call. Type constraints are pushed down into the vector index for O(log n) filtered search with LanceDB.
- Dual vector backends — default SQLite (brute-force cosine similarity), plus a LanceDB backend (ANN with predicate pushdown, Arrow columnar storage) behind
--features lancedb.vector.backendin config selects it in-binary: the CLI and server install the configured backend at open, so choosing LanceDB no longer requires an embedder to callStore::set_local_vector_backendby hand. - Context pipeline — unified knowledge context shaped for agent consumption. Text search + link expansion with configurable depth and budget.
- Agent-friendly feedback — validation errors include what failed, where, why, and what the valid alternatives are.
🧠 Reasoning Engine
- Datalog over EAVT — forward-chaining rules in Turtle DSL, evaluated by
datafrogwith semi-naive fixpoint. Supports stratified negation-as-failure; variables in negated atoms must be bound by positive atoms, and negation cycles are rejected. Derived facts are first-class triples with provenance. - Reactive evaluation —
TransactObserverre-runs affected rules on every write. Delta-aware: only changed predicates trigger re-evaluation. Behind the non-defaultreactive-reasonerfeature;reason --reactiveerrors without it. - Counterfactual queries —
Store::speculate()forks a hypothetical view via SQLite SAVEPOINT. Answer “what if we remove X?” without mutation. - Impact analysis — BFS walk over entity edges with configurable depth and predicate filters. CLI (
quipu impact), REST (POST /impact), and MCP tool.
⚙️ Infrastructure
-
Git-native composition —
quipu import <share-dir>verifies the v1 manifest and payload hashes, resolves exact entity matches, surfaces fuzzy candidates for review, validates against local SHACL shapes, and stages each source in a named graph. Off-vocabulary or non-conforming shares remain quarantined; onlyquipu import promote <share-id>explicitly admits an eligible graph to ROOT. -
Graph projection — materialize subgraphs into petgraph for centrality, connected components, shortest path algorithms.
-
Federation — a
GraphProvidertrait for multi-source queries, with aRemoteProvider(behind theremotefeature) built fromfederation.remotesconfig. The server health-checks every configured remote at startup, andPOST /querywith"federated": truefans out through the federated provider, reporting which members answered. Remotes carry declared trust labels at the federation edge, so a federated answer composes the labels of every member that contributed rather than silently inheriting the caller’s. Federation config is read-side only: adding a remote never turns it into an outbound replication target or bypasses the share scrub/import boundary. -
Graph explorer — the web UI draws the whole node-link view from a single
POST /graphpayload (nodes plus index-addressed edges), laid out with a Barnes-Hut force simulation on canvas. No CDN, so it renders on an air-gapped deploy. -
Four interfaces — Rust crate (embed), CLI (
quipu), REST API (quipu-server), and built-in web UI with embeddable web components. Plus 77 MCP tools for agent integration (79 with theowlfeature). -
“SQLite energy” — single process, no server required, inspect with
sqlite3, back up withcp. -
Automated releases — release-plz bumps versions from conventional commits, generates changelogs via git-cliff, and creates GitHub releases. Version discovery is
git_only = true: the baseline comes from this repository’s tags, never the unrelatedquipucrate on crates.io. CI runs fmt, clippy, tests, and markdown lint on every push./versionalso reports the deployed git SHA, which matters because a deployment can legitimately sit AHEAD of the newest tag — the SHA, not the version string, identifies what is actually running.
Tour: the library, the CLI, the server and the reasoner
As a Rust Library
[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu" }
#![allow(unused)]
fn main() {
use quipu::store::Store;
use quipu::rdf::ingest_rdf;
use quipu::sparql;
use oxrdfio::RdfFormat;
let mut store = Store::open_in_memory()?;
let turtle = r#"
@prefix ex: <http://example.org/> .
ex:alice a ex:Person ; ex:name "Alice" ; ex:knows ex:bob .
ex:bob a ex:Person ; ex:name "Bob" .
"#;
ingest_rdf(&mut store, turtle.as_bytes(), RdfFormat::Turtle,
None, "2026-04-04", None, None)?;
let result = sparql::query(&store,
"SELECT ?name WHERE { ?s a <http://example.org/Person> . ?s <http://example.org/name> ?name }")?;
}
From the Command Line
git clone https://github.com/scbrown/quipu && cd quipu
cargo build --release
# Load, query, explore
quipu knot data.ttl --db my.db
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10" --db my.db
quipu repl --db my.db
# Governance: check an agent's enforcement trace against the policy set
quipu audit trace.jsonl --db my.db # T ⊨ Σ — exits 1 on a violation
quipu audit inventory --db my.db # which tool classes are ungoverned
quipu audit namespace --db my.db # which minted predicates no shape mentions
quipu audit replay trace.jsonl --db my.db # advise → enforce readiness, per rule
REST API & Web UI
# quipu-server needs `onnx` (the embedding runtime) AND `server` (axum/tokio —
# the HTTP stack is feature-gated so the library does not carry a web server).
# Neither is on by default, and an unmet required-feature SKIPS the binary
# silently rather than erroring, so build the `full` bundle releases ship:
cargo build --release --features full
quipu-server --db my.db --bind 0.0.0.0:3030
# Open the interactive graph explorer in your browser
open http://localhost:3030
# Or use the REST API directly
curl localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'
The built-in web UI provides:
- Graph Explorer — force-directed visualization with type-based coloring, entity search, and detail panel
- SPARQL Workbench — syntax-highlighted editor with time-travel parameters and tabular/JSON results
- Episode Timeline — chronological view of ingested episodes with extracted entities
- Schema Inspector — type distribution, SHACL shape browser, and validation runner
Embeddable web components (<quipu-graph>, <quipu-sparql>, <quipu-entity>, <quipu-timeline>, <quipu-schema>) let you drop Quipu panels into any page:
<script src="http://localhost:3030/quipu-components.js"></script>
<quipu-graph endpoint="http://localhost:3030"></quipu-graph>
Semantic Web APIs for interoperability:
- Spotlight — entity recognition/disambiguation (
POST /spotlight) - Triple Pattern Fragments — LDF-compatible pagination (
GET /fragments) - OpenRefine Reconciliation — data cleaning integration (
POST /reconcile) - Content Negotiation —
GET /entity/{iri}returns JSON-LD, Turtle, or HTML based on Accept header
Reasoner
# Impact analysis — what depends on this entity?
quipu impact http://example.org/gateway --db ops.db
# Counterfactual — what breaks if we remove it?
quipu impact http://example.org/gateway --remove --db ops.db
# Run Datalog rules over the fact log
quipu reason --rules rules.ttl --db ops.db
The reasoner adds forward-chaining inference over the EAVT fact log:
- Datalog rule engine — rules written in Turtle DSL, evaluated with semi-naive
datafrog. Supports stratified negation-as-failure over materialized facts, including derived predicates from lower strata; unsafe negation and negation cycles are rejected. Derived facts written back viaStore::transact()with full provenance. - Reactive evaluation —
TransactObserverkeeps derived facts fresh as base facts change. Delta-aware: only affected rules re-run. Optionalreactive-reasonerfeature. - Counterfactual queries —
Store::speculate()forks a view (SQLite SAVEPOINT) to answer “what if?” without mutation. - Impact analysis — BFS walk over entity edges with configurable hop depth and predicate filters. Available as CLI, REST endpoint (
POST /impact), and MCP tool.
Architecture
┌──────────────────────────────┐
│ Agent / CLI / Bobbin │
└──────────┬───────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌──────┴──────┐
│ MCP Tools │ │ REST API │ │ Rust API │
│ (77 tools) │ │ + Web UI │ │ (crate) │
└─────┬─────┘ └─────┬─────┘ └──────┬──────┘
└────────────────┼────────────────┘
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
┌────┴────┐ ┌────┴─────┐ ┌──┴───────┐ ┌──────────────┴──────────────┐
│ SPARQL │ │ SHACL │ │ Reasoner │ │ KnowledgeVectorStore │
│ Engine │ │ Validator│ │ (Datalog)│ │ (trait) │
└────┬────┘ └────┬─────┘ └──┬───────┘ └──────┬─────────┬───────────┘
│ │ │ │ │
└─────┬───────┴───────────┘ ┌──────┴───┐ ┌───┴──────┐
│ │ SQLite │ │ LanceDB │
│ │ (default)│ │(optional)│
┌─────┴──────────────┐ └──────────┘ └──────────┘
│ EAVT Fact Log │
│ (SQLite) │
│ │
│ facts + terms + │
│ shapes + rules │
└────────────────────┘
Bobbin Integration
Quipu is designed as a Bobbin subsystem. Bobbin holds the thread (code context); Quipu ties knots of structured meaning into it.
Through Quipu’s own MCP server (quipu mcp, or quipu-server at /mcp),
agents get 77 MCP tools (79 with the owl feature). The two most
commonly used for knowledge-aware context:
quipu_context — unified knowledge discovery. Bobbin merges the result
with its own code search to give agents both code and knowledge in one response.
{
"tool": "quipu_context",
"input": { "query": "gateway reverse proxy", "max_entities": 10 }
}
// Returns ranked entities with facts, types, and relevance scores
quipu_episode — save agent-extracted structured knowledge with full
provenance tracking.
{
"tool": "quipu_episode",
"input": {
"name": "deploy-v3",
"source": "aegis/ellie",
"nodes": [{"name": "gateway", "type": "WebApplication",
"properties": {"version": "3.0"}}],
"edges": [{"source": "gateway", "target": "host-a", "relation": "runs_on"}]
}
}
Embeddings are shared: Bobbin’s ONNX pipeline (all-MiniLM-L6-v2) provides
384-dimensional vectors to both its code search and Quipu’s knowledge search,
enabling hybrid queries that span both domains.
Agent access
When Quipu MCP tools are configured, agents should use them first so structured
inputs, validation feedback, and provenance stay in the tool contract. Use the
native quipu CLI second for local databases and offline work. Raw HTTP is the
portability fallback; endpoint shapes and authentication are in the
REST API reference.
Named graphs are first-class query scopes, and named datasets are reusable sets
of graphs. ROOT remains the default until a caller explicitly selects a graph or
dataset with SPARQL FROM / FROM NAMED, the query graph field, or
the query tool’s graph field. This permits an application to ground a request in
one declared plane without silently widening into every graph. See
Named Graphs for the registration,
write, and trust-label rules.
Feature Matrix
Legend: ✅ available in the shipped quipu CLI / quipu-server · 🔩 library
primitive only, not reachable from the shipped binaries · 🔜 planned.
| Feature | Status | Notes |
|---|---|---|
| Core | ||
| EAVT bitemporal fact log | ✅ | Immutable, time-travel queries |
| RDF data model (oxrdf) | ✅ | Turtle, N-Triples, JSON-LD, RDF/XML |
| SQLite storage | ✅ | Single-file, embeddable |
| Retraction with valid-time closure | ✅ | |
| Graph labels (5-axis lattice + floors) | ✅ | freshness / trust / policy / durability / dataKind; composition never widens |
Graph kinds + include_kinds widening | ✅ | GET /graphs listing; fetch-time opt-in for composing cold graphs |
| Deep freeze / thaw | ✅ | quipu graph freeze|thaw|list — full-history read-only archive packs, auto-attached on open |
| SPARQL 1.1 | ||
| SELECT / ASK / CONSTRUCT / DESCRIBE | ✅ | |
| BGP, JOIN, UNION, FILTER, OPTIONAL | ✅ | |
| ORDER BY, GROUP BY, HAVING | ✅ | |
| Aggregates (COUNT, SUM, AVG, MIN, MAX) | ✅ | |
| BIND / Extend | ✅ | |
| Property paths | ✅ | ROOT default graph only; fails loud inside a named GRAPH |
VALUES inline relations | ✅ | Multi-column and UNDEF |
FILTER ... IN / NOT IN | ✅ | |
| Temporal queries (valid_at, as_of_tx) | ✅ | |
| RDFS subclass inference | ✅ | |
| SPARQL UPDATE | 🔜 | Planned |
Named graphs (GRAPH/FROM/FROM NAMED) | ✅ | Query side; writes go via overlays or /episode. See named-graphs.md |
| Full SPARQL federation (SERVICE) | 🔜 | Planned |
| Schema & Validation | ||
| SHACL write-time validation | ✅ | Optional shacl feature |
| Persistent shape storage | ✅ | |
| Aegis ontology shapes | ✅ | Infrastructure entities |
| Code entity shapes | ✅ | CodeModule, CodeSymbol, etc. |
| OWL reasoning | ✅ | Optional owl feature |
| Governance (SARC conformance) | ||
aegis:Policy write-time gate | ✅ | Class-aware effects, evaluated before commit |
| Constraint metadata (class, verification point, θ, τ_rev) | ✅ | shapes/governance.ttl |
Tripwire path-boundary policies (aegis:appliesTo) | ✅ | shapes/policies/tripwire.ttl; deny hard @ PAG, throttle soft @ PAA |
| Class ↔ placement conformance | ✅ | Refused at write; a soft constraint cannot be placed at the gate |
| Signed verdicts (ed25519) | ✅ | Evidence-hash-bound, verified against a human-authored root of trust |
| Escalation router with a bounded window | ✅ | Default-deny past τ_rev; records the request, does not deliver it |
| Authority intersection over named graphs | ✅ | Off by default; a delegate can only narrow |
T ⊨ Σ audit checker | ✅ | quipu audit; four passes, deterministic, never an LLM call |
| Dispatch-graph inventory (I7) | ✅ | quipu audit inventory; ungoverned classes are data, not prose |
| Namespace-drift report | ✅ | quipu audit namespace; report-only, never refuses a write |
| Replay / promotion readiness | ✅ | quipu audit replay; counts blocks, cannot label false positives |
| Attribution tree, constraint inheritance | ✅ | quipu audit tree / inheritance — reconstructed from principal chains |
| Trust predicate over imported state | 🔜 | The boundary is declared and reported; nothing evaluates the content |
Escalation queue metrics (W_q < τ_rev) | 🔜 | Needs a server behind the queue; unmeasured today |
| AI-Native | ||
| Episode ingestion (Graphiti-compatible) | ✅ | Typed nodes, edges, provenance |
| SQLite vector search (cosine) | ✅ | Default backend |
| LanceDB ANN + predicate pushdown | 🔩 | lancedb feature; embedder-only — not selectable via config in the CLI/server |
| LanceDB full-text search (BM25) | 🔜 | Library path exists but is unreachable from the shipped CLI/server; /context uses the SPARQL CONTAINS fallback |
| Hybrid SPARQL + vector search | ✅ | |
| Auto-embed on write | ✅ | Knot/episode hooks |
| ONNX embedding pipeline | ✅ | Shared with Bobbin |
| Context pipeline | ✅ | Text search + link expansion |
| Reasoner | ||
| Impact analysis (BFS) | ✅ | CLI, REST, MCP tool |
| Datalog rule engine (datafrog) | ✅ | Turtle DSL; stratified negation-as-failure with safe variable binding; negation cycles rejected |
| Reactive evaluation | ✅ | TransactObserver, delta-aware. Optional reactive-reasoner feature; reason --reactive errors without it |
| Counterfactual queries | ✅ | speculate() via SQLite SAVEPOINT |
| Incremental truth maintenance | 🔜 | Planned (Phase 5) |
| Interfaces | ||
| Rust crate (embed) | ✅ | |
CLI (quipu) | ✅ | knot, read, repl, episode, impact, reason, audit |
REST API (quipu-server) | ✅ | Axum-based |
| Web UI | ✅ | Explorer, workbench, timeline, schema |
| Graph explorer | ✅ | Canvas + Barnes-Hut layout, one POST /graph payload, no CDN |
| Web components | ✅ | Embeddable <quipu-*> elements |
| Semantic Web APIs | ✅ | Spotlight, TPF, OpenRefine reconciliation |
MCP tools (77; 79 with owl) | ✅ | Agent integration |
| Python bindings | ✅ | quipu-client under python/ — REST client, stdlib-only |
| Infrastructure | ||
| Graph projection (petgraph) | ✅ | Centrality, shortest path, etc. |
| GraphProvider federation trait | ✅ | RemoteProvider, startup health checks, federated: true on /query |
| Bobbin integration | ✅ | Namespace, IRI patterns, search |
| Automated releases (release-plz) | ✅ | |
| Clustering / replication | 🔜 | Planned |
Installation
As a Rust Dependency
Add to your Cargo.toml:
[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu" }
To use SHACL validation (enabled by default):
[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu", features = ["shacl"] }
To exclude SHACL (smaller binary, faster compile):
[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu", default-features = false }
From Source
qp is the preferred command name; quipu remains supported. Install both
names plus the companion server with:
just install
# Optional destination directory:
just install "$HOME/.local/bin"
qp --version
quipu --version
qp is a relative symlink to quipu, so both execute the same binary after
upgrades. The installer refuses an existing conflicting qp in the destination
or on PATH. It never replaces another tool’s command name. Release archives
also carry this relative link beside both binaries.
To add the command name to an existing installation without opening a store:
quipu install-alias --check
quipu install-alias
git clone https://github.com/scbrown/quipu
cd quipu
cargo build --release --features full
This produces two binaries:
target/release/quipu– CLI tooltarget/release/quipu-server– REST API server
quipu-server requires both onnx (the embedding runtime it uses to
auto-embed queries) and server (axum, tower-http and tokio — the HTTP stack is
feature-gated so the library does not carry a web server; server also implies
remote, the federation client). The full bundle is what releases are built
with and enables both. Neither is a default feature, so a
plain cargo build --release produces only the quipu CLI and silently omits
the server (cargo skips a bin whose required-features are off). If you only
want the CLI, cargo build --release is enough. Verify the server built:
ls target/release/quipu-server
Keep the CLI and server from the same build beside each other for quipu mcp.
Both quipu --version and quipu-server --version report git_sha and
git_dirty without opening a database or loading configuration. A source archive
without Git metadata reports git_sha: unknown; a dirty build reports
git_dirty: true. The HTTP /version response retains its existing format.
With Cargo
Cargo installs the executable targets; finish the installation with the alias command to provide both supported names:
cargo install --git https://github.com/scbrown/quipu --locked --features full
quipu install-alias
qp --version
install-alias uses the directory containing the running Quipu executable,
including Cargo’s --root destination. It refuses conflicting qp names and
verifies the same inode and exact version output. --check only checks for
collisions and does not create the link.
just install resolves its root as QUIPU_INSTALL_ROOT, then
CARGO_INSTALL_ROOT, then $HOME/.local, and installs into that root’s bin
directory. Its optional directory argument overrides this resolution.
The Full Stack (caboodle)
To install Quipu as part of the whole knowledge stack — caboodle interviews, plans, applies, and verifies the installation of every stack tool — use the wrapper script:
# Phase one: install caboodle if absent, write a reviewable plan, STOP.
scripts/install-stack.sh --profile kg
# Phase two: after reviewing caboodle-plan.toml, apply + verify,
# then verify and load knowledge packs into the target store.
scripts/install-stack.sh --profile kg --yes \
--pendant domain.pendant.db --db my.db
The two-phase gate is deliberate and mirrors caboodle’s own doctrine: nothing
installs until the written plan has been reviewed (or --yes given
explicitly). Every --pendant is checked with quipu pack --verify before
quipu unpack — a content-hash mismatch refuses the pack rather than
installing silently corrupted knowledge. --dry-run prints every command the
script would run and executes nothing; --profile selects the caboodle
profile (default kg).
Python Client
The REST API has a thin, zero-dependency Python client under python/:
pip install ./python
See the Python Client reference.
Requirements
- Rust 1.85+ (edition 2024)
- SQLite is bundled via rusqlite – no system dependency needed
- Python >= 3.11 for the optional Python client (stdlib only)
Quick Start
Rust Library
use quipu::store::Store;
use quipu::rdf::ingest_rdf;
use quipu::sparql;
use oxrdfio::RdfFormat;
fn main() -> quipu::error::Result<()> {
// Open a persistent store (or use open_in_memory() for testing)
let mut store = Store::open("my-knowledge.db")?;
// Ingest Turtle data
let data = r#"
@prefix ex: <http://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:alice a ex:Person ;
ex:name "Alice" ;
ex:age "30"^^xsd:integer ;
ex:knows ex:bob .
ex:bob a ex:Person ;
ex:name "Bob" ;
ex:age "25"^^xsd:integer .
"#;
let (tx_id, count) = ingest_rdf(
&mut store,
data.as_bytes(),
RdfFormat::Turtle,
None,
"2026-04-04T00:00:00Z",
Some("demo"),
Some("quick-start"),
)?;
println!("Ingested {count} triples in transaction {tx_id}");
// Query with SPARQL
let result = sparql::query(
&store,
r#"SELECT ?name ?age WHERE {
?s a <http://example.org/Person> .
?s <http://example.org/name> ?name .
?s <http://example.org/age> ?age .
FILTER(?age >= 28)
}"#,
)?;
println!("People aged 28+:");
for row in result.rows() {
println!(" {:?} age {:?}", row.get("name"), row.get("age"));
}
Ok(())
}
CLI
# Build
cargo build --release
# Load data
quipu knot data.ttl --db my.db
# Query
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10" --db my.db
# Interactive REPL
quipu repl --db my.db
REST API Server
# Build the server with the full bundle. quipu-server has
# required-features = ["shacl", "onnx", "server"], and cargo SILENTLY SKIPS a
# binary whose required features are missing — a plain `cargo build --release`
# (or a partial feature list) builds only the `quipu` CLI and you'd never know.
cargo build --release --features full
# Start
quipu-server --db my.db --bind 0.0.0.0:3030
# Health check
curl localhost:3030/health
# Query
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'
# Ingest an episode
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "test-episode",
"nodes": [
{"name": "alice", "type": "Person", "description": "Test user"}
],
"edges": []
}'
MCP (Agent Integration)
Run quipu mcp --db <path> (stdio) or quipu-server (/mcp over HTTP), and agents get Quipu’s tools:
{
"tool": "quipu_query",
"input": {
"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"
}
}
See the MCP Tools Reference for all available tools.
Configuration
Quipu is configured via .bobbin/config.toml in your project directory
or ~/.config/bobbin/config.toml for global defaults.
Config File
[quipu]
# Path to the SQLite triple store
store_path = ".bobbin/quipu/quipu.db"
# Base namespace new IRIs are minted under (optional; default is the aegis
# ontology namespace). A non-aegis deployment MUST set this before its first
# write — an IRI namespace is data identity and cannot be changed afterwards
# without re-ingesting every episode.
base_ns = "http://example.org/kb/"
[quipu.server]
# Enable the REST API server
enabled = false
# Bind address
bind = "127.0.0.1:3030"
# Require this bearer on write endpoints.
# auth_token = "current-token"
# During a credential rotation only, accept the old bearer for at most 24h.
# Use an absolute deadline so restarting cannot renew the old bearer.
# previous_auth_token = "old-token"
# previous_auth_token_expires_at_epoch_secs = 2000000000
[quipu.events]
# Event-log retention. Unset (the default) keeps every event forever.
# When set, the server hourly deletes events older than this many days —
# but never an event a registered consumer has not yet committed past, so
# a lagging consumer's replay is never broken (its backlog just stays on
# disk). A consumer registering AFTER a prune replays from the retained
# prefix, not from genesis.
# retention_days = 90
# Read-only databases mounted alongside the store. Each block is one layer:
# a knowledge pack, a shared reference database, a frozen archive someone
# handed you. Their named graphs become readable by `GRAPH <iri>` / `FROM <iri>`
# without changing what any existing query returns.
# [[quipu.attachments]]
# alias = "reference"
# path = "/srv/quipu/reference.pendant.db"
Config Fields
Every key below is wired — src/config.rs carries a test
(config_knobs_are_wired_or_listed_unwired) that fails if a documented knob
stops being read.
| Field | Default | Description |
|---|---|---|
store_path | .bobbin/quipu/quipu.db | SQLite database path |
base_ns | aegis ontology NS | Base namespace for minted IRIs (set before first write; --base-ns overrides per CLI call) |
server.enabled | false | Enable REST API server |
server.bind | 127.0.0.1:3030 | Server bind address |
server.auth_token | unset | Bearer token required on write endpoints when set |
server.previous_auth_token | unset | Previous write bearer accepted temporarily during rotation; requires server.auth_token and a positive grace duration |
server.previous_auth_token_expires_at_epoch_secs | unset | Absolute UTC Unix epoch expiry for the previous bearer; at most 24 hours away when starting; expired previous bearers are ignored |
server.read_only | false | Refuse all write endpoints |
server.cors_allowed_origins | [] | CORS allowlist for the UI/API |
server.read_pool_size | 4 | Read-only connection pool size (0 = all reads take the writer lock) |
server.update_full_copy_max_facts | 250000 | Facts an unsliceable /update (open subject AND open predicate) may copy before it is refused with advice; 0 = unbounded |
events.retention_days | unset (keep forever) | Prune events older than N days, never past any registered consumer’s committed offset |
labels.min_freshness | unset | Graph-label floor: refuse results staler than this |
labels.min_trust_rank / labels.min_trust_chain | unset | Trust floors on the query path |
labels.deny_policy_tokens | [] | Policy-class tokens that exclude a graph from results |
labels.deny_data_kinds | [] | Refuse queries composing graphs of these dataKind tokens (a blocklist — undeclared kinds pass) |
search.default_limit | 10 | Result limit when the caller passes none |
search.keyword | false | Opt-in SQLite FTS5 keyword search; activation installs schema/triggers without a bulk startup migration. Existing facts require explicit bounded backfill; reads refuse until complete. |
search.hybrid | false | Enable lexical/vector fusion after offline evaluation; no startup backfill. |
search.mode | "semantic" | Default mode for requests omitting it: semantic, keyword or hybrid. |
search.alpha | 0.5 | Semantic contribution in hybrid mode; 0 and 1 use exact pure endpoints. |
search.fusion | "rrf" | Weighted reciprocal ranks or "weighted" min-max scores. |
search.rrf_k | 60.0 | Positive RRF denominator offset. |
search.named_graphs | false | Named-graph scope for semantic and hybrid search, and automatic named-only embedding text. Keyword search accepts named scope without it. Prepare existing named-only vectors with bounded backfill before enabling. Disabling retains the ROOT exclusion for prepared vectors. |
search.default_graph_scope | unset (stored default) | New stores use "all": ROOT plus registered named graphs, minus exclusions. Existing stores retain "root" until explicitly broadened. Set "all" or "root" to override; semantic named scope also requires prepared vectors and named_graphs = true. Does not change SPARQL datasets. |
search.exclude_graphs | [] | Graph IRI exclusion list for all search selections, including explicit ones. Empty selections refuse. Retrieval policy, not authorization. |
search.max_limit | 1000 | Hard cap on requested result limits |
search.max_sparql_rows | 10000 | Cap on SPARQL result rows |
search.query_timeout_ms | 30000 | SPARQL evaluation deadline |
search.max_join_rows | 1000000 | Abort a join once an intermediate exceeds this |
search.oversample_factor | 10 | Vector-search oversampling before filtering |
shacl.validate_on_write | true (false in builds without the shacl feature) | Validate writes against the stored shapes: full validation on episode ingest, and cardinality/value admission on the touched nodes of every write. Set false to accept non-conformant writes |
owl.validate_on_write | true | Enforce owl:disjointWith / owl:FunctionalProperty at write time (with functional-property supersede); set false for an explicitly informal deployment |
governance.enforce_on_write | false | Evaluate action-boundary policies against every write (the write-time gate) |
governance.validate_placement | false | Check SARC class↔placement rules when a write defines/amends a policy |
governance.verify_transitions | false | Refuse a write landing an aegis:TransitionEvent whose signature is missing or does not verify under a registered key |
governance.enforce_authority | false | Make a supplied principal chain binding for graph writes |
governance.hardware_verdict_schemes | false | Accept WebAuthn and SSHSIG sk-ssh-ed25519 verdict signatures beside ed25519; while off, those verdicts and registrations declaring them are refused |
governance.quarantine.enabled | true | Record a sealed, digest-only quarantine entry for each verdict of a refused write, outside the governed graph, so quipu audit replay <verdict> can re-derive the refusal |
governance.quarantine.retain_full | [] | Graph IRIs (or "*") whose refused attempts are also kept in full, sealed; purge with quipu audit quarantine purge |
resolution.enabled | false | Entity resolution (dedup) on the episode write path |
resolution.threshold / top_k / strict_mode | 0.85 / 3 / false | Match threshold, candidate count, refuse-on-ambiguity |
embedding.auto_embed | true | Auto-embed entities on write (needs model/tokenizer paths) |
embedding.model_path / tokenizer_path | unset | ONNX model + tokenizer for embeddings |
embedding.dimension / max_sequence_length / embed_batch_size | 384 / 256 / 32 | Embedding runtime parameters |
vector.backend | sqlite | sqlite or lancedb; selects the store’s vector backend at open (see below) |
federation.remotes | [] | Remote quipu endpoints ({name, url, auth_token?, timeout_ms?}); health-checked at startup, queried via federated: true |
attachments | [] | Read-only databases mounted alongside the store ([[quipu.attachments]] with alias and path); see below |
Zero-downtime bearer rotation
Quipu captures bearer configuration at startup. To rotate without a write
freeze, configure the new value as server.auth_token, the old value as
server.previous_auth_token, and a short
server.previous_auth_token_expires_at_epoch_secs, then restart quipu-server. Both
bearers authenticate writes until the grace deadline; request telemetry records
authenticated_current or authenticated_previous without recording either
secret.
After consumers have switched, remove both previous_auth_token fields and
restart again. That explicitly invalidates the old bearer immediately. If the
cleanup restart is delayed, the old bearer still expires at the configured absolute
deadline, and a restart never extends it. On restart, an expired previous bearer is dropped with a warning; the current
bearer remains required and valid. Forgotten cleanup cannot cause a delayed
startup outage. Invalid pairs, identical bearers, and deadlines over
24 hours away still make startup fail closed.
Attachments
[[quipu.attachments]] mounts other SQLite databases alongside your store, so
their named graphs are readable in the same query as your own. Both binaries
honour it at open — every quipu subcommand and quipu-server alike.
[[quipu.attachments]]
alias = "reference"
path = "/srv/quipu/reference.pendant.db"
[[quipu.attachments]]
alias = "tenant_a"
path = "packs/tenant-a.db"
| Key | Required | Description |
|---|---|---|
alias | Yes | SQLite schema name the file mounts under, and the source its contributed graphs carry in the graph registry. Must match ^[a-z][a-z0-9_]*$ |
path | Yes | Path to the database file; relative paths resolve against the working directory |
What to expect:
- Nothing silently changes. The default dataset stays your own ROOT alone,
so an attachment is visible only to a query that names one of its graphs —
GRAPH <iri>,FROM <iri>, or a dataset that includes it. - Mounts are read-only, always. There is no
read_only = false: cross-database writes are permanently out of scope, so the key would be an affordance for something the store refuses anyway. - A bad declaration refuses the open — it never degrades into fewer rows. A
missing file, an invalid or duplicate alias, a file with no
gcolumn (it predates named graphs), or one whose term space collides with yours all fail at startup, naming the alias and the remedy. A term-space collision is fixed withquipu db respace. - The packs you already build are attachable.
quipu pack --space Nships a pack in its own term space, which is exactly what a collision-free mount needs.
See what is actually mounted — including deep freeze’s archives, which no config declares:
quipu db attach --list --db my.db
quipu-server prints the same list to stderr at startup.
Choosing a vector backend
[quipu.vector] backend selects the store’s vector backend at open, for both
binaries: search, entity resolution, auto-embedding and the MCP/REST search
tools all go through the selected one.
[quipu.vector]
backend = "lancedb"
lancedb_path = ".bobbin/quipu/quipu-vectors"
lancedb requires a binary built with the lancedb feature, and the release
binaries are not. The feature drags in protoc and the whole datafusion tree,
which most deployments do not need. A binary that lacks it refuses
backend = "lancedb" at startup and names the rebuild — it does not fall back
to the SQLite table, because a deployment that has run quipu migrate-vectors
would then have every search answered out of the store it migrated away from.
Move existing embeddings across with
quipu migrate-vectors --from sqlite --to lancedb. See
LanceDB Vector Backend.
Configuration coverage
Every documented key above is read by the shipped binaries. unwired_warnings()
reports any configured key that parses but is not acted on, so an inert setting
produces a warning.
Priority Order
Configuration is resolved in this order (highest priority first):
- CLI flags (
--db,--bind) - Project config (
.bobbin/config.tomlin working directory) - Global config (
~/.config/bobbin/config.toml) - Built-in defaults
CLI Overrides
CLI flags always take precedence:
quipu read "SELECT ..." --db /tmp/test.db # Overrides store_path
quipu-server --bind 0.0.0.0:8080 # Overrides server.bind
Read-only follower serving
A file-level replication operator can serve a changing follower with:
[quipu.server.replica]
primary_url = "https://primary.example"
Start quipu-server --read-only --db /path/to/follower.db from that configuration
directory. Keep --read-only in the service invocation: it refuses startup if
neither config nor --primary-url URL supplies a valid primary, even if the
config file disappears. --primary-url URL overrides the configured primary.
This is an opt-in serving role, separate from the older server.read_only
HTTP-only access-control flag. The default primary serving role is unchanged. Server startup now refuses invalid
or unreadable existing configuration instead of silently falling back to defaults.
Replica mode opens only existing SQLite databases read-only, with no schema
setup or migration. Every read connection explicitly waits up to one second for
short apply locks. It never falls back to opening a writable database. SQLite
may create WAL/SHM sidecars; the directory must support the follower’s locking
protocol. Do not use immutable=1 for a database that replication updates.
The replica does not initialize a keyword index, materialize OWL, register write observers, load a signing identity, reset/checkpoint WAL, deliver subscriptions, refresh graph-count snapshots, or prune events. Read policies (namespace, search limits, labels and resolution) are still configured. Keyword search requires an index already present on the primary. Attachments and external vector backends currently refuse startup rather than silently dropping configured providers. Graph-count metrics remain unavailable until an independent read-only refresh contract is implemented; they must not be interpreted as healthy zeros.
Mutating HTTP routes return 503 with reason: "read_only_replica" and the
configured primary URL. Writes are never forwarded or redirected, including
signed writes: the refusal occurs before credential/nonce settlement. SQLite’s
read-only handle independently refuses a planted writer that bypasses HTTP.
This role does not provision, replicate, promote or fail over a database.
Lag observations
GET /watermark returns the serving store’s store_id and watermark_tx using
indexed reads. A replica polls that endpoint on its configured primary every
15 seconds, with a two-second HTTP timeout, no redirects and a bounded response.
This is its only background task and performs no database writes.
Responses include X-Quipu-Replica-Lag-Tx. The metrics are:
quipu_replica_lag_transactions: primary minus follower transaction IDs; -1 means UNKNOWN.quipu_replica_lag_known: 1 only for a successful observation no older than 30 seconds; otherwise 0.
A failed request, malformed response, mismatched store lineage, primary behind
the follower, unavailable local connection, or stale observation gives UNKNOWN,
not zero. Values describe the most recent bounded observation, not an atomic
cross-host snapshot, elapsed seconds or proof of identical contents. The
replication operator must establish the same primary lineage and common-prefix
contract before interpreting transaction distance. A primary without
/watermark yields UNKNOWN until upgraded; no expensive fallback query runs.
just replica-test /path/to/quipu-server /path/to/quipu checks a scratch follower:
100 positive reads, unchanged main DB hash/counter/mtime across start and stop,
503 write responses, lag failure handling and a pending-subscription trap that
must never deliver. Library/server tests also plant a write against the actual
read-only connection. The scratch test does not change or probe production.
Private bounded query specimen
server.query_capture is omitted by default. An omitted configuration or
enabled = false creates no capture worker, file or capture-state log. An
operator can explicitly configure enabled, an exact lowercase query_sha256,
query_bytes (1–4096), a unique alphanumeric/underscore/hyphen window_id, and
absolute start_epoch_secs/end_epoch_secs. The window is at most five minutes;
startup refuses an expired window or a start more than fifteen minutes ahead.
A parsed selector/window that fails validation disables the diagnostic without
changing normal query results; malformed configuration still follows normal
strict configuration-loading errors. There is no HTTP endpoint to arm it.
Only the first matching Seeds-category POST /query request can consume the
claim. Eligibility is the intersection of the absolute UTC window and its
startup-pinned monotonic start/end, so clock jumps cannot extend or start it early. At startup the
server exclusively claims query-captures/<window_id>.json beside the canonical
store file, using a same-owner private directory and descriptor-relative,
no-follow exclusive creation. Existing claims are never overwritten, including
empty files left by a crash: restarting cannot renew the attempt. Unsupported
platforms refuse enabled capture. Operators retain and adjudicate these private
artifacts; the server does not delete or retry them.
A capacity-one worker writes at most 16 KiB outside store locks. The record contains exact raw SPARQL, bounded planning options, UTC, build identity and the existing request/process/query/input digests. Headers, response bodies and unknown JSON fields are excluded; omitted fields are explicitly flagged. Raw query literals can be sensitive, and a digest is not anonymization. The Seeds client category is an unauthenticated attribution label, not proof of caller identity. Serialization overflow consumes the attempt and records refusal without a truncated query. File failures leave an UNKNOWN partial or empty claim; no request is retried. Default-off source deployment does not authorize arming: use a separately approved window and flag-off rollback, then read back that the flag is off.
Triples and the Knowledge Graph
Everything in Quipu is a triple: a subject, a predicate, and an object.
<korimako> <runs> <traefik>
subject predicate object
This triple says “korimako runs traefik.” Three triples can encode a complete service dependency:
@prefix hw: <http://example.org/homelab/> .
hw:korimako a hw:Host .
hw:korimako hw:runs hw:traefik .
hw:traefik hw:dependsOn hw:pihole .
That’s it. No tables to design, no schema migrations. You add facts incrementally and query them with SPARQL.
IRIs: Naming Things
Every entity and predicate is identified by an IRI (Internationalized Resource Identifier) — a globally unique name like a URL:
http://example.org/homelab/korimako
Prefixes keep things readable. Instead of writing the full IRI every time:
@prefix hw: <http://example.org/homelab/> .
hw:korimako hw:runs hw:traefik .
hw:korimako expands to http://example.org/homelab/korimako.
Objects: References vs Literals
The object of a triple can be either:
- A reference to another entity (another IRI)
- A literal value (a string, number, boolean, or date)
hw:korimako hw:runs hw:traefik . # reference → another entity
hw:korimako hw:hostname "korimako.example" . # literal → a string
hw:korimako hw:cpuCores "4"^^xsd:integer . # literal → a typed number
How Quipu Stores Triples
Under the hood, Quipu stores triples as EAVT facts in an immutable log:
| Field | Meaning | Example |
|---|---|---|
| E (entity) | The subject | hw:korimako |
| A (attribute) | The predicate | hw:runs |
| V (value) | The object | hw:traefik |
| T (transaction) | When it was written | tx:42 |
Every fact also carries a valid-time window (valid_from, valid_to),
so you can model when facts were true in the real world — not just when
they were recorded. See The Temporal Model for details.
The RDF Data Model
Quipu uses the RDF data model, which means:
- Facts are interoperable with any RDF tool
- You can ingest data in Turtle, N-Triples, JSON-LD, RDF/XML, or TriG
- You query with SPARQL — the standard RDF query language
- You validate with SHACL — the standard RDF constraint language
You don’t need to know RDF theory to use Quipu. If you can read
subject predicate object . you’re ready to go.
Loading Triples
From a Turtle file:
quipu knot homelab.ttl --db homelab.db
From the REST API:
curl -s localhost:3030/knot -X POST \
-H "Content-Type: application/json" \
-d '{
"turtle": "@prefix hw: <http://example.org/homelab/> .\nhw:korimako a hw:Host ; hw:hostname \"korimako.example\" ."
}'
From an episode (structured agent input):
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "homelab-inventory",
"nodes": [
{"name": "korimako", "type": "Host", "properties": {"hostname": "korimako.example"}},
{"name": "traefik", "type": "WebApp"}
],
"edges": [
{"source": "korimako", "target": "traefik", "relation": "runs"}
]
}'
What’s Next
- The Temporal Model — how time-travel works
- SPARQL from Zero — querying your triples
- SHACL Validation — enforcing structure
The Temporal Model
Every fact in Quipu has two time dimensions:
- Transaction time — when the fact was recorded in the database
- Valid time — when the fact was true in the real world
This is called a bitemporal model, and it means you can always answer:
- “What did we know at time T?” (transaction time)
- “What was true at time T?” (valid time)
- “What did we know at time T1 about what was true at T2?” (both)
Why Bitemporality Matters
Say you record on April 1 that korimako has 4 CPU cores:
quipu knot - --db homelab.db --timestamp 2026-04-01 <<'EOF'
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
hw:korimako hw:cpuCores "4"^^xsd:integer .
EOF
On April 3 you upgrade to 8 cores and record the change:
quipu retract "http://example.org/homelab/korimako" \
--predicate "http://example.org/homelab/cpuCores" \
--db homelab.db --timestamp 2026-04-03
quipu knot - --db homelab.db --timestamp 2026-04-03 <<'EOF'
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
hw:korimako hw:cpuCores "8"^^xsd:integer .
EOF
Now you can time-travel:
# What's the current state?
quipu read "SELECT ?cores WHERE {
<http://example.org/homelab/korimako> <http://example.org/homelab/cpuCores> ?cores
}" --db homelab.db
# → 8
# What was true on April 2?
quipu read "SELECT ?cores WHERE {
<http://example.org/homelab/korimako> <http://example.org/homelab/cpuCores> ?cores
}" --db homelab.db --valid-at 2026-04-02
# → 4
The EAVT Fact Log
Under the hood, facts are stored as immutable rows:
| E (entity) | A (attribute) | V (value) | T (tx) | valid_from | valid_to | op |
|---|---|---|---|---|---|---|
hw:korimako | hw:cpuCores | 4 | 1 | 2026-04-01 | 2026-04-03 | Assert |
hw:korimako | hw:cpuCores | 4 | 2 | 2026-04-03 | 2026-04-03 | Retract |
hw:korimako | hw:cpuCores | 8 | 3 | 2026-04-03 | null | Assert |
Nothing is deleted. Retractions close the valid_to window on old facts
and add a new retraction record. The full history is always available.
Transaction Time vs Valid Time
| Dimension | What it tracks | Set by | Queryable via |
|---|---|---|---|
| Transaction time | When the database learned about the fact | System (auto-incremented tx ID) | --tx flag, as_of_tx parameter |
| Valid time | When the fact was true in reality | You (--timestamp flag) | --valid-at flag, valid_at parameter |
Transaction time is monotonic and system-controlled. Valid time is user-supplied and can refer to the past or future.
Querying Through Time
Current state (default)
SELECT ?host ?cores WHERE {
?host <http://example.org/homelab/cpuCores> ?cores .
}
Returns only currently-asserted facts (op=Assert, valid_to is null).
Valid-time travel
quipu read "SELECT ?host ?cores WHERE {
?host <http://example.org/homelab/cpuCores> ?cores
}" --db homelab.db --valid-at 2026-04-02
Returns facts that were valid at the specified point in time.
Transaction-time travel
quipu read "SELECT ?host ?cores WHERE {
?host <http://example.org/homelab/cpuCores> ?cores
}" --db homelab.db --tx 1
Returns only facts recorded up to transaction 1 — what the database knew at that point, regardless of valid-time windows.
REST API
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{
"query": "SELECT ?host ?cores WHERE { ?host <http://example.org/homelab/cpuCores> ?cores }",
"valid_at": "2026-04-02"
}'
Contradiction Detection
If two facts for the same entity+attribute have overlapping valid-time windows, Quipu flags a contradiction. This prevents conflicting states from silently coexisting:
#![allow(unused)]
fn main() {
let issues = store.detect_contradictions()?;
// Returns pairs of facts with overlapping intervals
}
Design Principles
- Append-only: Facts are never mutated or deleted
- Full audit trail: Every change is a transaction with metadata
- Time-travel by default: Any query can add a temporal context
- Contradiction-aware: Overlapping valid-time windows are surfaced
Named Graphs, Overlays & Datasets
Implementation status: ✅ Built on the sanctioned surfaces — the
gcolumn andgraphsregistry, overlay create/write/compose,GRAPH/FROM/FROM NAMEDevaluation, thegraphquery param, and named datasets, and (since 2026-08-21) a strictgraphparam onPOST /knotthat accepts only already-registered committed-class graphs. Deliberately not built: property paths underGRAPH ?g(explicitly refused, never a silent ROOT read). Seedocs/design/named-graphs.mdfor the full design.
Every fact in quipu’s EAVT store carries a graph coordinate on top of
(entity, attribute, value) and the two time axes — the store is really a
quad store. The g column says which subgraph a fact lives in, orthogonal
to when it holds (valid_from/valid_to) and when the store learned it
(tx). Retraction, time-travel, and contradiction detection all scope
within a graph: a retraction in graph A never touches graph B.
g = 0is the reserved ROOT graph — the default committed graph, the source of truth.- A named graph’s
gis the interned term id of its graph IRI, so resolvingGRAPH <iri>is a single term lookup. - The
graphsregistry keeps one row per graph with an enforcedclass:committed(a durable branch; ROOT is the seeded, self-rooted one) oroverlay(a layer over a committed parent). A graph’s class is fixed at create.
Querying: GRAPH, FROM, and the graph param
SPARQL reads are ROOT-scoped by default. The default graph is ROOT alone, not an all-graph union — silence must never expose another tenant’s overlay. A query widens its dataset only by saying so:
GRAPH <iri> { … }scopes the enclosed patterns to one named graph. An unknown IRI matches nothing.- ROOT is the default graph, not a named graph.
GRAPH <urn:quipu:graph:root>is refused with an error naming the default-graph form. Read ROOT without aGRAPHclause, or explicitly useFROM <urn:quipu:graph:root>. GRAPH ?g { … }ranges over the active named graphs, binding?gto each match’s graph IRI. Property paths underGRAPH ?gare refused with an explicit error rather than silently reading ROOT.FROM <g…>makes the default graph the RDF merge (union) of those graphs. An unknown graph contributes nothing; an all-unknownFROMset yields an empty default graph — never a fall-through to ROOT.FROM NAMED <g…>restricts which named graphs aGRAPHclause can see. A query withFROMbut noFROM NAMEDactivates no named graphs (per SPARQL 1.1), soGRAPHmatches nothing.
SELECT ?s ?title
FROM <http://example.org/graphs/derived>
WHERE { ?s <http://example.org/title> ?title }
/set and /retract operate only on ROOT and leave named-graph facts intact.
They refuse a supplied graph field, including null, before changing facts.
For a named graph, use the explicitly scoped /update endpoint instead.
POST /query and the quipu_query MCP tool also take a graph request
param — a convenience that scopes the query’s default graph to one named
graph without writing a FROM or GRAPH clause:
{"query": "SELECT ?s ?o WHERE { ?s <http://example.org/p> ?o }",
"graph": "http://example.org/graphs/derived"}
Omitting it keeps the ROOT default; an unknown IRI gives an empty default
graph; a FROM clause in the query text overrides the param. The param also
resolves dataset names: passing a dataset IRI scopes the query to that
dataset’s members, so FROM <dataset> and "graph": "<dataset>" mean the
same thing. The same graph param on quipu_export / POST /export
exports one named graph’s facts instead of ROOT.
Search has a broader default than SPARQL
On a new store, POST /search and quipu_search default to ROOT plus registered
named graphs. Existing stores retain ROOT until explicitly broadened with
[quipu.search] default_graph_scope = "all". When that setting is omitted, the
store persists its default; read-only legacy stores use ROOT without migration.
Coverage reports the effective default and its source. This policy is local to
the receiving store: importing a share does not replace it with the sender’s
choice. A whole-file backup retains the store’s policy.
For example, a work tracker can keep its records in its own project graph:
those records need not be copied into ROOT to be discoverable. This covers
graphs registered in the local store, including attached layers registered
there; it does not query remote federation endpoints automatically.
Configure retrieval policy under [quipu.search]:
default_graph_scope = "all"
exclude_graphs = ["urn:example:quarantine", "urn:example:private-work"]
Use default_graph_scope = "root" for the previous implicit scope. Requests
can select graph: "urn:quipu:graph:root", one named graph IRI, graphs: [iri, iri], or all_graphs: true. Configured exclusions apply to all selections;
a selection emptied by exclusions refuses. These controls are retrieval
policy, not access control. Deployments needing tenant isolation must
enforce authorization separately. Configure exclusions before exposing a
store containing scratch or quarantine graphs to general search.
Every successful search returns search_scope: selected graph IRIs,
configured exclusions, whether selection was explicit, named-vector enablement,
and the fact that index completeness is not certified. Result graph provenance
does not promote a fact or make its source authoritative. Similarity and context
admission still apply; an empty search does not prove a graph contains no facts.
Semantic retrieval uses one vector per entity. An entity with ROOT text keeps that representation; a named-only entity combines its named-graph facts. Selecting a graph filters membership, not the vector’s text provenance. For assertion-level literal matching within one graph, use keyword search. This distinction is why listing selected graphs does not certify complete indexing.
Semantic search across named graphs still requires named_graphs = true.
Prepare existing named-only vectors using bounded /embed_backfill_graph calls
before enabling it; an unprepared default all-graph request refuses instead of
silently reporting ROOT as complete coverage. Keyword search needs its enabled,
backfilled lexical index, but no embedding provider or named-vector flag.
ROOT-only content ranking, anchor traversal, and structured search refuse a
broader scope. See the search API.
Querying and promotion are separate operations
To inspect one graph, use GRAPH <iri> or the /query graph parameter above.
To join graph contents deliberately, use FROM <a> FROM <b> or a registered
dataset. Search scope never changes these SPARQL defaults or writes facts.
Promotion admits reviewed content into ROOT; it is not required merely to find
or query named-graph data. For bulk material, use the producer’s share, then
import to stage and validate it, inspect the admission result, and use
import promote <share-id> only when eligible and authorized. Do not replace
promotion with a loop of episode or update writes. See
bulk loads and migrations for exact commands,
shape admission, and fork rollback. A search hit from a staging graph is still
staged content until that governed promotion succeeds.
Property paths follow a fixed graph scope without crossing it: a
GRAPH <iri> closure stays inside that graph, a FROM <a> FROM <b> path
traverses their merge. A path never crosses a graph boundary — half a path
in an overlay and half in ROOT is not a fact either graph asserts.
Overlays
An overlay is a scratch layer over a committed parent branch: hypotheses go in the overlay, the committed base is never mutated. Two write primitives, one uniform read:
- Create (
quipu_overlay_create/POST /overlay/create) registers an overlay-class graph bound once to its committed parent branch (ROOT by default). Idempotent; rebinding to a different parent is an error — the binding is unforgeable. - Write (
quipu_overlay_write/POST /overlay/write) takes one of three ops:assertandretractare graph-scoped writes into the overlay;tombstonemarks a specific(e, a, v)from the parent as absent in the overlay’s composed view, without touching the parent. - Compose (
quipu_overlay_compose/POST /overlay/compose) resolves the stack[overlay > parent-branch-root]with a single rule: a triple is present iff asserted and not tombstoned, nearest-overlay-wins. Overlay asserts shadow the parent; overlay tombstones hide parent triples; everything else falls through. - Governed precedence (
"precedence": "governed"on the same tool / endpoint) inverts who wins, for reading a quarantine plane over a governed graph (quipu-e61, adopted from Spanner’s statically-defined-properties-win-over-dynamic rule): the parent’s facts are always present — an overlay value on a same-subject-same-predicate slot the parent claims is suppressed, and an overlay tombstone cannot mask a parent fact (it still masks the overlay’s own contributions). A low-trust plane may extend the governed graph but never alter what it says; promotion out of quarantine remains an explicit governed write, never a precedence flip.
Many tenants can extend the same base independently this way, and a
committed read never sees any of them unless the query names the overlay.
Overlays are one sanctioned write path for named graphs. POST /episode
accepts a graph field for ingestion into a named graph, and POST /knot
accepts a graph field under a strict contract: the target must already be
a registered committed-class graph (created via graph_create, where
authority checks live). An unknown IRI is an error and is never interned —
a typo’d plane name must not become a writable target as a side effect of
being rejected — and overlay-class graphs are refused (write through
overlay_write). The class invariant the earlier “no graph on /knot”
refusal protected survives because registration remains the only way to
mint a target. The semantic event taxonomy (episode.ingested, entity.*,
edge.*, type.new, predicate.new) emits for ROOT-graph commits only;
named-graph writes — overlay staging and committed-class targets alike — do
not emit it. Gate refusals, however, are recorded as write.refused events
for any destination graph (the payload names the graph IRI), and
registry changes (shapes.loaded, fork.*) emit their own events.
Datasets
A dataset is a name for an arbitrary set of graphs, queryable as one
unit — the reusable form of a FROM a b c clause, so a graph set can be
labelled, governed, and handed to another agent. Managed via the
quipu_datasets MCP tool or POST /datasets (create / list / show /
remove).
curl -s localhost:3030/datasets -X POST \
-H "Content-Type: application/json" \
-d '{"action": "create", "name": "http://example.org/datasets/hot",
"members": ["http://example.org/graphs/a", "http://example.org/graphs/b"]}'
FROM <dataset-iri>(and thegraphquery param) expands to the dataset’s members at resolve time; everything downstream reads the expanded set.- A dataset is never implicitly active — the ROOT-alone default is untouched; you get a dataset’s graphs only by naming it. Creating a dataset with a member that names no registered graph is refused, and the refusal names that member.
urn:quipu:graph:rootis a valid member and reads the default graph, the same asFROM <urn:quipu:graph:root>. A dataset of ROOT plus a named graph is how a stored query reads the default graph AND that graph, without hardcoding either in the query text.- Members may carry a declared ordering (
{"graph": …, "ord": N}); duplicate ranks are refused rather than tiebroken silently. An empty dataset is refused. - Datasets are mirrored into the meta-graph as
quipu:Dataset/quipu:includesGraphfacts, so they are queryable and governed like any other fact. Datasets are orthogonal to the overlay branch tree: the branch tree is compose’s resolution root, datasets are overlapping named sets. - The labels of a query’s active dataset compose across its member graphs — see Graph Labels for how freshness/trust/policy labels fold over the graphs a query actually reads.
Related
- REST API —
/query,/export,/overlay/*,/datasets - MCP Tools —
quipu_query,quipu_export,quipu_overlay_*,quipu_datasets - Graph Labels — labels on graphs and their composition over datasets
- Design doc:
docs/design/named-graphs.md
Create and use a named graph through MCP
Use a local scratch store for this recipe. Install the CLI and sibling server,
then configure your MCP client with quipu mcp --db ./tutorial.db. Local MCP
opens no HTTP listener. A configured protected server needs its existing write
credential; an actor field declares provenance and never grants permission.
See MCP connection setup.
Each JSON block below is an MCP tools/call parameter object. Send the blocks
in order. Inspect isError before reading content; an error is not an empty
result. These exact blocks are exercised by the fresh-store acceptance test.
The timestamps are sample transaction times; use current UTC in your own work.
1. Create a committed graph
{
"name": "quipu_graph_create",
"arguments": {
"graph": "urn:example:graph:job-search",
"actor": "urn:example:agent:researcher",
"timestamp": "2026-10-14T12:00:00Z"
}
}
Expect created: true and creation containing the original timestamp and declared actor. Repeating create returns created: false and preserves that provenance. An already interned IRI still needs registration.
2. Declare its labels
{
"name": "quipu_graph_label",
"arguments": {
"graph": "urn:example:graph:job-search",
"timestamp": "2026-10-14T12:00:00Z",
"actor": "urn:example:agent:researcher",
"kind": "operational",
"freshness": "fresh"
}
}
3. Load the new class vocabulary
{
"name": "quipu_shapes",
"arguments": {
"action": "load",
"name": "job-search",
"timestamp": "2026-10-14T12:00:00Z",
"turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> . @prefix ex: <https://example.org/> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . ex:JobShape a sh:NodeShape ; sh:targetClass ex:Job ; sh:property [ sh:path rdfs:label ; sh:minCount 1 ] ."
}
}
This loads the vocabulary into the local store; supplying a type name on a write does not create or govern that class. Shape changes on a shared server need that server’s normal schema authority.
4. Check the loaded vocabulary
{
"name": "quipu_shapes",
"arguments": {
"action": "vocabulary"
}
}
Expect https://example.org/Job in classes. Use this exact class IRI in your RDF.
5. Write one job into the graph
{
"name": "quipu_knot",
"arguments": {
"graph": "urn:example:graph:job-search",
"timestamp": "2026-10-14T12:00:00Z",
"actor": "urn:example:agent:researcher",
"turtle": "@prefix ex: <https://example.org/> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . ex:job1 a ex:Job ; rdfs:label \"Research engineer\" ."
}
}
6. Query this graph explicitly
{
"name": "quipu_query",
"arguments": {
"graph": "urn:example:graph:job-search",
"query": "SELECT ?label WHERE { <https://example.org/job1> <http://www.w3.org/2000/01/rdf-schema#label> ?label }"
}
}
Expect count: 1 and label Research engineer. Omitting graph queries ROOT; it must return no matching job in this fresh store.
7. List registered graphs and their provenance
{
"name": "quipu_graph_list",
"arguments": {}
}
Find the exact graph IRI, committed class, operational/fresh labels, and the same creation provenance from step 1. A ROOT row may also be present; compare the target row rather than assuming a total count.
Fix common errors
- Unknown graph / interned but not registered: call
quipu_graph_createwith the exact graph IRI beforequipu_knot. The error includes that call and the equivalentquipu graph create '<iri>' --db <path>command. - Unknown rdf:type / off-vocabulary class: load a SHACL shape with
sh:targetClassmatching that exact class IRI usingquipu_shapes, then checkaction: vocabularybefore repeating the rejected write. On a shared store, propose the shape through its governed schema lane instead of changing somebody else’s vocabulary without authority. A similarly spelled label or namespace is not a match. - SHACL validation failure: supply the required label; do not disable
validation. The recipe’s Job shape requires
rdfs:label. - Overlay graph: use
quipu_overlay_write; committed graph registration does not reclassify an overlay.
Creation provenance records caller-declared metadata. It is not proof that the caller owned the actor IRI. Older or imported graphs may have an unknown actor; repeating create never invents an original creator.
Compose knowledge packs
quipu compose loads verified local share directories or archives into one
store, retaining each pack as a named graph and naming their union as a dataset.
ROOT remains unchanged, including when validation passes. Composition is an
inspection operation; it does not grant foreign content trust.
quipu compose ./operations-pack ./repository-pack \
--shapes-from ./operations-pack --db composed.db > composition.json
The command returns JSON and exits 0 for an admitted union, 2 for a retained,
quarantined union, or 1 for a refusal. Exit 2 means the composition is available
for inspection in quarantine, not that no data was written. Integrity and
shape-selection errors refuse the whole operation. Internal shares require
explicit --destination internal, as on the import path.
Identity and provenance
Exact IRIs denote one entity across packs. Matching labels never merge entities.
An explicit owl:sameAs assertion supplies an auditable identity link; consumers
can follow it while the original names remain in the source graphs. Blank node
identifiers are scoped to the pack even when independent exports both use c14n0.
The result names the dataset and each source graph. Query the dataset explicitly:
SELECT ?item ?name FROM <urn:quipu:composition:HASH>
WHERE { ?item a <https://example.org/Item>; <https://example.org/name> ?name }
For a fact’s source membership, name the source graphs from the result:
SELECT ?pack
FROM NAMED <urn:quipu:composition:pack:FIRST_SHARE_HASH>
FROM NAMED <urn:quipu:composition:pack:SECOND_SHARE_HASH>
WHERE { GRAPH ?pack { <https://example.org/item> <https://example.org/name> "Item" } }
A fact supplied by both packs has two source memberships. The dataset’s RDF union
suppresses duplicate triples. urn:quipu:composition:metadata records JSON manifests
under urn:quipu:composition:manifest, including source references, share and store
identities, transaction anchors, original manifests, attestation status, and validation
results. Records describe observed
compositions; they are not an automatically maintained validation certificate.
Shape authority and partial snapshots
Without --shapes-from, every input must carry an identical shape bundle.
Different bundles are refused with their share IDs and hashes. Explicit selection
chooses that pack’s complete bundle for this composition. It never silently unions
conflicting constraints or installs foreign shapes as global policy.
Validation examines the union: another pack can supply a required field. Loaded
local shapes control admission when present, including when an incoming bundle
declares a conflicting policy. Without local shapes, explicit --shapes-from
selection authorizes that bundle’s quipu:onViolation "emit" policy. Default
identical bundles validate the union but do not implicitly authorize emit
admission. Missing policies mean reject; unknown or conflicting values refuse.
Admission parses policy by RDF subject, so compact Turtle, alternate prefixes, comments and string literals cannot route a neighboring reject shape into emit. Both validator graphs preserve all constraints and shared RDF-list dependencies; only inactive independent targets are removed, including implicit class targets. Policy on a nested property does not downgrade its enclosing reject shape. The four core target kinds are supported; custom SHACL-AF targets refuse when emit partitioning is requested. Canonical and legacy annotation IRIs are checked together, so conflicting declarations cannot choose their own precedence.
conforms retains strict SHACL meaning: any diagnostic, including an advisory
Violation or Warning, makes it false. Admission uses blocking separately.
Emit-policy diagnostics appear in advisory_results; reject-policy Violations
still quarantine the union. The report records admission_policy with its source,
shape hash and emit authorization. The existing shapes_authority field identifies
the selected input bundle.
The report preserves violation counts and counts by source shape. Its results
preview contains at most 40 diagnostics; when truncated, complete_results
contains every diagnostic and results_total records their count. Types outside
the authoritative vocabulary are reported separately as off_vocabulary and
also quarantine the union. Neither an admitted union nor an emit policy promotes
data to ROOT or grants foreign content trust.
The snapshot vector remains visible. Different source stores’ transaction anchors are incomparable. Mixed dates are permitted and reported; composition does not claim that snapshots were taken together, every referenced entity has arrived, or missing facts represent upstream deletion. Pack selection is explicit. Loading a different snapshot names another composition without replacing an earlier dataset.
Retractions and replay
Reloading a share reuses its source graph rather than refilling it. Attested shares still obey the ordinary signature and nonce-replay checks. A local retraction in that graph survives reload of the same snapshot. Validation on replay examines the current local union, so removing a required field changes the outcome to quarantine. Source graph edits affect every dataset that includes that graph.
A distinct historical snapshot is a separate inspection graph, not a restoration of ROOT or an automatic replacement of the selected dataset. Historical source membership records what a snapshot contained, not whether its facts remain current. Retract in the graph whose view you intend to change.
A stable name per repository
Each composition gets a new dataset name, so an agent has nothing stable to ask for “this repository’s knowledge now”. Repository mode gives it one:
quipu compose --repo scbrown/demo packs/main=./main.pendant packs/ops=./ops.pendant \
--db store.db
Each input is <pack-key>=<path>. The pack key (its path in the repository, for
example) is what makes two commits of a pack the same pack, so it is supplied,
never inferred from the bytes. An admitted composition moves two datasets:
urn:quipu:repo:<repo>:currentnames the latest admitted snapshot.urn:quipu:repo:<repo>:previousnames the snapshot before it.
A quarantined composition stays inspectable under its own
urn:quipu:repo:<repo>:snapshot:HASH dataset, but it never moves CURRENT.
Recomposing the snapshot CURRENT already names moves nothing.
SELECT ?item ?name FROM <urn:quipu:repo:scbrown/demo:current>
WHERE { ?item <https://example.org/name> ?name }
Pack graphs in this mode are scoped to the repository
(urn:quipu:repo:<repo>:pack:<key>:SHARE_HASH). Two repositories composing
identical bytes therefore get two graphs, and a retraction in one never shows
through the other. Older snapshot graphs that neither pointer names are dropped:
their facts are retracted and the graph is unregistered, and history stays
bitemporal. A graph that another dataset still names is kept and reported under
retained_shared.
Local retractions carry forward. Before each composition, statements retracted locally in this repository’s pack graphs are recorded as tombstones for that pack key. They are removed from every later snapshot of the pack before validation, so validation sees the union an agent will actually query. This holds across a version where the statement is absent, a version where it returns, retention pruning and rollback. Blank nodes are renamed per snapshot, so a retraction of a blank-node statement cannot be matched later. That case refuses the composition and names the statement; re-assert it, or give the node an IRI in the pack.
Roll back by moving the pointer:
quipu compose --repo scbrown/demo --rollback --db store.db
This swaps CURRENT and previous, and first applies any tombstone recorded since previous was current. The whole operation is one transaction: a failure at any step leaves both pointers, the graphs and the caches as they were.
Undo a local retraction
Revoke one carried-forward suppression explicitly, without editing SQLite:
quipu compose --repo scbrown/demo --pack-key packs/main \
--revoke-tombstone '<https://example.org/alice> <https://example.org/name> "Alice" .' \
--reason 'Undo an accidental local retraction' --db store.db
The statement must be exactly one N-Triples triple, with no blank nodes. A
nonblank reason is required. This operation takes no packs and cannot be combined
with rollback. It restores only matching, previously asserted composition facts
that are retracted in this repository’s owned graphs for the exact pack key,
then removes their carried-forward tombstone in the same transaction. The
receipt reports revoked and graphs_restored. Repeating a successful reversal
returns revoked: false; a missing statement never invents a fact.
Replaying or rolling back an already-stripped graph stays stripped. A tombstone may have removed the statement before that graph was first stored, so there is no historical assertion there to restore. Recovery is a fresh composition from a newly published source-pendant snapshot containing the fact (a distinct share ID), after revocation. Replaying the same stripped snapshot does not refill its graph. The operation never fabricates omitted facts from the requested statement alone.
CURRENT and PREVIOUS do not move, but restored facts become visible immediately in their graphs, including retained older graphs. Any other dataset referencing those same graphs sees the restoration too. Other repositories, other pack keys, unrelated statements and ROOT are unchanged. Recomposition and rollback do not recreate the revoked suppression; a later explicit local retraction can create it again. A failed operation rolls back both restoration and the ledger update.
Deleting a tombstone row by hand does not have these semantics: retracted facts in a retained graph would be harvested again on the next composition.
Pack pointers on first use
A manifest’s kind = "pack" pointers remain declarations at checkout and
ordinary composition. A consumer follows only the graph it needs, using
share_pointer_load::LazyPacks::use_graph or the native first-use command:
quipu compose --use-pointer urn:project:api \
--declaration repository.pendant.tar.gz \
--authority local-policy.pendant.tar.gz \
--repo-remote https://github.com/example/project.git \
--pointer-cache ./verified-archives --db ./inspection.db
The receiver supplies the repository remote and local authority. The command
runs caboodle packs trust check against that repository and the pointed
archive hash before reading the cache or fetching. A missing trust tool or a
refused decision stops the operation. Foreign bundles never become global
shape policy, and nothing is promoted into ROOT. Use the returned dataset in
an explicit SPARQL FROM clause. This command does not move repository CURRENT.
The archive sha256 and the enclosed manifest’s share_id, graph, shapes and
query hashes are verified independently. Cache keys use the archive digest;
reads reverify the bytes and recheck trust. Cached archives work offline. A
corrupt cache entry refuses rather than silently fetching a replacement.
Fetch, integrity and validation failures name the requested pointer and leave
no partial composition. Nested pointers are reported, not recursively loaded.
The download limit is 16 MiB and the expanded archive limit is 64 MiB.
Library consumers supply their own trust and transport callbacks plus an
ArchiveCache. Construction performs no network access. Invoke use_graph
when a graph is first requested, rather than scanning and following every
pointer. DirectoryCache provides atomic native archive writes.
Browser consumers
The explorer wasm bundle exports PointerExplorer. Supply the declaration
archive and an authority archive chosen by the receiving application. The
separate wasm/explorer/runtime/pointers.mjs helper provides bounded Fetch and optional
CacheStorage persistence:
const engine = new PointerExplorer(declarationBytes, localAuthorityBytes);
const cache = await caches.open('project-pointer-archives');
const result = await usePointerGraph(engine, 'urn:project:api', authorize, { cache });
const rows = engine.query(`SELECT ?s ?p ?o FROM <${result.dataset}> WHERE { ?s ?p ?o }`);
authorize(pointer) is the receiving application’s policy decision, such as a
Caboodle policy exported by its trusted host. It must return the literal
boolean true; the archive cannot supply consent. The helper checks it before
cache or network access. Rust checks trust and both integrity layers before
composition, using the same implementation as native. Browser requests omit
credentials and refuse redirects. Cached archives can be used by a fresh
instance offline. This is a read-only named dataset; it does not use the
ordinary explorer’s separate ROOT promotion ceremony.
Graph Labels & the Trust Lattice
Implementation status: 🟨 Substantially built. The axes (
Freshness,Trust,PolicyClass,Durability,DataKind), the meet/join algebra insrc/lattice.rs/src/lattice_kind.rs, label storage (set_graph_label/label_of, the reserved meta-graph, cache columns), dataset labels on the query path, label floors, expiring declarations, and derivation methods are all implemented. Statement-level labels (quipu #73) remain design-only. Seedocs/design/graph-labels.mdfor the full design.
Named graphs partition the store, but on their own they cannot say anything about a graph: how current its contents are, how far they should be trusted, or what policy governs them. Every consumer of Quipu was hand-rolling that missing layer differently. Graph labels make it a store primitive: labels on graphs, drawn from ordered value sets, composing under one invariant.
The one invariant: composition never widens
When graphs are combined into a dataset, the composed label may never claim more than any member does. The operator flips direction by axis, but the invariant holds both ways:
- Freshness, trust, and durability compose by meet — a union of graphs is only as fresh, as trusted, and as durable as its weakest member.
- Policy obligations and kinds compose by join — a union of graphs carries
the union of their restrictions, so one
no-exportgraph taints the set, and the union of their declared kinds, so a dataset touching anarchivegraph says so.
The axes
| Axis | Values | Compose |
|---|---|---|
quipu:freshness | fresh > recomputing > stale | meet |
quipu:trust | IRIs ranked by a declared chain | meet, within one chain |
quipu:policyClass | set of obligation tokens (pii, no-export, …) | join (union) |
quipu:durability | backed > reproducible > soleRecord | meet |
quipu:dataKind | one token per graph (knowledge, operational, identity, archive, …) | join (union) |
Four things to know about these values:
- Trust is not a hardcoded enum. Trust values are IRIs and the ordering is
data:
smac:canonical quipu:trustRank 40 ; quipu:inChain smac:ruleTierChain. Ranks are only comparable within a declared chain — comparing across chains is refused, never silently compared as integers. - Durability answers an owner-facing question the belief axes do not:
which facts would be lost if this store were lost?
backedis persisted elsewhere,reproducibleis re-derivable from a source that still exists,soleRecordmeans loss is permanent. A derived fact is only as durable as its least durable input, so it composes by meet. - Nothing is ever synthesized. A producer declares labels; Quipu never
observes staleness or infers
backedbecause a backup ran once. Related but distinct:quipu:derivedByrecords how to re-derive a fact (system, query, parameters). It is a per-fact value, not a lattice axis — two methods do not meet into a third.derivedBysays how to recover a fact;durabilitysays whether you must. - Kind is categorical, not ordered.
quipu:dataKinddeclares what sort of data a graph holds — the token space is lexically open ([a-z][a-z0-9-]*), parsed strictly, and never ranked, so it composes by union rather than by weakest member. It also drives fetch-time scope widening (include_kinds) and the deep-freeze lifecycle — see Graph Kinds & Deep Freeze.
Undeclared is not a lattice value
Every pre-existing graph is unlabelled, so the default matters. Defaulting to
top would fail-open trust; defaulting to bottom would drag every existing query
to the floor. Instead a composed label is a pair: the fold over the declared
labels, plus a coverage (full, partial, none) saying how much of the
dataset declared anything. An unlabelled graph reads as undeclared, never as
a fabricated fresh. Declarations can also expire (set_graph_label_until);
past their valid_to they simply become undeclared again.
Labels on the query path
The composed label is a property of the query’s dataset, computed once per
query — not per row. A dataset containing a stale graph is labelled stale even
if no returned row came from it; conservative cannot overstate. /query and
quipu_query responses carry a top-level labels key beside truncated:
{
"rows": ["..."],
"truncated": false,
"labels": {
"freshness": { "value": "stale", "coverage": "full" },
"durability": { "value": "soleRecord", "coverage": "partial" },
"trust": { "value": "smac:canonical", "chain": "smac:ruleTierChain", "coverage": "full" },
"policy": { "value": ["no-export"], "coverage": "full" },
"kind": { "value": ["knowledge", "archive"], "coverage": "full" }
}
}
The key is always present: null means nothing was declared, and a fold
refusal (member graphs trusted in different chains) is reported as
{"error": …} while the query still returns its rows. Old clients ignore the
extra key. Per-row label columns exist only where the graph is already bound
per row — under GRAPH ?g, opt-in.
Label floors
An opt-in enforcement floor in configuration:
[quipu.labels]
min_freshness = "fresh"
min_trust_rank = 30
min_trust_chain = "https://quipu.dev/ontology/defaultTrustChain"
deny_policy_tokens = ["no-export"]
deny_data_kinds = ["archive"]
When a dataset’s label falls below the floor, the query is refused, and
the refusal names the graph that dragged the label down. Undeclared fails a
configured floor — fail-safe at enforcement, honest at reporting. The one
deliberate exception is deny_data_kinds, which is a blocklist, not a
minimum: an undeclared kind passes, because kind is categorical and failing
every unlabelled graph the moment the key is set would be a different (and
unasked-for) migration. All keys are unset by default, and unset means zero behaviour change: no query is ever
refused by a store that has not configured a floor, and the unconfigured path
does no label work at all.
⚠ Label floors are NOT access control. A floor refuses a query; it does
not hide rows, and nothing stops a caller who names a graph directly from
reading it. aegis:authorityOver gates writes only; a read-side authority
check does not exist and is not built here. Presenting trust labels as a
confidentiality boundary would repeat the group_id mistake this stack
already documents.
One governance consequence worth knowing: labels live as ordinary facts in the
reserved meta-graph urn:quipu:graph:meta, so relabelling a graph requires
authority over the meta-graph, not over the graph being labelled — otherwise
a tenant could relabel itself attested.
Built vs designed
Built: the five axes and the fold, graph-label storage with the RDF meta-graph
as source of truth and cache columns checked by quipu doctor labels, dataset
labels on the query path, label floors, expiring declarations, durability and
derivation. Design-only: statement-level labels (#73) — the same vocabulary
attached to individual statements, with downward-only override.
Related
- Named Graphs — the substrate labels attach to
- Graph Kinds & Deep Freeze — the
dataKindaxis in use - Configuration — the
labels.*floor keys - REST API — the
/queryresponse shape - Design:
docs/design/graph-labels.md
Graph Kinds & Deep Freeze
Implementation status: ✅ Built on the sanctioned surfaces — the
quipu:dataKindlabel axis,GET /graphs,include_kindson/query, andquipu graph freeze|thaw|list. Seedocs/design/graph-kinds-and-deep-freeze.mdfor the full design.
The kind axis
quipu:dataKind declares what sort of data a graph holds — a fifth label
axis beside freshness, durability, trust and policy. Categorical, not
ordered: a dataset composes to the union of its members’ kinds, so an
answer that touched cold data says so in its labels.kind.
Conventioned values (the space is lexically open — [a-z][a-z0-9-]*):
knowledge— durable semantic content;operational— high-churn workflow/run/ticket state, written into time-windowed graphs (e.g.…/shuttle/runs/2026-08);identity— principals and verifier registrations, split out so freezing a window never strands the keys that verify its signatures;archive— frozen read-only history; set by the freeze operation.
Declare it like any label:
curl -s localhost:3030/graph/label -X POST -H "Content-Type: application/json" \
-d '{"graph": "urn:app:runs/2026-08", "kind": "operational",
"timestamp": "2026-08-24T00:00:00Z"}'
[quipu.labels] deny_data_kinds = ["archive"] refuses queries that
implicitly compose the named kinds — a blocklist (undeclared passes), not a
minimum, and like every floor it is not access control.
Deep freeze
quipu graph freeze <iri> relocates a whole graph’s full history —
retracted rows and transactions included — into a read-only archive pack,
verifies the copy by content hash, deletes the local rows, and re-attaches
the pack. The graph keeps its IRI and stays queryable; its durability
genuinely becomes backed. Writes to it are refused, naming
quipu graph thaw.
Compose frozen graphs back in, explicitly (silence never widens):
GRAPH <iri>orFROM <iri>— by name;FROM <urn:quipu:dataset:frozen>— the auto-maintained dataset of every frozen graph;"include_kinds": ["archive"]onPOST /query— by kind, so new frozen windows join automatically.
Known cost: as_of_tx time travel is refused while archives are attached
(the pre-existing rule for any attachment); valid-time queries survive.
quipu graph thaw <iri> restores the history byte-for-byte and reopens the
graph for writes — the pack file stays on disk, and the freeze registry row
is closed, never deleted.
Freezing and semantic search
Freezing costs the freezing store nothing here. Freeze deletes the graph’s
fact rows; it never touches the vectors table, so the embeddings of a frozen
graph’s entities stay in place and semantic search answers as it did before.
The archive carries them too. A freeze pack holds embeddings for the
graph’s own subjects, re-keyed by IRI, and both quipu graph thaw and
quipu graph import restore them — so a window handed to another store, or
thawed into a store rebuilt from packs, arrives with its semantic index rather
than needing a re-embed. Freeze and thaw report the count; the restore is
idempotent.
Two limits worth knowing:
- A delegated or LanceDB vector backend cannot be enumerated, so nothing
can be re-keyed out of it. The freeze still succeeds — relocating history is
not a vector operation — but it warns on stderr and stamps
vectors_omittedinto the pack manifest, so an incomplete archive never reads as “this graph had no embeddings”. Athawin the other direction refuses: restoring rows into a store whose live backend is not the built-in one would put them where nothing reads them. Runquipu migrate-vectorsfirst. - An attached archive’s embeddings are not searched from the pack. Vector search reads the local store only, deliberately — one index per question. Nothing is lost by it, because the local rows were never removed.
Governance: Policies, Verdicts & the Write Gate
Implementation status (2026-08-12): ✅ Built — the Phase-A write gate (
src/governance/guard.rs, wired throughstage_and_guardinsrc/store/ops.rs), the seven governance/overlay MCP tools (src/mcp/governance.rs), v1 verdict signing (src/signing.rs,src/governance/verdict_facts.rs), authority intersection, and the audit/replay machinery. ⬜ Not built: the hank-side structural-policy projection (Phase B),boundary:"transition"enforcement, the workflow half ofrequire-approval, and everything from §5 of the signing-plane design (shared signing crate, bitemporal key registry, task signing).
Quipu carries a declarative governance vocabulary and an engine that binds it to the write path. A policy is a fact in the graph like any other; whether it enforces is a runtime decision, and every enforced decision leaves a signed, auditable verdict behind.
The vocabulary
An aegis:Policy (shapes/governance.ttl) names four things: what it governs,
what compliance means, where it binds, and what happens on failure.
@prefix aegis: <http://aegis.gastown.local/ontology/> .
aegis:todo-needs-ticket a aegis:Policy ;
aegis:targets "aegis:CodeComment" ;
aegis:claim "ASK { $target aegis:citesTicket ?t }" ;
aegis:boundary "action" ;
aegis:effect "deny" .
targets— the entity type the policy applies to.claim— a SPARQL ASK stating the compliant condition;$targetis bound to the entity under evaluation. Satisfied = good.boundary—action(pre-edit/pre-write) ortransition(workflow step; declared but not yet enforced).effect—allow | warn | require-approval | deny | escalate | record.appliesTo(optional) — repo-relative path globs scoping where an action-boundary policy binds. Genuinely multi-valued: a policy scoped to three globs carries three values, and a consumer accumulates them rather than keeping whichever arrived first. Absent means unscoped. Declared withrdfs:rangeonly — nordfs:domain, so the same term reads identically on a futureTextRuleorDirective.
An optional aegis:evidenceProbe (another ASK: “does evidence exist yet?”)
lets the evaluator distinguish unknown from unsatisfied — no evidence is a
different fact from failing evidence, and neither is collapsed into the other.
Tripwires: path-boundary policies
A policy carrying aegis:appliesTo and no selector or predicate is a
tripwire: touching the path is the crossing, so the claim needs no
evidence beyond the action’s own target. shapes/policies/tripwire.ttl ships
the catalog — the governed twin of yupana’s local
[[yupana.policy.tripwires]], with quipu as the canonical store and yupana
holding only a projected cache. Placement follows SARC Table 3, not
convenience: the deny wire is hard at the PAG (admissibility is decided
before dispatch — the edit must never land), and the throttle wire is
soft at the PAA with a declared aegis:backoffFormula (it prices a
completed crossing and backs off the actions after it, never the crossing
itself). That formula is not optional decoration: under validate_placement
the write gate refuses any policy declaring effect "throttle" without an
aegis:backoffFormula — a throttle with no backoff is a response nobody can
compile, so the consumer records the crossing and applies no throttle, an
armed-looking wire that prices nothing. There is no soft-at-the-gate wire — a soft constraint has nothing to
price before the action lands. Re-scoping a wire is amending the policy:
the write gate treats an appliesTo write as governance-defining and
invalidates the cached policy registry.
The write-path gate
The single write choke point is Store::transact_to_graph. Inside the open
savepoint — datums staged, nothing committed — stage_and_guard
(src/store/ops.rs) hands the pending post-state to the policy guard
(PolicyRegistry::build + evaluate_write, src/governance/guard.rs). On a
blocking verdict the savepoint rolls back and the write never lands; the store
is byte-identical to before the call.
The registry is built once and cached: every active boundary:"action" policy,
indexed by target type. A write’s touched entities are intersected with the
governed-type set first, so a write touching no governed type runs zero
ASKs; the cache invalidates itself when a transaction writes a
governance-defining fact. Effects split into blocking — deny,
require-approval, escalate block when the claim is unsatisfied (a gate with
no approval channel fails closed rather than passing silently; escalate
additionally mints an aegis:DecisionRequest for the escalation router) — and
advisory — allow, warn, record never block.
Enforcement is opt-in:
[quipu.governance]
enforce_on_write = false # the default
Default off: existing deployments are unchanged, and turning the gate on
is a deliberate configuration act, not a side effect of upgrading. validate_placement (definition-time class↔placement
conformance) and enforce_authority (below) are separate flags with the same
opt-in posture.
On-demand evaluation: quipu_policy_check
The read-only half. Given a policy IRI (or an inline claim) and a target,
it evaluates the claim over the committed graph and returns a Verdict:
{
"predicate_id": "aegis:todo-needs-ticket",
"target_ref": "ex:comment-42",
"outcome": "unsatisfied",
"evidence_hash": "fnv1a:9c2a41d07be3f118",
"tier": "committed",
"verifier": "quipu",
"verifier_authorized": true,
"signed": true,
"signature": "…hex…"
}
outcome is satisfied | unsatisfied | unknown, bound to a reproducible
evidence_hash over (predicate, target, valid_at, bound claim) — any
verifier re-runs the same ASK over the same committed evidence and must get the
same verdict. Checked, not trusted. valid_at/tx make the evaluation as-of.
Verdict signing
Verdicts are attestations, not claims. If the store holds a signing identity
(ed25519 via ring, host-file key custody — explicitly v1), it signs the
canonical message v1|predicate|target|outcome|evidenceHash|tier|verifier
(src/signing.rs). No signing identity means no persisted verdict, never an
unsigned one — a bare satisfied fact is forgeable by anyone who can write.
Write-gate verdicts are persisted as bitemporal aegis:Verdict facts
(src/governance/verdict_facts.rs), staged during evaluation and flushed after
the savepoint resolves — so a denied write still records its verdict, and
unknown is recorded rather than skipped. Their evidence hash seals
attribution: sha256 over predicate|target|outcome|writer|chain, binding the
aegis:attributedWriter and aegis:principalChain into the signed seal. It is
deliberately not a hash of graph state, which has no stable serialisation.
Linking a verdict to the judged transaction
The shipped governance shape permits an optional aegis:gatedTx: one
xsd:integer greater than or equal to one. It identifies the committed
transaction whose delta was judged, not the later transaction that persists
the verdict. Transaction IDs are local to their originating store; cross-store
audits must retain that store’s provenance.
Refused or rolled-back attempts omit gatedTx. Their attempted transaction row
is rolled back, so its provisional ID is not a durable identity and must never
be predicted or reused as the link. Use a durable write.refused event identity
for a refusal correlation when the producer provides one. This shape does not
introduce that event link. Historical verdicts may also omit gatedTx; absence
means the transaction join is unavailable, not that the verdict was a refusal.
Do not guess a backfill from timestamps or matching predicate/target pairs.
This is the schema contract, not automatic producer support. A producer adding
gatedTx must bind it into the signed evidence and verdict identity: changing
the judged transaction must change the seal and identity. Otherwise identical
outcomes can collapse to one verdict with several transaction IDs, and the link
itself is not authenticated. SHACL checks cardinality and datatype; it does not
prove that the referenced transaction exists or that the signature binds it.
Replaying a refusal: the denial quarantine
A denied write is rolled back, so its verdict survives and the evidence
behind it does not. From the governed graph alone a refusal can only be
attested — the rules it cites were in force at the instant — never
re-derived, because the post-state the gate judged is gone. The denial
quarantine (src/governance/quarantine.rs) keeps that evidence outside
the governed graph: two plain tables, not facts, that no SPARQL pattern,
search, or fact read can reach. Refused content still never enters the
graph.
For each verdict of a refused write, the gate records — while its savepoint still holds the post-state — a hash of the attempted delta (the writer’s datums, canonical, IRIs rather than term ids), a digest of the post-state it evaluated, a digest of the rule set in force, and the transaction the pre-state ended at. The entry is keyed by the verdict and sealed with the store’s signing key. That is the default, digest-only: nothing refused is retained. A graph opted into full retention also keeps the attempted delta itself, sealed.
[quipu.governance.quarantine]
enabled = true # the default
retain_full = ["urn:quipu:graph:root"] # or ["*"]; default []
quipu audit replay <verdict> rebuilds the store as of the refusal in a
throwaway in-memory copy, applies the attempt — the sealed one, or one
presented with --delta and checked against the digest first — and runs
the real write path with the gate on. A refusal re-derives when the
write is refused again with the same outcome, under the same rule-set
digest, over the same post-state digest. A later rule change does not move
the answer: the replay reads the rules as of the refusal, not today’s. A
verdict whose write committed replays from that write’s transaction.
quipu audit quarantine purge erases sealed deltas by graph or by age and
keeps each entry’s digests and seal, so the audit can still say “refused,
content purged” and can still verify a delta someone presents. Full packs
carry the digests (they back verdicts that travel) and never the sealed
deltas.
Limits, stated. The post-state digest is over this store’s term ids, so a respaced copy replays its refusals as “post-state differs”. A refusal inside a multi-graph batch judged a post-state that held the batch’s earlier graphs, which no entry carries; its replay reports the divergence. The digest costs one scan of the live facts per denial (never per write) — about 2.4 s per million facts in a debug build.
The Phase-0 root of trust
Trust concentrates in a small, human-owned surface: aegis:VerifierRegistration
facts carry each verifier’s name, hex aegis:publicKey, and the predicates it
aegis:attests. A human registers a verifier; quipu never self-registers.
quipu_verifier_authorized— may this verifier attest this predicate?quipu_verdict_verify— verify a signed verdict’s fields against the registry. Returnssignature_valid,verifier_registered,verifier_authorized, andtrusted=signature_validANDverifier_authorized— the one property a consumer should gate on.
Verification is currently latest-only; the bitemporal key registry (rotation with as-of re-verification) is designed but not built.
Transition signatures at the write gate
Shuttle signs every workflow transition with the performing agent’s ed25519
key over the canonical message
shuttle-transition-v1|{run}|{step}|{from}|{to}|{at}|{agent} and exports the
signature as an ordinary aegis:signature fact on the
aegis:TransitionEvent — re-derivable from the exported facts alone, so
consumers can re-check it (shuttle verify). Under
[quipu.governance] verify_transitions (default off), quipu re-checks it
at the write gate (src/governance/transition.rs): a write landing a
TransitionEvent that is unsigned, signed by an agent with no
aegis:VerifierRegistration, or whose signature does not verify over the
staged fields under any of the agent’s registered keys is refused before it
commits — forged and tampered transitions become unwritable rather than
merely detectable. The registry read spans every graph (shuttle’s convention
keeps registrations in a never-frozen dataKind=identity named graph);
protecting the registry itself from unauthorized writes is enforce_authority’s
job, exactly as for signed decisions.
Authority over graphs
aegis:Principal facts hold aegis:authorityOver graph IRIs (or *). A call
chain’s effective authority is the intersection of every link’s, so
delegation only narrows and an empty intersection refuses
(src/governance/authority.rs). Gated by enforce_authority (default off) and
inert for callers that present no principal chain. This gates writes only —
it is not a read-side confidentiality boundary.
The authority reader accepts both legacy and Quechua vocabulary spellings for
Principal, principalId, and authorityOver, including mixed spellings.
Identical aliases collapse; conflicting identity values grant no authority.
Graph grants remain set-valued, and the reader retains its committed root-graph
scope. This compatibility does not change principal identities or grant writers.
Audit & replay
The rest of src/governance/ closes the loop after the fact:
quipu_audit_check (audit.rs) mechanically checks a recorded trace against
the policy spec — coverage, class↔placement, outcome consistency, attribution —
deterministically, never an LLM call. denial_replay.rs re-derives one recorded
gate decision as of its transaction (see the quarantine above). replay.rs measures whether an advisory
rule is ready for promotion to enforcement (liveness, both outcomes,
recoverability). router.rs queues require-approval escalations as
DecisionRequests with expiry (only an approval permits; a rejection outranks
an approval); tree.rs, inventory.rs, and inheritance.rs reconstruct
attribution, check dispatch-graph coverage, and detect constraint laundering
under delegation.
See also
- MCP tools reference — the seven governance and overlay tools.
- REST API reference — the mirrored HTTP routes.
docs/design/policy-edit-hooks.md— the write-gate design and its backlog.docs/design/signing-plane.md— where signing goes next (proposed).
Post-commit reactions and firing records
shapes/aegis-ontology.shapes.ttl defines aegis:Reaction and
aegis:ReactionFiring for Chaski. These describe post-commit or scheduled
reactions. Loading the schema does not start an evaluator, install a graph
sink, or deliver an alert.
A reaction has one rdfs:label, triggerKind (event or schedule),
condition (SPARQL SELECT ?focus), severity (info, warning, critical
or page), and string owner. It requests one or more action values:
alert, page, bead, or webhook. The existing string properties
severity, owner, and schedule are reused.
Event reactions use a comma-separated eventTypes string; scheduled reactions
use an ISO-8601 duration string in schedule, such as PT5M. Chaski’s current
consumer supports time durations expressed with hours, minutes and seconds.
enabled is an optional boolean; Chaski treats omission as enabled. The graph
does not assert that default. A single rdfs:comment can explain the rule.
SHACL checks the declared field types, cardinalities and enums. The consumer
must also validate the trigger-specific configuration and query: the shape
alone does not parse SPARQL or duration strings.
A firing records a transition, never each evaluation. It has exactly one
firedBy link to a typed Reaction, one IRI focus, and one xsd:dateTime
startedAt. Its stable IRI identifies the machine-emitted record; it does
not require an rdfs:label. The rule and its durable firing record are BFO
continuants, separate from the execution that produced the record.
resolvedAt is absent while active and set once via /set when
the condition resolves. A failed or timed-out query is unknown and must not
resolve an existing firing. A later firing after resolution is a new
occurrence. SHACL does not enforce these temporal rules or timestamp ordering.
Stable delivery identity
ReactionFiring optionally carries one string eventId. When present, derive
the firing IRI deterministically from (reaction label, eventId) using an
unambiguous encoding, for example a hash of a JSON array containing both
strings. The event ID identifies an item and transition; including the stable
reaction label separates reactions receiving the same event. If an adapter’s
event ID does not uniquely identify its focus, it must include that focus in
the identity input too. Do not substitute a send-time random ID.
For a Chaski outbox event, take startedAt from the frozen payload’s
observed_at, never the retry time. Persist the graph-sink payload before its
first send and reuse it unchanged after a crash. Stable identity alone does
not provide idempotence when timestamps or descriptions change between
attempts. Verify receiver state after an uncertain write; an unchanged
response alone is not a presence check. Test send-then-crash replay at the sink.
This schema does not itself implement an idempotent receiver or guarantee
exactly-once external actions.
For an adapter that evaluates whether work has become unblocked, condition
selects candidate WorkItems with aegis:blockedOn; the adapter decides their
verdict. State that interpretation in the reaction’s rdfs:comment. Ordinary
scheduled transition firings may omit eventId and retain a durable occurrence
identity for each transition of the (reaction, focus) pair.
Knowledge Packs
Implementation status (2026-08-12): ✅ Built.
src/pack.rs—Manifest,pack,pack_turtle,unpack,verify,content_hash— with thequipu pack/quipu pack --verify/quipu unpackCLI, and the stored-query registry packs draw from (src/store/queries.rs).--space <term-space>on pack export is built too (2026-08-25). Still open: the design’s retrieval-policy block (quipu:defaultDataset/quipu:recommendsFloor) — a pack today carries the graph’s labels but not the fuller policy vocabulary. Seedocs/design/knowledge-packs.md.
A binary pack is an internal graph artifact: one graph’s
current facts, plus the shapes, stored queries, and labels that make it usable,
in a single file you can version, hand to another agent or environment, verify,
and import. The artifact format is the database format — a pack is an
ordinary Quipu SQLite store with a one-row pack_manifest table describing
itself.
For publication in a git repository, a pendant is the text share directory
(named for the khipu’s pendant cords, each a self-contained record hanging from
the primary cord; see Pendants).
The artifact was called a qpack before; .qpack names still load for one
release. The binary .pendant.db is internal plumbing, not the published artifact. A share
is the canonical, line-oriented interchange surface that makes review and
three-way history meaningful:
First prepare the store’s identifier-policy catalogue and load the shapes governing its data. These outward-share examples assume both prerequisites are present in the selected store:
quipu share --output graph-share
quipu share --output project-share --group-id project-a --shapes project-shapes --turtle
Every share contains normative, sorted export.nt, a non-empty shapes.ttl,
and manifest.json. By default all loaded shape sets are included; repeat
--shapes to select a subset. A store with no loaded shapes is refused unless
the producer deliberately requests a shapes-free bundle with --no-shapes.
The manifest records the stable store id,
transaction anchor, graph and shapes hashes, scope, and optional parent-share
hash. --turtle adds a derived export.ttl for people; it is not the graph
identity. Stored queries registered against the scope travel as a sealed
queries.ttl (select with --queries, omit with --no-queries) and install in
the receiver’s registry under a pack namespace — see
Stored queries. The anchored transaction timestamp—not the wall clock—is used for
created_at, so exporting unchanged state with the same options is
byte-identical. Use --parent-share sha256:... when continuing a lineage.
Before any bundle is returned or its output directory is published, Quipu
evaluates every block-tier InternalIdentifierPattern present in the local
ROOT and named graphs against the exact graph, shapes, and optional Turtle bytes. A hit
refuses the entire share and leaves no partial directory. The gate never
rewrites a match: IRIs are entity identity, so replacing a private-looking IRI
would silently create a different graph. Warning-tier rules do not block.
Rules must be complete within one graph; statements split across graphs are
not combined into a rule. Duplicate rules are checked once. Catalogue location
does not change the selected share payload scope.
An empty block-tier catalogue refuses outward sharing: the CLI exits 2
(cannot verify), distinct from exit 1 for a matched identifier and exit 0 for
a checked, clean share. Load a catalogue before sharing outward. The explicit
--destination internal path remains available for internal transfers.
Remote callers can request the identical artifact without access to the server’s
filesystem using POST /share. The response is
{"manifest": {...}, "files": {"manifest.json": "...", "export.nt": "...", "shapes.ttl": "..."}};
export.ttl is also present when turtle_view is true. Request fields mirror the
CLI options: scope, shapes, no_shapes, parent_share, and turtle_view.
An optional max_bytes may lower the server’s 8 MiB response cap. Every string in
files is the exact UTF-8 file content, so consumers reconstruct the directory
without reimplementing manifest canonicalization, hashes, or share IDs.
JSON-LD is deliberately not a share payload in v1. Quipu’s JSON-LD endpoint is
an entity-oriented negotiated view, not a canonical RDF-dataset serializer;
adding it would create bytes whose ordering and identity contract are weaker
than the sorted N-Triples producer. export.nt remains normative and
export.ttl remains the optional human-readable derived view.
What goes in a pack
- Facts — the current facts of the source graph, written through the ordinary transaction path so term ids are correct by construction (a raw row copy would carry the producer’s private id assignment; a pack never does).
- Shapes — named explicitly with
--shapes, since shapes are global and carry no graph linkage. The selection is recorded in the manifest. - Stored queries — named with
--queries, so a domain layer ships the competency questions that make it usable on arrival, not just its triples. - Labels — the graph’s freshness/trust/policy label travels with the pack, so a consumer can compose it without a side channel.
- Vectors (optional,
--with-vectors) — embeddings re-keyed by IRI. Restricted to the built-in SQLite vector backend; a delegated or LanceDB backend cannot be enumerated, so the flag is refused rather than silently producing a pack with no vectors. - The manifest — pack format, name, producer-declared semver, term space, content hash, creation time, source graph, producer identity, and row counts.
Creating a pack
Omit the graph argument to pack the ROOT default graph, or explicitly use
urn:quipu:graph:root. The manifest records that IRI and defaults the name to
root. ROOT packs contain only current ROOT facts, excluding named graphs and
materialized inference graphs. --with-vectors carries embeddings for terms in
the pack. ROOT uses its own graph-label row, without borrowing labels from an
interned term with the same IRI. Verification and Turtle export use the same scope.
Unpacking defaults to ROOT for these packs; --into <iri> selects a named graph.
quipu pack --out root.pendant.db --with-vectors
quipu pack urn:example:graph --out domain.pendant.db --name "domain" --version 1.0.0
quipu pack urn:example:graph --out domain.pendant.db --shapes s --queries q --with-vectors
The output is a single clean file — the build goes through VACUUM INTO, so
no -wal/-shm siblings ride along beside the file you actually copy.
--format turtle emits an interop bundle instead: a directory of
graph.ttl, shapes.ttl, queries.json, and manifest.json, for consumers
that are not Quipu. It is export-only — nothing unpacks it — but it carries
the same content hash as the .pendant.db form, because the hash is computed
from canonical content, not from the emitted bytes.
Unpacking
quipu unpack domain.pendant.db --into urn:local:domain --db my.db
unpack materializes the pack’s facts into a local graph (defaulting to the
pack’s own graph IRI) and installs its shapes and stored queries through the
versioned write paths — never an overwrite of registries the consumer
already has. The report states what arrived: facts, shapes, queries.
Verification
quipu pack --verify domain.pendant.db
Verification recomputes the pack’s content hash and compares it to the manifest. The hash is sha256 over the lexically sorted, deduplicated N-Triples of the graph plus the packed shapes, queries, and labels — sorted because the store’s own emission order depends on term-id assignment, and the hash must describe the content, not the producer. Two stores holding the same triples hash the same.
This makes the hash the pack’s citable version reference: pin it in an environment, promote the same hash-verified file dev → staging → prod, roll back by re-attaching the prior pack. Never re-pack between environments — that creates a different artifact and defeats the hash as the promotion identity. There are no signatures; verification is integrity, not provenance.
Use cases
- Shipping a distilled derived layer — pack a curated or derived graph with its shapes and competency queries, and hand consumers a verifiable artifact instead of access to the producing store.
- Small, portable subsets — cut a wasm-sized slice of a larger graph for embedded or edge deployment, where a single self-describing file matters.
- Environment promotion — one artifact, one hash, attached identically in each environment.
Built vs designed
Built: quipu pack, quipu unpack, quipu pack --verify, the Turtle interop
bundle, vector export on the SQLite backend, the stored-query registry, and
--space <term-space> on export — the pack is built in space 0 and shipped
through the same respace machinery as quipu db respace, so a consumer can
attach it as-is without an id collision (.pendant.db packs only; a Turtle
bundle carries IRIs, not term ids, so --space does not apply there).
Designed but not yet built: the retrieval-policy block (default-dataset and
recommended-floor facts a consumer could SPARQL), and delta/diff packs —
v1 packs are whole-layer, read-only artifacts.
Related
- CLI reference — the
quipu pack/quipu unpackflags in full. docs/design/knowledge-packs.md— the full design, including the retrieval-policy vocabulary and the promotion workflow.
Explore the contributor constellation
Open Explore to follow the ideas behind Quipu into the code. The repository pack includes the vision, source-backed episodes, design decisions, a trust directive, and the book’s chapter order alongside indexed code and docs. The vision remains an aspiration; each decision links to its own evidence and implementation. These narratives are extracted from repository documents, not imported from an operational knowledge graph.
A phone walkthrough
At a 390px-wide viewport:
- In Knowledge constellation, search for vision, then select Quipu vision.
- In its card, tap guides: Federation has explicit boundaries.
- Read the decision and its source excerpt. Tap governs: src/provider/mod.rs to reach the actual indexed code module.
- Follow another related entity. Each click centers that entity in the map. The card shows its types, with Properties and Provenance disclosures. Read the source opens the repository file; Inspect facts & edit opens its local facts. SPARQL, export and the propose-as-PR flow remain available.
Related entities come from queries against the whole loaded graph. Arrows mark
outgoing (→) and incoming (←) links, and every link names its predicate. The list
includes entities outside the initial contributor overview, such as types and
provenance sources. An engine-truncated answer is explicitly marked incomplete.
The URL’s entity parameter identifies the selection: browser back and forward,
reloading, or opening that URL in another tab restores it in the map.
The main cord joins five pendant cords: vision/book, decisions/rules, episodes, code, and reading trail. These are deterministic topic/type groups, not inferred Louvain communities. Entity IRIs sort the knots along each pendant; following a link can add entities beyond the initial overview. Drag or use arrow keys to pan; pinch, use the mouse wheel or the zoom buttons to zoom. All clusters fits the reading map; tap a cluster to enter it. At that overview scale, cluster controls replace individual node controls. Overview cords show up to eight actual knots in IRI order at reduced scale. Visible node targets and controls are at least 44 CSS pixels, independent of zoom. The node card and search results provide text navigation without hovering or precise graph taps.
Knot size and relation details
Knot radius is 5 + min(13, 3 × sqrt(degree)) CSS pixels. Degree counts distinct
incoming and outgoing (subject, predicate, object) links in the currently loaded
map, including newly explored neighbors. It is local view importance, not global
PageRank. Selection changes highlighting, never importance size. The on-screen
legend states the metric. Geometry is static; no force simulation or animation
is needed, and reduced-motion preferences disable transitions.
Click a relation line, focus it and press Enter/Space, or use Relation details in the entity card. The panel names the predicate, subject, and object as full IRIs, with buttons to inspect each endpoint or the predicate itself. Subject provenance properties are labeled as subject-level evidence, not edge provenance.
Fact history, valid time & transaction time shows the receiving store’s
assertions/retractions, valid intervals, transaction IDs/timestamps, sources and
actors. Relation history filters by the exact predicate and IRI object. Entity
history covers its outgoing facts in ROOT, matching this map’s dataset. History
is capped at 2,000 facts and explicitly marked incomplete at the cap. It is not
an all-graph or producer audit view. A static RDF share does not carry the original
producer transaction log: receiver import time must never be presented as original
author time. Episode/source properties retain whatever the pack actually states.
An older browser bundle without factHistory displays an unavailable message;
it never fabricates history from current SPARQL rows. The query and edit paths
still wait for the normal native verification ceremony, including saved previews.
Ask the same question with SPARQL
The canned How does the vision guide code? query follows two explicit edges and checks the indexed module’s path. It does not infer that a similarly named file implements a decision.
PREFIX q: <https://quipu.dev/knowledge/>
SELECT ?decision ?module WHERE {
q:vision q:guides ?decision .
?decision q:governs ?module .
?module a ?type .
}
Keep the released knowledge current
The release producer runs scripts/build-contributor-knowledge.mjs after Bobbin
indexes the repository. Its curated registry is
docs/knowledge/contributor-stories.json. Each story names a source passage and
code witnesses; generation refuses a missing passage, missing file, or private
identifier in the curated text. The book order comes from SUMMARY.md.
The producer includes this projection in its ordinary CONSTRUCT share scope, then adopts shapes, imports and promotes into a fresh receiver. The contributor proof requires all six knowledge classes, four source-backed episodes, and vision → decision → typed code paths. A code-only pack cannot pass that proof. The release workflow ships that same output as its repository pendant asset.
Use just contributor generate to inspect the deterministic projection.
QUIPU_BIN=/path/to/quipu just contributor pack /tmp/repository-share runs the
full producer and receiver proof. The output directory must not already exist.
The page consumes the released artifact when the documentation build stages it;
a source change alone does not change the pack already on the site.
The browser’s delta producer uses the same 128 MiB payload budget as its full export, so adding contributor knowledge does not disable proposing an edit. The default remote delta budget remains 8 MiB. During release publication, docs allow missing, not-yet-staged assets for 30 minutes. Once staged, every asset must be published byte-for-byte; beyond the grace a missing asset fails.
SHACL Validation
SHACL (Shapes Constraint Language) lets you define what valid data looks like and enforce it at write time. When an agent or user tries to add facts that violate a shape, Quipu rejects the write and returns structured feedback explaining exactly what’s wrong.
Why Validate?
Without validation, agents can write anything:
hw:korimako hw:cpuCores "lots" . # Should be an integer
hw:korimako a hw:Host . # Missing required hostname
With SHACL shapes loaded, Quipu catches these problems before they enter the fact log.
Class targets and subclasses
A sh:targetClass selects instances of that class and of its subclasses,
following the data graph’s rdfs:subClassOf edges transitively. For example,
if the validation data declares ex:Step rdfs:subClassOf ex:Action, an entity
typed only ex:Step must satisfy a shape targeting ex:Action. Cycles and
multiple inheritance do not duplicate a focus node within the same shape.
This follows SHACL class-based targets.
Target selection leaves asserted data unchanged. It does not manufacture
rdf:type ex:Action values for a constraint such as sh:hasValue on
rdf:type. An explicit-type requirement still needs an explicit assertion.
Subclass declarations present only in the shapes graph are not merged into
the validation data by this mechanism. Write vocabulary admission remains a
separate check against loaded shapes.
Defining a Shape
A shape declares constraints for a class of entities. Here’s a shape that says “every Host must have exactly one hostname (a string) and at least one cpuCores (an integer)”:
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
hw:HostShape
a sh:NodeShape ;
sh:targetClass hw:Host ;
sh:property [
sh:path hw:hostname ;
sh:datatype xsd:string ;
sh:minCount 1 ;
sh:maxCount 1 ;
] ;
sh:property [
sh:path hw:cpuCores ;
sh:datatype xsd:integer ;
sh:minCount 1 ;
] .
Loading Shapes
CLI
quipu shapes load --name homelab --file shapes/homelab.shapes.ttl --db homelab.db
REST API
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{
"action": "load",
"name": "homelab",
"turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> .\n@prefix hw: <http://example.org/homelab/> .\nhw:HostShape a sh:NodeShape ; sh:targetClass hw:Host ; sh:property [ sh:path hw:hostname ; sh:minCount 1 ] ."
}'
Listing loaded shapes
quipu shapes list --db homelab.db
Declaring ontology classes and properties
The bundled governance shape set sanctions named ontology resources typed
rdfs:Class and rdf:Property. Load that set before an adapter imports its
ontology declarations through /knot. These declarations require IRI subjects.
Declaring <urn:example:Task> a rdfs:Class in the data graph does not
authorize instances of urn:example:Task. The write vocabulary still comes
from loaded shape sets; add and load a shape targeting the intended class
before writing its instances. Unknown types continue to be refused.
Demoted derivation records
shapes/demoted-derivation.ttl defines the product evidence schema in
http://quipu.local/graph#, matching the truth-maintenance runtime vocabulary.
Ship and load this set together with the compatible demotion runtime.
A DemotedDerivation is an IRI identifying an rdf:Statement. It retains
exactly one rdf:subject, rdf:predicate, rdf:object, premiseGraph,
deriverSource, promotionTx, invalidationTx, and prov:invalidatedAtTime.
Subject, predicate and premise graph are IRIs; the object may be any RDF term.
The source is a string, transaction IDs are integers at least one, and the
invalidation time is an xsd:dateTime.
derivationState must be exactly one string: unsupported or resolved.
Resolution preserves the evidence record. Extra annotations are allowed.
The shape rejects missing or ambiguous fields and incorrect RDF term kinds.
It does not establish atomic demotion, deterministic identity, exclusion from
premise evaluation, or authorization to promote; those are runtime guarantees.
Loading the shape does not repair data or promote unsupported evidence.
Validation in Action
Recorded text-rule cases
The bundled aegis-ontology shapes allow aegis:mustMatch and
aegis:mustNotMatch on TextRule and InternalIdentifierPattern. Each property
accepts zero or more xsd:string values: example text that should trigger the
rule, or should stay silent, respectively. Existing rules without cases remain
valid. IRIs, language-tagged strings and numeric literals are not case text.
Schema validity does not establish enforcement readiness. A readiness checker must require at least one case of each polarity and execute every case through the rule engine against a current projection. Missing cases or indeterminate execution must not pass. These text-only examples do not prove path or repository exemptions; contextual cases require a separate model. Recording cases does not change a rule’s enforcement tier.
Rejected writes
Try to add a Host without a hostname:
quipu knot - --db homelab.db --shapes shapes/homelab.shapes.ttl <<'EOF'
@prefix hw: <http://example.org/homelab/> .
hw:badhost a hw:Host .
EOF
Quipu rejects it with structured feedback:
{
"conforms": false,
"violations": 1,
"issues": [
{
"severity": "Violation",
"focus_node": "http://example.org/homelab/badhost",
"path": "http://example.org/homelab/hostname",
"component": "MinCountConstraintComponent",
"message": "Less than 1 values for hw:hostname",
"source_shape": "http://example.org/homelab/HostShape"
}
]
}
This feedback is designed for agents — structured JSON with enough detail to fix the problem automatically.
Value sets across writes
A valid delta can still violate a stored upper bound. For example, writing
ex:item ex:value "A" and later ex:item ex:value "B" must not bypass
sh:maxCount 1 just because each request carries one value.
Write admission checks predicate and inverse-predicate sh:maxCount,
sh:uniqueLang true, sh:disjoint, sh:lessThan, and sh:lessThanOrEquals
against the staged current
facts inside the same SQLite savepoint as the write. The second distinct
value is rejected and rolled back. Split writes cannot bypass language-tag
uniqueness or comparisons between the stored values of two properties.
Reasserting an identical RDF term remains
idempotent; retracting the old value and asserting its replacement in one
transaction is valid. Historical facts remain available.
This check covers CLI knot --shapes, the knot and SPARQL Update entrypoints,
inline episode shapes, conditional writes with loaded shapes, and configured
validate_on_write transactions. It preserves each entrypoint’s existing
reject/advisory policy. Low-level transactions without explicit shapes or
write validation configured do not acquire an implicit shape policy.
Value-set validation uses each destination graph’s own values. ROOT type and subclass
context can select targets in a named graph; ROOT property values and unrelated
third graphs do not contribute to its sets. The context is bounded to touched
subjects and referenced objects, with at most 100,000 facts and 1,024 classes.
Exceeding these limits, or encountering another complex value-set property path,
refuses admission with cannot verify rather than accepting partial evidence.
Capability and incoming-context obligations use the batch validator’s compiled shape graph. Its deactivation flags suppress obligations for inactive node shapes and their solely dependent properties. A shared property still imposes its obligations when another active shape or its own target reaches it; logical and qualified shape references retain that conservative check. Unsupported active paths still refuse.
Other constraints retain payload validation and contextual type/required-field
repair. This is not whole-store conformance checking or automatic revalidation
of every dependent node after changing a schema, class hierarchy, or target
relation. Standalone validate remains a check of the supplied data only.
Supported Constraints
| Constraint | What it checks |
|---|---|
sh:minCount / sh:maxCount | Cardinality (how many values) |
sh:datatype | Value type (xsd:string, xsd:integer, etc.) |
sh:minInclusive / sh:maxInclusive | Numeric ranges |
sh:minLength / sh:maxLength | String length |
sh:pattern | Regex match |
sh:in | Allowed values (enumeration) |
sh:class | Referenced entity must have rdf:type |
sh:node | Nested shape reference |
sh:or / sh:and / sh:not | Logical constraints |
sh:equals / sh:disjoint | Property pair constraints |
Quipu supports the full SHACL Core specification via the rudof library.
Dry-Run Validation
Validate data without writing it to the store:
quipu validate --shapes shapes/homelab.shapes.ttl --data data.ttl
curl -s localhost:3030/validate -X POST \
-H "Content-Type: application/json" \
-d '{
"shapes": "@prefix sh: ... shapes turtle ...",
"data": "@prefix hw: ... data turtle ..."
}'
Domain shapes in practice
Domain constraints belong to their domain producer. The staged Camayoc domain profiles separate infrastructure, personal media and software production. The worked example explains their compatibility and rollout. The transition retains the existing bundled Aegis shapes until destination publication, pinning, loading and validation have been verified. They cover:
LXCContainer,ProxmoxNode,BareMetalHost— compute resourcesSystemdService,WebApplication,Database— servicesCommandDiskImpactObservation— one privacy-preserving command/filesystem measurement.diskDeltaBytesis signed: positive values mean space consumed and negative values mean space freed. Command and filesystem identity use canonical classes rather than raw arguments or paths; aggregate counts and quantiles belong in a separate summary entity.ConfigFile— optional migration-safeconfigPathandcontentSha256facts support exact drift lookups. When present, each is single-valued and the digest must be a full lowercase SHA-256 hexadecimal string.CiJobandLocalCommand— map a CI job to at most one typed local equivalent, the repository paths it gates, and the command text used to run that equivalent before push.- Common properties: hostname, ipAddress, memoryMB, cpuCores, dependsOn
An offline check can use the producer-owned file:
quipu validate --shapes ../camayoc/shapes/aegis/homelab-infra.shapes.ttl --data example.ttl
Best Practices
- Load shapes before data — shapes must be present to validate incoming writes
- One shape set per domain — group related constraints (e.g., “homelab”, “code”)
- Start permissive, tighten later — begin with minCount constraints, add datatype checks as your ontology stabilizes
- Use validation feedback — the structured JSON is designed for automated remediation by agents
Episode provenance activities
The shipped shapes/provenance.ttl includes a minimal prov:Activity profile
for episode provenance. It requires a string rdfs:label, which the episode
producer emits. Body and source fields remain optional so historical episodes
remain valid, and multiple retained comments are permitted. This profile does
not authorize a new write path or change import/promotion checks. A receiver
must load its reviewed shapes and verify the loaded vocabulary before importing
activity facts; a source file alone does not establish runtime governance.
Write-time shape pre-parsing uses the batch validator’s fallback base IRI for relative Turtle identifiers, including relative manifest references in shape documents. An explicit Turtle base still takes precedence. This compatibility does not relax cross-write constraint checks or the atomic refusal of invalid post-state data.
Domain shapes in practice
Quipu enforces engine contracts: provenance, historical evaluation, admission, content-bound verdicts, certification, trust roots and single-use nonces. A domain producer defines what a host, a media record or a work item means. Those are separate responsibilities even when the same validator checks both.
The Camayoc domain example stages three domain files:
homelab-infra.shapes.ttl: hosts, networking, services and storage.personal-media.shapes.ttl: people, accounts, libraries and entertainment.software-factory.shapes.ttl: crew and software production.
Camayoc records the exact upstream source and each shape’s destination. Existing IRIs remain unchanged. A separate compatibility payload retains the original bytes while the source transition is reviewed. The destination’s union/parity tests detect a lost constraint; class competency queries have populated positive and missing-label negative fixtures. Neither test claims production acceptance.
Vocabulary before custom classes
Quechua’s trim decisions already distinguish reusable governance terms, public replacements and infrastructure-specific vocabulary. Keep those decisions: a domain-specific class does not become a new public Quechua term merely because it moved files. Public replacements are explicit profile changes, not namespace renames or automatic rewrites of existing data.
Work uses the Seeds profile
Seeds owns its work-item profile.
Camayoc references its pinned source rather than maintaining another WorkItem
gate. New records use schema:Action; historical WorkItem/Bead names remain
deprecated compatibility vocabulary. The existing replacement/subclass chain
does not itself rewrite stored types.
An iCalendar Vtodo remains a separate node linked through skos:exactMatch,
as Seeds’ profile specifies. Do not replace that relation with owl:sameAs or
claim that the records share an IRI.
A transition without a validation gap
- Review and publish the producer’s destination packs and competency queries.
- Propose exact released source pins through the deployment repository.
- Load destination bytes before removing original source ownership, preserving the existing loaded-set naming/count contract and classification guard.
- Revalidate existing data with no new violations and prove the intended alias queries, using the deployment’s bounded, authorized reader.
- Only then remove compatibility source copies. Retain prior pins and the prior engine artifact for the normal scoped rollback.
This chapter describes the source transition. It grants no load, unload, private data migration or deployment authority. The original Quipu copies remain until the destination gates have been measured.
Warning-first propagation
The source CI compares immutable RDF snapshots, including anonymous constraints,
against a revision-scoped term/CQ catalogue. It retains a JSON report and warns
about unresolved entries. Read and parse failures remain failures. Gaps become
blocking only after the October backfill and a separate enforcement decision.
Anonymous node shapes, including the four standard target forms, are compared
as a multiset of canonical constraint components. Their report-local key
_:anonymous-NodeShapes identifies that collection, not a production IRI.
Evidence for it must match the complete collection digest. Blank-node spelling
and declaration order do not change it; changed constraints and duplicate
components do. Typed node shapes without targets are included as well.
An event receiver must deduplicate by exact merge identity and retain uncertain
writes for native readback; source CI alone does not create a task or change a pin.
Warning check in the existing CI target
The existing Pre-commit checks job runs the shape-propagation hook on every invocation. Its Python environment pins rdflib 7.6.0, runs the RDF and CI counterexamples, resolves the exact event base and checked-out head, and reads Quechua/Camayoc inputs at fixed commits. Shallow CI checkouts explicitly fetch source history. Execution and input-read errors fail; evidence gaps remain WARN. The complete delimited JSON report is retained in the job log, including source commits and input hashes. This replaces the earlier proposed separate workflow steps and report artifact; the workflow files themselves are unchanged.
For an offline local check, set QUIPU_PROPAGATION_QUECHUA_REPO and QUIPU_PROPAGATION_CAMAYOC_REPO to existing source repositories. That mode reads the pinned Git objects rather than working files, never fetches, and refuses a missing object without falling back to network. With neither set, public source reads have byte and timeout limits. No service credential or shape write is used.
Schema Evolution
Quipu enforces strict ontology rules via SHACL shapes. When an agent writes data that fails validation because the schema is too restrictive or missing a class/property, it can propose a schema change instead of asking a human to edit shapes manually.
The Proposal Workflow
Agent writes data → SHACL rejects it → Agent submits proposal → Approver accepts/rejects
Proposals follow a simple lifecycle: pending → accepted or pending → rejected. Every proposal requires an explicit approver — there is no auto-accept.
Proposal Kinds
| Kind | Description |
|---|---|
shape | New or updated SHACL shape (Turtle fragment) |
ontology | OWL axiom change (future) |
class | New RDF class definition |
property | New or modified property definition |
MCP Tools
Submit a Proposal
{
"kind": "shape",
"target": "PersonShape",
"diff": "@prefix sh: ... ex:PersonShape a sh:NodeShape ; ...",
"rationale": "Need email property for contact info",
"proposer": "agent/data-enricher",
"trigger_ref": "validation-failure-42"
}
Tool: quipu_propose_schema_change
List Proposals
{ "status": "pending" }
Tool: quipu_list_proposals
Accept a Proposal
{ "id": 1, "decided_by": "reviewer-1", "note": "Looks good" }
Tool: quipu_accept_proposal
When a shape proposal is accepted, Quipu:
- Validates the Turtle diff is syntactically correct
- Verifies it parses as valid SHACL
- Writes the shape to the
shapestable - Records the approver and timestamp
If the Turtle is invalid, the proposal stays pending and an error is returned.
Reject a Proposal
{ "id": 1, "note": "Too permissive — would allow unconstrained strings" }
Tool: quipu_reject_proposal
CLI
# List all pending proposals
quipu propose list --status pending
# Submit a proposal from a Turtle file
quipu propose submit shape PersonShape shape.ttl --proposer agent/enricher
# Accept
quipu propose accept 1 --note "Approved"
# Reject
quipu propose reject 1 --note "Needs tighter cardinality"
Validation Hints
When quipu_knot rejects data due to SHACL violations, the response includes a
hint field pointing to quipu_propose_schema_change. When
validate_or_reject fails, the error message includes the same hint. This gives
agents a clear remediation path instead of a dead-end error.
Design Notes
- The
proposalstable uses a textdiffcolumn so that SHACL shape diffs and future OWL axiom diffs can coexist. Thekindcolumn disambiguates. - Proposals only mutate shape/ontology definitions — they never touch the EAVT fact store directly.
- Over MCP and REST there is no default approver: accept and reject refuse a
request without
decided_by. Thequipu propose accept|rejectCLI takes--decided-by <id>(or--decided-by=<id>); without the flag the CLI recordscli:<QUIPU_AGENT or USER>, and with neither set it refuses rather than record an anonymous decision. A flag given with no usable value also refuses; it never falls back to the environment. A capability-based authorization system (quipu.schema.approve) is a future concern.
OWL Ontology Layer
Quipu supports OWL 2 RL reasoning through a built-in ontology engine. OWL
ontologies define class hierarchies, property characteristics, and constraints
that Quipu uses to materialize inferred facts, and — when
owl.validate_on_write is enabled — enforces at write time.
Loading an Ontology
Ontologies are OWL axioms expressed in Turtle format. Load one via the CLI or MCP tool:
quipu ontology load aegis-ontology ontology.ttl --db quipu.db
Or via the MCP quipu_load_ontology tool:
{
"action": "load",
"name": "aegis-ontology",
"turtle": "@prefix owl: <http://www.w3.org/2002/07/owl#> ...",
"timestamp": "2026-04-13T00:00:00Z"
}
On load, Quipu:
- Parses the Turtle and extracts OWL/RDFS axioms
- Persists the ontology in SQLite (like SHACL shapes)
- Materializes entailments into the fact log
Supported Axioms
| Axiom | Effect |
|---|---|
rdfs:subClassOf | Transitive closure: instances of a subclass are also instances of all superclasses |
owl:disjointWith | Write-time validation (opt-in): rejects an entity typed with two disjoint classes |
rdfs:subPropertyOf | Materialization: a fact under a subproperty is restated under every superproperty (transitive) |
owl:inverseOf | Materialization: (a P b) produces (b Q a) |
owl:FunctionalProperty | Write-time validation (opt-in): rejects a second value on a functional property |
owl:SymmetricProperty | Materialization: (a P b) produces (b P a) |
owl:equivalentClass | Materialization: instances of A become instances of B and vice versa |
owl:TransitiveProperty | Materialization: full closure — (a P b), (b P c) produce (a P c), chained to fixpoint |
owl:equivalentProperty | Materialization: facts under either property are restated under the other |
rdfs:domain / rdfs:range | Materialization: infers type from property usage |
owl:sameAs | Materialization: identity closure (symmetric + transitive), and every fact about one individual is restated about its co-referents. Subjects and objects only — predicates are not rewritten. See below |
owl:TransitivePropertyandowl:equivalentPropertywere parsed and counted but not materialized before 2026-08-27 — the same silently-dropped shaperdfs:subPropertyOfhad before aegis-qfncf. Loading one reported success and derived nothing.
owl:sameAs: identity comes from your DATA, not from the ontology
Every other axiom in the table is read from the ontology document you load.
owl:sameAs is not — it is read from the graph itself, because identity
between individuals is asserted as ordinary data (the quipu align verbs and
/knot both write it). You do not declare owl:sameAs in an ontology; you
assert it about two things, and materialization picks it up:
ex:dolt owl:sameAs ex:doltLan .
ex:dolt ex:hosts ex:beads .
After materialization ex:doltLan ex:hosts ex:beads is entailed, and the
identity itself is closed both ways and through chains: with a sameAs b and
b sameAs c, facts about a reach c.
⚠️ Predicates are not rewritten. If you assert
owl:sameAsbetween two PROPERTIES, the identity itself is closed, but facts are not restated under the co-referent property —ex:box ex:hosts ex:svcwithex:hosts owl:sameAs ex:runsdoes not entailex:box ex:runs ex:svc. This is OWL 2 RL’seq-rep-p, and it is not implemented: the rule language (reasoner/ast.rs) can only put variables in argument position, so a rule quantifying over the predicate is not expressible. Useowl:equivalentPropertyinstead, which IS materialized and is the right axiom for saying two properties mean the same thing. Tracked as the named gap on aegis-yro9m.
Materialization
Materialized facts are written with source = "owl:materialize" into ROOT’s
companion inferred graph (urn:quipu:graph:root#inferred, quipu-0b6) —
quarantined by placement, composed back in with
FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>. When an
ontology changes, derived facts can be re-materialized.
Materialization runs to fixpoint across axiom families: a type introduced
by rdfs:range feeds the subclass closure of the next pass, and passes repeat
until one derives nothing new. Each pass derives only facts not already present,
so re-running
materialization at fixpoint is a no-op and the report counts stay honest.
Materialization can also stay live: with [quipu.owl] reactive_materialize = true (requires the owl and reactive-reasoner
features — release full builds have both), the server re-runs
materialization whenever a committed write touches vocabulary the loaded
ontologies mention, so the closure extends as members arrive instead of going
stale after load. This setting defaults to true; set it to false to disable
the per-write materialization cost.
ex:fido a ex:Dog .
ex:Dog rdfs:subClassOf ex:Mammal .
ex:Mammal rdfs:subClassOf ex:Animal .
After materialization,
ASK FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred> { ex:fido a ex:Animal } returns true — a plain ASK does not, because the
entailment lives in the companion, not beside its premises.
Write-Time Validation
Two OWL constraints are enforced at write time by default when built with the
owl feature. Set owl.validate_on_write = false only for an explicitly
informal deployment. Before adopting new axioms, measure the existing graph:
turning an incompatible declaration into live policy can reject future writes
that touch historical drift.
Disjoint classes: If ex:Person owl:disjointWith ex:Robot, then an entity
cannot be typed as both. Attempting to assert ex:alice a ex:Robot when
ex:alice a ex:Person already exists returns a structured error and the write
is rolled back.
Functional properties: If ex:ssn a owl:FunctionalProperty, an entity can
have at most one current value. A later value supersedes the earlier one while
preserving history; two competing values in one batch are rejected because the
write provides no ordering.
Both reject the whole transaction: the constraint runs inside the write’s savepoint, so a violating batch commits nothing. Violations are reported together rather than one per round-trip.
Constraints are evaluated against the union of all loaded ontologies, and a
load or remove through POST /ontology takes effect on the next write.
Feature Flag
OWL support is behind the owl feature flag:
cargo build --features owl
cargo test --features owl
The shacl feature continues to work independently.
The Reasoner
Raw facts tell you what is. The reasoner tells you what follows.
Quipu’s reasoner is a stratified Datalog engine that reads the EAVT fact log,
applies rules, and writes derived facts into the store. Derived facts are
quarantined by placement (quipu-0b6, 2026-08-27): they live in the premise
graph’s companion inferred graph — <graph>#inferred, with ROOT’s at
urn:quipu:graph:root#inferred — never beside the facts they were derived
from. A plain query sees asserted facts only; a query that wants the closure
composes it explicitly:
ASK FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>
{ <traefik> <runsOn> <korimako> }
Within the companion, derived facts behave like any other fact — SPARQL,
SHACL, time travel all work — their source tag traces to the rule that
produced them, and the graph itself carries aegis:sourceKind "inferred" and
a quipu:derivedAsOfTx freshness note (the premise-side transaction head the
closure last reflected). The #inferred suffix is reserved: external
writes to a companion are refused, so entailments cannot be forged by hand.
Pre-regime stores move their legacy-placed derivations across with
quipu db migrate-inferred.
Why Derive Facts?
Consider a homelab with containers running on hosts. You’ve recorded:
traefik runsOn webproxy
webproxy runsOn korimako
A human reads this and concludes “traefik transitively runs on korimako.” But SPARQL doesn’t know that unless you either:
- Query-time: write a property path (
runsOn+) every time you ask - Write-time: materialise the transitive closure once and query it directly
Option 1 works for simple cases. But as your graph grows — services depending on packages, packages installed in containers, containers running on hosts — the property paths get unwieldy, slow, and duplicated across queries. Option 2 is what the reasoner does: derive the facts once, keep them fresh, and let every query benefit.
Rules as Horn Clauses
A rule says “if these conditions hold, then this fact is true”:
runsOn(?svc, ?host) :- runsOn(?svc, ?container), runsOn(?container, ?host).
Read this as: “if service S runs on container C, and container C runs on
host H, then service S runs on host H.” The part before :- is the head
(what gets derived). The parts after are the body (what must already be
true).
Rules in Quipu are written in Turtle files using a simple vocabulary:
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runsOn(?svc, ?host)" ;
rule:body "runsOn(?svc, ?container), runsOn(?container, ?host)" .
The head and body are string literals that the reasoner parses. Bare predicate
names like runsOn are expanded using a configurable prefix, so you don’t
need to write full IRIs inside the rule strings.
Stratification: Layered Evaluation
When rules depend on each other, the reasoner needs to evaluate them in the right order. This is called stratification.
Consider two rules:
dependsOn(?a, ?c) :- dependsOn(?a, ?b), dependsOn(?b, ?c).
affects(?pkg, ?svc) :- installedIn(?pkg, ?c), runsService(?c, ?svc).
The first rule is self-recursive — it reads and writes dependsOn. The
second reads installedIn and runsService (which no rule produces) and
writes affects. These rules are independent and could run in either order.
The stratifier builds a dependency graph between predicates and groups rules into strata (layers):
- Stratum 0: Base facts — predicates that appear only in rule bodies
(
installedIn,runsService). No rules to evaluate here. - Stratum 1: Rules that only depend on base facts (
affects). - Stratum 2: Rules that depend on stratum 1 results.
- And so on.
Rules within the same stratum can be positively recursive (like transitive
dependsOn). The evaluator handles this with semi-naive iteration: it
keeps applying the rule until no new facts are derived, using only the
newly-derived facts from the previous round to avoid redundant work.
What the stratifier won’t allow is a negation cycle — rule A negates a predicate that rule B produces, and rule B negates something rule A produces. This would make evaluation non-deterministic, so the reasoner rejects it at load time with an error naming the offending predicates.
The Evaluation Cycle
Each time the reasoner runs, it performs a full re-derive-and-diff:
- Stratify the ruleset (this is cheap — just graph analysis)
- Load the current world: read all relevant facts from the store
- Evaluate each stratum in order, running rules to fixpoint
- Diff each rule’s new derivations against its old ones
- Write asserts for new facts, retracts for stale ones
Step 4 is the key insight. Rather than tracking which base facts changed and propagating deltas (truth maintenance), the reasoner re-derives everything and compares. At the target scale of ~50K facts, this is fast enough to complete in milliseconds and dramatically simpler to get correct.
Every derived fact is written through Store::transact() with a source tag
like reasoner:depends_on_transitive, so you can always tell which rule
produced a fact and when.
Reactive Evaluation
Running quipu reason manually works, but you’d have to remember to run it
after every change. Reactive evaluation automates this: the reasoner
registers as a TransactObserver on the store and fires automatically after
every commit.
When a transaction lands, the reactive reasoner:
- Checks which predicates changed
- Finds the rules whose bodies reference those predicates
- Follows the dependency chain to find transitively affected rules
- Re-evaluates only the affected strata
- Writes any new asserts or retracts
This means derived facts stay fresh without explicit invocation. Add a new
runsOn edge, and the transitive closure updates in the same transaction
boundary.
The reactive reasoner is smart enough to skip its own output — when it sees
a transaction with source = "reasoner:...", it doesn’t re-trigger. This
prevents infinite loops.
Speculate: “What If?” Queries
Sometimes you want to explore hypothetical changes without committing them.
The speculate() API does exactly this:
#![allow(unused)]
fn main() {
let report = store.speculate(&hypothetical_datums, timestamp, |store| {
// Inside here, the store contains the hypothetical facts.
// Run the reasoner, query the results, whatever you need.
evaluate(store, &ruleset, timestamp)
})?;
// Here the hypothetical facts are gone — the store is unchanged.
}
Under the hood, speculate() opens a SQLite savepoint, applies the
hypothetical facts, runs your closure, then rolls back. The store is never
modified. This lets you answer questions like:
- “What would change if I remove this package from this container?”
- “If korimako goes down, which derived dependencies break?”
- “What’s the blast radius of upgrading this library?”
Provenance: Tracing Derived Facts
Every derived fact carries metadata that traces it back to its source:
| Field | Example |
|---|---|
source | reasoner:depends_on_transitive |
actor | reasoner |
valid_from | 2026-04-04T12:00:00Z |
You can query derived facts by their provenance:
PREFIX ont: <http://aegis.gastown.local/ontology/>
SELECT ?entity ?attr ?value
WHERE {
?entity ?attr ?value .
# Filter to only reasoner-derived facts
FILTER(STRSTARTS(STR(?source), "reasoner:"))
}
Or from the Rust API, filter on the source field of returned facts.
The datafrog Engine
Under the hood, evaluation is powered by datafrog, a ~1500-line Rust crate used by the Rust compiler itself for borrow checking. It implements semi-naive evaluation with no runtime dependencies, no services to manage, and no allocation overhead worth measuring at homelab scale.
You never interact with datafrog directly — it’s an implementation detail.
The reasoner compiles your Horn clause rules into datafrog join plans and
runs them inside while iteration.changed() loops.
What’s Next
- The Rule Builder — write your first rules
- Reasoner Reference — rule syntax, CLI, API, errors
- Impact Analysis — put the reasoner to work
Four-valued rule answers
rule-query reads two explicit evidence predicates for one grounded claim.
It does not change SPARQL ASK, Datalog negation-as-failure, or materialization.
The support bits are independent:
| Positive | Explicit negative | Truth |
|---|---|---|
| true | false | supported |
| false | true | refuted |
| true | true | both |
| false | false | neither |
Only complete scoped reads can establish neither. A failed read, missing
purpose profile, cap, invalid judgment, or unsupported scope produces
status: unknown with truth: null. Transport failures are also UNKNOWN to a
consumer. A successful health check cannot establish evidence completeness.
Configured ROOT label floors remain enforced; a quality refusal is UNKNOWN,
not a filtered empty evidence set. Label floors are not access control.
Request and interfaces
CLI: quipu rule-query request.json --db existing.db.
HTTP: POST /rule_query. MCP: quipu_rule_query.
All use the same native query implementation and response types.
{
"subject": "urn:claim",
"positive_predicate": "urn:supported-by",
"negative_predicate": "urn:refuted-by",
"object": {"iri": "urn:evidence"},
"judgment_policy": "urn:policy:referee",
"witness_limit": 64
}
Version 1 supports IRI and plain-string objects ({"str":"text"}), current
primary ROOT only. Predicates must differ. IRIs must be absolute. Unknown fields,
named graphs, temporal selectors, and unsupported term variants refuse instead
of selecting another scope. Attached/composed stores are unsupported. The CLI
opens an existing database read-only and does not create or migrate it.
Only ROOT assertions participate. The normal reasoner/OWL companion inferred
graph is not folded into this scope, and the query does not run an engine or
certify derived closure. Use an explicitly composed SPARQL dataset when that
different scope is required.
CLI and direct HTTP JSON decoding reject duplicate fields. MCP arguments have already become a JSON object in the upstream protocol parser; this tool cannot recover duplicate keys discarded before its invocation. All adapters reject undeclared fields in the object they receive.
Response and completeness
The protocol is quipu.rule-answer/v1. A complete response contains evidence
and judgment. Evidence includes typed truth, positive_supported,
negative_supported, positive/negative witness arrays, stable store_id,
transaction watermark, ROOT graph IRI, and scope
current_primary_root_all_physical_assertions.
claim_context binds the exact grounded claim, its digest, and the expected
judgment policy to the response, so retained results do not depend on an
out-of-band request to identify what was checked.
Each witness retains the asserting transaction, valid-from spelling, and nullable source. Sources are historical or caller-declared metadata, not authenticated authority. Physical source owners and legacy string representations participate; the witness cap (1–256 per side) is conservative across them. Exceeding it yields UNKNOWN, never a truncated answer. A missing source remains explicit. Metadata is also bounded before loading strings: 16 KiB per field and 64 KiB per support side. Exceeding either byte bound yields UNKNOWN.
Support and judgment reads share one SQLite read transaction. Reads do not
intern missing terms, materialize facts, admit verdicts, or modify the ledger.
Witnesses and admitted verdict identifiers have deterministic order. Removing
one side of both leaves the other; unrelated claims are unaffected.
Every response says action_authority: not_granted. There is no boolean
conversion. Before adopting this answer in a boolean consumer, define and review
an explicit policy for conflict, neither, deferral, and UNKNOWN.
Separate judgment disposition
Judgment is none_recorded, deferred, or resolved. It is not a fifth truth
value. none_recorded means absence from the complete supported current ledger
scope, not absence of judgments elsewhere. An unresolved or unverifiable ledger
is UNKNOWN. Deferral and resolution preserve the support bits, including both.
The response names the judgment scope
current_root_records_with_retained_marker_history. Retained historical markers
locate withdrawn or altered judgments; retracting a marker cannot turn a deferral
into none_recorded. Those records must still verify in their current state or
the answer is UNKNOWN. This is not a certificate of history outside the retained
primary store, and it does not provide historical snapshot selection.
The purpose profile in shapes/rule-answers.ttl targets only Decisions carrying
http://quipu.local/rule#judgmentClaim. It reuses the governed Decision class;
legacy Decisions outside that marker remain outside the profile. The reviewed
profile must be loaded as rule-answers with exact source bytes before the query
can establish judgment completeness. Source presence alone does not load it.
Normal schema review and delivery are required; this query never promotes it.
The marker is SHA256 (lowercase hex) of UTF-8 JSON serialized in this field order:
subject, positive_predicate, negative_predicate, object, graph.
The graph is urn:quipu:graph:root; object uses the request’s externally tagged
IRI/plain-string form. Native serialization defines the exact escaping. This is
not a claim of JSON Canonicalization Scheme conformance. Policy and witness cap
are excluded, so a record with a mismatching policy is detected rather than
disappearing into absence.
The Decision’s rdf:value is strict quipu.rule-judgment/v1 JSON with protocol,
the full claim above, named authority, nonempty reason, disposition
(deferred/resolved), and nullable predecessor. The existing decisionPolicy
must match the request. Resolution’s predecessor names the earlier admitted
verdict and exact decision_digest (sha256:...). Earlier records remain.
The existing Decision carrier also retains outcome: approve, by equal to
the named authority, and evidenceHash: sha256:<claim-digest>. Its ordinary
approve option endorses the sealed disposition record. These fields satisfy
the existing Decision shape; the new purpose profile does not replace it.
Only an existing admitted sealed Decision counts: the normal seal verifier checks
content, signature, historical registered HumanTier policy authorization, expiry
at admission, and the native nonce-spend/verdict transaction binding. The named
authority must match that verifier. The admitted outcome must be approve;
deferred/resolved comes from the sealed JSON, never from an unsealed label.
A copied signature or caller’s authorization assertion
cannot supply this proof. No signer registration or authority policy is added.
The reader bounds records, entity bytes and references before invoking the seal verifier. The purpose profile supports scalar sealed Decision fields, avoiding unbounded blank-node CBD traversal. Unsupported fields, missing predecessors, tampering, mismatched policy/claim/authority, orphan resolution, and competing lifecycle heads yield UNKNOWN. Version 1 supports one deferral and its retained resolution, not arbitrary judgment histories or reopening.
Standards comparison
RIF-FLD Second Edition, sections 3.2 and 3.6 permits multiple truth values and distinguishes symmetric negation from NAF. The two-bit wire envelope here is an implementation contract, not an asserted RIF dialect or a RIF-prescribed JSON format.
LegalRuleML 1.0, sections 4.2.1 and 4.3.4 discusses conflicting defeasible conclusions and named authority metadata. This foundation reports both supports; superiority and defeasible adjudication are separate. Named metadata is distinct from local seal authorization. Neither standard prescribes this envelope or its judgment lifecycle.
Embeddings and Semantic Search
Quipu answers two different kinds of question. Lexical retrieval (SPARQL,
CONTAINS, exact match) needs nothing but the fact log. Semantic retrieval
(/context, quipu_hybrid_search, /search) needs vectors, and vectors need
an embedding provider you configure yourself.
Automatic embedding is enabled by default when a provider is configured.
No model is downloaded automatically. Without model and tokenizer paths,
lexical graph operations still work and the loader reports that semantic
indexing is unavailable. Set auto_embed = false to disable automatic vector
writes. This does not disable model initialization, explicit semantic queries,
or existing import identity resolution, which may also use the provider.
Writes and staged imports
Native CLI writes (knot, ingest, episodes) and server writes use the configured
provider. A share import indexes eligible, uniquely owned entities in its
staging graph. It does not adopt the graph into ROOT or turn on named-graph
semantic search. Explicit search scope and search policy still apply.
Import responses include embedding.state, candidate/indexed counts, the local
provider recipe when available, and an explanatory message. ready certifies
indexing for eligible staged entities; partial preserves entities already
owned by ROOT or another graph. pending, unavailable, failed, disabled,
external, and quarantined must not be described as searchable completion.
Vector-index inference uses bounded batches outside the server’s global writer
lock. Existing import identity resolution remains a separate admission step;
this indexing status does not certify that step’s lock latency.
A share may declare manifest.embedding with an ONNX basename,
model_sha256, tokenizer_sha256 (lowercase SHA-256), and dimension. These
fields participate in the share identity. The native provider hashes its actual
files and validates the model output dimension. Import with an
available mismatched provider refuses before staging. Legacy shares without
this recipe remain importable; local indexing does not attest which model the
publisher intended. Re-importing with a real provider recomputes eligible
vectors instead of treating matching text as proof of model identity.
Existing graph data can still use the explicit startup --embed-backfill or
bounded POST /embed_backfill_graph operations when needed.
Browser explorer boundary
The explorer uses its separate JavaScript ONNX adapter and self-hosted model
assets. It loads the runtime on the first semantic query and rebuilds its
in-memory vectors when the ROOT label/comment snapshot changes. It does not
copy staged named graphs into that index. Native import readiness does not
certify browser indexing or browser model-byte identity; the current browser
adapter does not validate manifest.embedding against its JavaScript model.
A loaded pendant alone must not be presented as browser semantic readiness.
Configuring a provider
Two things are required, and having one without the other is the common failure:
- The runtime. Build with
--features onnx. This supplies the ONNX runtime only. It does not supply a model, and a build with the feature on is not a build that can embed. - A model on disk, plus the paths to it in
.bobbin/config.toml:
[quipu.embedding]
auto_embed = true
model_path = "models/all-MiniLM-L6-v2/onnx/model.onnx"
tokenizer_path = "models/all-MiniLM-L6-v2/tokenizer.json"
dimension = 384
The model files are fetched separately (for example from the
sentence-transformers/all-MiniLM-L6-v2 repository on Hugging Face). Sandboxed
environments may not have network access to a model host, in which case the
files have to be provisioned into the image or volume ahead of time.
dimension must match the model. Both model_path and tokenizer_path must
be set. A partial or invalid configuration is an error; omitting both produces
a visible unavailable status. The same setup is used by native CLI loaders and
the server.
Telling whether it worked
Three signals, in the order you will meet them.
At startup, a loaded provider announces itself:
embedding provider ready (dim=384, auto_embed=true)
--embed-backfill is fatal when it cannot run. The flag is an explicit
request for a capability, so a server that cannot honour it exits non-zero
rather than starting up without it. The error names the configuration it
needs. Drop the flag if you deliberately want to serve without embeddings.
Every retrieval response carries its own status. /context,
quipu_unified_search, and quipu_hybrid_search all report:
{
"entities": [],
"summary": {
"total_entities": 0,
"direct_hits": 0,
"embeddings": { "configured": false, "embedded_entities": 0 }
}
}
Read it as:
configured | embedded_entities | Meaning |
|---|---|---|
false | 0 | No provider. Configure [quipu.embedding]. |
true | 0 | Provider attached, store never embedded. Run a backfill. |
true | > 0 | Semantic retrieval is live — an empty result really is “no match”. |
That last row is the point of the field: without it, an empty entities list
means either “nothing matched” or “this was never going to work”, and the two
are indistinguishable from the response alone.
What degrades without a provider
Only the semantic half. Everything lexical keeps working:
- Works: SPARQL (including exact-match grounding),
/query,/knot,/contexttext search, link expansion, SHACL, the reasoner. - Empty or refused: vector similarity in
/searchandquipu_hybrid_search, theSemanticrelevance hits inside/context.
quipu_hybrid_search called with a query string and no provider returns an
error naming the missing configuration, rather than an empty result set. You
can also bypass the provider entirely by passing a pre-computed embedding
array.
SPARQL from Zero
This tutorial teaches SPARQL using a concrete homelab dataset. Every query has sample data, the query itself, and the results table — so you can follow along by loading the data into Quipu and running the queries yourself.
The Sample Dataset
Save this as homelab.ttl:
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
# --- Hosts ---
hw:korimako a hw:Host ;
rdfs:label "korimako" ;
hw:hostname "korimako.example" ;
hw:cpuCores "8"^^xsd:integer ;
hw:memoryMB "32768"^^xsd:integer ;
hw:role "hypervisor" .
hw:palau a hw:Host ;
rdfs:label "palau" ;
hw:hostname "palau.example" ;
hw:cpuCores "4"^^xsd:integer ;
hw:memoryMB "16384"^^xsd:integer ;
hw:role "storage" .
hw:yap a hw:Host ;
rdfs:label "yap" ;
hw:hostname "yap.example" ;
hw:cpuCores "4"^^xsd:integer ;
hw:memoryMB "8192"^^xsd:integer ;
hw:role "edge" .
# --- Services ---
hw:traefik a hw:WebApp ;
rdfs:label "traefik" ;
hw:runsOn hw:korimako ;
hw:port "443"^^xsd:integer ;
hw:dependsOn hw:pihole .
hw:pihole a hw:Service ;
rdfs:label "pihole" ;
hw:runsOn hw:korimako ;
hw:port "53"^^xsd:integer .
hw:grafana a hw:WebApp ;
rdfs:label "grafana" ;
hw:runsOn hw:korimako ;
hw:port "3000"^^xsd:integer ;
hw:dependsOn hw:prometheus .
hw:prometheus a hw:Service ;
rdfs:label "prometheus" ;
hw:runsOn hw:palau ;
hw:port "9090"^^xsd:integer .
hw:minio a hw:Service ;
rdfs:label "minio" ;
hw:runsOn hw:palau ;
hw:port "9000"^^xsd:integer .
hw:nginx a hw:WebApp ;
rdfs:label "nginx" ;
hw:runsOn hw:yap ;
hw:port "80"^^xsd:integer ;
hw:dependsOn hw:minio .
# --- Type hierarchy ---
hw:WebApp rdfs:subClassOf hw:Service .
Load it:
quipu knot homelab.ttl --db homelab.db
1. Your First Query: SELECT
A SPARQL query matches patterns against the graph. The simplest pattern is a single triple with a variable:
SELECT ?host
WHERE {
?host a <http://example.org/homelab/Host> .
}
This says “find every ?host that has type hw:Host.” The a keyword
is shorthand for rdf:type.
Run it:
quipu read "SELECT ?host WHERE { ?host a <http://example.org/homelab/Host> }" \
--db homelab.db
| ?host |
|---|
http://example.org/homelab/korimako |
http://example.org/homelab/palau |
http://example.org/homelab/yap |
2. Multiple Patterns: JOIN
Add more patterns to narrow results. Patterns in the same WHERE block
are joined — every pattern must match:
SELECT ?host ?cores
WHERE {
?host a <http://example.org/homelab/Host> .
?host <http://example.org/homelab/cpuCores> ?cores .
}
| ?host | ?cores |
|---|---|
hw:korimako | 8 |
hw:palau | 4 |
hw:yap | 4 |
3. Using Prefixes
Full IRIs are verbose. SPARQL supports PREFIX declarations (without the @
and trailing . that Turtle uses):
PREFIX hw: <http://example.org/homelab/>
SELECT ?host ?cores
WHERE {
?host a hw:Host .
?host hw:cpuCores ?cores .
}
The results are identical. Use prefixes in every query from here on.
4. FILTER: Narrowing Results
FILTER applies conditions to bound variables:
PREFIX hw: <http://example.org/homelab/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?host ?mem
WHERE {
?host a hw:Host .
?host hw:memoryMB ?mem .
FILTER(?mem > 10000)
}
| ?host | ?mem |
|---|---|
hw:korimako | 32768 |
hw:palau | 16384 |
String filters
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc ?label
WHERE {
?svc a hw:Service .
?svc <http://www.w3.org/2000/01/rdf-schema#label> ?label .
FILTER(CONTAINS(?label, "pi"))
}
| ?svc | ?label |
|---|---|
hw:pihole | “pihole” |
Available filter functions: =, !=, <, >, <=, >=, &&, ||,
!, BOUND(), CONTAINS(), REGEX(), LCASE(), isIRI().
5. OPTIONAL: Left Joins
Not every service has a dependsOn edge. OPTIONAL includes the match
if it exists, but doesn’t exclude the row if it doesn’t:
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc ?dep
WHERE {
?svc a hw:Service .
OPTIONAL { ?svc hw:dependsOn ?dep . }
}
| ?svc | ?dep |
|---|---|
hw:traefik | hw:pihole |
hw:pihole | |
hw:grafana | hw:prometheus |
hw:prometheus | |
hw:minio | |
hw:nginx | hw:minio |
Services without dependencies appear with an empty ?dep column.
6. UNION: Combining Patterns
UNION matches rows from either branch:
PREFIX hw: <http://example.org/homelab/>
SELECT ?thing ?label
WHERE {
{
?thing a hw:Host .
?thing <http://www.w3.org/2000/01/rdf-schema#label> ?label .
}
UNION
{
?thing a hw:WebApp .
?thing <http://www.w3.org/2000/01/rdf-schema#label> ?label .
}
}
Returns all hosts and web apps.
7. ORDER BY, LIMIT, OFFSET
Sort and paginate results:
PREFIX hw: <http://example.org/homelab/>
SELECT ?host ?mem
WHERE {
?host a hw:Host .
?host hw:memoryMB ?mem .
}
ORDER BY DESC(?mem)
LIMIT 2
| ?host | ?mem |
|---|---|
hw:korimako | 32768 |
hw:palau | 16384 |
OFFSET 1 LIMIT 1 would skip korimako and return only palau.
An explicit ORDER BY … LIMIT k OFFSET n retains the best k + n
solutions when only column projection separates the limit from the sort and
keys are constants or consistently typed variables. Mixed incomparable values,
non-finite numbers, and computed keys retain the full-sort path.
Response-provenance columns also keep the full relation until projection, so
metadata present only outside the requested page cannot disappear from headers.
It still evaluates the complete input: this optimization does not push a
scan limit through filters, joins, DISTINCT, or aggregates. Equal sort keys
preserve input order, and each sort expression is evaluated once per solution.
Native complete sorts above 4,096 input rows use private temporary-file runs; smaller sorts retain the in-memory path. The existing 65,536-key-entry and 8 MiB retained-key guards are unchanged. Spill runs use a 2 MiB key budget, at most 128 initial runs, a 1 MiB encoded-record limit, and a cumulative 256 MiB write quota including merge passes. Files contain evaluated keys and row positions, are private to the process, and are removed when closed. Disk errors, quota exhaustion, oversized keys, and deadlines return errors; they never return a silently shortened sorted result. WebAssembly keeps the explicit in-memory refusal because it has no native temporary-file backend.
These limits bound sorting keys and temporary storage. Earlier query operators
may already have materialized the input rows, so they are not a bound on total
query memory. A small LIMIT also cannot make an expensive input scan cheap.
8. Aggregates: COUNT, SUM, AVG
Group and aggregate with GROUP BY:
PREFIX hw: <http://example.org/homelab/>
SELECT ?host (COUNT(?svc) AS ?serviceCount)
WHERE {
?svc hw:runsOn ?host .
}
GROUP BY ?host
ORDER BY DESC(?serviceCount)
| ?host | ?serviceCount |
|---|---|
hw:korimako | 3 |
hw:palau | 2 |
hw:yap | 1 |
Total resources
PREFIX hw: <http://example.org/homelab/>
SELECT (SUM(?cores) AS ?totalCores) (SUM(?mem) AS ?totalMem)
WHERE {
?host a hw:Host .
?host hw:cpuCores ?cores .
?host hw:memoryMB ?mem .
}
| ?totalCores | ?totalMem |
|---|---|
| 16 | 57344 |
HAVING: Filter on aggregates
PREFIX hw: <http://example.org/homelab/>
SELECT ?host (COUNT(?svc) AS ?n)
WHERE {
?svc hw:runsOn ?host .
}
GROUP BY ?host
HAVING(?n > 1)
| ?host | ?n |
|---|---|
hw:korimako | 3 |
hw:palau | 2 |
9. RDFS Subclass Inference
Remember that hw:WebApp rdfs:subClassOf hw:Service. A constant type form
infers; a variable type form does not. The two spellings answer different
questions, and the response says which one you got.
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc
WHERE {
?svc a hw:Service .
}
This returns every service including those asserted only as hw:WebApp,
because a constant type form is expanded over rdfs:subClassOf. Quipu’s formal
default is reasoning-on.
Two spellings, two questions
# INFERS — a constant type is expanded over rdfs:subClassOf. -> 6
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a hw:Service }
# ASSERTED ONLY — a VARIABLE type plus a filter is not expanded. -> 3
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a ?t . FILTER(?t = hw:Service) }
On this tutorial’s dataset that is 6 against 3: three entities are asserted
hw:Service (pihole, prometheus, minio) and three are asserted hw:WebApp
(traefik, grafana, nginx), which the constant form folds in.
| Question | Form |
|---|---|
| “what depends on X”, blast radius, impact, “find it the way a reader would” | constant form ?s a hw:Service (infers) |
“is this entity DIRECTLY typed hw:Service?” | ?s a ?t . FILTER(?t = hw:Service) |
| vocabulary census, governance gating, “who emits the wrong type” | the asserted-only form |
⚠️ A hit on the constant form does not prove direct typing. An entity typed
only as hw:WebApp satisfies ?s a hw:Service. If you are checking that
something carries a type — a governance gate, an ingest read-back — the constant
form will pass on a subclass and tell you nothing was wrong. Use the variable
form plus a filter, or read the marker below.
The explicit path form remains available and is unaffected by the default:
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a/rdfs:subClassOf* hw:Service }
The marker: what was folded in
Ambiguity here is expensive: both spellings return HTTP 200 and both counts are individually plausible, so a number answering the other question does not look wrong. Quipu therefore reports the expansion, on exactly the queries whose answer it could have changed:
{
"count": 6,
"inference": {
"applied": true,
"expandedTypes": [
{"type": "http://example.org/homelab/Service",
"subclasses": ["http://example.org/homelab/WebApp"]}
],
"note": "RDFS subclass expansion was applied to the constant rdf:type pattern; use a variable type plus FILTER for an asserted-only census"
}
}
The field is absent when the query was not expanded, so its presence is the
signal. The subclasses are named because “inference happened” is not actionable
on its own — the reader needs to know that Service swallowed WebApp. A leaf
type is never reported: with no subclasses there is nothing to fold in.
Every result shape carries it — including ASK
The marker is not a SELECT feature. ASK is the shape most in need of it:
ASK { hw:postgres a hw:Service } # -> {"result": true, "inference": {...}}
If hw:postgres is typed as hw:DatabaseService and that class is a subclass
of hw:Service, the constant-type ASK includes it through subclass expansion.
The inference marker identifies that expansion. CONSTRUCT and DESCRIBE
results also carry the marker when their query patterns expand.
What the marker claims. It reports the type expansion applied to this query. A marked ASK can also match a directly asserted fact; the marker alone cannot prove how a particular row matched. To inspect asserted types, ask:
SELECT ?t WHERE { hw:postgres a ?t } # what is it ACTUALLY typed as?
Standard result formats: the marker moves to a header
If you request a W3C shape with Accept
(application/sparql-results+json, application/sparql-results+xml,
text/turtle), the body is fixed by spec and has nowhere to put the marker.
It travels as a response header instead, naming the affected type constants:
x-quipu-inference: applied: http://example.org/homelab/Service
The header is absent when no type expansion was applied. The body is
untouched and stays conformant, so a standard parser is unaffected — but a
client that ignores headers gets no signal, which is a reason to prefer the
default JSON shape when the distinction matters. Full expandedTypes detail is
available in the default JSON response.
Standard HTTP responses also carry X-Quipu-Truncated: true when the server
row ceiling removed results, or false when the result is complete. This
header applies to SPARQL JSON/XML, CSV/TSV and RDF graph output; browser clients
can read it through CORS. Check it before treating a result as a complete
answer. A result exactly at the ceiling is complete if no extra row was removed.
The default JSON format continues to report its existing truncated field.
10. Property Paths
SPARQL 1.1 property paths let you traverse edges without binding intermediate variables.
Sequence (/)
“What hosts do web apps’ dependencies run on?”
PREFIX hw: <http://example.org/homelab/>
SELECT ?app ?depHost
WHERE {
?app a hw:WebApp .
?app hw:dependsOn/hw:runsOn ?depHost .
}
hw:dependsOn/hw:runsOn means: follow dependsOn, then follow runsOn.
| ?app | ?depHost |
|---|---|
hw:traefik | hw:korimako |
hw:grafana | hw:palau |
hw:nginx | hw:palau |
Transitive closure (* and +)
If you had a chain like A dependsOn B dependsOn C, you could traverse
the full dependency chain:
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc ?transitiveDep
WHERE {
?svc hw:dependsOn+ ?transitiveDep .
}
+ means “one or more hops.” * means “zero or more” (includes the
starting node itself).
Alternative (|)
Match either predicate:
PREFIX hw: <http://example.org/homelab/>
SELECT ?thing ?name
WHERE {
?thing (hw:hostname|<http://www.w3.org/2000/01/rdf-schema#label>) ?name .
}
Reverse (^)
“What services does korimako host?” using reverse traversal:
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc
WHERE {
hw:korimako ^hw:runsOn ?svc .
}
^hw:runsOn means “follow runsOn edges backwards.”
11. Temporal Queries
Every SPARQL query in Quipu can include a temporal context.
Valid-time travel
“What did the homelab look like on March 15?”
quipu read "PREFIX hw: <http://example.org/homelab/>
SELECT ?host ?cores WHERE {
?host a hw:Host .
?host hw:cpuCores ?cores .
}" --db homelab.db --valid-at 2026-03-15
Via REST:
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{
"query": "PREFIX hw: <http://example.org/homelab/> SELECT ?host ?cores WHERE { ?host a hw:Host . ?host hw:cpuCores ?cores }",
"valid_at": "2026-03-15"
}'
Transaction-time travel
“What did the database know after the first 5 transactions?”
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" --db homelab.db --tx 5
12. Other Query Forms
ASK: Yes/No Questions
PREFIX hw: <http://example.org/homelab/>
ASK { hw:korimako a hw:Host }
Returns true or false.
CONSTRUCT: Build New Triples
PREFIX hw: <http://example.org/homelab/>
CONSTRUCT {
?svc hw:colocatedWith ?other .
}
WHERE {
?svc hw:runsOn ?host .
?other hw:runsOn ?host .
FILTER(?svc != ?other)
}
Returns triples showing which services share a host.
DESCRIBE: Entity Details
PREFIX hw: <http://example.org/homelab/>
DESCRIBE hw:korimako
Returns all triples where korimako is the subject.
Cheat Sheet
| Pattern | Meaning |
|---|---|
?x a hw:Host | ?x has type Host |
FILTER(?n > 5) | Numeric comparison |
FILTER(CONTAINS(?s, "abc")) | Substring match |
OPTIONAL { ... } | Include if available |
{ A } UNION { B } | Either pattern |
GROUP BY ?x | Aggregate per group |
ORDER BY DESC(?n) | Sort descending |
LIMIT 10 OFFSET 5 | Paginate |
?x hw:a/hw:b ?y | Path sequence |
?x hw:a+ ?y | Transitive closure |
?x ^hw:a ?y | Reverse edge |
?x (hw:a|hw:b) ?y | Either predicate |
What’s Next
- Homelab Operator Tutorial — model a full infrastructure
- Temporal Model — deep dive on time-travel
- REST API Reference — every endpoint
The Homelab Operator
“I want to know what breaks if korimako goes down.”
You run a homelab — a handful of machines with dozens of services, wired together with reverse proxies, DNS, and hope. You need a way to track what runs where, what depends on what, and what the blast radius is when a host goes down.
This tutorial walks through modeling your infrastructure as a knowledge graph, querying dependencies with SPARQL, and ingesting changes from monitoring agents.
Step 1: Model Your Infrastructure
Create homelab.ttl with your hosts and services:
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
# Type hierarchy
hw:WebApp rdfs:subClassOf hw:Service .
hw:Database rdfs:subClassOf hw:Service .
# Hosts
hw:korimako a hw:Host ;
rdfs:label "korimako" ;
hw:hostname "korimako.example" ;
hw:cpuCores "8"^^xsd:integer ;
hw:memoryMB "32768"^^xsd:integer .
hw:palau a hw:Host ;
rdfs:label "palau" ;
hw:hostname "palau.example" ;
hw:cpuCores "4"^^xsd:integer ;
hw:memoryMB "16384"^^xsd:integer .
# Services
hw:traefik a hw:WebApp ;
rdfs:label "traefik" ;
hw:runsOn hw:korimako ;
hw:port "443"^^xsd:integer ;
hw:dependsOn hw:pihole .
hw:pihole a hw:Service ;
rdfs:label "pihole" ;
hw:runsOn hw:korimako ;
hw:port "53"^^xsd:integer .
hw:grafana a hw:WebApp ;
rdfs:label "grafana" ;
hw:runsOn hw:korimako ;
hw:dependsOn hw:prometheus ;
hw:dependsOn hw:postgres .
hw:prometheus a hw:Service ;
rdfs:label "prometheus" ;
hw:runsOn hw:palau .
hw:postgres a hw:Database ;
rdfs:label "postgres" ;
hw:runsOn hw:palau ;
hw:port "5432"^^xsd:integer .
hw:nginx a hw:WebApp ;
rdfs:label "nginx" ;
hw:runsOn hw:palau ;
hw:dependsOn hw:postgres .
Load it:
quipu knot homelab.ttl --db homelab.db
Step 2: Query — What Runs on Each Host?
PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?host ?svc
WHERE {
?svc hw:runsOn ?host .
?host rdfs:label ?hostLabel .
?svc rdfs:label ?svcLabel .
}
ORDER BY ?hostLabel
quipu read "PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?hostLabel ?svcLabel WHERE {
?svc hw:runsOn ?host .
?host rdfs:label ?hostLabel .
?svc rdfs:label ?svcLabel .
} ORDER BY ?hostLabel ?svcLabel" --db homelab.db
| ?hostLabel | ?svcLabel |
|---|---|
| korimako | grafana |
| korimako | pihole |
| korimako | traefik |
| palau | nginx |
| palau | postgres |
| palau | prometheus |
Step 3: Impact Analysis — What Breaks if Korimako Goes Down?
This is the killer query. Find all services on korimako, then find everything that depends on them (directly or transitively):
PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?affected ?label
WHERE {
?affected hw:dependsOn+/hw:runsOn hw:korimako .
?affected rdfs:label ?label .
}
The property path hw:dependsOn+/hw:runsOn means: follow one or more
dependsOn edges, then one runsOn edge, and check if it lands on korimako.
| ?affected | ?label |
|---|---|
hw:traefik | traefik |
Traefik depends on pihole, which runs on korimako. But traefik itself also runs on korimako — so if korimako goes down, you lose traefik, pihole, and grafana (all locally hosted), plus anything that transitively depends on them.
A more complete impact query — services that run on korimako OR depend on services running on korimako:
PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT DISTINCT ?svc ?label
WHERE {
{
?svc hw:runsOn hw:korimako .
}
UNION
{
?svc hw:dependsOn+/hw:runsOn hw:korimako .
}
?svc rdfs:label ?label .
}
Step 4: Enforce Structure with SHACL
Prevent malformed data from entering the graph. Create homelab.shapes.ttl:
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
hw:HostShape a sh:NodeShape ;
sh:targetClass hw:Host ;
sh:property [
sh:path hw:hostname ;
sh:datatype xsd:string ;
sh:minCount 1 ;
sh:maxCount 1 ;
] ;
sh:property [
sh:path hw:cpuCores ;
sh:datatype xsd:integer ;
sh:minCount 1 ;
] .
hw:ServiceShape a sh:NodeShape ;
sh:targetClass hw:Service ;
sh:property [
sh:path hw:runsOn ;
sh:class hw:Host ;
sh:minCount 1 ;
sh:maxCount 1 ;
] .
Load shapes and validate:
quipu shapes load --name homelab --file homelab.shapes.ttl --db homelab.db
Now any new service without a runsOn edge is rejected.
Step 5: Ingest from Monitoring Agents
When an agent discovers new infrastructure, it can push episodes:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "prometheus-discovery-2026-04-04",
"source": "prometheus-sd",
"nodes": [
{"name": "redis", "type": "Service", "description": "Cache layer"},
{"name": "yap", "type": "Host", "properties": {"hostname": "yap.example"}}
],
"edges": [
{"source": "redis", "target": "yap", "relation": "runsOn"},
{"source": "nginx", "target": "redis", "relation": "dependsOn"}
]
}'
The episode creates entities and relationships in a single transaction, with provenance tracking back to the discovery agent.
Step 6: Time-Travel After Changes
After adding redis, query what the graph looked like before:
quipu read "PREFIX hw: <http://example.org/homelab/>
SELECT ?svc WHERE { ?svc a hw:Service }" --db homelab.db --tx 1
Transaction 1 only had the original six services. The current state includes redis.
Useful Queries for Operators
Services with no dependencies (leaf nodes)
PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?svc ?label
WHERE {
?svc a hw:Service .
?svc rdfs:label ?label .
FILTER NOT EXISTS { ?svc hw:dependsOn ?dep }
}
Hosts sorted by service count
PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?hostLabel (COUNT(?svc) AS ?n)
WHERE {
?svc hw:runsOn ?host .
?host rdfs:label ?hostLabel .
}
GROUP BY ?hostLabel
ORDER BY DESC(?n)
Resource utilization summary
PREFIX hw: <http://example.org/homelab/>
SELECT (SUM(?cores) AS ?totalCores) (SUM(?mem) AS ?totalMB)
(COUNT(?host) AS ?hostCount)
WHERE {
?host a hw:Host .
?host hw:cpuCores ?cores .
?host hw:memoryMB ?mem .
}
Step 7: Materialise Dependencies with the Reasoner
The property path queries above (dependsOn+, runsOn+) re-derive
transitive chains every time you run them. For a graph you query often,
the reasoner can materialise those chains once and keep them fresh.
Create infra-rules.ttl:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://example.org/rules/> .
ex:homelab a rule:RuleSet ;
rule:defaultPrefix "http://example.org/homelab/" .
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "dependsOn(?a, ?c)" ;
rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runsOn(?svc, ?host)" ;
rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .
Run it:
quipu reason --rules infra-rules.ttl --db homelab.db
Now the “what breaks if korimako goes down?” query simplifies — no property paths needed:
PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT DISTINCT ?svc ?label
WHERE {
?svc hw:runsOn hw:korimako .
?svc rdfs:label ?label .
}
This returns both directly-hosted services AND services that transitively run on korimako via containers, because the reasoner already computed the transitive closure.
Stay Fresh with Reactive Evaluation
Enable reactive mode so derived facts update whenever you add or remove infrastructure:
quipu reason --reactive --rules infra-rules.ttl --db homelab.db
Now when a monitoring agent reports a new container on korimako, the
transitive runsOn edges update automatically in the same transaction.
“What If?” Before You Change
Before decommissioning a host, ask the reasoner what would break:
#![allow(unused)]
fn main() {
// Hypothetical: retract all runsOn edges to korimako
let report = store.speculate(&koror_retractions, timestamp, |s| {
evaluate(s, &ruleset, timestamp)
})?;
println!("Decommissioning korimako would retract {} derived facts", report.retracted);
}
The store remains unchanged — you see the impact without making the change. See The Rule Builder for a complete walkthrough.
What’s Next
- The Rule Builder — write custom rules step by step
- Impact Analysis Recipe — more impact patterns
- SPARQL from Zero — full SPARQL reference tutorial
- Knowledge Gardener — maintain ontology quality
The AI Agent Builder
“I want my agents to share structured knowledge.”
You’re building AI agents that observe the world and need to share what they learn. One agent monitors deployments, another reads incident reports, a third answers questions. They need a shared knowledge layer — structured, validated, and queryable.
Quipu gives agents three things:
- Episodes — structured write path for agent observations
- MCP tools — native integration for LLM tool-use
- Temporal queries — “what did the system look like yesterday?”
Step 1: Agent Writes an Episode
An agent observes a deployment and records it as an episode — a batch of nodes and edges with provenance:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "deploy-v2.3",
"source": "deploy-agent",
"episode_body": "Deployed v2.3 of the API service to production",
"group_id": "deployments",
"nodes": [
{
"name": "api-v2.3",
"type": "Deployment",
"description": "API service version 2.3",
"properties": {
"version": "2.3.0",
"environment": "production",
"replicas": 3
}
},
{
"name": "api-service",
"type": "Service",
"description": "Core API service"
}
],
"edges": [
{"source": "api-v2.3", "target": "api-service", "relation": "deploys"}
]
}'
Response:
{"tx_id": 1, "count": 12}
The episode created 12 triples in a single transaction: entities with types, labels, descriptions, properties, relationships, and provenance metadata.
Step 2: Another Agent Queries It
A Q&A agent needs to answer “what was deployed recently?” It uses the MCP
tool quipu_query:
{
"tool": "quipu_query",
"input": {
"query": "PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> SELECT ?name ?desc WHERE { ?d a <http://aegis.gastown.local/ontology/Deployment> . ?d rdfs:label ?name . ?d rdfs:comment ?desc }"
}
}
Response:
{
"variables": ["name", "desc"],
"rows": [
{"name": "api-v2.3", "desc": "API service version 2.3"}
],
"count": 1
}
Step 3: Search by Meaning, Not Just Structure
Agents don’t always know the exact IRI to query. The quipu_search_nodes
tool does natural language entity search:
{
"tool": "quipu_search_nodes",
"input": {
"query": "API deployment",
"max_results": 5
}
}
Response:
{
"nodes": [
{
"name": "api-v2.3",
"entity_type": "Deployment",
"description": "API service version 2.3",
"score": 0.87
}
],
"count": 1
}
For relationship search, use quipu_search_facts:
{
"tool": "quipu_search_facts",
"input": {
"query": "deploys",
"max_results": 10
}
}
Step 4: Temporal Queries — What Changed?
An incident-response agent needs to see what the graph looked like before
a problem started. Add valid_at to any query:
{
"tool": "quipu_query",
"input": {
"query": "PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> SELECT ?name WHERE { ?d a <http://aegis.gastown.local/ontology/Deployment> . ?d rdfs:label ?name }",
"valid_at": "2026-04-03"
}
}
This returns only deployments that existed as of April 3 — before today’s deploy. The agent can diff the two result sets to see what changed.
Step 5: Validate Agent Output
Agents make mistakes. SHACL shapes catch them before they pollute the graph.
Define what a valid Deployment looks like:
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ont: <http://aegis.gastown.local/ontology/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ont:DeploymentShape a sh:NodeShape ;
sh:targetClass ont:Deployment ;
sh:property [
sh:path <http://www.w3.org/2000/01/rdf-schema#label> ;
sh:minCount 1 ;
sh:datatype xsd:string ;
] ;
sh:property [
sh:path <http://www.w3.org/2000/01/rdf-schema#comment> ;
sh:minCount 1 ;
] .
Load the shapes, then include them in episode ingestion:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "bad-deploy",
"nodes": [{"name": "oops", "type": "Deployment"}],
"edges": [],
"shapes": "@prefix sh: <http://www.w3.org/ns/shacl#> ..."
}'
If the episode data violates the shapes, the write is rejected with structured feedback the agent can parse and fix.
MCP Tool Reference
| Tool | Purpose |
|---|---|
quipu_query | Run SPARQL queries (SELECT, ASK, CONSTRUCT, DESCRIBE) |
quipu_knot | Assert Turtle facts with optional SHACL validation |
quipu_cord | List entities, optionally filtered by type |
quipu_unravel | Time-travel query (by transaction or valid time) |
quipu_episode | Ingest a structured episode |
quipu_search | Vector similarity search |
quipu_hybrid_search | Combined SPARQL filter + vector search |
quipu_search_nodes | Natural language entity search |
quipu_search_facts | Natural language relationship search |
quipu_validate | Dry-run SHACL validation |
quipu_shapes | Load, list, or remove SHACL shapes |
quipu_retract | Retract facts about an entity |
See MCP Tools Reference for full parameter details.
Patterns for Multi-Agent Systems
Shared ontology, independent episodes
Each agent writes episodes with its own source and group_id. The shared
ontology (types, relationships) is defined once via SHACL shapes. Any agent
can query the full graph.
Agent as knowledge gardener
One agent periodically validates the graph against shapes, finds violations, and either fixes them or files issues. See The Knowledge Gardener.
Provenance tracking
Every episode records which agent wrote it. Query provenance:
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?entity ?episode
WHERE {
?entity prov:wasGeneratedBy ?ep .
?ep rdfs:label ?episode .
}
Step 6: Derived Knowledge with the Reasoner
Agents write raw facts — “traefik runs on webproxy”, “webproxy runs on korimako”. But other agents need to query derived facts — “traefik runs on korimako” (transitively). Instead of making every consuming agent write property path queries, use the reasoner to materialise derived facts that every agent can query directly.
Rules as Shared Infrastructure
Define rules once, and every agent benefits:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://aegis.gastown.local/rules/> .
ex:agent_rules a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
# If A depends on B and B depends on C, then A depends on C
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "dependsOn(?a, ?c)" ;
rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .
Reactive: Derive on Write
With reactive evaluation enabled, derived facts update every time an agent writes an episode:
quipu reason --reactive --rules agent-rules.ttl --db knowledge.db
Now when the deploy agent writes a new dependsOn edge, the transitive
closure updates in the same transaction. The Q&A agent’s next query sees
the full dependency chain without any property paths.
Pre-Flight Checks with Speculate
Before a deploy agent pushes a change, it can ask “what would this break?” without actually modifying the graph:
#![allow(unused)]
fn main() {
// Hypothetical: remove the old service version
let report = store.speculate(&removal_datums, timestamp, |s| {
evaluate(s, &ruleset, timestamp)
})?;
if report.retracted > 0 {
println!("WARNING: removing old version would retract {} derived facts", report.retracted);
// Agent can decide to proceed or alert a human
}
// Store is unchanged — safe to inspect before committing
}
This is especially powerful in multi-agent systems: one agent proposes a change, the reasoner evaluates the impact, and a separate agent decides whether to approve it.
Provenance for Derived Facts
Derived facts carry source tags like reasoner:depends_on_transitive.
Agents can distinguish raw observations from derived knowledge:
PREFIX ont: <http://aegis.gastown.local/ontology/>
SELECT ?a ?b
WHERE {
?a ont:dependsOn ?b .
# This returns BOTH direct and transitively-derived dependencies
}
The provenance is in the fact metadata — agents that need to distinguish
can filter on the source field.
What’s Next
- The Rule Builder — write custom rules step by step
- MCP Tools Reference — full tool docs
- Knowledge Ingestion Recipe — batch patterns
- REST API Reference — all HTTP endpoints
The Code Archaeologist
“I want to understand how this codebase evolved.”
You’re investigating a codebase — not just what the code does now, but what decisions shaped it. Which commit caused that outage? What design rationale lives only in someone’s memory? Quipu, paired with Bobbin, links code symbols to knowledge entities and lets you search across both.
Step 1: Model Code as Knowledge
Code entities (modules, functions, types) become nodes in the graph:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "code-index-2026-04-04",
"source": "bobbin-indexer",
"group_id": "code-symbols",
"nodes": [
{
"name": "sparql-engine",
"type": "CodeModule",
"description": "SPARQL 1.1 query evaluation engine",
"properties": {"path": "src/sparql/mod.rs", "language": "rust"}
},
{
"name": "property-path-eval",
"type": "CodeSymbol",
"description": "Evaluates SPARQL property path expressions",
"properties": {"path": "src/sparql/property_path.rs", "symbol": "eval_path"}
},
{
"name": "store-transact",
"type": "CodeSymbol",
"description": "Core transaction write path for the fact log",
"properties": {"path": "src/store/ops.rs", "symbol": "transact"}
}
],
"edges": [
{"source": "property-path-eval", "target": "sparql-engine", "relation": "partOf"},
{"source": "store-transact", "target": "sparql-engine", "relation": "usedBy"}
]
}'
Step 2: Link Decisions to Code
Record architectural decisions as knowledge entities linked to code:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "adr-eavt-design",
"source": "human",
"group_id": "decisions",
"nodes": [
{
"name": "adr-001-eavt",
"type": "Decision",
"description": "Chose Datomic-style EAVT fact log over traditional triple store for bitemporal support and append-only safety"
},
{
"name": "adr-002-property-paths",
"type": "Decision",
"description": "Implemented custom SPARQL property path evaluator instead of using existing library for tighter integration with temporal model"
}
],
"edges": [
{"source": "adr-001-eavt", "target": "store-transact", "relation": "influences"},
{"source": "adr-002-property-paths", "target": "property-path-eval", "relation": "influences"}
]
}'
Step 3: Hybrid Search — Code AND Decisions
Search for entities by meaning, not just name:
curl -s localhost:3030/search/nodes -X POST \
-H "Content-Type: application/json" \
-d '{
"query": "how does the transaction write path work",
"max_results": 5
}'
This returns both the store-transact code symbol and the adr-001-eavt
decision that influenced it — answering “what” and “why” in one search.
SPARQL for precise queries
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX ont: <http://aegis.gastown.local/ontology/>
SELECT ?decision ?description
WHERE {
?decision a ont:Decision .
?decision ont:influences ?code .
?code rdfs:label "store-transact" .
?decision rdfs:comment ?description .
}
Step 4: Incident Correlation
When something breaks, link the incident to code and time:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "incident-2026-04-02-query-timeout",
"source": "incident-agent",
"group_id": "incidents",
"nodes": [
{
"name": "inc-2026-04-02",
"type": "Incident",
"description": "SPARQL queries timing out after property path merge",
"properties": {
"severity": "P2",
"started": "2026-04-02T14:30:00Z",
"resolved": "2026-04-02T16:00:00Z",
"commit": "abc123"
}
}
],
"edges": [
{"source": "inc-2026-04-02", "target": "property-path-eval", "relation": "causedBy"},
{"source": "inc-2026-04-02", "target": "sparql-engine", "relation": "affected"}
]
}'
Now you can query: “What code has caused incidents?”
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?code ?codePath ?incident ?description
WHERE {
?incident a ont:Incident .
?incident ont:causedBy ?code .
?incident rdfs:comment ?description .
?code rdfs:label ?codePath .
}
Step 5: Time-Travel for Context
Combine temporal queries with code knowledge:
“What did we know about the SPARQL engine before the incident?”
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{
"query": "PREFIX ont: <http://aegis.gastown.local/ontology/> PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> SELECT ?entity ?desc WHERE { ?entity ont:partOf <http://aegis.gastown.local/ontology/sparql-engine> . ?entity rdfs:comment ?desc }",
"valid_at": "2026-04-01"
}'
Step 6: Graph Projection — Dependency Analysis
Visualize module dependencies using graph projection:
curl -s localhost:3030/project -X POST \
-H "Content-Type: application/json" \
-d '{
"type_filter": "http://aegis.gastown.local/ontology/CodeModule"
}'
This returns a petgraph-compatible adjacency structure. Use it for:
- Centrality: Which modules are most depended upon?
- Components: Which modules form independent clusters?
- Shortest path: How are two modules connected?
curl -s localhost:3030/project -X POST \
-H "Content-Type: application/json" \
-d '{
"predicate_filter": "http://aegis.gastown.local/ontology/usedBy"
}'
Patterns for Code Archaeology
Link commits to knowledge changes
When a significant commit lands, create an episode with the commit hash, affected code symbols, and a description of intent. Over time, the graph becomes a searchable record of why the code looks the way it does.
Cross-reference documentation
Store doc sections as entities linked to the code they describe. When code changes, search for linked docs that may need updating.
Build a decision log
ADRs (Architecture Decision Records) stored as entities with influences
edges to code. When someone asks “why did we do it this way?”, the graph
has the answer — queryable by code symbol, by date, or by topic.
Step 7: Derive Influence Chains with the Reasoner
You’ve recorded influences edges between decisions and code symbols. But
influence is transitive — if decision A influences module M, and module M
is used by module N, then decision A indirectly influences module N. The
reasoner can materialise these chains.
Create archaeology-rules.ttl:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://aegis.gastown.local/rules/> .
ex:archaeology a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
# Transitive influence: if A influences B and B is usedBy C, A influences C
ex:influence_through_usage a rule:Rule ;
rule:id "influence_through_usage" ;
rule:head "influences(?decision, ?downstream)" ;
rule:body "influences(?decision, ?code), usedBy(?code, ?downstream)" .
# Transitive partOf: if A is partOf B and B is partOf C, A is partOf C
ex:part_of_transitive a rule:Rule ;
rule:id "part_of_transitive" ;
rule:head "partOf(?a, ?c)" ;
rule:body "partOf(?a, ?b), partOf(?b, ?c)" .
Run it:
quipu reason --rules archaeology-rules.ttl --db knowledge.db
Now you can answer “which decisions influenced this module?” across any depth of the dependency graph, with a flat query:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?decision ?description
WHERE {
?decision ont:influences <http://aegis.gastown.local/ontology/sparql-engine> .
?decision a ont:Decision .
?decision rdfs:comment ?description .
}
Blast Radius for Code Changes
Before refactoring a module, use speculate() to see what derived
relationships would break:
#![allow(unused)]
fn main() {
// Hypothetical: remove the usedBy edge from store-transact to sparql-engine
let report = store.speculate(&retractions, timestamp, |s| {
evaluate(s, &ruleset, timestamp)
})?;
println!("Decoupling these modules would affect {} derived influence chains",
report.retracted);
}
This tells you which architectural decisions and incident correlations would lose their path to downstream code — before you make the change.
What’s Next
- The Rule Builder — write custom rules step by step
- Incident Correlation Recipe — more patterns
- SPARQL from Zero — learn query patterns
- Graph Projection — API details
The Knowledge Gardener
“I want to curate and validate our ontology.”
You’re responsible for the quality of your knowledge graph. Entities drift, agents write messy data, and over time the graph accumulates orphan nodes, stale edges, and schema violations. Your job is to define what valid data looks like and keep the garden tidy.
Step 1: Define Your Ontology with SHACL
Start by declaring what types of entities exist and what properties they
must have. Create ontology.shapes.ttl:
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ont: <http://aegis.gastown.local/ontology/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
# Every entity must have a label
ont:LabeledShape a sh:NodeShape ;
sh:targetSubjectsOf rdfs:label ;
sh:property [
sh:path rdfs:label ;
sh:datatype xsd:string ;
sh:minCount 1 ;
] .
# Hosts must have hostname and cpuCores
ont:HostShape a sh:NodeShape ;
sh:targetClass ont:Host ;
sh:property [
sh:path ont:hostname ;
sh:datatype xsd:string ;
sh:minCount 1 ;
sh:maxCount 1 ;
] ;
sh:property [
sh:path ont:cpuCores ;
sh:datatype xsd:integer ;
sh:minCount 1 ;
] ;
sh:property [
sh:path ont:memoryMB ;
sh:datatype xsd:integer ;
] .
# Services must reference a valid Host
ont:ServiceShape a sh:NodeShape ;
sh:targetClass ont:Service ;
sh:property [
sh:path ont:runsOn ;
sh:class ont:Host ;
sh:minCount 1 ;
sh:maxCount 1 ;
] .
# Dependencies must point to Services
ont:DependencyShape a sh:NodeShape ;
sh:targetSubjectsOf ont:dependsOn ;
sh:property [
sh:path ont:dependsOn ;
sh:class ont:Service ;
] .
Load the shapes:
quipu shapes load --name ontology --file ontology.shapes.ttl --db knowledge.db
Step 2: Validate Existing Data
Run a dry-run validation against your current graph:
quipu validate --shapes ontology.shapes.ttl --data <(quipu export --db knowledge.db)
Or via REST:
# Export current data
DATA=$(curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{"query": "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"}' | jq -r '.triples')
# Validate
curl -s localhost:3030/validate -X POST \
-H "Content-Type: application/json" \
-d "{
\"shapes\": \"$(cat ontology.shapes.ttl)\",
\"data\": \"$DATA\"
}"
The response tells you exactly what’s wrong:
{
"conforms": false,
"violations": 3,
"warnings": 0,
"issues": [
{
"severity": "Violation",
"focus_node": "http://aegis.gastown.local/ontology/orphan-svc",
"path": "http://aegis.gastown.local/ontology/runsOn",
"component": "MinCountConstraintComponent",
"message": "Less than 1 values for ont:runsOn"
}
]
}
Step 3: Find Orphan Nodes
Entities with no incoming or outgoing relationships are usually noise:
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?entity ?label
WHERE {
?entity rdfs:label ?label .
FILTER NOT EXISTS { ?entity ?anyPred ?anyObj . FILTER(?anyPred != rdfs:label && ?anyPred != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>) }
FILTER NOT EXISTS { ?other ?rel ?entity }
}
Step 4: Find Stale Edges
Edges to entities that no longer exist (were retracted):
PREFIX ont: <http://aegis.gastown.local/ontology/>
SELECT ?source ?rel ?target
WHERE {
?source ?rel ?target .
FILTER(isIRI(?target))
FILTER NOT EXISTS { ?target a ?type }
FILTER(?rel != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)
}
This finds triples where the object is an IRI but has no type — likely a reference to a retracted or never-created entity.
Step 5: Progressive Schema Tightening
Start with loose shapes and tighten as your ontology stabilizes:
Phase 1: Required types and labels
ont:BasicShape a sh:NodeShape ;
sh:targetSubjectsOf <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ;
sh:property [
sh:path rdfs:label ;
sh:minCount 1 ;
] .
Phase 2: Add datatype constraints
ont:HostShape a sh:NodeShape ;
sh:targetClass ont:Host ;
sh:property [
sh:path ont:cpuCores ;
sh:datatype xsd:integer ; # Was accepting any literal
sh:minCount 1 ;
] .
Phase 3: Add referential integrity
ont:ServiceShape a sh:NodeShape ;
sh:targetClass ont:Service ;
sh:property [
sh:path ont:runsOn ;
sh:class ont:Host ; # Must reference a Host entity
sh:minCount 1 ;
] .
Phase 4: Add logical constraints
ont:ServiceShape
sh:property [
sh:path ont:dependsOn ;
sh:not [
sh:equals ont:runsOn ; # Can't depend on your own host
] ;
] .
Step 6: Automated Gardening
Set up an agent to periodically validate and report:
{
"tool": "quipu_validate",
"input": {
"shapes": "@prefix sh: ... your shapes ...",
"data": "@prefix ont: ... current data ..."
}
}
The structured feedback is machine-readable — an agent can:
- Parse violations
- Attempt automated fixes (e.g., add missing labels from entity names)
- File issues for violations it can’t fix
- Report on graph health trends over time
Step 7: Manage Multiple Shape Sets
Different domains can have different shapes:
# Infrastructure shapes
quipu shapes load --name infra --file infra.shapes.ttl --db knowledge.db
# Code entity shapes
quipu shapes load --name code --file code.shapes.ttl --db knowledge.db
# List all loaded shapes
quipu shapes list --db knowledge.db
# Remove outdated shapes
quipu shapes remove --name old-shapes --db knowledge.db
All loaded shapes are combined during validation — a write must satisfy all applicable shapes.
Gardening Queries Cheat Sheet
| Goal | Query Pattern |
|---|---|
| Orphan nodes | Entities with no relationships beyond type/label |
| Missing types | FILTER NOT EXISTS { ?x a ?type } |
| Duplicate labels | GROUP BY ?label HAVING(COUNT(?x) > 1) |
| Stale references | Object IRI with no type assertion |
| Type distribution | SELECT ?type (COUNT(?x) AS ?n) GROUP BY ?type |
| Most connected | SELECT ?x (COUNT(?rel) AS ?n) GROUP BY ?x ORDER BY DESC(?n) |
| Recent additions | Time-travel with --tx to compare states |
Step 8: Derived Relationships as Garden Health
The reasoner doesn’t just derive facts for operators — it’s a gardening tool. Materialised transitive closures reveal structural properties of your graph that are hard to see from raw facts alone.
Completeness Checks via Derived Facts
Define a rule that derives “this service is reachable from at least one
host” by closing the runsOn chain:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://aegis.gastown.local/rules/> .
ex:garden a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runsOn(?svc, ?host)" ;
rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .
After running the reasoner, query for services that don’t transitively reach any host:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?svc ?label
WHERE {
?svc a ont:Service .
?svc rdfs:label ?label .
FILTER NOT EXISTS {
?svc ont:runsOn ?host .
?host a ont:Host .
}
}
These are services with incomplete runsOn chains — either they reference
a container that doesn’t exist, or the chain breaks somewhere. This is a
data quality signal: the garden needs tending.
Reactive Gardening
With reactive evaluation enabled, derived facts update as agents write new data. You can run validation checks after the reasoner fires to catch problems immediately:
- Agent writes a new service with
runsOnpointing to a container - Reactive reasoner fires, tries to derive the transitive
runsOnto a host - If the container doesn’t have its own
runsOnedge, no transitive fact is derived - Your gardening query finds the gap
This turns the reasoner into an early warning system: gaps in the transitive closure signal incomplete data at the source.
Monitoring Derived Fact Counts
Track the health of your derived facts over time. After each reasoner run,
the EvalReport tells you how many facts were asserted and retracted. A
sudden spike in retractions might mean an agent is writing bad data that
broke a dependency chain. A plateau in assertions might mean your rules
have converged and the ontology is stable.
quipu reason --rules garden-rules.ttl --db knowledge.db
# reasoner: 3 rules across 2 strata — asserted 0, retracted 0
# ^ All derived facts are up to date — the garden is healthy
What’s Next
- The Rule Builder — write custom rules step by step
- SHACL Validation — constraint reference
- SPARQL from Zero — query patterns
- Knowledge Ingestion Recipe — bulk loading
The Rule Builder
“I don’t want to write the same SPARQL property path in every query. I want the graph to know that traefik transitively runs on korimako.”
You have a knowledge graph full of infrastructure facts. You’ve been writing
SPARQL queries with dependsOn+ and runsOn+ property paths to chase
transitive chains, and it works — but every query re-derives the same
relationships from scratch. You want the graph to materialise those
relationships once, keep them current, and let you query them directly.
This tutorial walks you through writing Datalog rules, running the reasoner, enabling reactive evaluation, and asking counterfactual “what if?” questions.
Prerequisites
You need a Quipu store with some infrastructure data. If you followed The Homelab Operator, you already have one. If not, create a quick test store:
quipu knot - --db lab.db <<'EOF'
@prefix hw: <http://example.org/homelab/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
hw:korimako a hw:Host ;
rdfs:label "korimako" .
hw:webproxy a hw:Container ;
rdfs:label "webproxy" ;
hw:runsOn hw:korimako .
hw:traefik a hw:WebApp ;
rdfs:label "traefik" ;
hw:runsOn hw:webproxy ;
hw:dependsOn hw:pihole .
hw:pihole a hw:Service ;
rdfs:label "pihole" ;
hw:runsOn hw:korimako .
hw:grafana a hw:WebApp ;
rdfs:label "grafana" ;
hw:runsOn hw:webproxy ;
hw:dependsOn hw:prometheus .
hw:prometheus a hw:Service ;
rdfs:label "prometheus" ;
hw:runsOn hw:korimako ;
hw:dependsOn hw:postgres .
hw:postgres a hw:Database ;
rdfs:label "postgres" ;
hw:runsOn hw:korimako .
EOF
Step 1: Your First Rule — Transitive runsOn
Traefik runs on webproxy, and webproxy runs on korimako. You want to materialise the fact that traefik runs on korimako without writing a property path query every time.
Create my-rules.ttl:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://example.org/rules/> .
ex:homelab a rule:RuleSet ;
rule:defaultPrefix "http://example.org/homelab/" .
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runsOn(?svc, ?host)" ;
rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .
Let’s unpack this:
rule:RuleSetwithrule:defaultPrefixtells the parser that bare names likerunsOnexpand tohttp://example.org/homelab/runsOn.rule:idis the provenance tag — every derived fact will carrysource = "reasoner:runs_on_transitive"so you know where it came from.rule:headis what gets derived:runsOn(?svc, ?host).rule:bodyis the condition: if?svcruns on?mid, and?midruns on?host, then?svcruns on?host.
The shared variable ?mid is the join key — it’s the container in the
middle of the chain.
Step 2: Run the Reasoner
quipu reason --rules my-rules.ttl --db lab.db
Output:
reasoner: 1 rules across 1 strata — asserted 3, retracted 0
per-rule contributions:
runs_on_transitive 3
Three new facts were derived. The reasoner found that traefik, grafana, and prometheus all transitively run on korimako (via webproxy or directly).
Query the derived facts:
quipu read "PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?svc ?host WHERE {
?svc hw:runsOn ?host .
?host a hw:Host .
?svc rdfs:label ?label .
}" --db lab.db
The transitive runsOn edges are now first-class facts — no property paths
needed. Every SPARQL query, every API call, and every agent context lookup
benefits.
Step 3: Add a Join Rule — Cross-Predicate Derivation
A single-predicate transitive closure is useful, but the real power is joining across predicates. Add a second rule that derives “this service is affected by this host going down”:
ex:affected_by_host a rule:Rule ;
rule:id "affected_by_host" ;
rule:head "affectedByHost(?svc, ?host)" ;
rule:body "runsOn(?svc, ?container), runsOn(?container, ?host)" .
Wait — that’s the same rule as runs_on_transitive. Let’s do something
more interesting. Suppose you also track package dependencies:
quipu knot - --db lab.db <<'EOF'
@prefix hw: <http://example.org/homelab/> .
hw:nginx_pkg a hw:Package ;
hw:installedIn hw:webproxy .
hw:traefik hw:usesPackage hw:nginx_pkg .
EOF
Now add a rule that derives “service S is affected by package P”:
ex:affected_by_package a rule:Rule ;
rule:id "affected_by_package" ;
rule:head "affectedByPackage(?svc, ?pkg)" ;
rule:body "usesPackage(?svc, ?pkg), installedIn(?pkg, ?container)" .
This joins across usesPackage and installedIn via the shared variable
?pkg. The head projects the service and package, dropping the container
(which was only needed for the join condition).
Add both rules to my-rules.ttl and run again:
quipu reason --rules my-rules.ttl --db lab.db
Step 4: Transitive Dependencies
The most common pattern is transitive closure over dependsOn. Add:
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "dependsOn(?a, ?c)" ;
rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .
This rule is positively recursive — it reads and writes the same
predicate (dependsOn). The reasoner handles this correctly: it keeps
applying the rule until no new facts are derived (fixpoint), using
semi-naive evaluation to avoid redundant work.
After running the reasoner, you can find the full transitive blast radius of any service with a simple flat query:
quipu read "PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?affected ?label WHERE {
?affected hw:dependsOn <http://example.org/homelab/postgres> .
?affected rdfs:label ?label .
}" --db lab.db
No property paths, no dependsOn+ — the transitive closure is already
in the store.
Step 5: Understanding Stratification
Let’s look at what happens when you have multiple rules that depend on each other. Your complete ruleset now has:
depends_on_transitive— reads and writesdependsOnruns_on_transitive— reads and writesrunsOnaffected_by_package— readsusesPackageandinstalledIn, writesaffectedByPackage
The reasoner stratifies these automatically:
- Stratum 0: Base facts (
usesPackage,installedIn) — no rules produce these, so they’re treated as ground truth. - Stratum 1: All three rules. Rules 1 and 2 are self-recursive (they read their own output), but there are no cross-rule dependencies, so the stratifier groups them together.
The important principle: positive recursion is fine within a stratum. The evaluator runs all rules in a stratum to fixpoint together. What isn’t allowed is negation within a cycle — but since none of these rules use negation, stratification is straightforward.
If you had a rule like:
# NOT YET SUPPORTED at eval time, but parsed and stratified
ex:orphan a rule:Rule ;
rule:id "orphan" ;
rule:head "orphan(?svc)" ;
rule:body "service(?svc), not dependsOn(?other, ?svc)" .
The stratifier would place orphan in a higher stratum than
depends_on_transitive, because it needs the complete dependsOn
relation (including derived transitive edges) before it can evaluate the
negation. This is stratification at work — it ensures negation only
reads “finished” relations.
Step 6: Go Reactive
Running quipu reason manually is fine for batch processing, but in a
live system you want derived facts to update automatically when base facts
change.
Enable reactive evaluation:
quipu reason --reactive --rules my-rules.ttl --db lab.db
Now the reasoner registers as an observer on the store. Any subsequent
transact() call — whether from the CLI, the REST API, an episode
ingestion, or an MCP tool — triggers automatic re-derivation of affected
rules.
Add a new dependency:
quipu knot - --db lab.db <<'EOF'
@prefix hw: <http://example.org/homelab/> .
hw:grafana hw:dependsOn hw:traefik .
EOF
The reactive reasoner:
- Sees that
dependsOnchanged - Finds
depends_on_transitiveusesdependsOnin its body - Re-evaluates that rule
- Derives new transitive edges (grafana now transitively depends on pihole, via traefik)
All of this happens in the same transaction boundary — by the time the
knot command returns, the derived facts are already updated.
How the Reactive Reasoner Avoids Loops
When the reactive reasoner writes derived facts, those writes are also
transactions. To prevent infinite recursion, the observer checks the
source field of every incoming transaction. If it starts with
"reasoner:", the observer skips it. This is simple, correct, and
zero-cost.
Step 7: Ask “What If?” with Speculate
The most powerful feature of the reasoner is counterfactual reasoning. Instead of actually removing a host from your graph, you can ask “what would happen if I removed it?” and get a precise answer.
From the Rust API:
#![allow(unused)]
fn main() {
use quipu::reasoner::{evaluate, parse_rules};
use quipu::store::Store;
use quipu::types::{Datum, Op, Value};
let mut store = Store::open("lab.db")?;
let ruleset = parse_rules(&std::fs::read_to_string("my-rules.ttl")?, None)?;
// Hypothetical: retract all runsOn edges to korimako
let koror_id = store.lookup("http://example.org/homelab/korimako")?.unwrap();
let runs_on_id = store.lookup("http://example.org/homelab/runsOn")?.unwrap();
let retractions: Vec<Datum> = store
.current_facts()?
.iter()
.filter(|f| f.attribute == runs_on_id && f.value == Value::Ref(koror_id))
.map(|f| Datum {
entity: f.entity,
attribute: f.attribute,
value: f.value.clone(),
valid_from: "2026-04-04T00:00:00Z".into(),
valid_to: None,
op: Op::Retract,
})
.collect();
// Ask "what if?"
let report = store.speculate(&retractions, "2026-04-04T00:00:00Z", |s| {
evaluate(s, &ruleset, "2026-04-04T00:00:00Z")
})?;
println!("If korimako went down: {} facts retracted", report.retracted);
}
The store is unchanged after speculate() returns. The hypothetical
facts were applied inside a SQLite savepoint and rolled back. You get the
evaluation report without any side effects.
This is ideal for:
- Pre-change impact assessment: “What breaks if I decommission this host?”
- Capacity planning: “What if I move these containers to a new host?”
- Incident simulation: “What’s the blast radius of this failure?”
Step 8: Debugging Rules
Common Errors and Fixes
“head variable ?z is not bound in the body”
Every variable in the head must appear in at least one positive body atom.
If your head mentions ?z, make sure ?z appears in a body atom (not
just under negation).
# Bad: ?host not in body
rule:head "runsOn(?svc, ?host)" ;
rule:body "service(?svc)" .
# Good: ?host bound by second body atom
rule:head "runsOn(?svc, ?host)" ;
rule:body "service(?svc), assignedTo(?svc, ?host)" .
Historical errors you will no longer see (lifted 2026-08-27,
quipu-923): "two-atom body must share exactly one variable" and "body with more than 2 atoms". Bodies of any length now compile to a join
pipeline — p(?a, ?b), q(?b, ?c), r(?c, ?d) works directly, atoms may
share zero, one, or both variables, and not q(?x, ?y) applies stratified
negation. Decomposing a long body into intermediate predicates (the old
workaround) still works and remains useful when an intermediate relation is
worth naming or reusing.
“rule set is not stratifiable: negation cycle through […]”
Your rules have a cycle through negation. Rule A negates something rule B produces, and rule B negates something rule A produces. The fix is to restructure so negation only flows in one direction (from higher strata to lower ones).
Inspecting Derived Facts
Derived facts live in the companion inferred graph (quipu-0b6), so select it — or compose it with the base — explicitly:
quipu read "SELECT ?e ?a ?v FROM <urn:quipu:graph:root#inferred> WHERE { ?e ?a ?v }" \
--db lab.db
To re-run the reasoner and see what changed:
quipu reason --rules my-rules.ttl --db lab.db
If the report shows asserted 0, retracted 0, the derived facts are
already up to date — nothing changed since the last run.
Your Complete Ruleset
Here’s the full my-rules.ttl from this tutorial:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://example.org/rules/> .
ex:homelab a rule:RuleSet ;
rule:defaultPrefix "http://example.org/homelab/" .
# Transitive dependency closure
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "dependsOn(?a, ?c)" ;
rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .
# Transitive runsOn closure
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runsOn(?svc, ?host)" ;
rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .
# Package impact: which services are affected by a package?
ex:affected_by_package a rule:Rule ;
rule:id "affected_by_package" ;
rule:head "affectedByPackage(?svc, ?pkg)" ;
rule:body "usesPackage(?svc, ?pkg), installedIn(?pkg, ?container)" .
What’s Next
- The Reasoner — how it works under the hood
- Reasoner Reference — complete rule syntax and API
- Impact Analysis — more patterns for blast radius queries
- The Homelab Operator — model your infrastructure from scratch
CLI Commands
The quipu binary provides a command-line interface for all operations.
Global Flags
| Flag | Description |
|---|---|
--db <path> | Store database path (default: .bobbin/quipu/quipu.db) |
Build identity
quipu --version (also -V or version) reports build identity without loading
configuration or opening a database:
quipu <version>
git_sha: <build-commit>
git_dirty: <true-or-false>
The first line retains the version-only format for existing parsers. The second
line identifies the source commit, or unknown when built without Git metadata.
The third reports the build-time working-tree dirty flag. quipu-server --version
(and -V) exposes the same fields with a quipu-server first-line prefix.
Compare known build commits to detect differences between releases; equal version
strings alone do not establish that two binaries contain the same code.
Commands
quipu knot <file.ttl>
Load RDF facts from a Turtle file.
quipu knot data.ttl --db my.db
quipu knot data.ttl --shapes schema.ttl --db my.db # With SHACL validation
quipu knot data.ttl --timestamp 2026-03-15T00:00:00Z --db my.db # Source-true valid-time
| Flag | Description |
|---|---|
--shapes <file> | SHACL shapes file for write-time validation |
--timestamp <ISO-8601> | valid_from for the facts (default: now). Supply the source event time when ingesting history |
Alias: load
quipu read "<sparql>"
Execute a SPARQL query.
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10" --db my.db
quipu read "SELECT ?s WHERE { ?s a <http://ex.org/Person> }" --valid-at "2026-03-01"
| Flag | Description |
|---|---|
--valid-at <date> | Time-travel: query as of this ISO-8601 timestamp |
--tx <N> | Time-travel: query as of this transaction ID |
--fork <name> | Scope the default graph to a named fork (see quipu fork); unknown or dropped forks are refused |
Alias: query
quipu cord
List entities, optionally filtered by type.
quipu cord --db my.db
quipu cord --type "http://example.org/Person" --limit 50 --db my.db
| Flag | Description |
|---|---|
--type <IRI> | Filter by rdf:type |
--limit <N> | Maximum results (default: 100) |
quipu unravel
Time-travel query: view facts at a past point.
quipu unravel --tx 5 --db my.db
quipu unravel --valid-at "2026-03-15T00:00:00Z" --db my.db
Requires at least one of --tx or --valid-at.
quipu episode <file.json>
Ingest a structured episode from a JSON file.
quipu episode deploy.json --db my.db
echo '{"name": "test", "nodes": []}' | quipu episode - --db my.db # stdin
quipu episode deploy.json --base-ns "https://quarterdeck.internal/ontology#" --db my.db
quipu episode deploy.json --timestamp 2026-03-15T00:00:00Z --db my.db
| Flag | Description |
|---|---|
--base-ns <IRI> | Namespace to mint entity IRIs in (default: the built-in aegis namespace). Lets non-aegis deployments use the episode abstraction |
--timestamp <ISO-8601> | valid_from for the facts (default: now) |
quipu retract <entity-IRI>
Retract facts for an entity.
quipu retract "http://example.org/old-service" --db my.db
quipu retract "http://example.org/alice" --predicate "http://example.org/email" --db my.db
| Flag | Description |
|---|---|
--predicate <IRI> | Only retract facts with this predicate |
--timestamp <ISO-8601> | Transaction valid-time for the retraction (default: now) |
quipu shapes
Manage persistent SHACL shapes.
quipu shapes load person-shape schema/person.ttl --db my.db
quipu shapes list --db my.db
quipu shapes remove person-shape --db my.db
Loaded shapes automatically validate all future writes.
quipu validate
Dry-run SHACL validation without writing.
quipu validate --shapes schema.ttl --data test-data.ttl
quipu validate --shapes schema.ttl --data test-data.ttl --format json
--format json prints one JSON object with conforms, violations,
warnings and every result (severity, focus node, component, path, value,
source shape, message), in the order the engine reported them and without
deduplication. The text form prints only the counts on a conforming run and
omits the shape, component and value. The exit status is the same in both
forms: 0 when the data conforms, 1 otherwise or on an error.
quipu export
Export deterministic RDF from ROOT or one explicit scope.
quipu export --db my.db # N-Triples (default)
quipu export --format turtle --db my.db # Turtle
quipu export --group-id project-a --db my.db # provenance group
quipu export --construct 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }' --db my.db
| Flag | Description |
|---|---|
--format <fmt> | Output format: ntriples (default) or turtle |
--graph <iri> | Export one named graph |
--group-id <id> | Export entities attributed to one episode group |
--construct <query> | Export a SPARQL CONSTRUCT or DESCRIBE graph |
The three scope flags are mutually exclusive. Omit all three for ROOT.
quipu share
Write a deterministic directory intended for git storage and interchange. First load an identifier-policy catalogue and your data’s shapes into the same store. The examples below assume that setup. Outward shares refuse with exit 2 when no block-tier catalogue is available, exit 1 when a rule matches, and exit 0 when the checked payload is clean.
quipu share --output knowledge-share --db my.db
quipu share --output project-share --group-id project-a --shapes project-shapes --turtle
quipu share --output next-share --parent-share sha256:abc123 --db my.db
| Flag | Description |
|---|---|
--output <dir> | New destination directory (required; an existing path is refused) |
--graph <iri> | Share one named graph |
--group-id <id> | Share entities attributed to one episode group |
--construct <query> | Share a SPARQL CONSTRUCT or DESCRIBE result |
--shapes <name> | Include one loaded shape set; repeatable |
--no-shapes | Explicitly create a shapes-free share |
--parent-share <id> | Record the prior share_id in this lineage |
--turtle | Add the derived, human-readable export.ttl view |
The three scope flags are mutually exclusive and default to ROOT. By default,
the share includes every loaded shape set. An explicit --shapes selection
narrows that set. If no shapes are loaded, the command refuses to produce a
silent empty bundle unless --no-shapes is supplied. Required
payloads are export.nt, shapes.ttl, and manifest.json. The graph payload
is sorted and duplicate-free; the manifest hashes the exact payload bytes and
uses the anchored transaction timestamp, so unchanged state produces
byte-identical output.
quipu status and quipu merge
Compare an incoming share with local ROOT using the snapshot named by its
parent_share, then reconnect the two histories with an RDF-aware three-way
merge:
quipu status shares/alice-next --db my.db
quipu merge shares/alice-next --actor reviewer --db my.db
The base snapshot must exist exactly once beneath the incoming share’s parent
directory. Missing or ambiguous lineage is refused. status reports additions,
removals, divergence, and the same structured DecisionRecord conflicts that
merge would encounter.
Unconstrained multi-valued predicates use set union. A predicate governed by
sh:maxCount becomes a conflict when the merged cardinality exceeds its bound;
a delete racing a replacement on sh:maxCount 1 is also a conflict. Conflicted
slots are held at their base values and merge exits 2 without writing ROOT.
A clean merge applies assertions and retractions atomically, with both the local
graph hash and incoming share_id recorded as provenance parents.
quipu stats
Show store statistics.
quipu stats --db my.db
Output: fact count, entity count, predicate count.
quipu reason
Run the Datalog reasoner to derive facts from rules.
quipu reason --db my.db
quipu reason --rules custom-rules.ttl --db my.db
# --reactive needs a non-default feature (see below):
quipu reason --reactive --db my.db # requires: cargo build --features reactive-reasoner
| Flag | Default | Description |
|---|---|---|
--rules <file> | shapes/aegis-rules.ttl | Turtle file containing rules |
--reactive | off | Register reactive observer after evaluation. Requires the non-default reactive-reasoner feature; on a build without it, quipu reason --reactive errors and exits non-zero rather than silently doing nothing. |
Output shows asserted/retracted counts per rule. Derived facts are written
with source = "reasoner:<rule-id>" provenance.
See Reasoner Reference for full details on rule syntax and the evaluation model.
quipu demotions
List unsupported Datalog demotions in a premise graph’s companion:
quipu demotions --db my.db
quipu demotions --graph urn:example:premises --db my.db
The JSON rows identify the retained record, subject, predicate, object, premise
graph, deriver source, promotion transaction and invalidation transaction.
--graph defaults to ROOT. The command installs the shipped
unsupported_demotions stored query if missing; a fresh store returns no rows.
After the companion is initialized, HTTP clients can call
POST /ask with {"name":"unsupported_demotions"}; for a named companion, add
"params":{"graph":"urn:example:premises#inferred"}.
Support loss removes the original triple from first-class standing and retains
one quipu:DemotedDerivation reification record. Datalog, OWL and RDFS
materializers exclude plane bookkeeping from premises. Ordinary graph queries
can still inspect the record; it does not assert the reified triple. Restored
support marks it resolved and recreates the derivation only in the companion.
Resolved records remain available through graph queries but leave this list.
This is truth maintenance, not a promotion or automatic-promotion API.
Load the product evidence schema alongside the application’s other shapes:
quipu shapes load demoted-derivations shapes/demoted-derivation.ttl --db my.db
Demotion does not load shapes automatically: installing the first shape set would activate the vocabulary gate for unrelated writes in an ungoverned store.
quipu impact <entity-IRI>
Bounded BFS over entity edges: what is downstream of this entity? With
--remove, speculatively retracts the entity (SQLite savepoint, no mutation),
re-runs the reasoner inside the fork, and walks the result — “what would break
if I removed this?”.
quipu impact http://example.org/traefik --hops 3 --db my.db
quipu impact http://example.org/traefik --remove --db my.db
| Flag | Description |
|---|---|
--remove | Counterfactual: impact of removing the entity |
--hops <N> | Walk depth (default from DEFAULT_HOPS) |
--predicate <IRI> | Restrict to these predicates (repeatable) |
quipu project
Run graph algorithms over the projected knowledge graph: stats, in_degree,
pagerank/ppr, components, louvain, shortest_path.
quipu project --algorithm pagerank --limit 10 --db my.db
quipu project --algorithm pagerank --seed http://example.org/alice --db my.db # PPR
quipu project --algorithm shortest_path --from <IRI> --to <IRI> --db my.db
| Flag | Description |
|---|---|
--algorithm <name> | Algorithm to run (default: stats) |
--type <IRI> / --predicate <IRI> | Restrict the projection |
--graph <IRI> | Project one named graph’s own facts instead of ROOT |
--seed <IRI> | PPR seed (repeatable; switches pagerank to personalized) |
--damping / --max-iters / --tolerance | PageRank parameters |
--limit <N> | Max results (default: 20) |
--from / --to | Endpoints for shortest_path |
quipu render
Render markdown and byte-addressed includes from a stored specification or an
ad-hoc query into an owned destination. Supports single-file and per-entity
hierarchies, --valid-at, a no-write destination dry-run and local/remote reads.
See the render reference for the command, manifest and refusal
contract. A remote server prepares bytes; only the client publishes files.
quipu report
Graph health report: hub entities (god-nodes), surprising connections, and suggested competency questions.
quipu report --hubs 10 --surprises 5 --db my.db
| Flag | Description |
|---|---|
--hubs / --surprises / --questions | How many of each to return |
--type <IRI> / --predicate <IRI> | Restrict the underlying projection |
quipu repl
Interactive SPARQL prompt.
quipu repl --db my.db
Type SPARQL queries at the prompt. Use :quit or :q to exit.
quipu audit <trace.jsonl>
Check an enforcement trace against the constraint specification in the store —
SARC’s T ⊨ Σ.
quipu audit ~/.local/state/hank/metrics.jsonl --db my.db
quipu audit trace.jsonl --json --db my.db
| Flag | Default | Description |
|---|---|---|
--json | off | Emit the full report as one JSON object instead of readable lines |
Exit code 1 when the trace contradicts the spec, 0 otherwise — so a CI
job can gate on it without parsing anything.
Four passes run over every record: coverage (is every constraint the trace
cites actually in Σ, and does every refusal name one), placement (was each
constraint evaluated at a point its class can be enforced at, does the record
agree with Σ about its class, and does the layer that actually evaluated it match
the aegis:hostedAtLayer the policy claims — SARC I6), outcome (does the
response taken match the one declared, at the recorded mode), and
attribution (does the record say who is answerable). Every pass is a
comparison between two declared values; none of them calls a model.
The I6 check is one-directional. A policy claiming "tool" while a hook in the
agent’s own loop evaluated it is a violation — it reads as enforced somewhere an
agent cannot route around while being enforced somewhere an agent can. A policy
claiming "orchestration" while something stronger enforced it is silent:
understating your own robustness misleads nobody in a direction that costs them.
Findings come in two severities and only one of them fails the gate:
- violation — the trace contradicts Σ. A soft constraint that blocked, a
declared
denythat only warned underenforce, a record whose declared principal chain disagrees with the process that ran. - incompleteness — the trace does not say enough to decide. No principal chain, no declared class, a constraint Σ declares that this window never exercised.
Incompleteness never changes the exit code. A checker that failed the build over
a missing planner would be switched off within a week, and then the violations
would stop being caught too.
Two limits worth stating before reading a T ⊨ Σ result as reassurance. Coverage
is checked in the direction quipu can decide — nothing is cited that Σ does not
define — because the other direction, was every constraint that applied
evaluated, means re-running the selector against the file as it stood, and quipu
has neither by default. The opt-in Git and Yupana passes below supply that
evidence. The report counts lines it could not
read rather than skipping them, so N line(s) unreadable is always part of the
summary: conformance over a window that was only partly read is not conformance.
Git path-policy backstop
quipu audit trace.jsonl --repo . --from BASE --to HEAD --json --db my.db
All three Git flags are required together. BASE is exclusive; HEAD is
inclusive. Both resolve to full immutable commit ids before enumeration. The
base must be an ancestor. Shallow repositories and ancestry grafts are refused;
replacement objects are ignored. The git
object reports those ids, commits and changed paths checked, path-policy count,
and unresolved coverage. An empty policy catalogue is unproven, not a clean scan.
The additional git-coverage pass reads ROOT’s current action policies
with appliesTo path globs. Globs use the same glob::Pattern matching as
Yupana tripwires. It checks each commit, not just the window’s net diff: a
crossing followed by a revert is still visible. Renames check both old and new
paths. The window includes side-branch commits. A merge only contributes paths
whose committed state differs from every parent (including mode changes
and deletions): inherited content is audited where it was introduced, while
new merge content still needs its own evaluation. This also handles octopus
merges; comparing against just one parent would either duplicate evidence or
miss a new merge change.
Policy scope is the supplied repository; use a store containing the policies
intended for that repository. Policies introduced after the audited commits
also apply: this is a current-policy retrospective audit, not historical policy
reconstruction.
A changed matching path needs a conclusive evaluation with the exact policy id,
repository-relative path, and full git_commit id in the trace record.
Missing evidence produces a bypassed enforcement violation. A path-only
deny crossing produces a violation even with a recorded evaluation: a claim
that it was blocked cannot excuse the committed crossing. The report includes
the commit author and declared Co-Authored-By, Claude-Session, and
SHANTY_AGENT metadata for investigation.
Existing pre-edit spools do not emit git_commit; they remain valid for the
trace-only checker but do not establish Git coverage. A trusted producer must
bind an evaluation to its actual commit; do not backfill every old path record
with the current tip. This reader does not authenticate such bindings.
Git mode exits 1 for a violation, 2 when Git coverage cannot be verified
(including malformed trace lines, invalid refs/globs, no path policies, or
unsupported selector replay without another violation), and 0 otherwise.
Trace-only incompleteness retains its original semantics. Without --yupana,
selector/predicate policies with matching appliesTo are unresolved and
selectors without path scope remain outside the path-only pass.
Replay tree-sitter policies at each commit
quipu audit trace.jsonl --repo . --from BASE --to HEAD --db my.db \
--yupana /trusted/path/to/yupana --json
The explicit executable must provide Yupana’s audit-rule JSON interface,
schema version 1. Quipu loads each policy’s current selector and predicate
from ROOT and supplies the exact regular-file blob from each commit to that
parser. It never reads the working tree’s contents or follows committed
symlinks. Unscoped structural policies are included in this mode; Yupana’s
own path-to-language classifier decides language applicability. appliesTo
still narrows a policy when present.
A replayed unsatisfied claim is a violation even if its trace claims success.
Successful replay does not erase a missing enforcement record. The git
object reports selectors_checked separately from paths and commits. Deleted
paths, non-UTF-8 or oversized blobs, malformed/ambiguous selector definitions,
unsupported grammars, invalid syntax, a missing executable, and protocol or
process failures are unresolved. A pure path policy still covers deletions.
Source blobs are limited to 8 MiB; each evaluator invocation has a 10-second
limit and a 1-MiB output limit. Only tree-sitter selector definitions with
regex predicates are supported; entity-grounding and other selector tiers
remain unresolved.
This is an agent-invoked audit library/CLI, not an installed scheduler or forge gate. Select and pin a trusted Yupana build: invoking a caller-selected executable does not authenticate that executable or the response it produces.
This catches only changes reaching the selected Git window. Uncommitted edits, ignored files and writes outside the repository remain invisible. Authors, trailers and trace evidence can be forged: this is an audit backstop, not a server-side enforcement boundary. Human edits are audited too, because an absent agent trailer does not prove a human author. Signed exception verdicts and a required forge check are not implemented by this command.
quipu audit inventory
Check the dispatch graph rather than a trace — SARC I7, enforcement completeness.
quipu audit inventory --db my.db
quipu knot shapes/dispatch-inventory.ttl --db my.db # load the shipped seed first
I7 is a property of the dispatch graph, not of any one constraint: a harness
exposes N classes of tool call, and completeness is the question of whether every
class that can change state passes through a point where a constraint could stop
it. aegis:ToolClass declares each class, whether it is executable, and which
governedAt points it traverses.
Findings, in the same two severities:
- violation — an executable class that traverses no enforcement point and has
no
aegis:ungovernedReason. An unknown hole. - incompleteness — an executable class that traverses nothing but says
why: an acknowledged bypass surface. Reported on every run, because a
bypass surface an operator has stopped seeing is one they have stopped
weighing. Also: a class that does not declare
aegis:executable, since whether it needs a point is then undecidable. - violation, the other direction — a constraint in Σ placed at a point no declared executable class traverses. It reads as governance in the catalog and can never fire in the deployment.
- incompleteness, the zero-trust boundary — a class declaring
aegis:importsUntrustedStatebrings content into the agent’s context that has not been through this deployment’s constraints (a sub-agent’s response, an MCP server’s output, retrieved documents). Reported whether or not the class is governed:governedAtsays its own actions traverse a point and says nothing about what it returned. No trust predicate evaluates imported content today, so this is an open boundary reported on every run rather than a closed gap. A class that imports and declares noaegis:untrustedOriginis a violation — an import channel nobody can describe is one nobody can weigh.
An empty inventory is reported as an incompleteness, never as a pass: an unwritten dispatch graph is not an empty one.
shapes/dispatch-inventory.ttl ships the seed for this stack — the edit path and
quipu’s own write gate as governed, reads as non-executable, and Bash, Task, CI
pipelines, cron, remote shells, a sibling session’s VCS index and a hostile agent
as acknowledged surfaces with where each is enforced instead. Nothing derives it
from the harness’s actual tool registry, so it can drift from reality the way a
prose list does; the difference is that a drifted declaration is a wrong answer
to a question something asks rather than a paragraph nobody re-reads.
quipu audit namespace
List the base-namespace predicates episode ingest minted that no loaded shape
mentions — namespace drift, in the same shape as quipu audit inventory.
quipu audit namespace --db my.db
quipu audit namespace --graph urn:example:tenant --json --db my.db
Exits 0 whatever it finds, and refuses nothing. Every key in an episode node’s
properties map becomes a predicate in the base namespace via
sanitize_iri_local, with no shape governing which keys are admissible, so
agents writing free-form properties mint predicates indefinitely and nothing
reported the drift. A gate here would reject writes every deployment is already
making — the ontology in the store today was grown by exactly this path — so it
would be switched off within a day and the drift would go back to being
invisible. A report an operator reads beats a gate nobody leaves on.
Per ungoverned predicate: the IRI, how many current facts use it, how many distinct episode-written subjects carry it, and the window it has been in use.
namespace: 2 ungoverned predicate(s), 1 governed, minted by episode ingest over
2 episode-written subject(s) in urn:quipu:graph:root against 1 loaded shape(s)
UNGOVERNED http://aegis.gastown.local/ontology/rackUnit: 1 fact(s) on 1 subject(s),
in use 2026-01-01T00:00:00Z .. 2026-01-01T00:00:00Z
What counts as minted here. A predicate is reported when its subject carries
prov:wasGeneratedBy pointing at a {base}episode_… activity, the predicate is
in the configured base namespace, and the object is a literal. That last
condition is what separates the properties map from the edge path: edge
relations resolve to node references and already pass through
resolve_edge_predicate, which is a fence. The two predicates episode ingest
emits structurally — aegis:groupId and aegis:contentHash — are excluded by
name, because the writer’s own vocabulary reported as agent drift would put a
permanent floor under every report.
What “no shape mentions it” means. A predicate is treated as governed if its
IRI appears anywhere in any loaded shape’s graph — as an sh:path, a target,
or any other position. That is the widest reading of “mentions”, chosen
deliberately: this is a report an operator acts on, and a false alarm costs more
here than a missed one.
What the seen window honestly is. first_seen / last_seen are the earliest
and latest valid_from among the facts using the predicate. The store keeps no
separate mint timestamp, so this answers “since when has this predicate been in
use”, not “when was this IRI first interned” — and a fact re-asserted with an
older valid time genuinely moves first_seen backwards.
Scans the ROOT graph by default; --graph <iri> scans one named graph instead. A
graph IRI that names no graph is an error, not an empty result — “no drift in the
graph you named” and “there is no such graph” are different answers and only one
should let an operator stop looking.
quipu audit replay <trace.jsonl>
Re-check a recorded window against the current Σ and report what promoting
each rule from advise to enforce would do.
quipu audit replay ~/.local/state/hank/metrics.jsonl --db my.db
quipu audit replay trace.jsonl --json --db my.db
Exits 0 whatever it finds. Replay reports readiness, and readiness is a
judgement an operator makes: failing a build because a rule has not yet fired
would turn “we have not measured this” into “this is broken”, which are different
states needing different responses.
Per rule, five gates — each a reason not to promote:
| gate | what it asks | why it blocks promotion |
|---|---|---|
| liveness | did it ever fire? | a rule promoted without firing has been tested by nothing |
| both outcomes | did it record satisfied and unsatisfied? | a one-sided check is vacuous or universal, and neither is distinguishable from broken |
| in spec | is it in Σ at all? | a rule enforcing outside the specification has nothing to be promoted to |
| recoverability | after a refusal, did work on that target ever succeed? | a rule nobody has got past is an outage with a reason attached |
| new blocks | how many more actions would enforce refuse? | not a gate — the number the operator is actually deciding about |
Nothing is re-evaluated. The predicate needed the file as it stood and that file is gone, so this is deterministic arithmetic over records rather than a simulation.
Three limits, printed with every summary rather than kept in a footnote. It measures only traffic that happened, so a rule that would block a kind of edit nobody attempted shows zero new blocks and is not therefore safe. It counts false-positive candidates and never false positives — a block is wrong only if the action was legitimate, and no record carries that judgement. And it bounds no false negatives at all: actions a rule let through without firing look exactly like actions it correctly approved.
quipu audit replay <verdict>
Re-derive one recorded gate decision against the store as of its
transaction. When the argument is not a file, it is a verdict IRI (or its
local verdict_… name).
quipu audit replay verdict_3f9a… --db my.db
quipu audit replay verdict_3f9a… --delta attempt.json --json --db my.db
A refused write replays from its quarantine entry: the store is rebuilt as
of the refusal in memory, the attempt applied, and the gate re-run. It
re-derives when the write is refused again with the same outcome, the
same rule-set digest and the same post-state digest. Under digest-only
retention, present the attempt with --delta (a quipu-refused-delta/v1
JSON document); a delta whose canonical hash matches no quarantined attempt
is refused as not the delta the gate judged. A verdict whose write committed
replays from that write’s transaction.
Exits 1 only when a replay contradicts the record — a different
outcome, rule set or post-state, or a broken seal. “Attestation only”
(content purged, or digest-only with nothing presented) exits 0: it is
incomplete evidence, not contrary evidence.
quipu audit quarantine
quipu audit quarantine --db my.db # list entries
quipu audit quarantine list --verdict <iri> --json --db my.db
quipu audit quarantine purge --graph urn:quipu:graph:root --db my.db
quipu audit quarantine purge --older-than 90 --db my.db
purge erases the sealed attempts matching --graph, --before <ts> or
--older-than <days> (or --all, which must be named) and keeps each
entry’s digests and seal, and the verdict facts untouched.
quipu audit tree <trace.jsonl>
Reassemble the dispatch forest from the principal chains a trace carries.
quipu audit tree trace.jsonl
quipu audit tree trace.jsonl --json
Needs no store — the tree is a property of the trace alone — and exits 0
always. A shape is not a verdict; the findings that are verdicts (a laundered
chain, a partial attribution tuple) belong to quipu audit <trace>.
SARC §9.5’s attribution dilution is what this addresses: an orchestrator dispatches, a worker acts, and a flat record cannot say which link was answerable. The trace this stack emits is a sequence, so the tree here is reconstructed rather than structural, and the output says so in three places:
- Unattributed records are not placed. A record with no chain is counted and left out. Attaching it to whichever root happened to be first would invent an answer to the question the tree exists to answer.
- Implied dispatch nodes are flagged. A chain
[orchestrator, worker]proves an orchestrator exists; it does not prove the orchestrator’s own actions are in this window. “This agent did nothing” and “this agent’s actions were not recorded” are different facts and only one is good news. - Collapsed nodes get a note. Two separate dispatches of the same worker by the same caller produce the same chain and land on one node. Not an error — one agent legitimately does many things — but the reader must not be told the tree is unambiguous when it is not.
quipu audit inheritance <trace.jsonl>
Check whether a constraint declared aegis:inheritedByDelegates stopped applying
under delegation — SARC §9.5’s constraint laundering.
quipu audit inheritance trace.jsonl --db my.db
Exits 1 on a violation, like quipu audit <trace>.
An orchestrator is bound by a constraint, dispatches a sub-agent, and the constraint is not re-applied at the deeper layer. Nobody decided to drop it; it simply was not carried, and the record looks exactly like a constraint that legitimately did not apply.
Two findings at deliberately different severities, because one has evidence and the other has a question:
- Violation. Constraint C decided on target T under chain
[orchestrator], and a deeper action on the same target under[orchestrator, worker]did not evaluate it. C proved it could decide for T, so its absence deeper is a drop. - Incompleteness. C was evaluated at a dispatch node and never anywhere in its subtree. That might be laundering and might be a selector that legitimately matched nothing deeper; deciding needs the files as they stood, which quipu does not have.
Reporting both as violations would make the strong one unfindable.
aegis:onUndecidable admits only "escalate" — the same shape as
aegis:onTimeout admitting only "deny". That is the decidability rescue:
evaluate at the deepest layer where the constraint still decides, or hand it to a
human. A constraint that silently stops applying where it cannot be checked is
one an agent escapes by dispatching into a context where the evidence is absent.
A Σ where no constraint declares inheritedByDelegates reports an
incompleteness, never a clean pass: “nothing is inherited” and “nothing has said
whether anything is inherited” are different states.
quipu propose
Schema-change proposal queue: submit a shapes/ontology change for review rather than applying it directly.
quipu propose list --status pending --db my.db
quipu propose submit shape ex:PersonShape new-shape.ttl --proposer agent-1 --rationale "tighten cardinality"
quipu propose accept 3 --note "LGTM" --db my.db
quipu propose reject 4 --note "breaks existing data" --db my.db
| Subcommand | Description |
|---|---|
list [--status pending] | List proposals |
submit <kind> <target> <file.ttl> --proposer <id> [--rationale <text>] [--trigger <ref>] | Queue a change |
accept <id> [--note <text>] | Apply and record the decision |
reject <id> --note <reason> | Reject with a reason |
quipu policy
Policy by example: draft a placement-aimed advisory policy from an exemplar,
then replay it over recorded history before anything is created. The ordering
is the point — draft, backtest, read the hit list, and only then quipu knot
the file, at which point the definition-time placement check still runs and can
still refuse.
quipu policy draft --exemplar http://example.org/verdict/17 --name no-bare-secrets \
--label "never commit a bare secret again" \
--targets http://example.org/CodeEdit \
--claim 'ASK { FILTER NOT EXISTS { $target ex:containsSecret true } }' \
--out draft.ttl
quipu policy backtest draft.ttl --last-txs 500 --db my.db
quipu knot draft.ttl --db my.db
| Subcommand | Description |
|---|---|
draft --exemplar <iri> --name <slug> --label <sentence> --targets <type-iri> --claim <ask> | Emit advisory Turtle for one policy. Never writes to the store |
backtest <candidate.ttl> | Replay the candidate over the store’s transaction log |
draft flags:
| Flag | Description |
|---|---|
--exemplar <iri> | The Verdict / DecisionRequest / edit record that motivated the rule (required) |
--name <slug> | Local name for the policy IRI; sanitised to [A-Za-z0-9_-] (required) |
--label <sentence> | The intent sentence, kept verbatim as rdfs:label (required) |
--targets <type-iri> | Target entity type, aegis:targets (required) |
--claim <ask> | The compliant condition: a SPARQL ASK over $target (required) |
--class soft|hard | aegis:constraintClass (default: soft) |
--point <point> | aegis:verificationPoint (default: derived from the class — soft→PAA, hard→PAG) |
--layer <layer> | aegis:hostedAtLayer (default: tool) |
--authority <who> | aegis:authority on the parent Directive |
--out <file.ttl> | Write the Turtle to a file instead of stdout |
A drafted policy is born advisory — aegis:effect "warn" is a constant, not
a flag. Promotion to enforcement goes through the existing advisory→enforcing
gates over recorded traffic.
backtest flags:
| Flag | Description |
|---|---|
--last-txs <N> | Window the replay to the last N transactions (default: the whole log) |
--from-tx <A> --to-tx <B> | Explicit transaction window; both must be given together |
Output is one line per hit (tx <id> (<timestamp>): would have fired on <target>) followed by a summary. The summary distinguishes “0 hits” from
“cannot evaluate”, and the command exits 1 when nothing could be measured
so a script that knots on success cannot read an unevaluable candidate as
clean.
quipu gate shadow
Judge a candidate policy set over recorded history before any of it is created, and report which committed writes it would refuse that the governing set admitted (and the reverse). Never writes.
sqlite3 live.db ".backup copy.db" # a quiescent copy, never the live store
quipu gate shadow --rules candidate.ttl --db copy.db --since 7d
quipu gate shadow --rules candidate.ttl --db copy.db --from-tx 1000 --to-tx 2000 --json
| Flag | Description |
|---|---|
--rules <file.ttl> | Candidate policies: action-boundary aegis:Policy nodes with aegis:targets and aegis:claim (required) |
--db <copy.db> | A quiescent copy of the store (required; see refusals below) |
--since <dur> | Window: transactions stamped within 90m, 24h, 7d, … |
--from-tx <A> --to-tx <B> | Explicit window; both together |
--last-txs <N> | The last N transactions (default: the whole log) |
--max-txs <N> | Stop after judging N; the report says where it stopped |
--mode add|replace | add (default) layers the candidate over the governing set, same IRI replacing; replace makes the candidate the whole set |
--json | Machine-readable report |
Each transaction is judged by the write gate’s own evaluator against the post-state it saw, with the router read as of that transaction’s time. The baseline is the policy set in force at that transaction, so a policy change inside the window is reported as a boundary and never attributed to the candidate.
Refusals, not advice. The command refuses the configured live store and any
path with a -wal, -shm or -journal sidecar (an open connection, or a copy
taken mid-write): it reads with SQLite immutable=1, which ignores locks and
the WAL and could see torn state on a live file.
Limits it reports rather than hides: refused writes are rolled back and
cannot be replayed, so they are counted, not judged; recorded verdicts are
joined by aegis:gatedTx when present and otherwise by the next-transaction
convention, with the match rate printed (and UNMEASURED when nothing joined);
and a baseline that would refuse a committed write is counted as the fidelity
signal (enforcement off then, an approval, or a reconstruction gap).
quipu path
Golden-path analysis over recorded trajectories: the provenance cone, the
backtest, and a grammar draft. All three are reads; draft prints Turtle for a
human to review and load. See the
golden paths design.
quipu path cone http://example.org/traj/42 --via http://example.org/derivedFrom --hops 6 --db my.db
quipu path backtest http://example.org/traj/42 --omit http://example.org/step/3 --json --db my.db
quipu path draft http://example.org/traj/42 --name fast-review --label "the short path" \
--via http://example.org/derivedFrom \
--omit http://example.org/step/3 --by http://example.org/decision/9 --db my.db
The trajectory IRI is the first positional argument to every subcommand.
| Subcommand | Description |
|---|---|
cone <trajectory-IRI> | Which steps did the falsifier-gated verified result depend on? |
backtest <trajectory-IRI> | Replay a pruned candidate over past trajectories sharing a work-item topic |
draft <trajectory-IRI> | Emit gp-grammar/1 Turtle for the blessed path |
| Flag | Subcommands | Description |
|---|---|---|
--via <predicate-IRI> | cone, draft | Derivation predicate to walk, in addition to verifiedBy (always followed). Repeatable |
--hops <N> | cone | Depth bound for the derivation walk (default: 8) |
--omit <step-IRI> | backtest, draft | Step the candidate omits. Repeatable |
--by <decision-IRI> | draft | The human Decision authorising the paired --omit. Repeatable |
--dead-end <step-IRI> | draft | Mark a step a dead end in the drafted grammar. Repeatable |
--name <local-name> | draft | Local name for the drafted grammar (required) |
--label <text> | draft | Human label for the drafted grammar (required) |
--json | cone, backtest | Emit the report as JSON instead of the text table |
cone verdicts are IN-CONE (load-bearing; pruning needs a human Decision),
OUT-OF-CONE (mechanically prunable) or CANNOT-EVALUATE (no derivation edges
recorded — never silently prunable). draft refuses when the count of --omit
flags does not match the count of --by flags: a human cut without its Decision
is a silent edit of history.
quipu ontology
Manage stored OWL ontologies (versioned: re-loading a name closes the prior
version). Requires the owl feature.
quipu ontology load my-domain domain.ttl --db my.db
quipu ontology list --db my.db
quipu ontology remove my-domain --db my.db
quipu doctor labels
Diagnose graph-label state: which graphs carry freshness/trust/policy labels and which are undeclared.
quipu doctor labels --db my.db
quipu pack / quipu unpack
Knowledge packs: export one named graph as a self-describing, attachable
.pendant.db artifact (facts, manifest, shapes, stored queries, optionally
vectors), verify one, or import one into a local graph.
quipu pack urn:example:graph --out domain.pendant.db --name "domain" --version 1.0.0
quipu pack urn:example:graph --out domain.pendant.db --shapes s.ttl --queries q.json --with-vectors
quipu pack urn:example:graph --out domain.pendant.db --space 7
quipu pack --verify domain.pendant.db
quipu pack urn:example:repo --out repo.pendant.db --repo scbrown/example --repo-sha "$BASE_SHA" --model-id all-MiniLM-L6-v2 --model-version 1
quipu unpack repo.pendant.db --expect-repo scbrown/example --head-sha "$(git rev-parse HEAD)" --into urn:local:domain --db my.db
| Flag | Description |
|---|---|
--out <file> | Output pack path (required for pack) |
--name / --version | Manifest metadata |
--space <N> | Ship the pack in term space N so it attaches to a consumer without id collisions (same machinery as quipu db respace; the content hash is unchanged — a space moves ids, not content). Not applicable to --format turtle |
--shapes <S> / --queries <Q> | Ship shape sets / stored queries (repeatable) |
--with-vectors | Include embeddings (refused unless the SQLite vector backend is active) |
--format turtle | Also embed a Turtle serialization |
--full | A LOSSLESS whole-store pack for internal backup, read by quipu restore. Carries every carried table with its full history, so it refuses an outward destination |
--full --format text | The same whole-store pack as text — a git-friendly directory rather than a SQLite file. Does not transport derived data (vectors); the manifest carries the recipe to rebuild it |
--verify <file> | Recompute and check the pack’s content hash |
--into <graph-iri> | Unpack target graph (default: the pack’s own graph IRI) |
--repo / --repo-sha / --model-id / --model-version | All-or-none provenance for a repository pack. The manifest also carries the Quipu version, build SHA, and pack schema version. |
--expect-repo / --head-sha | Verify repository identity while loading and report the incremental ingestion range from the pack SHA to checkout HEAD. Exact pack content already loaded returns unchanged without duplicate facts. |
Repository packs should be attached to a GitHub release, not committed to the
repository, once they exceed 10 MiB. A loader must download to a temporary
path, run quipu pack --verify, and delete a failed download before opening the
destination. quipu unpack repeats verification before every load. After a
successful load, ingest repository changes over repository_sha..head_sha;
future setup runs are incremental because the destination records the verified
content hash and returns unchanged for the same asset.
quipu pack --full --format text
A lossless whole-store pack rendered as text. --full on its own is
lossless but binary (a VACUUM INTO copy); quipu share is text but carries
only current facts. This is the artifact that is both: git-friendly and
reconstructing.
quipu pack --full --format text --destination internal --out store-pack/ --db my.db
quipu restore store-pack/ --db restored.db
It writes a directory, not a file:
store-pack/
manifest.json what the pack claims, including its content hash
schema.sql DDL for every object, applied before any row
data/<table>.sql canonical INSERTs, one file per table
Rows are emitted as one INSERT each, ordered by the row text itself. That
ordering is what makes the format git-friendly: a row moving on disk produces no
diff, so a pack committed to a repository only changes when its contents do.
restore accepts either form. For a text pack it rebuilds the store, checks
referential integrity, and then refuses unless the reconstruction hashes
identically to what the manifest claims — nothing is written to the
destination until that holds. A dump missing a file, a table, or a single row is
rejected rather than installed as a quietly smaller store.
A text pack does not carry derived data (vectors), because hex-encoded
embeddings would make it gigabytes. It records the row count and the embedding
recipe instead, and restore prints a REGENERATE: line so an incomplete store
cannot be mistaken for a complete one. At pack time, omitted vector rows with a
missing model name or SHA-256 digest make packing refuse with exit code 1 before
writing the destination. To explicitly accept a backup without reproducible
vectors, pass --allow-missing-embedding-recipe; this succeeds with a WARNING:
on stderr and preserves the incomplete recipe honestly.
Set [quipu.embedding] model_path to the original readable model file and repack
to record its name, digest, and configured dimension. A recorded recipe identifies
the configured model; it does not verify that this model produced the source vectors.
An empty vector table needs no warning. Binary pack --full retains vectors and
does not require a regeneration recipe.
Like --full, this refuses an outward destination: it carries the event log and
every operational table, and publishing one is the operator’s decision.
quipu graph
The graph-registry commands: offline import, and the deep-freeze lifecycle (see Graph Kinds & Deep Freeze).
quipu graph import other.db --as urn:app:imported --db my.db
quipu graph freeze urn:app:runs/2026-07 --out /var/quipu/archive --db my.db
quipu graph thaw urn:app:runs/2026-07 --db my.db
quipu graph list --kind operational --db my.db
quipu graph list --frozen --db my.db
freeze exports the graph’s full history to a .pendant.db archive, verifies
it by content hash, deletes the local rows and re-attaches the pack
read-only; the graph stays queryable at the same IRI and refuses writes
until thaw. list prints iri class kind lifecycle source per graph.
quipu import
Stage a git-native share directory without touching ROOT:
quipu import ./share --source https://example.org/alice --db my.db
quipu import promote sha256:0123... --actor reviewer --db my.db
The first command reads manifest.json, export.nt, and shapes.ttl, verifies
their hashes, resolves exact local identities, surfaces fuzzy review candidates,
and quarantines facts that fail local vocabulary or SHACL checks. The second is
the separate, explicit ROOT-admission step and only accepts eligible staged
shares. Both print the same JSON fields as the REST endpoints.
quipu fork
Persistent named forks (quipu-gp5): fork ROOT as of any transaction into an
independent committed-class named graph (urn:quipu:fork:<name>), read it
exactly like the main line, diff it, then drop it or promote it. Promotion
re-enters through the SHACL + policy write gates — a refused promotion writes
nothing and the fork stays open. Fork ergonomics are never a gate bypass.
quipu fork 42 --name experiment --db my.db # fork ROOT as of tx 42
quipu fork list --db my.db
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" --fork experiment --db my.db
quipu fork diff main experiment --db my.db # each side: a fork name, or 'main'
quipu fork promote experiment --db my.db # delta re-enters via the gates
quipu fork drop experiment --db my.db # terminal; the name is not reusable
| Subcommand | Description |
|---|---|
<tx> [--name <n>] | Create: materialize ROOT-as-of-<tx> into a new fork (default name fork-<tx>) |
list | Name, fork-tx, status, created-at for every fork |
diff <a> <b> | Present-state triple diff between two forks (or main) |
promote <name> | Apply the fork’s delta to ROOT through the write gates; SHACL refusal leaves ROOT untouched |
drop <name> | Close the fork; its facts remain as history, the name is not reusable |
Reads: --fork <name> on quipu read, or the fork field on
POST /query / quipu_query. Unknown and dropped forks are refused
loudly — never a silent fall-through to ROOT.
quipu db attach --list
List the databases mounted alongside this store — the [[quipu.attachments]]
layers (see Configuration)
and deep freeze’s archives, which no config declares.
quipu db attach --list --db my.db
Output is alias, path, and mount mode (always ro), tab-separated. A
declared layer that could not be mounted refuses the open instead of appearing
here, so everything listed is genuinely composed.
quipu db respace
Move a store into a fresh term space so it can be attached to another store without id collisions. Reads the source read-only; writes a new file.
quipu db respace --into 7 --out respace.db --db my.db
quipu events refusals
Count refused writes by gate (shacl | policy | authority | owl | placement) — the incident-rate denominator. Reads the write.refused events
the write gates record; the raw events are served by
GET /events?types=write.refused. See the REST API reference for what a
refusal event records (metadata only, never the refused bodies) and the
speculate exclusion.
quipu events refusals --db my.db
quipu graph import <db>
Import another quipu database’s ROOT graph as a named graph in this store.
quipu graph import other.db --as urn:import:other --db my.db
quipu migrate-vectors
Migrate stored embeddings between vector backends (requires the lancedb
feature).
quipu migrate-vectors --from sqlite --to lancedb --dry-run --db my.db
quipu hook session-capture and quipu hooks
quipu ships one agent hook: a Stop hook that asks the agent, once per session,
whether the session produced durable knowledge worth writing as an episode.
The agent may act or reply skip; the hook never blocks a second stop.
quipu hooks bundle # print the hook bundle (schema st.hook-bundle/1)
quipu hooks install --harness claude # merge into ~/.claude/settings.json
quipu hooks install --harness codex # merge into $CODEX_HOME/config.toml (default ~/.codex)
quipu hooks status # exit 1 unless every harness has the hook
quipu hooks uninstall # remove only quipu's hook
With no --harness, both harnesses are written. --project writes
./.claude/settings.json or ./.codex/config.toml instead. Install is
idempotent, leaves every other hook in place, and keeps the previous file as
<file>.bak-quipu. When a shantytown registry answers (st ops hooks list),
install and uninstall register the bundle with st instead, which renders it
into every role’s settings; pass --no-st to write the harness config directly.
The hook itself, quipu hook session-capture, reads the Stop-hook JSON on
stdin and prints at most one response. It always exits 0, and every failure is
silence rather than a malformed response. Its guards, in order:
- Scope. The crew comes from
GT_CREW, else from a.../crew/<name>/...working directory. An unidentified crew is never interrupted.QUIPU_HOOK_CREWS(space-separated, default*) narrows the scope. - Loop guard.
stop_hook_active: truemeans the hook already fired this turn, so it stays silent. - Once per session. A
solicited-<session_id>marker in the state directory. Markers older than two days are reaped. - Durable denominator. Each solicitation appends
{ts, session_id, crew}tosolicit-log.jsonlbefore it is sent. If that append fails the hook stays silent, so the log can never undercount.
| Variable | Default | Meaning |
|---|---|---|
QUIPU_SERVER | http://127.0.0.1:3030 | Server URL named in the message |
QUIPU_HOOK_GROUP | default | Episode group_id named in the message |
QUIPU_HOOK_CREWS | * | Crews the hook may interrupt |
QUIPU_HOOK_STATE_DIR | ~/.quipu-hook | Markers and solicit-log.jsonl |
GT_CREW | (unset) | The crew name, when the cwd does not carry it |
The message prefers authenticated Quipu MCP tools or the deployment’s gated
graph-extract helper. For HTTP fallback, /search stays open, but /shapes
vocabulary reads require a bearer just like /episode and /propose.
The agent resolves a non-empty QUIPU_AUTH_TOKEN, then
QUIPU_AUTH_TOKEN_FILE or the configured client token file. A missing
credential stops the request before HTTP; it is not a reason to send an
unauthenticated probe. Credentials stay server-side where possible and must
never be printed. Requests carry X-Quipu-Client: session-capture. The act rate is
episodes whose source carries the session id / lines in solicit-log.jsonl.
Create and label named graphs
quipu graph create urn:example:jobs --actor urn:example:agent --db tutorial.db
quipu graph label urn:example:jobs --label '{"kind":"operational","freshness":"fresh"}' --db tutorial.db
quipu graph list --json --db tutorial.db
Both verbs call the same handlers as MCP/REST. --timestamp <RFC3339> pins the
creation/declaration time; otherwise current UTC is used. --actor is declared
provenance, not authorization. Create is idempotent and preserves the original
provenance. Label fields match quipu_graph_label, including trust, policy,
durability and validity bounds. JSON listing exposes creation and all labels;
the existing tab-separated listing remains the default.
graph overlay-create, graph overlay-write, and graph overlay-compose
accept --input '<JSON object>' using the corresponding MCP tool input fields.
For the complete new-class write recipe, see
named graphs through MCP.
CLI: sharing, import and legacy packs
Reference for the commands behind Sharing & Federation.
Every flag here is checked against quipu --help by tests/cli_doc_drift.rs, so
this page cannot quietly fall behind the binary.
A note on vocabulary: the share is the portable artifact. quipu share
writes its standard text files directly; releases may carry the same files in a
deterministic .pendant.tar.gz archive. The older pack and unpack commands
remain for local SQLite compatibility, but a .pendant.db is not the published
interchange format.
quipu share — produce a share
Prerequisite: load the identifier-policy catalogue
and the shapes governing your data. The default destination is outward.
An empty block-tier catalogue exits 2 (cannot verify); a matching identifier
exits 1; a checked, clean share exits 0. --no-shapes does not bypass this check.
quipu share --output <dir> [--graph IRI|--group-id ID|--construct QUERY]
[--shapes NAME]... [--no-shapes] [--queries NAME]... [--no-queries]
[--parent-share ID] [--since <parent-reference>] [--turtle]
[--destination internal]
Writes a deterministic, git-native share into <dir>: RDFC-1.0 canonical
export.nt (the facts), shapes.ttl (the constraints they were validated
against), queries.ttl (the stored queries that answer questions about them,
when there are any), and JSON plus PROV-O/DCAT/SPDX Turtle manifests.
| Flag | Effect |
|---|---|
--output <dir> | where to write. Required. |
--graph <IRI> | share one named graph |
--group-id <ID> | share by group |
--construct <QUERY> | share exactly what a CONSTRUCT query yields |
--shapes <NAME> | include a named shape set; repeatable |
--no-shapes | omit shapes.ttl — the receiver then has no constraints to validate against, so prefer not to |
--queries <NAME> | carry exactly these stored queries in queries.ttl; repeatable. Default: every query registered against the scope — see Stored queries |
--no-queries | carry no stored queries; no queries.ttl is written |
--parent-share <ID> | record lineage: the share this one descends from |
--since <reference> | emit a parent-bound SPARQL Update delta instead of a full share; the parent may be a directory, archive or URL, not a share_id |
--turtle | additionally write a Turtle view for humans |
--destination internal | skip the outward scrub and stamp the manifest destination: internal. LAN-internal destinations only — see below |
The outward scrub, and --destination internal
Every share is checked against the store’s own aegis:InternalIdentifierPattern
catalogue — the rules tiered block — and refused if the payload matches one.
Nothing is rewritten: an internal hostname or an RFC1918 address is entity
identity, and silently editing it would produce a share that says something the
store never said.
That is the right default for a share bound for a public remote, and the wrong
one for a share bound for an internal forge, where those identifiers are the
point. --destination internal is the single explicit way to say so:
quipu share --output pendant/today --destination internal
It does three things, and the third is what makes the first two safe:
- Skips the outward scrub entirely. Not a per-pattern exception, not an allowlist — the check does not run.
- Stamps
destination: "internal"intomanifest.json, andquipu:destination "internal"intomanifest.ttl. The exemption travels with the bytes instead of living in the shell history of whoever produced them. - Binds that stamp into
share_id. Unlikeattestation, the field is not stripped before the manifest is hashed. Deleting it to launder the payload onward leaves a manifest that no longer hashes to the id it carries, and every consumer’s verification refuses it.
There is no environment variable and no config setting. A share that says
nothing is scrubbed, including every share produced over HTTP: POST /share
cannot select a destination, because a caller who could would be turning the
guard off on a server they do not own.
What the marker buys you downstream. quipu import of a stamped share runs
the scrub the producer skipped. If the payload passes it imports normally — a
share marked internal out of caution is not quarantined for it. If the payload
fails, the import is refused unless the operator repeats the declaration with
quipu import <dir> --destination internal. So internal facts cannot enter a
store silently and then leave it in someone else’s outward share.
Deltas are scrubbed separately, and they have to be. delta.ru is not built
from the store: its DELETE clause is lifted verbatim from the parent’s
export.nt. An identifier retracted from the graph yesterday is still quoted in
today’s delta — and the parent is usually the very internal share that was
allowed to carry it. A full outward share of the same store passes cleanly while
that delta does not, which is exactly why the delta document gets its own check
rather than riding on the result share’s.
--since compares the current share with the referenced parent. Its default
8 MiB limit applies to the serialized delta file map (update, shapes and
manifests), not to the full result graph. A large unchanged graph can therefore
produce a small delta; a large insertion or deletion can still exceed the limit.
The producer still materializes the full result internally, so this transport
limit is not a memory bound. Use --parent-share to record an identity without
computing a delta.
--parent-share is what makes quipu merge possible later. A share without a
parent cannot be three-way merged — merge refuses with “incoming share has no
parent_share; three-way merge has no base” — so record it at production time,
when you know it, rather than trying to reconstruct it at reconnect time.
Stored queries (queries.ttl)
A share carries the stored queries quipu_ask answers by name, so a pendant
ships its competency questions beside its data. Each query is described as RDF
in queries.ttl; the manifest seals that file’s SHA-256 as queries_hash and it
takes part in the share_id, so changing one query’s text changes the share’s
identity. A share with no queries has no queries.ttl and a manifest
byte-identical to one produced before the member existed.
@prefix dct: <http://purl.org/dc/terms/> .
@prefix quipu: <https://quipu.dev/ontology/> .
<urn:quipu:query:members-of> a quipu:StoredQuery ;
quipu:queryName "members-of" ;
quipu:queryForm "SELECT" ;
dct:description "Who belongs to a team" ;
quipu:parameter [ a quipu:QueryParameter ; quipu:parameterName "team" ;
quipu:parameterIndex 0 ; quipu:parameterKind "iri" ; quipu:required true ] ;
quipu:sparqlTemplate """SELECT ?p WHERE { ?p <http://example.org/memberOf> <{team}> }""" .
quipu:targetsClass lists every constant rdf:type object in the template’s
patterns. The vocabulary is governed by
shapes/stored-queries.ttl,
which the producer and the importer both apply.
Which queries a share carries by default. A query is registered against
the shared graph when quipu_ask would answer it from that graph: an unscoped
query (no dataset) belongs to a ROOT share and to --group-id and --construct
slices of ROOT; a dataset-scoped query belongs to a --graph <IRI> share whose
IRI is a member of its dataset, and to nothing else. The dataset IRI itself is
producer-local and is not carried.
A carried query is data, not code. Only SELECT, CONSTRUCT, ASK and
DESCRIBE travel. The form is decided by parsing the template with Quipu’s
SPARQL parser — at share time and again at import — and a SPARQL Update is
refused at both ends. Import evaluates nothing: a query runs only when somebody
asks for it, under the receiver’s normal read policy and timeouts.
Import namespaces and never overwrites. Carried queries install in the
receiver’s registry as <namespace>/<name>. The namespace defaults to
pack-<12 hex> derived from the producer’s store identity, so newer shares of
the same pack land on the same names; --query-namespace chooses one. A local
query with the same name is never touched. A different definition already at a
pack-scoped name is reported under queries.collisions and left in place, and a
pack-scoped query the share no longer carries is reported as stale.
--replace-queries replaces the differing ones and closes the stale ones
(closed, never deleted — the prior version stays queryable). A query that targets
a class the receiver’s loaded shapes do not sanction adds the blocker
query_off_vocabulary: the pack is quarantined and none of its queries is
installed. A full share installs its queries when it stages, before promotion,
so run quipu import promote before asking questions of the staged data in ROOT.
A delta share carries the resulting share’s complete queries.ttl, like its
shapes, so queries added, replaced or removed since the parent arrive as what
the member now says, sealed by the result manifest’s queries_hash.
quipu import — receive a share, into quarantine
quipu import <share-dir|archive|URL> [--source <uri>] [--actor <id>]
[--destination internal] [--query-namespace NS]
[--replace-queries] [--db <path>]
quipu import delta <parent-share> <delta-share> [--actor <id>]
[--query-namespace NS]
Verifies the manifest and payload hashes, then stages a local directory in its
selected store. Archives and URLs are fetched under fixed size limits and
materialized in a fresh in-memory store by default, so no downloaded artifact
or database is left behind. With an explicit --db <path>, archives and URLs
stage in that database using its loaded shapes and registered identities, just
like a directory. Carried shapes are not automatically adopted: a receiver
without a matching local vocabulary still quarantines the typed data. Import never touches ROOT without promotion. A hash mismatch
is refused outright:
share graph hash mismatch: manifest=… actual=…
| Flag | Effect |
|---|---|
--source <uri> | record where the share came from; defaults to the directory, archive path, or URL |
--actor <id> | attribute the import |
--db <path> | stage in this store, including archive and URL imports |
--query-namespace <NS> | install carried queries as NS/<name> ([A-Za-z0-9._-]+); default pack-<12 hex> from the producer’s store identity |
--replace-queries | replace differing NS/* queries and close ones the share no longer carries; otherwise both are only reported |
import delta verifies the full parent and the delta’s lineage, hashes and
restricted DELETE DATA / INSERT DATA operations, materializes the declared
result, then sends that result through the same verified in-memory import path.
Loaded local shapes determine admission. Their explicit
quipu:onViolation "emit" diagnostics are advisory; reject-policy Violations
quarantine the share. Missing policies mean reject, and unknown or conflicting
policy values refuse. Carried shapes cannot downgrade a local reject policy.
The report keeps strict conforms separate from blocking, with complete
diagnostics and advisory_results. An emit-only Violation can therefore produce
conforms: false with blocking: false. Vocabulary, integrity, trust and explicit
promotion requirements still apply.
quipu import promote — admit a staged share into ROOT
quipu import promote <share-id> [--actor <id>] [--db <path>]
The second, separate verb. Nothing reaches ROOT because a file arrived; it reaches ROOT because someone ran this. Keeping admission in its own command is the point rather than an inconvenience — see the primitive.
The project graph: quipu share --project and quipu load
quipu share --project [<id>] [--no-shapes] [--destination internal] [--db <path>]
quipu load <bundle-dir> [--destination internal] [--actor <id>] [--db <path>]
A repository commits its project’s graph under .quipu/, tool-neutral:
| path | committed | what |
|---|---|---|
.quipu/project | yes | one line, the project id; the graph is urn:quipu:project:<id> |
.quipu/graph/ | yes | the share bundle for that graph (manifest.json, export.nt, …) |
.quipu/.gitignore | yes | written by quipu: an allow-list for the two above |
.quipu/local.db*, .quipu/verifier.pk8 | never | the local store, and the host’s PRIVATE signing key |
share --project <id> names the project once (it is then committed) and
re-shares the graph into .quipu/graph/. Re-running it replaces the bundle;
it never re-points a repository at a different id. The outward scrub applies
exactly as for any share, because the repository may be public; use
--destination internal only for a private one.
load <dir>, on a directory holding a share manifest, is the one command a
fresh clone needs:
git clone <repo> && cd <repo>
quipu load .quipu/graph --db .quipu/local.db
It runs the ordinary import,
with every gate import has, and then promotes into the bundle’s own graph
instead of ROOT, as a diff. A re-load after git pull changes only what
changed; an unchanged bundle opens no transaction. A quarantined import loads
nothing. load <file.ttl> still means knot.
The server equivalent is POST /import of the same bundle, which stages it.
quipu status — has this share diverged?
quipu status <share-dir> [--db <path>]
Reports divergence between the local store and the share’s parent, as JSON. Read
it before merge to see what a reconnect would have to decide.
quipu merge — three-way reconnect
quipu merge <share-dir> [--actor <id>] [--db <path>]
Locates the common base through parent_share, merges shape-aware (SHACL
cardinalities decide what is a conflict), and on conflict keeps the base value
and records a decision rather than guessing.
| Exit | Meaning |
|---|---|
0 | merged |
2 | conflicts — nothing was guessed; the decision records name what needs a human |
1 | error |
Exit 2 is a distinct code precisely so a script can tell “needs a decision”
from “went wrong”.
Resolving conflicts: emit, propose, decide, apply
quipu merge <share-dir> --emit-decisions <file.json> [--slot-scoped] [--propose] [--db <path>]
quipu merge <share-dir> --decisions <file.json> --reviewer <who> [--dry-run] [--actor <id>] [--db <path>]
The same split as quipu align: a merge that cannot auto-merge is finished by a
person, with the evidence in front of them.
- Emit.
--emit-decisionswrites one row per conflict to a JSON file and changes nothing. Each row has the slot (subject,predicate,max_count), thebase/ours/theirsvalues, and each side’s provenance: ROOT’s current facts with theirvalid_from, transaction, actor and source; the incoming and base shares’ id,created_at, producer store and whether they are attested. For a person reading it, each row also has akind(max_count_exceededordelete_replace) and arulein words (“sh:maxCount 1 on status: ours and theirs together hold 2 values”), and the file carrieslabels: onerdfs:labelper IRI it mentions. Default emission stays v1, strictly bound to all ROOT changes. Explicit--slot-scoped(REST/MCP:slot_scoped: true) emits v2. These files bind each reviewed slot’s current values, local provenance and last-modifying ROOT transaction (including retractions) throughlocal_slot_hash, plus the incoming share and base identities. Unrelated ROOT writes can survive an apply; the clean merge is recomputed against current ROOT. A slot changed and restored is stale, including empty -> value -> empty. Legacy v1 files and files with blank-node conflict subjects retain the conservative whole-ROOT binding. Older clients refuse v2 files; re-emit with the client that will apply the decisions. - Propose (agent).
--proposealso fills each row’sproposal(chooseplusevidence) from mechanical evidence: a side unchanged from base, the newer side, an attested share. It never setsdecision. - Decide (operator, or any tool that edits the file). A tool may also set
decided_byanddecided_atper row; apply echoes them back. They are a claim made by that tool. The attested record is the transaction’s reviewer and the decisions file’s hash. Set each row’sdecisionto{"choose": "ours"},{"choose": "theirs"},{"choose": "base"}, or{"values": ["\"an edited value\""]}(N-Triples terms). - Apply.
--decisionscommits the clean part of the merge plus every decided slot in one transaction. Its source records both parents, the reviewer and the SHA-256 of the decisions file, and the output lists each applied row.
Apply refuses, and writes nothing, when:
| Refusal | Why |
|---|---|
a row has no decision | nothing is guessed |
| a reviewed slot’s values or provenance changed, or its hash is missing | the decisions were made against other evidence; emit again |
| the incoming share, its graph hash, or base identity changed | the decisions target another merge; emit again |
| ROOT changed for a legacy v1 file | v1 retains its whole-ROOT binding; emit again |
| the conflicts differ from the file’s rows | same reason |
a decision has more values than the slot’s sh:maxCount | it would re-create the conflict |
| a value is not an RDF term | it cannot be stored |
| the file has a field its merge-decisions schema does not define | apply would drop it while the file’s hash still covered it |
--dry-run runs every check above and reports the counts that apply would
assert and retract, without writing anything. A CI check uses it to validate a
decided file before merge.
Plain quipu merge without these flags behaves as before.
quipu pack / quipu unpack — legacy SQLite compatibility
quipu pack <graph-iri> --out <file.pendant.db> [--name N] [--version V] [--space N]
[--shapes S]... [--queries Q]... [--with-vectors] [--format turtle]
quipu pack --verify <file.pendant.db>
quipu unpack <file.pendant.db> [--into <graph-iri>] [--db <path>]
| Flag | Effect |
|---|---|
--out <file> | destination |
--verify <file> | check an existing pack instead of writing one |
--name / --version | identify the pack in its manifest |
--space <N> | term space to write into |
--shapes <S> / --queries <Q> | carry shape sets and named queries alongside the facts; repeatable |
--with-vectors | include embeddings |
--format turtle | carry the payload as Turtle |
--into <graph-iri> | unpack into a named graph |
--verify answers whether a legacy SQLite pack is intact before loading it.
New repository and release workflows use share and import; they do not
publish .pendant.db files.
quipu pack --full / quipu restore — whole-store packs
A --full pack is a different artifact from everything above: not a graph, but
the whole store, carried losslessly for internal backup. share carries
current facts; --full carries facts as whole rows — g, tx, valid_from, valid_to, op, retracted_tx — so history, including what was retracted, travels
with it.
quipu pack --full [--format text] --destination internal --out <path> [--db <path>]
quipu restore <file.pendant | text-pack-dir> [--force] [--db <path>]
| Flag | Effect |
|---|---|
--full | pack the whole store losslessly, rather than one graph |
--format text | render that whole-store pack as a git-friendly DIRECTORY of text instead of a SQLite file |
--force | allow restore to replace a destination that still holds live facts |
Both forms refuse an outward destination, and that refusal is atomic — no output is left behind. A full pack carries the event log and every operational table that is not explicitly excluded, so publishing one is the operator’s decision rather than something this command may acquire by convenience.
--format text does not transport the declared regenerated set. Today that
is vectors: embeddings are derived data, roughly 2.2 GB of floats at homelab
scale, and quote() renders a BLOB as X'<hex>' — so inlining them would
produce a 4–5 GB “git-friendly” artifact, which is not one. The manifest instead
records what was left out, how many rows it was, and the recipe to rebuild it
(embedding model name, its SHA-256, and the dimension), and restore prints a
REGENERATE: line naming them. A restore from a text pack is therefore complete
in facts, history and provenance, and not complete in derived data until
those are rebuilt — which is why it says so rather than reporting plain success.
The binary --full pack still transports vectors: a backup that forces a
re-embed on restore is a poor backup. The two whole-store packs therefore carry
different content by design, and their content hashes are not comparable to
each other.
--format text writes manifest.json, schema.sql, and data/<table>.sql.
Rows are emitted one INSERT per line, ordered by the row text itself, so a row
moving on disk produces no diff and a committed pack changes only when its
contents do. restore rebuilds the store, checks referential integrity, and
refuses unless the reconstruction hashes identically to what the manifest
claims — nothing reaches the destination until that holds, so a dump missing a
file, a table or a single row is rejected rather than installed as a quietly
smaller store.
restore REPLACES; to merge a published pack into an existing store use
unpack. Each verb refuses the other’s format by name rather than reporting an
intact artifact as corrupt.
quipu knot — assert facts, including identity across stores
quipu knot <file.ttl> [--graph <iri>] [--shapes <shapes.ttl>]
[--timestamp <ISO-8601>] [--db <path>]
Asserts Turtle into the store, validated against shapes. In the sharing context
this is how owl:sameAs between two stores’ IRIs gets written — identity is a
fact in the graph, visible and retractable, not a string-matching heuristic.
quipu load is an alias for knot.
Archives
quipu graph freeze <iri> [--out <dir>] [--actor <who>] [--db <path>]
quipu graph thaw <iri> [--actor <who>] [--db <path>]
quipu graph list [--kind <token>] [--frozen] [--db <path>]
Deep freeze produces read-only, full-history graphs. See Graph Kinds & Deep Freeze.
Producer attestation and the three trust tiers
A share can carry a signed statement of who produced it. Every import reports the tier it reached, and the three are genuinely different claims — not degrees of the same one.
| tier | what it means |
|---|---|
transport | No envelope. The payload hashes verify, so the bytes are intact, but nothing says who produced them. |
claimed | A signature verifies against the key the share itself supplied. The bundle is unaltered since signing and its identity fields are bound together — but nobody here vouched for that key. Integrity without provenance. Replay is not defended at this tier. |
attested | The signature verifies against a session binding registered out of band on the importing store. |
Minting a share with an attestation
quipu share --output <dir> ... --attest \
--attest-agent <agent> --attest-session <session> --attest-introducer <who> \
--attest-issued-at <epoch> --attest-nonce <32 hex chars> \
[--attest-key <path>] [--attest-ttl <secs>]
--attest-issued-at is required rather than defaulted to the wall clock: two runs
over one pinned dataset must produce the same signed bytes, or the share is not
re-derivable. --attest-nonce must be 32 lowercase hex characters and is checked at
mint time — a share minted with any other nonce is refused by every importer.
The key comes from --attest-key, else $QUIPU_SIGNING_KEY, else
.quipu/verifier.pk8, created 0600 on first use. That is v1 host-file custody, the
same the governance plane uses; it is not an HSM.
Registering a producer, out of band
quipu attest register --agent <a> --session <s> --public-key <hex> \
--introducer <who> --issued-at <epoch> --expires-at <epoch> [--allow-write] [--db <path>]
quipu attest allow-write|deny-write <session> [--db <path>]
quipu attest revoke <session> [--db <path>]
quipu attest list [--db <path>]
A binding is share-only unless an operator grants it write (--allow-write at
registration, or allow-write later). Only then may its key sign HTTP writes. list
prints each binding’s allow_write, expires_at and revoked, so the scope a key
holds is visible without reading the store. revoke keeps the row: a revoked binding
refuses as revoked, which is a different finding from an unbound key. Rotation is a
new binding plus a revoke of the old one.
To set up an agent end to end (its own user, sd key init, enrollment, verification
and revocation), follow CABOODLE’s
agent identity and write access
guide.
Importing a share never registers its producer. This is the point, not an omission: a key that vouches for the bundle it arrived in vouches for nothing, and an attacker substituting the whole bundle would substitute the key with it. Registration is a separate act by the consumer, using a key obtained some other way — the same rule the governance plane states as quipu never self-registers.
So a first import from an unknown producer reports claimed, and reports it honestly.
Reaching attested requires someone to decide that this key is that producer.
Automated callers should require attested. Accepting claimed is reasonable, but
it should be a deliberate choice by a caller who says so, not the effect of a tier that
merely does not read as failure.
Keeping this page honest
tests/cli_doc_drift.rs reconciles three surfaces: the dispatch arms in
src/main.rs, the --help text, and this page. Checking any two is not enough —
when that test was written, --help documented share, status, merge and
unpack but not import, so a page-versus-help check would have passed while
the verb that receives a share stayed undiscoverable.
quipu share diff — what changed between two packs
quipu share diff <old> <new> [--format text|markdown|json]
Each side is a pack directory (the standard artifact’s payload.nq, else a
legacy share’s export.nt) or a single N-Triples/N-Quads file. Like the Git
transport commands below, it reads files only and opens no store.
The diff compares facts, not lines, and groups them by subject entity:
~ Alice (people/alice)
~ age: "30"^^xsd:integer -> "31"^^xsd:integer
- nickname: "Al"
+ Carol (people/carol)
+ rdfs:label: "Carol"
+ role: "designer"
2 entities: 1 changed, 2 added, 1 removed facts
That is the whole output for the fixture pair in tests/fixtures/share-diff/,
whose raw line diff is 11 lines of full IRIs and blank-node labels: Alice’s
address is a blank node that RDFC relabelled (_:b0 to _:c14n7) without any
change to its content, and it contributes nothing.
- An entity is shown by its
rdfs:labelwith a compact name beside it; an unlabelled IRI is shown compactly (prefix:localfor well-known vocabularies, otherwise its last two path segments). Predicates show their label or local name. When two distinct predicates under one entity would show the same name (ex:nameandschema:nameboth labelled “name”, or two IRIs ending/name), each carries its compact IRI —name (ex/name),name (schema:name)— or its full IRI if even those collide. Compaction depends only on the IRI, never on the data, so both sides of a diff name things the same way. ~ predicate: old -> newis reported only when the slot (subject, predicate, graph) holds exactly one value on both sides. A multi-valued slot shows its removed and added values separately.- Blank nodes are matched by structure, not label. RDFC-1.0 can relabel
every blank node between two versions of a payload; a pure relabel is zero
lines. A blank node referenced from another node is shown inline
(
[ city "Paris" ; zip "75001" ]) as part of the referencing fact, so an edit inside it is a change of that fact. Structurally identical blank nodes on one slot are one fact with a count: cardinality matters to shapes (sh:maxCount), so adding a second copy is shown as~ p: [ r "v" ] x1 -> [ r "v" ] x2, and the textconv marks a fact asserted more than once withxN. Limits: identical values nested inside an inlined blank node still collapse; a blank node referenced only from inside a blank-node cycle has no named root and is not shown; a blank graph name is keyed by its label. --format markdownsuits a PR comment;--format jsonis the same structure (entities[].changed/added/removed, plus totals) for tools.
quipu share diff --report — the pull-request review
quipu share diff <old> <new> --report [--format markdown|json]
[--old-shapes <ttl>] [--new-shapes <ttl>] [--decisions <json>]
[--fail-on-introduced]
The report a reviewer reads when a pull request changes a pack. When <old>
and <new> are pack directories, each side’s shapes.ttl and the new side’s
decisions.json are picked up from the directory; the flags name them
explicitly otherwise. The repository’s pendant review workflow
(.github/workflows/pendant-review.yml) runs it for every pack a pull request
touches and publishes the result as the check’s job summary.
Reading the PR report
Sections always appear in this order:
- Facts. The
quipu share diffoutput above: per-entity changed, added and removed facts, by label. - SHACL violations introduced. Old data is validated against the old
pack’s shapes, new data against the new pack’s shapes, and the report lists
the violations that occur more often in new than in old, keyed by focus
node, path, constraint component and value. A violation already present
before the change is counted as pre-existing, never as introduced; the
count of violations the change resolves is shown beside it. When the
shapes change, a row whose old data already fails the NEW shapes is marked
shapes change: the data did not move, the rules did, and the pack still
stops conforming. If the new pack ships no shapes, or the binary was built
without the
shaclfeature, the section says NOT CHECKED. That is not a zero, and--fail-on-introducedrefuses (exit 1) on a build without SHACL rather than pass. A surviving pack with missing new shapes also refuses this gate. Whole pack deletion is distinguished by the workflow only when every artifact file is absent at the head; incomplete surviving packs refuse. No new-head validation is claimed for a proven deletion. Blank-node labels are collapsed when matching, because RDFC may relabel every blank node between versions. - Merge decisions. When the change carries a
decisions.jsonsidecar fromquipu git-merge(see below), each conflict is listed with its subject, predicate, the constraint that made it a conflict (sh:maxCount 1), the base/ours/theirs values and the recorded resolution, or UNRESOLVED. Alias proposals recorded by the driver are listed the same way. The CI script renders a sidecar only when the pull request adds or changes it. - Alias caveat. Always present. A triple-level diff, merge or validation cannot see two different IRIs minted for one real entity; such a pair looks like two healthy entities. Below the caveat are advisory candidates: an entity ADDED by the change whose normalized label is at least 0.90 Jaro-Winkler-similar to a same-type entity in the old pack, or to another added entity. This is the merge driver’s proposer, reused. No candidates is not evidence of no aliases.
- Summary. The report’s last line, used as the check annotation title:
pendant review: 1 entity changed (1 changed, 0 added, 0 removed facts); SHACL 0 introduced; no merge decisions; 0 alias candidates
--format json carries the same structure (diff, shacl, decisions,
alias_candidates, summary). With --fail-on-introduced the exit status is
3 when at least one violation is introduced; the report is printed in full
first.
The check is red only when a pack introduces a SHACL violation or its report
cannot be computed. Everything else is information for the reviewer. A pack, for
the workflow, is a directory holding export.nt or payload.nq together with
manifest.json or manifest.ttl; the two versions compared are the merge-base
version and the pull request head. The workflow runs with a read-only token. It
can also keep one sticky PR comment up to date, but only when the repository
variable QPACK_REVIEW_COMMENT is true: that job needs pull-requests: write,
and granting it is a security-posture decision, so it is off by default.
High-stakes (class-B) DecisionRecords are not settled by approving the pull request. They are resolved through the hardware-signed human verdict tracked as aegis-kzt0ql.9; the report only shows them.
quipu diff-textconv — readable git diff for pack files
quipu diff-textconv <file>
Prints one payload file as stable, labelled, entity-grouped text: one header
per entity (sorted by IRI, so a relabel never reorders the file), one
predicate: value line per fact, blank nodes inline. Git’s textconv runs it
on both sides of a diff, so an ordinary git diff, git log -p or git show
reads like this instead of two lines of full IRIs:
@@ -1,5 +1,5 @@
AAA Tracking (ability/aaa-tracking)
- abbrev: "AAA"
+ abbrev: "AAA CHANGED"
effectText: "x2 vs. air attacks"
Setup, once per clone (the attribute is already in this repository’s
.gitattributes; add it to your own):
printf '*.nt diff=quipu\n*.nq diff=quipu\n' >> .gitattributes
git config diff.quipu.textconv "quipu diff-textconv"
Without the git config line the attribute is inert and Git diffs raw lines.
git diff --no-textconv shows the raw form on demand. A file that does not
parse (a working copy with merge conflict markers, say) is printed unchanged,
so git diff never fails on it. Textconv affects display only: merges, hashes
and pendant-check still operate on the canonical bytes.
Git transport: driver, decisions, and CI
These commands operate on repository files and immutable Git snapshots. They do
not open a Quipu store. Use a build with the shacl feature (the default).
They shell out to the git executable found on PATH (the wrapper, the driver’s
snapshot reads, the shapes three-way merge, and pendant-check all do). If git
cannot be found, the command exits 1 with `git` executable not found on PATH
and writes nothing.
quipu git-merge <ref>
quipu merge-driver <base-file> <ours-file> <theirs-file> <path>
quipu pendant-resolve <base-ref> <ours-ref> <theirs-ref> <dir> <key> <choice>
quipu pendant-check <base-ref> <ours-ref> <theirs-ref> <result-ref>
Version attributes for each share directory (adjust pendant to your layout):
pendant/export.nt merge=quipu
pendant/shapes.ttl merge=quipu
pendant/manifest.json merge=quipu
pendant/manifest.ttl merge=quipu
Start at the repository root with a clean index and worktree. Save the branch tips for later checks:
ours=$(git rev-parse HEAD)
theirs=$(git rev-parse topic)
base=$(git merge-base "$ours" "$theirs")
quipu git-merge "$theirs"
git-merge supplies a command-local merge.quipu.driver definition and the
three immutable commit IDs to its child Git process. Nothing is installed in
global Git configuration. This context matters: Git does not promise to merge
shapes.ttl before export.nt. The driver reads and merges shapes from the
three commits first, rather than trusting whichever working file happens to
exist. Directly invoking the low-level driver without context refuses.
Overlapping shape edits refuse before the wrapper changes the worktree; resolve
those edits on the branches first. Criss-cross bases and incompatible
store/scope/destination/layout metadata also refuse.
The graph operator is the same one used by store-level quipu merge. Functional
conflicts retain the base value and create entries in decisions.json. No Git
conflict markers are written to N-Triples. New entities from opposite branches
with a common type are compared locally: whitespace-normalized, lowercase labels,
Jaro-Winkler similarity at least 0.90, top five candidates per new entity.
These are proposals, never automatic identity assertions. No paid model or
network call is used. This baseline can miss aliases and can propose false
matches; the decision belongs to the reviewer.
Exit 2 means decisions or Git conflicts remain. Inspect the sidecar, then choose:
quipu pendant-resolve "$base" "$ours" "$theirs" pendant conflict:0 ours
quipu pendant-resolve "$base" "$ours" "$theirs" pendant alias:0 reject
Conflict choices are base, ours, or theirs; alias choices are accept
(add an explicit owl:sameAs) or reject (keep identities distinct). Resolution
reconstructs the operator result from Git and refuses stale or altered proposal
records. The last resolution must pass SHACL. It rewrites canonical export.nt,
regenerates both manifests, drops old attestations and the optional derived
export.ttl, and records parent_share=ours plus both IDs in merge_parents.
Input tx_anchor and created_at remain the ours snapshot’s metadata; a Git
merge does not fabricate a store transaction. Each file is replaced atomically;
the directory is not a multi-file transaction, so the hash gate must pass before
use. Review and stage the complete directory, then commit:
git add pendant
git commit
quipu pendant-check "$base" "$ours" "$theirs" HEAD
The wrapper always stops before commit, including clean merges. It reconciles whole pendants after Git because Git may skip individual drivers when one file is unchanged. Blank-node graphs currently refuse divergent merges: canonical blank labels are not stable identities across snapshots. Skolemize those nodes first.
CI is required even when local driver use is documented. A clone with no
driver definition, a forge merge button, or a rebase can fall back to text merge.
The repository’s SHACL test job runs scripts/ci/pendant-merge-check.py with full
history and the PR’s synthetic merge commit. It also replays two-parent merges introduced on the topic branch, then
reconstructs base/ours/theirs,
requires recorded resolutions, compares the committed graph, shapes and manifest
with the operator result, and validates SHACL. A hand-rehashed unsafe result
still fails. Push/nightly runs validate the committed packs too; a squash commit
alone cannot reconstruct the original branch pair, so the PR check remains
load-bearing. Keep the SHACL test check required in branch protection.
Known gap: an administrator bypasses the gate. The replay that can actually
fail is the PR check. On a push event the final replay is trivially
(base, ours, theirs) = (before, before, result): a fast-forward of the previous
tip, so it re-validates the committed packs but cannot reconstruct a merge
that happened elsewhere. This repository’s main protection uses strict status
checks, so a squash merge’s tree equals the synthetic merge commit the PR check
tested. It does not enforce protection for administrators, so an admin’s
direct push, or an admin merge that skips pending or failing checks, lands a
pendant that no merge replay has verified. Treat an admin bypass as unverified and
run quipu pendant-check on the original base/ours/theirs yourself, or enable
“include administrators” if that gap is unacceptable.
Measured on the repository’s real Datalinks pendant, replacing the same functional
value on both branches produces a text conflict, not a clean double. Git
writes three marker lines into the RDF. Separate new IRIs with identical labels
merge cleanly without an identity decision; disjoint additions merge cleanly and
correctly. tests/git_qpack_merge.rs exercises those cases, decision resolution,
and the driver-absent CI refusals using actual Git repositories.
Carry alignment evidence
quipu share --alignments FILE --output DIR carries an optional, hash-sealed
alignments.ttl. Input is bounded UTF-8 Turtle; an explicit empty carrier is
valid. The manifest seals its exact bytes and fixed filename in the share ID.
Missing, altered, misnamed or undeclared evidence is refused before import stages
data. Legacy shares without this member retain their existing identity.
This is evidence transport, not identity adoption. Imported bytes are retained separately; neither import nor ROOT promotion applies the mappings or loads their shapes. Use the existing alignment propose/decide/apply workflow for curation. The browser reader uses the same verification, and Explorer export preserves the imported carrier. A release needs a compatible engine and actual asset readback; source configuration alone does not establish availability.
Render graph data into owned files
qp render produces markdown locally from a stored specification or an ad-hoc
query. A remote server prepares a read-only plan; the client writes the files.
Templates have no file loader, network access or nondeterministic functions.
mkdir -p output
qp render --query 'SELECT ?title WHERE { VALUES ?title { "Example" } }' \
--template '# {{ rows[0].title }}' --dest output/report --db private.db
qp render --query 'SELECT ?title WHERE { VALUES ?title { "Example" } }' \
--template '# {{ rows[0].title }}' --dest output/report --db private.db --dry-run
The second command reports a no-op. Both the file and manifest retain their bytes and modification times. Dry-run prints the complete file list and the content/mode diff without creating directories, lockfiles, manifests or events. The destination’s parent must already exist.
Store the specification as data
@prefix render: <urn:quipu:render:> .
<urn:example:report> a render:RenderSpec ;
render:query "SELECT ?title WHERE { VALUES ?title { 'Example' } }" ;
render:format "markdown" ;
render:layout "single" ;
render:path "report.md" ;
render:template "# {{ rows[0].title }}" .
Validate this file against the supplied shapes/render.ttl when importing it,
then render <urn:example:report>:
qp knot report.ttl --shapes shapes/render.ttl --db private.db
qp render urn:example:report --dest output/stored --db private.db
A specification carries its query, layout, path, optional template, includes
and optional provenance footer. markdown is the first format; the renderer
trait leaves room for other document formats. Binary postprocessing is outside
core. Stored specifications and included byte literals travel in a pendant.
Normal share/export scrub and destination rules still apply.
Hierarchies and byte-exact includes
Use per-entity layout and a path such as {scope}/{slug}/SKILL.md. Each row has
its own row context; single-file templates receive all sorted rows. The
small deterministic template language supports loops, conditionals and the
upper and lower filters on bounded strings. Fields, indexes, scalar literals,
comparisons and boolean conditions are supported. Allocation operators (~, +,
*, **), reassignment, captures, recursive loops, function calls and dynamic
container literals are refused before template compilation. Templates are capped
at 256 KiB and 4096 tokens; final files share one incremental 16 MiB budget.
Undefined variables, path collisions, traversal,
case aliases and incomplete queries refuse before publication.
Includes are a JSON literal in render:includes, or an includes column on
per-entity rows. Each entry has a relative path, sha256: content address,
base64 bytes and a mode of 0644 or 0755. Raw markdown templates can copy
row.content without parsing or rewriting YAML front matter. Includes preserve
executable modes and are verified against their hashes.
Render skills into their own root outside the harness’s skills directory, then let the existing tooling projection mount individual skill names. A scoped query can select internal skills. The renderer does not overwrite unrelated harness entries or manage those mounts.
Ownership, time and publication
.quipu-render.json records the specification, source_tx, valid_at, the
render timestamp and every managed file’s hash/mode. The complete plan is
prepared within one SQLite read snapshot. Present reads pin that transaction;
historical --valid-at reads use the same snapshot without changing the native
mixed-clock guard. A write committed during rendering cannot change the plan.
qp render urn:example:report --valid-at 2026-01-15T00:00:00Z \
--dest output/historical --db private.db
qp render urn:example:report --dest output/remote --server https://graph.example
Remote /render requests require an accepted bearer even on a read-only server.
The client follows the documented credential order: nonempty QUIPU_AUTH_TOKEN,
then QUIPU_AUTH_TOKEN_FILE, then ~/.config/quipu/token. Credentials are never
passed as arguments or printed. Without a configured credential, remote rendering
refuses; local rendering requires no bearer.
Only manifest-owned files can be updated or removed. Unknown files, edited managed bytes, changed modes, symlinks and a changed destination refuse. The client stages a complete directory and atomically publishes it on Linux/macOS. New roots are private (0700); updates preserve the existing root mode. The manifest is private (0600). Parent directories are opened component by component with no-follow capabilities. Unchanged files retain their inode and modification time across an update. A failed or indeterminate publication is not permission to retry blindly; inspect the retained destination and any reported staging directory first.
The manifest is the receipt for the current render, not a disk-to-graph sync or an append-only audit history. Historical graph access, installed CLI identity, shape projection and a real consumer run are separate from source tests.
Recorded CLI demonstration
docs/probes/pr-demos/render/reproduce.py invokes the actual candidate CLI
against a private temporary local store. The recorded result is:
{
"target": "candidate CLI / private local store",
"before": "destination absent",
"first_files": [
"report.md"
],
"after_utf8": "# Example",
"repeat_noop": true,
"repeat_same_bytes_mtime_inode": true,
"unknown_file_refused_and_preserved": true,
"running_installation": "not invoked"
}
The absent destination becomes one markdown file; the repeated render preserves bytes, inodes and modification times. An unknown file is refused and preserved. This recording makes no installed-service or deployment claim.
REST API
Request logs distinguish caller-declared host and agent from the receiver’s
observed network hop. Direct HTTP requests include transport_peer and
transport_peer_source: "socket"; unavailable transport context is null with
source "unavailable". A proxy’s socket identifies that proxy, not the originating
agent. Internal MCP or stdio dispatch may have no socket context. Forwarded
headers do not establish this observation, and neither a socket address nor a
declared agent authenticates a writer. Credentials and request bodies are not
included in these fields. Existing authorization outcomes remain separate.
Write-route logs also include the bounded declaration class in
request_provenance and missing required field names in
request_provenance_missing. These describe requests, including refusals;
they do not assert that a transaction committed and must not replace committed
write-coverage metrics. Model and session values are not added to logs.
The quipu-server binary exposes all Quipu operations over HTTP (Axum).
Versioned file-memory RPCs
These JSON operations are disabled by default. To use them, a deployment must
register the graph and configure quipu.memory_files.enabled, graph,
deployment and project. They create neither graphs nor credentials. An
owner is the exact deployment, project and crew IRI tuple; labels and caller
actor fields do not authenticate it.
| Endpoint | Request and response | Identity |
|---|---|---|
POST /memory/read | Owner tuple; current manifest or null, with explicit repository-owner coverage | Inherited deployment read boundary, including cross-crew reads |
POST /memory/export | Owner tuple; complete manifest and identified source revisions | Same inherited read boundary |
POST /memory/projection | Owner tuple; export plus authenticated_owner | Existing verified named principal must equal the owner |
POST /memory/receipt | Exact original commit intent; original receipt or null | Matching named principal |
POST /memory/commit | One file intent; verified receipt with file and snapshot revisions | Matching named principal and normal writable-server policy |
Commit intents include owner, relative path, UTF-8 text, expected snapshot and file revisions, idempotency key, harness and opaque account label. Null text is an explicit tombstone. Receipt lookup precedes stale-parent rejection: replay the exact intent and key after an indeterminate result, rather than generating a new write. Stale new intents return HTTP 409; denied ownership returns 403. Disabled or invalid configuration refuses instead of accepting memory writes.
Requests are bounded to 1 MiB; a file is at most 256 KiB and an owner snapshot contains at most 128 entries and 4 MiB of live text. Existing graph, vocabulary, SHACL and write-policy checks remain in force. Projection reads data; it does not select or overwrite a native memory directory. See versioned file memory for the lossless envelope, local CLI workflow, public export boundary and filesystem/harness guarantees.
Queued write timeouts
When a request budget expires before a tool write starts, the server returns
HTTP 503, Retry-After: 1, and a JSON error with code: "write_not_started",
write_started: false, and waited_ms. This covers write admission and the
first writer acquisition of the tool endpoints, including /episode and
/set. The check runs before signed nonce settlement. The tool did not run,
so the caller can retry after the indicated delay.
A real query timeout still returns 408 with query-specific advice. Do not infer that a write was unrun merely from a 408, a zero elapsed time, a transport failure, or a gateway error. A timeout after execution begins may have committed; verify the requested facts and preserve the same payload before retrying. These diagnostics do not change the configured request budget or bound direct protocol handlers that bypass tool-write admission.
Unrecognized request fields
Successful tool-backed JSON endpoints report unrecognized top-level request keys
in a sorted ignored_fields array. Requests containing only recognized keys omit
that field. The accepted keys come from the tool’s input schema, with HTTP adapter
fields accounted for separately. /reason, /subscriptions, /graph/create,
and /graph/label also report unrecognized keys. Errors retain their existing status and response body.
This is reporting, not rejection or a preview mode. For example, a successful
POST /knot containing "dry_run": true still writes and includes
"ignored_fields": ["dry_run"]. /knot does not support dry runs.
POST /export and /query responses negotiated as standard SPARQL results or RDF
preserve their standard bodies and report the JSON array in the
X-Quipu-Ignored-Fields response header instead. /search/nodes reports verbose
as ignored; the separate /search_nodes endpoint supports it.
Reporting covers undeclared top-level fields, not nested keys, value types, or
whether a recognized option applies to a particular action. It does not cover
non-tool endpoints such as /share, /import, /import/promote, entity GETs,
SPARQL protocol form fields, or direct Rust library calls. An absent warning on
those surfaces is not evidence that every supplied field was used.
Starting the Server
quipu-server --db my.db --bind 0.0.0.0:3030
| Flag | Description |
|---|---|
--db <path> | Store database path (default: .bobbin/quipu/quipu.db) |
--bind <addr> | Bind address (default: 127.0.0.1:3030) |
GET /.well-known/void
Returns a live VoID and SPARQL 1.1 Service Description projection. The default
representation is Turtle; send Accept: application/ld+json for JSON-LD. The
document advertises the query endpoint, exact dataset and named-graph counts,
used vocabulary namespaces, executable result formats, and compiled entailment
features. Quipu’s share manifest.json remains the integrity contract.
Read Concurrency
Reads are served from a pool of read-only connections; writes keep the single FIFO-fair writer connection. WAL already permits N concurrent readers alongside one writer — before the pool, every read took the writer’s mutex, so that capability was present and unused.
[quipu.server]
read_pool_size = 4 # 0 disables the pool; every read then serialises
MEASURED on a 160k-fact store, same binary, pool the only variable (server-CPU
divided by wall time, so it counts cores actually used rather than inferring
them):
| N=8 concurrent | N=16 | |
|---|---|---|
read_pool_size = 0 | 1.09s, 1.00 cores | 2.18s, 0.99 cores |
read_pool_size = 8 | 0.43s, 6.40 cores | 0.80s, 6.80 cores |
quipu_store_wait_seconds_total — time spent acquiring, exported on /metrics —
falls to 0.000s with the pool on. That is the number to watch: a rising
wait means readers are queueing again.
Wall-clock speedup is smaller than the core count because each query costs more CPU when eight run at once (2.6x for a full scan, 1.4x for an index lookup — shared-cache contention, not a lock). The pool removes the serialisation; it cannot make a memory-bandwidth-bound scan free.
Two cases where the pool disables itself, both announced on stderr at startup:
- an in-memory store — each
:memory:connection is its own empty database, so a pool there would not be slow, it would be wrong; - a configured vector delegate or local vector backend — those are not
shareable with a read-only connection, and several pooled handlers
(
/search_nodes,/search_facts,/unified_search,/ask) are vector-backed. Rather than answer the same question from two different indexes depending on which connection took it, the pool stands down.
read_pool_size = 0 is the rollback, and it is runtime config — no redeploy.
Authentication
Legacy migration profile: reads are open; writes need a bearer token. When required session keys are disabled and an auth token is configured, write endpoints require:
Authorization: Bearer <token>
In that legacy profile, reads such as /query, /search, entity lookups,
/health and /version need no credential. /identity always requires a signed
session identity.
A bearer refusal returns HTTP 401 with a JSON reason of
missing_or_invalid_bearer_token, credential_type: "bearer", the endpoint,
and provisioning guidance. Clients should configure a matching
QUIPU_AUTH_TOKEN or QUIPU_AUTH_TOKEN_FILE; retrying an absent or rejected
credential does not repair it. Signed authentication uses the existing protected
registry with a registered, unexpired identity and write scope; read routes also
accept that signature. Signed mutations remain restricted to the four audited
routes, and signed requests cannot carry URL query strings.
Set [quipu.server] session_keys = { required = true, onboarding_read_paths = [] }
only after completing the client and distinct-OS-user migration. This policy
requires signed keys even for reads; a shared or named bearer is insufficient.
The optional onboarding list admits only unique exact /health and /version
GET/HEAD paths. Other paths, wildcards and duplicates fail startup validation.
Strict mode refuses OPTIONS outside CORS, requiring a same-origin client or an
authenticated broker. This option is default OFF and performs no enrollment.
Provision a client credential
The server administrator issues credentials through the deployment’s approved secret distribution channel. For a shared bearer, the supplied value must match an active server bearer. For a named credential, the administrator registers its verifier and activates the validated registry before supplying the plaintext to the client. Installing an arbitrary token on a client cannot grant access.
Shantytown and CABOODLE use ~/.config/quipu/token as the default credential
file. A nonempty QUIPU_AUTH_TOKEN overrides QUIPU_AUTH_TOKEN_FILE, which
overrides this default. Files are read at request time. This is a client
convention; the server neither reads your home directory nor distributes secrets.
install -d -m 700 "$HOME/.config/quipu" && install -m 400 /secure/issued-token "$HOME/.config/quipu/token"
st ops doctor quipu --no-latest
# Or, on a CABOODLE host:
caboodle doctor
Keep an existing explicit file override until provisioning and rotation move together. Do not create independent copies that can diverge at rotation. Never put a token value in command arguments, logs, issues, or version control.
The doctor check POSTs an empty object to /episode. Authentication and
read-only policy run before episode parsing: an accepted request receives
HTTP 400 with invalid episode JSON: missing field ..., and stores nothing.
HTTP 401 means a missing or rejected bearer; HTTP 403 with
reason: server_is_read_only means no credential can permit the write.
Network failures and unexpected responses leave authorization unproven. This
check proves access to the write handler, not a successful storage commit.
Additive named credentials
[quipu.server].crew_credentials_file optionally points to a local JSON registry
of SHA-256 verifiers for administrator-issued, high-entropy bearer tokens. Keep
auth_token in place: named credentials are additional identities and do not
replace the current or previous shared bearer. Public reads and read-only mode
retain their behavior. An unconfigured registry changes nothing.
The file has version: 1 and a credentials array. Each entry contains
credential_id (a unique non-secret identifier), principal (an absolute crew
IRI), audience (exactly quipu), and token_sha256 (64 lowercase hex digits,
the SHA-256 of the presented token string). Issue at least 32 cryptographically
random bytes per credential and encode them for transport; this fast verifier
is not suitable for human passwords. The issuer must verify that the principal
is an existing crew identity. Parsing a registry checks syntax, not graph membership.
No bearer plaintext belongs in this file or in the graph.
Loading captures an immutable policy at startup. Invalid optional registries are reported without their contents; shared authentication remains available. Validate and atomically install a complete registry before provisioning clients. A restart with an invalid registry will not activate its named credentials; preserve the last validated file and verify candidate configuration before a rollout. This initial implementation provides no issuance, rotation, revocation, or hot reload.
Named writes produce authenticated_request_start and
authenticated_request_complete JSON audit events with credential ID, principal,
method, route, and a process-local correlation ID. Missing completion is
indeterminate. The credential principal is independent of declared actor, task,
and source values. Import/promotion and RDF graph-store transactions use the
registry principal through their existing authenticated-actor paths. Locally
created fact transactions also retain separate authenticated evidence, exposed
as authenticated by /transactions. Generic handlers preserve caller-declared
actor and source; neither can replace this credential evidence.
The evidence is inserted inside the fact transaction’s savepoint, including fork
materialization and overlay tombstones. Rollback removes it. Owned request
context crosses blocking dispatch and deferred snapshot promotion, and is
restored before a worker thread is reused. Schema/registry operations without a
fact transaction retain request-level audit only. Library/CLI writes without an
HTTP identity and copied historical transactions have null local authentication
evidence; a foreign actor is not proof of possession of a local credential. End-to-end MCP attribution
requires the proxy to select the corresponding downstream Quipu credential.
The shared bearer remains legacy-shared-bearer, including if a registry entry
accidentally duplicates its verifier.
For a coherent, pinned source receipt, use POST /acquire for a single file,
or POST /acquire/composed for qualified MAIN scope with fenced immutable
providers. The latter uses a separate version-gated contract and refuses
unsupported required attached or derived scope;
see atomic acquisition for its versioned contract.
POST /acquire/eligibility answers only whether each named graph would pass
that admission and the configured label floor at the current head, from graph
metadata alone; it is an observation, not a receipt (see
eligibility observation).
POST /share is also read-only. It returns the canonical Git-share manifest and
exact file contents in one JSON response, allowing a proxy on another host to
forward Quipu’s own canonicalization, hashes, and share ID instead of reproducing
them. The body accepts scope, shapes, no_shapes, parent_share,
turtle_view, and an optional max_bytes that can lower (but not raise) the 8
MiB server cap.
The authoritative list is http_auth::WRITE_ENDPOINTS in src/http_auth.rs, not
this page. It is enforced: write_endpoints_cover_every_route fails the build if
any registered route is unclassified, so the code cannot drift from itself — but
this page can drift from the code, so treat it as a summary and the constant as the
answer.
Two entries surprise people, and both are deliberate:
| Endpoint | Why it is a WRITE |
|---|---|
/project | Looks read-only — stats, pagerank, ppr, components only read — but louvain with persist: true writes quipu:memberOfCommunity and supersedes any prior derivation. The route is gated as a whole. |
/shapes | Gated even to list. Loading a shape set persists it, and a listed-but-unloaded set validates nothing while still reporting success. |
A refusal is never silent. Both refusals return a JSON body naming
the cause, so curl -s cannot render an auth failure as an empty result:
{"endpoint":"/project","reason":"missing_or_invalid_bearer_token","error":"unauthorized: ..."}
{"endpoint":"/knot","reason":"server_is_read_only","error":"read-only mode: ..."}
reason is the stable field to branch on; error is prose and may be reworded.
Request Attribution
Callers can attach two optional headers to every endpoint:
| Header | Meaning | Missing/invalid value |
|---|---|---|
X-Quipu-Client | Stable caller kind, such as query-first or graph-extract | Falls back to User-Agent, then unattributed |
X-Quipu-Task | Work-item join key, normally a bead id such as aegis-3aybc | unattributed; there is deliberately no inferred fallback |
Both values appear in request start/completion logs. The client, task, and
route-template dimensions are also exported by
quipu_http_client_requests_total and
quipu_http_client_request_seconds_total. Client and task identities have
independent cardinality budgets; excess caller-controlled values fold into a
visible other bucket instead of growing the registry without bound. Use
increase(...[window]) for counter comparisons so process restarts do not
invalidate the result.
Endpoints
All POST endpoints accept Content-Type: application/json.
GET /watermark
A bounded, read-only metadata endpoint for replication observers. Returns
{"store_id":"<store lineage>","watermark_tx":123} using the indexed latest
transaction ID. It does not enumerate facts, advance a cursor or create a
transaction. A shared store ID establishes lineage, not proof that two copies
have identical contents.
Opt-in read-only followers
poll their configured primary every 15 seconds (two-second HTTP timeout). Their
responses carry X-Quipu-Replica-Lag-Tx: primary minus follower transaction IDs
from the last fresh observation, or unknown. Metrics
quipu_replica_lag_transactions (-1 for unknown) and quipu_replica_lag_known
expose the same bounded observation. Failure, stale metadata or mismatched
lineage never means zero lag. Replica writes return 503 naming the primary;
they are not forwarded.
GET /health
Health check.
curl localhost:3030/health
Response: {"status": "ok"}
POST /identity
Requires an existing session-attestation signature. Returns the principal,
public key ID and session selected from the protected binding, with
authenticated: true and auth_class: "attested_session". A bearer or unkeyed
request returns 401. Agent/session declaration headers (X-Quipu-Agent,
X-Quipu-Session) are declared provenance. They must match the verified binding
only when session keys are required ([quipu.server.session_keys] required = true); with it off, a differing declaration is admitted and the returned
principal is still the binding’s. Replay, expired/revoked bindings and wrong keys
refuse.
No private key is returned. Use sd identity --json with an enrolled own key;
plain curl without a signed envelope cannot produce the authenticated result.
GET /stats
Store statistics.
curl localhost:3030/stats
Response: {"facts": 1234, "entities": 56, "predicates": 12}
GET /query and POST /query
Execute a SPARQL query. Quipu implements the SPARQL 1.1 Query Protocol GET and direct-query POST transports, while retaining its JSON POST extension:
# Standard GET (request target limited to 8 KiB)
curl -G localhost:3030/query \
-H 'Accept: application/sparql-results+json' \
--data-urlencode 'query=SELECT ?s WHERE { ?s ?p ?o } LIMIT 5'
# Standard direct-query POST (body limited to 1 MiB)
curl localhost:3030/query -X POST \
-H 'Content-Type: application/sparql-query' \
-H 'Accept: text/turtle' \
--data 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 5'
# Quipu JSON extension
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'
For SELECT and ASK, request application/sparql-results+json or
application/sparql-results+xml, text/csv, or
text/tab-separated-values. CSV and TSV follow the SPARQL 1.1 Results
formats, including term spelling and escaping. For CONSTRUCT and DESCRIBE, request
text/turtle or application/n-triples. All transports share the configured
query deadline and return HTTP 408 when evaluation exceeds it.
POST /update
Execute SPARQL 1.1 Update using either application/sparql-update or an
application/x-www-form-urlencoded body containing exactly one update
parameter. The endpoint requires write authentication when a bearer is
configured. Protocol using-graph-uri and using-named-graph-uri parameters
scope a DELETE/INSERT WHERE; mixing them with an in-body USING clause is
rejected. Every affected graph passes through Quipu’s normal authority and
governance gates, and the request commits atomically across graphs.
curl localhost:3030/update -X POST \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/sparql-update' \
--data 'INSERT DATA { <http://example/s> <http://example/p> "value" }'
A successful update returns 200 with what it committed:
{"tx": 4182, "asserted": 1, "retracted": 0,
"graphs": [{"graph": null, "tx": 4182, "asserted": 1, "retracted": 0}]}
asserted and retracted count the triples the update changed, so a
conditional DELETE/INSERT ... WHERE used as a compare-and-swap (claim if
unassigned, release if still mine) can tell whether it won without a read-back:
both zero means the WHERE matched nothing, or the update changed nothing, and
tx is null. Each graph with changes commits its own transaction (graph is
null for the default graph); tx is the last of them, usable with --at /
tx reads. The SPARQL 1.1 Protocol leaves this body to the implementation.
Attribute a write with the optional actor and source query parameters (or
form fields beside update=), each at most once, 1–256 characters, no control
characters: POST /update?actor=agent:wu&source=seeds:claim. They are recorded
on the transaction as declared, like /knot’s fields; the verified caller is
recorded separately as its authenticated principal. Without actor the
transaction’s actor is unknown (null); source defaults to sparql-update.
Quipu’s JSON extension compacts result IRIs to CURIEs by default using prefixes
declared by the currently loaded shape sets; unknown namespaces remain full
IRIs. Pass "verbose": true in a JSON request, or ?verbose=1 on GET, to
return full IRIs. Standards result formats always retain their prescribed RDF
term representation.
Optional fields: valid_at (ISO-8601), tx (integer), graph (a
named-graph IRI or dataset name that scopes the query’s default graph
without writing a FROM/GRAPH clause — an unknown IRI yields an empty
default graph, never a silent ROOT fall-through), and fork (a fork name
registered by quipu fork; unknown or dropped forks are refused loudly;
mutually exclusive with graph).
include_kinds (array of dataKind tokens, e.g. ["archive"]) widens the
default graph set with every registered graph declaring one of those kinds —
the explicit opt-in for composing cold/frozen graphs into a hot read. Silence
never widens: absent or empty means the scope is unchanged, a FROM clause in
the query text still overrides the request-level scope, and fork +
include_kinds is refused (one scope authority). The response’s composed
labels.kind then honestly reports every kind that contributed.
With "federated": true the whole query text fans out through the federated
provider — the local store plus every [[quipu.federation.remotes]] — and the
response adds a per-member providers list (each carrying the remote’s
operator-declared label) and a complete flag, with every row
_provider-tagged and, for declared remotes, _trust/_freshness-stamped.
The composed dataset labels fold the remotes in as members, and configured
[quipu.labels] floors refuse a federated query exactly as a local one — an
undeclared remote fails a configured freshness/trust floor (quipu-fd1). The
temporal/graph fields are refused on a federated query (they only shape the
local evaluator’s context). See
Federation.
POST /knot
Assert facts from Turtle data.
curl -s localhost:3030/knot -X POST \
-H "Content-Type: application/json" \
-d '{"turtle": "@prefix ex: <http://example.org/> . ex:alice a ex:Person ."}'
Optional fields: shapes (SHACL Turtle), timestamp, valid_from, actor,
source, replace_snapshot + snapshot (diffed replacement of a producer’s
prior facts under a stable key), and graph (a named-graph IRI that must
already be registered committed-class via POST /graph/create; unknown IRIs
error, overlay-class targets are refused, omitted means ROOT).
timestamp is transaction time (when this store came to believe the facts,
queried with tx/as_of_tx); valid_from is valid time (when they became
true of the world, queried with valid_at). Omitting valid_from reuses
timestamp for both, which is the historical behaviour. valid_from is RFC
3339 and is normalised to YYYY-MM-DDTHH:MM:SSZ — valid-time is compared as
text, so an un-normalised offset sorts wrongly rather than merely looking
untidy. A malformed value is refused before anything is written.
# a commit authored in March, ingested tonight
curl -s localhost:3030/knot -X POST \
-H "Content-Type: application/json" \
-d '{"turtle": "@prefix ex: <http://example.org/> . ex:c1 a ex:Commit .",
"valid_from": "2026-03-04T09:15:00+01:00"}'
Response: {"tx_id": 1, "count": 2, "conforms": true, "valid_from": "2026-03-04T08:15:00Z"} — the echoed valid_from is the
normalised key the facts were actually stored under.
Resumable snapshot promotion and cancellation
POST /knot/stage stores inert, content-addressed parts. Transport parts are
limited to 512 KiB; the assembled upload is limited to 128 MiB and 512 parts.
Those are byte limits, not a bound on promotion memory. Promotion still
validates and replaces the complete producer snapshot atomically.
Start or poll with POST /knot/promote and {"upload_id":"sha256:..."}.
A pending response includes "state":"running" and "lease_seconds":30.
Poll regularly (for example once per second) using the same authenticated
identity. Each live poll renews the lease. Bound the heartbeat request timeout
and retry sequence below the lease (for example a five-second timeout with
three attempts). A 30-second request timeout can itself exhaust this lease;
a network partition can therefore cancel a still-running client. If the producer stops polling, its
lease expires and the worker cooperatively cancels before committing the
snapshot. A late poll cannot revive an expired lease. Only one promotion per
store is admitted at a time; another upload receives an admission refusal.
For an explicit stop, send the same upload ID to POST /knot/cancel, using the
identity that started promotion. Caller-declared actor is not authority.
"cancellation_requested":true acknowledges the request, not worker exit.
Poll /knot/promote until {"pending":false,"state":"cancelled","cancelled":true}
confirms the worker has exited without committing its snapshot replacement.
The previous snapshot and other producers’ facts remain active. Durable policy
and refusal audit records are preserved; validation advisories from the aborted
payload are discarded. Inert staged
bytes and dictionary terms can remain until their normal cleanup; cancellation
is not deletion of staging or an undo of an earlier successful promotion.
Cancellation competes with a single final commit claim. If commit wins,
/knot/cancel reports "cancellation_requested":false and "state":"committing"
or "complete"; the client must read the final promotion result. Once committed,
deferred embedding may still be finishing and cannot be undone by cancellation.
Checks run between upload parts, RDF rows, retraction comparisons and staged
fact writes, and before commit. This is cooperative cancellation, not a promise
that every validation or individual SQLite statement can be interrupted within
30 seconds. Measure stop latency and peak memory for the intended workload
before admitting a larger publication; raising memory limits is not a substitute.
Terminal receipts are retained for an hour in a bounded process-local cache.
Cancelled jobs stay terminal while their receipt is retained; polling does not
restart them. To deliberately resume after confirmed terminal cancellation, the
same identity can send {"upload_id":"sha256:...","retry_cancelled":true} to
/knot/promote. This flag cannot restart a still-running worker, a failed job
with an indeterminate commit, or a completed job. A fresh server process does
not retain these receipts. After a
restart or an indeterminate response, verify snapshot contents before starting
another promotion. Staging remains content-addressed and independently expires.
POST /cord
List entities.
curl -s localhost:3030/cord -X POST \
-H "Content-Type: application/json" \
-d '{"type": "http://example.org/Person", "limit": 50}'
POST /unravel
Time-travel query.
curl -s localhost:3030/unravel -X POST \
-H "Content-Type: application/json" \
-d '{"tx": 5}'
POST /episode
Ingest an episode.
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "deploy-v2",
"nodes": [{"name": "myapp", "type": "WebApplication"}],
"edges": [{"source": "myapp", "target": "kotare", "relation": "runs_on"}]
}'
A bare node type such as WebApplication names a class under the store’s
base_ns. To name a class in the public Quechua vocabulary instead, prefix it:
"type": "quechua:WorkItem" asserts
<https://scbrown.github.io/quechua/ns#WorkItem>. The prefix changes only the
class. The node’s own IRI is still minted under base_ns. Like any other type, a
Quechua class is refused until a loaded shape sanctions it. No other prefix is
accepted.
Set "replace_snapshot": true for producers whose payload is the complete
current state of an inventory. Facts previously asserted by the same episode
name but absent from the new payload are retracted atomically with the new
assertions. The default is false, preserving additive knowledge-ingestion
semantics. Reusing a stable episode name is required for replacement.
outcome: what the ingest DID — branch on this, never on count
/episode is idempotent. The activity IRI is derived from the episode name and
stamped with a content hash, so re-posting identical content is a no-op and
retrying after a lost response is SAFE.
The response says which of three things happened:
outcome | Meaning | count | tx_id |
|---|---|---|---|
created | The episode did not exist; its facts were written. | > 0 | > 0 |
updated | It existed with DIFFERENT content; stale activity facts were retracted and the new content written. | > 0 | > 0 |
unchanged | It already existed with identical content. Nothing was written and nothing needed to be. This is success. | 0 | 0 |
Why this field exists. Before it, the idempotent no-op returned count: 0, tx_id: 0 — byte-for-byte what a write that achieved nothing returns — while the
documented success check for callers of this API was “HTTP 200 with count > 0”.
So a successful retry reported as a failure. The natural recovery from “my
episode did not land” is to re-post it under a different name or with re-worded
nodes, and that mints duplicate entities. The safe mechanism was steering
callers into the unsafe action.
So the success check is:
# right: the facts are in the store for all three outcomes
curl -s .../episode -X POST ... | jq -e '.outcome' >/dev/null
# WRONG: reports a successful idempotent retry as a failure
curl -s .../episode -X POST ... | jq -e '.count > 0'
count > 0 remains a useful “did this call write anything” question. It was
never a “did the write land” question, and only looked like one because the
first post and the only post were usually the same post.
Two things it does not promise, both still on the caller:
outcomedescribes THIS episode name. Re-posting the same knowledge under a different name is a new episode and will becreated— idempotency is keyed on the name plus content hash, not on meaning.- A
200still is not proof of retrievability. A node filed under a type nobody queries iscreatedand unreachable. Ask it back the way a reader would.
Work-item context without duplicate entities
An episode may carry one aegis:aboutWorkItem edge from its generated activity
(episode_ plus the sanitized episode name) to an existing WorkItem IRI. This
claims the episode’s context, not that the work is implemented or complete.
Knowledge remains reachable by joining prov:wasGeneratedBy to that activity.
Do not copy the WorkItem into the knowledge graph merely to create a reference.
The shipped AboutWorkItemShape constrains the reference to one IRI. The
producer must first verify the target’s direct WorkItem type in its authoritative
records graph, behind a passing control, and read the edge back after writing.
SHACL does not prove a target’s type in a graph outside its validation dataset.
The property has no global domain or range, so linking cannot manufacture the
type assertion used by that verification. Historical episodes may omit it.
Select both graphs explicitly when retrieving bead-related knowledge. For
example, with ex: bound to the deployment vocabulary, join the records graph’s
ex:WorkItem to the knowledge graph’s activity and generated entities:
PREFIX ex: <https://example.org/vocab/>
PREFIX prov: <http://www.w3.org/ns/prov#>
SELECT DISTINCT ?knowledge WHERE {
GRAPH <urn:example:records> {
<https://example.org/vocab/task-123> a ?type .
FILTER(?type = ex:WorkItem)
}
GRAPH <urn:example:knowledge> {
?episode ex:aboutWorkItem <https://example.org/vocab/task-123> .
?knowledge prov:wasGeneratedBy ?episode .
}
}
The producer chooses the primary task explicitly; do not guess it from every
identifier mentioned in free text. Direct /episode calls do not parse task
markers automatically. This relationship does not repair historical omissions
or establish that a tracker backlog has drained.
Edge relation: which vocabularies /episode can write
/episode represents the caller’s predicate faithfully or refuses it with
an explanation of which path to use. It never silently rewrites a predicate.
relation | Emitted |
|---|---|
runs_on | aegis:runs_on — the domain vocabulary, unchanged |
owl:sameAs, rdfs:seeAlso, rdf:*, skos:*, prov:*, quipu:*, xsd:*, sh:* | verbatim, in that namespace |
<http://example.org/p> | verbatim (full IRI in angle brackets) |
foo:bar (undeclared prefix) | 400, naming /set and the angle-bracket form |
runs on (would not round-trip sanitization) | 400 — spaces are not accepted in a bare relation |
The declared prefix set is KNOWN_PREFIXES in src/episode/mod.rs, kept in lockstep
with the @prefix block episode_to_turtle emits.
Asserting an alias — entity dedup with owl:sameAs
owl:sameAs is this graph’s alias convention, and /episode’s resolution_hints
exist to tell you at ingest time that you are about to split an entity. Acting on that
hint is a normal /episode edge:
curl -s localhost:3030/episode -X POST -H "Content-Type: application/json" \
-d '{"name": "alias-fix", "source": "<bead-id>",
"nodes": [{"name": "backup-freshness.timer"}, {"name": "backup-freshness-exporter"}],
"edges": [{"source": "backup-freshness.timer",
"target": "backup-freshness-exporter",
"relation": "owl:sameAs"}]}'
Two rules that are not obvious from the 200:
-
Reuse the existing node names byte-for-byte. Node identity here is the literal name string: quipu matches
canonical_name:exactand merges, or it does not match and mints a second node. Re-wording a name on a follow-up post is how aliases get created rather than resolved./searchor/resolvefirst, and copy the name out. -
count > 0proves the write landed, not that a reader can find it. Follow every alias write with the query a reader would actually run:curl -s localhost:3030/query -X POST -H "Content-Type: application/json" \ -d '{"query":"SELECT ?o WHERE { <http://aegis.gastown.local/ontology/backup-freshness.timer> <http://www.w3.org/2002/07/owl#sameAs> ?o }"}'A
0here on a200write is the silent-rewrite shape: the fact is present and misnamed. Pair it with a control (query a predicate you know is populated) before believing an empty result.
Historical note, since the answer is not guessable from the data: the alias pairs
predating this fix were written through /knot (Turtle), which is the only write
path that accepts a caller-supplied source. Their transaction source strings are
free text — e.g. "schema-gate ruling 2026-07-20" — where /episode always stamps
episode:<name>, /set stamps set, and /retract stamps retract. Some are stamped actor: null, source: null: /knot called with neither,
which lands a structural identity fact with no audit trail. Pass actor and source.
POST /set
Atomic single-call supersede: set (entity, predicate) to exactly value, retracting
every current object on that predicate and asserting the new one in ONE transaction.
Re-parenting (reports_to A → B) is one call with no window where the predicate is
empty and no way to end up multi-valued by forgetting the retract half.
curl -s localhost:3030/set -X POST -H "Content-Type: application/json" \
-d '{"entity": "http://example.org/svc",
"predicate": "http://example.org/reports_to",
"value": {"iri": "http://example.org/new-boss"},
"actor": "<who>"}'
Optional: timestamp, actor. Returns
{"tx_id", "retracted": N, "asserted": 0|1, "entity", "predicate"}; setting the
already-sole-current value is an idempotent no-op (tx_id: 0, retracted: 0, asserted: 0).
This operation is ROOT/default-graph only. Named-graph facts are untouched.
A supplied graph field, including null or ROOT’s IRI, is refused with HTTP
400 before mutation. Omit it for ROOT; use graph-scoped /update for a named graph.
- The predicate is a full IRI, from any vocabulary. This is the endpoint
/episodenames in its refusal when an edge relation uses an undeclared prefix. - SINGLE-VALUE semantics: all current objects are replaced. For
add-without-remove, assert via
/knot. - The entity must already exist —
/seton a typo’d IRI must not mint an unlabelled orphan node. The predicate may be new. - The
valueshape discipline is the same as/retract: a bare string is a literal; an edge must be{"iri": "..."}. A bare IRI-shaped string aimed at a Ref-holding predicate is a loud 400, not a mis-shaped write.{"str": "..."}states that a literal is intended and disarms that heuristic.
When write validation is enabled, SET validates the replacement against loaded SHACL shapes, including the existing subject’s types. A tagged IRI does not bypass a string datatype constraint. Refusal preserves the previous value. Explicit operation shapes remain enforced when persistent validation is off. A concurrent change to the complete predicate slot produces a conditional-write conflict instead of silently overwriting a value the request did not observe.
POST /validate
Dry-run SHACL validation.
curl -s localhost:3030/validate -X POST \
-H "Content-Type: application/json" \
-d '{"shapes": "@prefix sh: ...", "data": "@prefix ex: ..."}'
Decision deck: POST /deck/present, POST /deck/attest, POST /deck/receipt
A curated decision request is settled by an enrolled human’s WebAuthn
assertion. These routes are off by default: they answer 403 unless
[quipu.server] deck_http = true. Even then they settle nothing until an
operator has configured and enabled the store’s deck, which no route can do.
/deck/present(write):{"request", "revision", "credential", "choice"}returns{"challenge", "webauthn_challenge"}. Signwebauthn_challenge(unpadded base64url) exactly as returned. Never rebuild the challenge client-side: the server verifies against the challenge it stored./deck/attest(write):{"nonce", "client_data_json", "authenticator_data", "signature"}, each byte field base64url, returns{"decision", "nonce", "recorded_at"}. A challenge settles once: a replayed, expired or stale one is refused. After a lost response, look the receipt up; do not resubmit./deck/receipt(read):{"nonce"}returns{"found", "receipt", "tx"}.
Unknown fields are refused on all three.
POST /retract
Retract facts for an entity.
curl -s localhost:3030/retract -X POST \
-H "Content-Type: application/json" \
-d '{"entity": "http://example.org/old-service"}'
Optional: predicate (only retract matching), timestamp, actor, and value
(retract only the one matching triple).
This operation retracts ROOT/default-graph facts only; named-graph facts
survive. Supplying graph, including null, is refused with HTTP 400 before
mutation. Use an explicitly scoped /update for named-graph retractions.
The value shape matters. An object that is an IRI reference — the target of
an edge such as reports_to or rdf:type — must be given as a tagged object, not
a bare string:
# retract exactly <svc> reports_to <boss>
curl -s localhost:3030/retract -X POST -H "Content-Type: application/json" \
-d '{"entity": "http://example.org/svc",
"predicate": "http://example.org/reports_to",
"value": {"iri": "http://example.org/boss"}}'
A bare string ("value": "http://example.org/boss") is matched as a string
literal, which can never equal a stored IRI reference. Rather than silently
report {"retracted": 0} — indistinguishable from “the triple was already gone” —
the endpoint now returns a 400 error naming the {"iri": ...} form whenever a
bare string cannot match: either the predicate’s stored objects are IRIs, or the
string itself parses as an IRI (has a scheme://). A correctly shaped {"iri": ...} (or a genuine string literal) for a triple that does not exist is still a
quiet, idempotent {"retracted": 0}.
POST /retract/source
Preview or apply a retraction by the exact transaction source string. Requires
source and a nonempty repair ticket/reason. The default apply: false only
previews; apply: true retracts the planned facts and stamps the transaction source
as repair:<ticket>. Optional fields are graph (defaults to ROOT), timestamp,
and actor. The graph must be committed; source matching is literal, not a prefix
or pattern. As a write route, even preview requires the configured bearer.
The response reports planned, the affected entities count, a bounded sample,
sample_truncated, and repair_source. A preview has applied: false and null
tx_id/retracted; an applied result reports the actual retraction transaction.
Source cleanup removes only that source’s ownership claims. A statement remains
visible while another recorded claim survives. This protection is forward-only
for newly recorded claims. Older versions skipped identical assertions from
later producers, leaving no recoverable claim. Reassert the relevant producer
snapshots before cleaning up old shared data; this release does not backfill it.
The retracted count reports source-owned statements processed, not necessarily
statements removed from the visible graph.
POST /episode/retract
Episode-scoped logical retraction. Retracts the facts an episode’s ingest
contributed — its activity node, generated entities, the bare relationship
triples (edges), and any reified confidence statements — by closing their
valid_to via the bitemporal retract path. Facts are never physically deleted,
so time-travel queries (/cord, /unravel) still show them.
Identity of surviving nodes is preserved by default. Identity triples
(rdfs:label, rdf:type) are ordinary facts, so a naive scope retraction would
strip them from any node this episode named even when edges from other episodes
keep that node alive — leaving a “ghost”: a node that answers predicate queries
but is invisible to every label scan and type query. The on_orphan parameter
decides that contract:
on_orphan (alias orphan_policy) | Behaviour |
|---|---|
preserve (default) | Keep rdfs:label / rdf:type alive for nodes that retain surviving references. So the default does not retract “every currently-active fact” — it spares the identity of nodes that would otherwise be orphaned. |
refuse | If the retraction would orphan any node’s identity, reject the whole operation (400) and change nothing. The safe mode when you do not want to strand entities. |
allow | Legacy behaviour: retract every currently-active fact the episode wrote, orphaned identity included. |
Regardless of policy the response reports identity_orphans (a count) and names
the affected nodes, so a caller can tell a cleanup from a mutilation.
The retraction unit is the episode’s ingest transaction(s), identified by their
source = "episode:{name}" tag. Because identical assertions are deduplicated to
a single owning transaction, retracting an episode only removes the facts that
episode actually wrote — entities and facts contributed by other episodes (even
about the same shared IRIs) survive untouched. This is the safe way to undo a
specific episode’s contributions without SQL surgery on shared entities.
curl -s localhost:3030/episode/retract -X POST \
-H "Content-Type: application/json" \
-d '{"episode": "goldblum-deploy-verify-032"}'
Aliases for episode: episode_id, name. Optional: timestamp, actor,
on_orphan (preserve | refuse | allow, default preserve — see the table
above). Idempotent — retracting an already-retracted or unknown episode
returns {"retracted": 0} and changes nothing.
Response fields: tx_id, retracted (count), episode, statements (the
retracted facts), and the identity accounting — on_orphan (the policy applied),
identity_preserved (count) with identity_preserved_statements, and
identity_orphans (count) with identity_orphan_entities (entity,
lost_label, lost_type).
Auth (hq-azs / hq-otm). Retraction is a write — and a more sensitive one than assertion, since it removes facts from current views. The endpoint is in
http_auth::WRITE_ENDPOINTS, so it already honours read-only mode and the bearer token like every other write. When per-principal scopes (hq-azs) and crew identity (hq-otm) land, retraction should be gated to an authorized principal, not merely the same token that permits assertion.
POST /resolve
Ask what entity resolution would say about a name, without writing anything.
Returns the same candidate list the ingest path computes, so “is this a duplicate of something we already have?” can be answered before minting the entity.
curl -s localhost:3030/resolve -X POST \
-H "Content-Type: application/json" \
-d '{"name": "example-service", "properties": {"type": "DatabaseService"}}'
# {"candidates":[{"iri":"http://example.org/ontology/example-service",
# "score":0.9,"matched_on":"canonical_name:jaro_winkler:0.90"}],
# "count":1,"has_matches":true}
name is required. properties (object), top_k and threshold are optional;
top_k and threshold default to [quipu.resolution] config, so this route and
the ingest path agree by construction rather than by convention.
Both matchers run: Jaro-Winkler over rdfs:label (matched_on: canonical_name:jaro_winkler:<score>) and vector similarity when an embedding
provider is configured (matched_on: embedding:<score>). The embedding half is
the reason a client-side name check is not a substitute.
Notes:
- It does not write — and that is guaranteed by a test, not by the type. The
handler takes a
&Store, but do not read that as a read-only capability:Storewrites through&selfmethods via interior mutability, so a&Storehandler can commit. Several routes registered the same way do write, which is why/overlay/createsits inWRITE_ENDPOINTSdespite its signature. What actually holds this route read-only is the explicit assertion intool_resolve_entity_is_a_genuine_read_commits_nothing. - It is not a write endpoint (absent from
http_auth::WRITE_ENDPOINTS), so it needs no bearer token and answers normally on aread_only = trueserver — wherePOST /episodereturns 403. - It does not require
[quipu.resolution].enabled. Resolution being off disables the ingest-time hints; this route still answers. - Not to be confused with
POST /reconcile— the W3C Reconciliation API, which has its own substring scoring on a 0-100 scale and does not consult embeddings. (It is routed but undocumented here, as are/spotlightand/fragments.)
POST /shapes
Manage persistent SHACL shapes.
# Load
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{"action": "load", "name": "person", "turtle": "@prefix sh: ..."}'
# List
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{"action": "list"}'
# Remove
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{"action": "remove", "name": "person"}'
Rule Turtle (a rule:Rule subjects) may be stored alongside SHACL shapes. A
successful load or remove also hot-reloads the reactive reasoner’s ruleset
— rules take effect on the next write, no restart (before 2026-08-27 the
ruleset was a startup snapshot).
POST /reason
Run a Datalog ruleset to fixpoint and persist its derivations
(source = reasoner:<rule-id>). A write endpoint — derivations assert and
retract through the fact log — so it is bearer-gated like /episode.
Derivations land in the target graph’s companion inferred graph
(<graph>#inferred; ROOT’s is urn:quipu:graph:root#inferred, quipu-0b6).
Read them composed: FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred> in a /query body. The suffix is reserved —
external writes to a companion graph are refused.
Body fields, all optional: rules (inline rule Turtle; absent, the stored
combined shapes are used), prefix (default IRI prefix for unqualified
predicate names), graph (a named-graph IRI — premises and derivations both
scope to it; absent means ROOT), timestamp (valid-from for derived facts).
# Evaluate the rules already loaded via /shapes, against ROOT
curl -s localhost:3030/reason -X POST \
-H "Content-Type: application/json" -d '{}'
# Evaluate an inline ruleset against a named graph
curl -s localhost:3030/reason -X POST \
-H "Content-Type: application/json" \
-d '{"rules": "@prefix rule: ...", "graph": "http://example.org/graphs/staging"}'
# {"rules":2,"strata_run":1,"asserted":14,"retracted":0,
# "per_rule":[{"rule":"R1","asserted":14},{"rule":"R2","asserted":0}]}
POST /explain
Walk a fact’s derivation chain from the provenance in the fact log. Read-only
and open (no bearer token). Body: s, p, o (IRIs; a non-IRI o is
treated as a string literal), optional depth (default 5).
A base fact answers with its transaction and source. A reasoner:<rule-id>
fact answers with the rule and the premise facts it currently re-matches; an
owl:materialize fact answers with every axiom family that currently
re-derives it — premises recursed, so the tree bottoms out in base facts.
Support is re-matched, not stored: a premise retracted since derivation
shows as absent support, which is itself diagnostic.
curl -s localhost:3030/explain -X POST \
-H "Content-Type: application/json" \
-d '{"s": "http://example.org/a", "p": "http://example.org/dependsOn",
"o": "http://example.org/c"}'
# {"fact":{...},"found":true,"tx":42,"source":"owl:materialize",
# "derivation":{"kind":"owl","families":[{"family":"transitive",...}]}}
POST /search
Usable context versus ranked candidates
Every successful response also carries the quipu-context/v1 contract:
{
"insufficient_context": true,
"context_results": [],
"context": {
"contract": "quipu-context/v1",
"outcome": "NO CONTEXT",
"reason": "subject_not_covered",
"similarity_floor": 0.52,
"minimum_term_coverage": 0.5,
"subject_term": "unrecordedsubject",
"required_identifiers": [],
"top_term_coverage": 0.6667,
"candidate_count": 3,
"admitted_count": 0
}
}
Consumer validation requires a finite declared floor in [0.52, 1] before
accepting either CONTEXT or NO CONTEXT. A lower, missing or malformed floor
is UNKNOWN in these consumers, even when the server reports no context.
results, count, ranking scores, ordering, scope and temporal provenance remain
unchanged diagnostic search output. They are not evidence that an answer
exists. Context consumers must render NO CONTEXT when
insufficient_context is true and must not inject diagnostic candidate text.
When it is false, consume only context_results. A missing or unknown contract,
transport error or malformed response is UNKNOWN, never an empty successful
search. This contract does not change storage, ranking or graph isolation. Native MCP
renders only this admitted-context view: its results and count describe the
admitted rows and omit diagnostic neighbors. Unsupported context responses are
MCP errors marked UNKNOWN. REST retains the diagnostic candidate view.
Admission requires the top candidate’s raw similarity to reach the floor,
the first non-stopword query term to occur as a complete word, every explicit
identifier to occur exactly, and at least half the distinct non-stopword query
terms to occur. Matching is case-insensitive. Identifier boundaries preserve
hostnames, unit names, item IDs, IRIs/paths and hexadecimal identifiers of seven
or more characters. An uncorroborated top candidate produces NO CONTEXT even
if a lower candidate appears usable. Admitted rows preserve source and valid-time
metadata. An admitted historical incident is not a verified cause or current state.
The default raw-cosine floor is 0.52, provisionally measured on a small set
with only a 0.023 separation margin. context_similarity_floor may explicitly
select a finite floor in [0,1]; it does not change ranking. Coverage and subject
checks are conservative heuristics, not a calibrated answerability probability.
The first-subject rule is order-sensitive; an entity-first query is clearest.
A high score alone is insufficient: a nonsense subject with domain vocabulary
can exceed the floor. Re-evaluate thresholds against positive and truly absent
controls before changing policy.
Without query terms, or without a finite raw cosine (for example a keyword-only
or lexical-only fusion result), context admission refuses with an explicit
reason. BM25, normalized fusion, content and graph scores never substitute for
cosine. Empty candidates produce no_candidates; all candidates below the floor
produce no_result_above_floor. A below-floor top hit with another above-floor
candidate produces top_result_below_floor. Other reasons include
similarity_unavailable, query_terms_unavailable, query_too_long,
subject_not_covered, identifier_not_covered, and insufficient_term_coverage.
Queries over 4096 UTF-8 bytes cannot supply usable context under this contract.
Vector similarity search. Body: embedding (or query), optional limit,
valid_at, ranking, and best-effort scoping by group_ids / entity_type.
Optional mode: "keyword" selects the derived SQLite FTS5 index described below;
omitted mode or mode: "semantic" preserves vector search.
Select one registered graph with graph: "<IRI>", several with
graphs: ["<IRI>", "<IRI>"], or ROOT plus all named graphs with
all_graphs: true (also graph: "all"). New stores default to ROOT plus all
registered named graphs, minus [quipu.search] exclude_graphs. Existing stores
retain ROOT until explicitly broadened with default_graph_scope = "all".
An unset configuration uses the persisted store default; read-only legacy
stores resolve to ROOT without a schema write. The response coverage reports
the resolved default and its default_source. An explicit configuration
overrides the stored choice. Set default_graph_scope = "root", or pass
graph: "urn:quipu:graph:root" for one ROOT search. Selectors
are mutually exclusive and unknown IRIs are refused. Explicit results carry
graph and selected entity memberships in graphs. For keyword mode,
graph identifies the matching assertion; for semantic mode it is the first
selected membership. The graph
metadata plane is excluded from all-graphs scope. Named-graph semantic text
uses one vector per entity; entities with ROOT text keep it, otherwise text
combines their named-graph facts. Scopes containing named graphs refuse ROOT
content and anchor reranking; an explicit ROOT selection supports both.
Named-graph retrieval for semantic and hybrid search, and automatic
named-only embedding text, are disabled by default ([quipu.search] named_graphs = false). Keyword mode (mode: "keyword") accepts an explicit
scope without the flag, because the lexical index records each assertion’s
graph; it still refuses until that index is complete. Prepare existing named-only
vectors through bounded POST /embed_backfill_graph calls, then enable the
flag. This avoids turning the next producer snapshot into an unpaced initial
embedding drain. Graph backfill leaves entities with any ROOT history alone,
including ROOT entities without vectors. Roll back exposure by setting default_graph_scope = "root" and disabling
named_graphs; retain this binary’s ROOT exclusion while named vectors exist.
Reverting to a pre-scope binary after backfill can expose those vectors in
unscoped search.
A local named_search_entities origin cache is created when named vectors are
prepared. It retains their exclusion from ROOT even if the named facts are
physically cleaned up. The cache is regenerated with embeddings when a store
is reconstructed; it is not exported as factual content.
Hybrid fusion
Hybrid is disabled by default. With [quipu.search] hybrid = true and a ready
keyword index, request mode: "hybrid", alpha in [0,1],
fusion: "weighted" | "rrf", and positive rrf_k (default 60).
Alpha is the semantic contribution: alpha: 1 returns the existing semantic
response exactly, without accessing the lexical index; alpha: 0 returns the
keyword response exactly, without embedding. Omitted mode and parameters use
the server search configuration; the default mode remains semantic.
Weighted fusion min-max normalizes each candidate list independently. A
nonempty equal-score list normalizes to one; missing candidates contribute zero.
RRF uses alpha/(rrf_k + semantic_rank) + (1-alpha)/(rrf_k + keyword_rank),
with one-based ranks and zero for a missing branch. Equal fused scores are
ordered by entity identifier. Candidate lists use the configured oversampling,
capped at the server result limit and 1000 (the response reports candidate_limit);
normalization is over this bounded pool, not the entire corpus. Intermediate
hybrid requests whose effective result limit exceeds 1000 are refused; the pure
semantic and keyword endpoints retain their configured result limits.
Both branches retain temporal, graph and provenance scopes. Intermediate hybrid
weights use semantic type inference for both branches (infer_types: true);
asserted-only hybrid type scopes are refused until both branches support them.
Content reranking is not composed with intermediate fusion. Graph anchors
compose with semantic, keyword and hybrid ranking when anchored = true.
Intermediate weights require query text; an optional embedding supplies the
semantic branch. Endpoints retain their pure modes’ input requirements and
restrictions (keyword rejects embeddings; semantic permits an embedding without
query text).
Empty/punctuation-only lexical expressions are refused as in keyword mode.
explain: true adds raw BM25, cosine, fused score, branch ranks, actual matched
FTS columns and applied filters. A missing branch component is null, not zero.
Lexical snippets use the winning FTS assertion and tokenizer; semantic-only
snippets use the vector text. Both are bounded, HTML-escaped text with <mark>
highlights. The CLI accepts --mode hybrid --alpha 0.5 --fusion rrf --rrf-k 60 --explain; native MCP exposes the same fields. Fleet proxy rollout is a separate
surface gate. Disable search.hybrid for rollback: callers omitting mode return
to semantic even if the configured default remains hybrid. Explicit hybrid
requests are refused while disabled. Fusion creates no additional persisted index.
Keyword index
Keyword search is off by default ([quipu.search] keyword = false). Explicit
activation installs an empty FTS5 index and same-transaction fact triggers; it
never backfills during startup or a read. Writes, source replacement, closure,
rollback and physical deletion are reflected atomically. Index documents are
assertion rows with graph IDs; omitted scope reads ROOT only. valid_at
checks their original valid-time intervals, with an exclusive valid_to
boundary. Attached packs are not part of this index.
An existing ROOT-only index is upgraded without dropping its documents. The upgrade replaces its derived view/triggers and invalidates completeness; bounded backfill must revisit fact ranges before keyword reads resume. A named-graph query against the older index is refused rather than reported as an empty graph.
Backfill is explicit and resumable:
quipu search-index backfill --batch-size 500 --db /path/to/store.db
quipu search-index status --db /path/to/store.db
quipu search '"complete episode phrase"' --mode keyword --db /path/to/store.db
quipu search 'Memory Beads' --mode keyword --graph urn:example:knowledge --db /path/to/store.db
quipu search 'Memory Beads' --mode keyword --all-graphs --db /path/to/store.db
One command commits one batch (1–10000 scanned fact rows), then releases the
writer. A durable cursor/highwater bounds historical work, while insertion
triggers index newer writes. The CLI refuses backfill during UTC minutes
10–20, inclusive. Repeat calls outside ingestion lanes until complete is
true; an incomplete index returns an error instead of a partial answer.
mode: "keyword" requires query text and needs no embedding provider. It
accepts literal terms and double-quoted phrases, with conjunctions scoped to
one fact document; it does not interpret field/boolean syntax yet. All literal
codecs are decoded, full comments/body text is retained, and type/entity local
names include CamelCase tokens. Scores are positive BM25 relevance (score,
higher first); bm25 exposes SQLite’s negative raw rank. Entity duplicates
are collapsed and group/type scope is filtered before applying the limit.
Embedding arrays, anchors and content reranking are refused in this mode.
Rollback: disable the flag, then quipu search-index drop --db <path> to remove
the derived index and its triggers/progress only. Existing facts/vectors are
preserved. A previously activated index continues following writes until it
is dropped, even when keyword queries are disabled; no query silently serves
stale documents after a flag toggle. Index size/RSS depend on the corpus and
must be measured on an isolated restored store before production backfill.
Semantic ranking
Opt-in ranking: "content" reranks the oversampled candidates before the
result limit. A Section, Chunk, or CodeSymbol with no explanatory comment
or content beyond its label/name is demoted: positive cosine similarity is
halved, and negative similarity is reduced by half its magnitude. Metadata such
as paths, line numbers and revisions does not count as content. Exact label/name
queries are exempt. Artifacts with explanatory text and other entity types keep
their similarity score. This is a relevance heuristic, not a trust classification.
The default ranking: "semantic" preserves the original cosine order.
Choose content when retrieving explanatory operational knowledge; keep
semantic for general search or locating code and documentation. An identifier
can be the correct answer without any body text, so content ranking must not
be applied indiscriminately. Each result exposes
similarity (raw cosine), score (ranking score), and ranking_reason
(semantic or contentless_artifact). No vectors are deleted or re-embedded.
Only the existing bounded candidate pool is reranked: this cannot recover an
entity outside that pool or guarantee semantic equivalence detection. Historical
searches classify candidates using facts valid at the requested valid_at.
curl -s localhost:3030/search -X POST \
-H "Content-Type: application/json" \
-d '{"embedding": [0.1, 0.2, ...], "limit": 10}'
group_ids is a best-effort provenance filter, not an isolation boundary:
it narrows to entities whose facts trace (via prov:wasGeneratedBy → episode → groupId) to a listed group, and it drops ungrouped /knot facts (they have
no episode to trace). entity_type restricts to an rdf:type IRI. See
group-isolation.
⚠️ The
type: A, Bin a result’stextis NOT valid as/episodeinput. A multi-typed entity renders as... type: Feature, Tool, Concept, and that string looks exactly like atypevalue you could paste back. It is not one —/episodetakestypeas a SINGLE class and refuses a comma-separated value (400). To give an entity several types, send one node entry per type, repeating the same name; the canonical-name resolver folds them into one entity:{"nodes":[{"name":"governor","type":"Feature"},{"name":"governor","type":"Concept"}]}This bites careful readers specifically: searching first to reuse existing conventions is what hands you the string, so following the “search before you mint” rule is what leads into it.
Why the rendering is not simply changed: that
textis the EMBEDDING SOURCE (src/embedding.rs,format!("type: {}", types.join(", "))), not a display string. Altering the separator changes the text every stored vector was computed from, so it would need a full re-embed backfill to stay coherent — a much larger change than it looks. Documented here rather than “fixed” cheaply and inconsistently.
Anchored search
Off unless the server sets [quipu.search] anchored = true. An anchor sent to
a server with it off is refused rather than answered unanchored. A request
without anchor is unchanged.
| Field | Default | Meaning |
|---|---|---|
anchor | One entity, as an IRI, a CURIE or an exact rdfs:label. Ambiguity or absence is refused, with the candidates named. | |
max_hops | 3 (cap 4) | Neighbourhood radius |
anchor_mode | decay | decay: score × decay^hops, unreachable × decay^(max_hops+1). sort: hops first, then score. filter: reachable only. |
decay | 0.5 | Per-hop multiplier, in (0, 1] |
via | Traverse only these predicates (replaces the default exclusions) | |
direction | both | out, in or both |
explain | false | Adds one shortest path per result |
The neighbourhood is walked with bound single-pattern lookups over current ROOT
facts (valid_at applies). owl:sameAs costs no hop. rdf:type,
rdfs:subClassOf, PROV links, distinctFrom, mentions and inDocument are
not traversed. Without that, everything is two hops from everything through a
class, an activity or a document. A node with more than 150 edges is reached
but not expanded along ordinary edges; zero-cost aliases remain traversable.
Non-anchor reads fetch at most 151 object edges per direction before applying
the hub rule. At the outer hop ring, only alias edges are queried. The anchor
read is bounded by the node budget, and a possibly incomplete ring is reported
as truncated. The walk stops at 5,000
nodes. The response’s anchor block reports reached, hubs_not_expanded,
truncated and truncated_at_hop. A truncated ring is never presented as
complete.
Each result gains hops (null when unreachable) and text_score (the
blended text score before hop ranking). Global text candidates and candidates
restricted to the bounded neighbourhood are unioned before fusion. Each source
gets half the configured candidate pool; each text branch is capped at 1,000.
This admits a near text match that falls below the global pool. Normalization
and ranks use that bounded union. Anchored result limits above 1,000 are refused.
Explicit named-graph anchors remain unsupported; traversal is ROOT-only.
Malformed anchors and traversal options are refused.
The CLI exposes the same controls:
quipu search "backup failure" --mode hybrid --alpha 0.5 --fusion rrf \
--anchor https://example.org/host --max-hops 3 --anchor-mode sort \
--direction both --decay 0.5 --explain
POST /hybrid_search
Combined SPARQL filter + vector ranking.
curl -s localhost:3030/hybrid_search -X POST \
-H "Content-Type: application/json" \
-d '{
"sparql": "SELECT ?s WHERE { ?s a <http://example.org/Service> }",
"embedding": [0.1, 0.2, ...],
"limit": 5
}'
POST /project
Graph projection and algorithms.
curl -s localhost:3030/project -X POST \
-H "Content-Type: application/json" \
-d '{"algorithm": "in_degree", "limit": 10}'
POST /render
Prepare a deterministic markdown file plan from a stored specification or an
ad-hoc specification. This protected computation requires an accepted bearer;
it remains a pooled read-only endpoint, including on read-only servers. Missing
or invalid credentials refuse, and open-write mode does not open this route.
The server neither writes files nor accepts a destination. The CLI declares cli-render in
X-Quipu-Client and publishes the verified plan locally.
{
"adhoc": {
"query": "SELECT ?title WHERE { VALUES ?title { 'Example' } }",
"template": "# {{ rows[0].title }}",
"layout": "single",
"path": "report.md"
}
}
Pass spec as an absolute specification IRI instead of adhoc. Optional
graph selects the same default dataset as /query; optional valid_at is an
ISO timestamp. Unknown fields, including dest and dry_run, refuse rather
than being ignored. Existing query floors, truncation and time-budget guards
apply. Incomplete data cannot authorize a deletion.
The response contains schema, spec, source_tx, valid_at and files.
Each file has base64 bytes, a sha256: content address and a mode. Output is
bounded to 2,000 files and 16 MiB of decoded bytes. The client verifies the whole
plan and the destination ownership manifest before atomic publication. The
render reference explains includes, temporal snapshots and local
refusal/no-op behavior. Preparing a plan is not a publication or a delivery
receipt.
GET|POST /report
Live graph report: top hubs (god-nodes), surprising cross-community connections,
and auto-suggested questions (see quipu_report in the
MCP tools reference). Read-only. GET returns the report with
defaults; POST accepts an options body (type, predicate, hubs,
surprises, questions).
curl -s localhost:3030/report
curl -s localhost:3030/report -X POST \
-H "Content-Type: application/json" \
-d '{"hubs": 5, "surprises": 5, "questions": 6}'
GET /graphs
List registered named graphs with class, source, storage lifecycle, and label
cache (freshness / durability / trust / policy / kind). Query params: kind
(a dataKind token) and lifecycle (frozen). Also the consumer
capability probe for the graph-kinds surface: a 404 means the store
predates it — treat that as “cannot tell”, never as “no graphs”.
The optional graph=<absolute-IRI> selects one exact registered identity. It
cannot be combined with kind or lifecycle. The response retains
{ "count": 0|1, "graphs": [...] }, including creation.timestamp and
creation.actor (null for a legacy/imported source without a declaration).
Point mode never enumerates the registry or computes materialization.
Missing, interned-only and removed registry entries return count zero; malformed
selectors, storage failures and ambiguous registered source identities are errors.
No missing IRI is interned by this read.
For attached/frozen graphs, creation metadata comes from the selected source,
not mutable local events. A frozen pack’s timestamp describes that pack’s
registry identity, not an invented original creation time; existing pack formats
may have no creator declaration. Each source needs creator index v1
events(type, subject, offset). An older immutable source is refused explicitly,
never migrated or scanned by this read. The CLI equivalent is
quipu graph list --graph <IRI> --json --db <existing-db>; its metadata reader
opens read-only without schema migration, registration or lexical initialization.
It selects an active frozen source by the requested IRI with a bounded indexed
lookup; an unrelated broken frozen pack does not block that point read.
Existing list mode and its enrichment remain unchanged.
Index migration v1 follows the store’s ordered additive migration seam on a
writable open; it is idempotent and changes no facts or events. Its private
fixture build measurements do not establish production build cost or admission.
Operational rollback requires the prior binary and dropping only
main.idx_events_type_subject_offset and main.idx_frozen_packs_graph_thawed;
no database rewind or event deletion. The latter bounds the lookup of a frozen
graph’s recorded active source alias. Retained local frozen IDs resolve through
that alias; unrelated duplicate registered identities are errors.
Do not drop the index while a point consumer depends on it. Live migration,
resource admission and rollout remain separate operational decisions.
A graph whose latest RML materialization is on record additionally serves a
materialization object (quipu-212): the mapping IRI, mapping-closure hash,
external-truth subject, verified source hash, transaction, and timestamp
of the last executor commit — the comparands a freshness verdict needs
(camayoc’s rml_executor.py freshness/remap read them from here). Parsed
from transaction provenance, so it cannot drift from what actually
committed; omitted rather than faked on graphs with no RML history.
curl -s 'localhost:3030/graphs?kind=operational'
POST /graph/label
Declare a graph’s labels — any subset of the five axes. Required: graph,
timestamp. Optional: freshness, durability, kind (a dataKind token,
strictly parsed), trust ({"iri", "chain", "rank"}), policy (array of
obligation tokens), valid_to (expiring declaration), actor. Each axis is
parsed strictly: an unrecognised value is an error, never a dropped axis.
Returns {"tx_id": N}. Write endpoint; honors bearer auth.
curl -s localhost:3030/graph/label -X POST -H "Content-Type: application/json" \
-d '{"graph": "urn:app:runs/2026-08", "kind": "operational",
"freshness": "fresh", "timestamp": "2026-08-24T00:00:00Z"}'
POST /graph/freeze and POST /graph/thaw
The deep-freeze surface — same inputs and outputs as the quipu_graph_freeze
/ quipu_graph_thaw MCP tools. Freeze relocates a graph’s full history into
a read-only archive pack (kept addressable and composable at query time);
thaw restores it. Both are write endpoints and honor bearer auth.
curl -s localhost:3030/graph/freeze -X POST -H "Content-Type: application/json" \
-d '{"graph": "urn:app:shuttle/runs/2026-07", "timestamp": "2026-08-24T00:00:00Z"}'
POST /graph
Render-ready node-link projection — the single payload the web UI draws from.
curl -s localhost:3030/graph -X POST \
-H "Content-Type: application/json" \
-d '{"limit": 250}'
Body: optional limit (nodes, ranked by degree; default 250, max 2000),
type (restrict to one rdf:type IRI), include_episodes (default false).
{
"nodes": [{"iri": "…", "label": "kotare", "type": "…/ProxmoxNode", "deg": 8}],
"edges": [[0, 10, "managed_by"]],
"types": [{"iri": "…", "label": "SystemdService", "count": 15}],
"truncated": {"shown": 250, "of": 1180},
"stats": {"nodes": 250, "edges": 612}
}
edges address nodes by index into nodes, not by IRI — an IRI averages
~45 bytes and would otherwise repeat at both ends of every edge. prov:Activity
episodes and rdf/rdfs/prov scaffolding predicates are excluded by default
so the domain graph is not buried in provenance. truncated always states what
was dropped rather than silently capping.
POST /context
Knowledge context pipeline.
curl -s localhost:3030/context -X POST \
-H "Content-Type: application/json" \
-d '{"query": "traefik", "max_entities": 10}'
The summary carries an embeddings block reporting whether semantic
retrieval was possible at all, so an empty entities list is not ambiguous:
"embeddings": { "configured": true, "embedded_entities": 2579 }
configured: false means no embedding provider is attached;
embedded_entities: 0 with configured: true means the store was never
embedded (quipu knot does not embed — run a backfill). See
Embeddings and Semantic Search.
POST /unified_search
Unified knowledge search (text + optional vector); results tagged
source="knowledge" with normalized 0–1 scores. Body: query, optional
embedding, limit, expand_links, max_facts_per_entity.
POST /ask
Run a curated, parameterized named query by name (see quipu_ask in the
MCP tools reference). Body: name (omit or "list" to list
the catalog), optional params map. Parameters are validated and escaped by
type. Response: query, resolved sparql, columns, rows, count.
curl -s localhost:3030/ask -X POST \
-d '{"name":"service_deps","params":{"entity":"http://example.org/traefik"}}'
POST /search_nodes
Search entities by natural-language query (text matching). Body: query,
optional group_ids, max_results, entity_type_filter.
POST /search_facts
Search relationships/edges by natural-language query. Body: query, optional
group_ids, max_results.
POST /search/nodes
Graphiti-compatible node search (mirrors Graphiti’s search_nodes shape).
The optional group_ids filter matches direct group labels or episode provenance
in ROOT. It recognizes the configured vocabulary, the legacy vocabulary, and
https://scbrown.github.io/quechua/ns#groupId; unrelated predicates with the same
local name do not match. This is a provenance filter, not an access boundary.
Results include distinct group_ids and retain scalar group_id when exactly
one group is known. Property keys use the local name for both slash and hash IRIs.
POST /episodes/complete
Graphiti-compatible flat episode ingestion. Body: name, optional
episode_body, group_id, source_description, timestamp.
POST /impact
Impact analysis: walk downstream from an entity, optionally counterfactual.
Body: entity, optional remove, hops, predicates, timestamp.
POST /path/cone
Golden paths: the provenance cone of a trajectory — which steps did its
falsifier-gated verified result depend on? Read-only. Body: trajectory (the
Trajectory IRI, required), optional via (array of derivation predicate IRIs
walked in addition to verifiedBy, which is always followed), hops (walk
depth, default 8), base_ns (vocabulary namespace override; defaults to the
store’s configured base_ns).
curl -s localhost:3030/path/cone -X POST \
-H 'Content-Type: application/json' \
-d '{"trajectory": "http://example.org/traj/42", "hops": 6}'
Returns the cone report: the trajectory, the hop bound, the verifications it
was checked against, and one entry per step carrying iri, order, verdict
(InCone / OutOfCone / CannotEvaluate) and the human-readable reason.
Refuses a trajectory with no steps or no falsifier-gated verification.
POST /path/backtest
Golden paths: replay a pruned candidate (the exemplar trajectory minus omitted
steps) over recorded history — which past trajectories sharing a work-item
topic would have conformed under gp-grammar/1, and how did their work items
close? Read-only. Body: exemplar (the exemplar Trajectory IRI, required),
optional omit (array of step IRIs the candidate omits), base_ns.
curl -s localhost:3030/path/backtest -X POST \
-H 'Content-Type: application/json' \
-d '{"exemplar": "http://example.org/traj/42",
"omit": ["http://example.org/step/3"]}'
Returns the backtest report: the exemplar, the grammar, the matched topics, one
row per replayed trajectory, and the conformer/deviator completion counts with
an explicit cannot_evaluate tally — 0 matches and “nothing measurable” are
never reported as the same thing.
POST /propose
Submit a schema-evolution proposal. Body: kind, target, diff, proposer,
optional rationale, trigger_ref, timestamp.
POST /proposals
List schema-evolution proposals. Body: optional status
(pending/accepted/rejected).
POST /proposal/accept
Accept a pending proposal. Body: id, decided_by (required), optional
note, timestamp.
POST /proposal/reject
Reject a pending proposal. Body: id, note, decided_by (required),
optional timestamp.
POST /entity_history
Return the full fact history (across transactions) for an entity. The body field is
iri, not entity — {"entity": ...} returns
{"error": "missing 'iri' parameter"}.
curl -s localhost:3030/entity_history -X POST -H "Content-Type: application/json" \
-d '{"iri": "http://example.org/svc"}'
Returns {"iri", "count", "history": [{"op", "predicate", "value", "tx", "valid_from", "valid_to"}, ...]}. The tx is the handle for /transactions below —
together they answer “which write path asserted this fact, and who owned it”.
GET /transactions
List transactions in the store, oldest first.
| Param | Effect |
|---|---|
| (none) | the whole log |
since=<tx> | only transactions newer than <tx> — the poller’s cursor, so a watermarked poll is O(new) rather than O(log) |
limit=<n> | clamped to 1..=10_000; applies from the start of the log, not the end |
There is no offset. Passing limit alone therefore returns the oldest N — on a
38k-transaction store, ?limit=40000 hands back transactions 1–10000 and nothing
recent. To look up a specific transaction, use ?since=<tx-1>&limit=1.
Each entry is {id, timestamp, actor, source, authenticated}. authenticated
is null when local credential evidence is absent; otherwise it contains
principal, credential_id (null for a shared bearer), and auth_class
(named_bearer or legacy_shared_bearer). It is separate from declared fields. source identifies the write path:
episode:<name> (/episode), set (/set), retract (/retract,
/episode/retract), or caller-supplied free text (/knot). actor and source are
both optional on /knot. Supply them for source provenance; authenticated
credential evidence does not reconstruct a missing source or historical actor.
POST /embed_backfill
Backfill embeddings for entities that lack them. Returns
{"status": "error", ...} when no embedding provider is configured; the
--embed-backfill startup flag instead exits non-zero rather than serving
without the capability it was asked for.
POST /embed_backfill_graph
Embed up to max_entities (default 256, clamped to 1-2000) entities of one
registered named graph that have no current vector. Body:
{"graph": "<IRI>", "max_entities": N}. Returns entities_embedded,
remaining (still un-vectored in that graph) and stale_skipped (entities
edited during the call, left for the next one). An unknown graph is refused.
Each call is deliberately bounded: pace repeated calls and watch server memory
between them rather than draining a large graph in one pass. Entities that
exist only in named graphs are embedded from their named-graph facts, and they
are searchable with quipu_search graph=<IRI>; unscoped search stays ROOT.
GET /preview/{iri}
Return a preview rendering of an entity by IRI.
Service Metadata
GET /version
What build is actually running: {"version", "git_sha", "git_dirty", "features", "read_contracts"}. The git SHA is the field that matters for “is the fix
deployed?” — a semantic version does not move when a fix lands. features
maps every declared Cargo feature to whether this binary compiled it in.
read_contracts lists recognized versioned source-acquisition contracts.
GET /metrics
Prometheus scrape endpoint (text/plain; version=0.0.4). Request counters
come from the middleware. Graph-size gauges use a per-store snapshot: a single
background task scans the live root graph at startup and again five minutes
after each refresh completes. Scrapes neither acquire database connections nor
trigger scans. WAL size still comes from a current filesystem metadata read.
quipu_http_requests_started_total counts HTTP arrivals before handler dispatch,
including pending requests, cancelled requests and metrics scrapes. It has no
labels and resets when the process restarts. Use it to measure arrival rate:
quipu_http_requests_total and quipu_http_client_requests_total count terminal
request observations, including dropped request futures with synthetic status
499. Endpoint histograms and client-duration counters include elapsed time until
that drop. quipu_http_requests_cancelled_total{client,endpoint} separately
counts dropped futures; its client budget is 31 named callers plus other, and
endpoint labels use route templates. Low terminal rates alone do not establish
low traffic.
quipu_http_auth_refusals_total{client,endpoint,method,expected_probe} counts
completed HTTP 401 responses, including protected GET requests. Observed
successful requests initialize the corresponding counter at zero. Methods
are bounded to GET, POST, HEAD and OTHER; routes use templates and caller
overflow folds into other. expected_probe is operational attribution,
not an authenticated identity: it is true for the designated link-check GET
caller and the designated negative-auth POST caller on /episode or /shapes.
Exclude these controls when measuring unexpected authentication failures,
and retain a separate check that the metric is being scraped. A newly seen
counter can start above zero, so an increase-only alert may miss its first
refusal.
Before the first successful refresh, graph-size gauges are omitted and
quipu_graph_counts_ready is zero. A failed refresh retains the last successful
snapshot and increments quipu_graph_counts_refresh_failures_total; it never
substitutes zero counts. quipu_graph_counts_age_seconds and
quipu_graph_counts_last_success_timestamp_seconds expose stale data, while
quipu_graph_counts_refresh_duration_seconds includes the last attempt’s pool
wait and scan time. These freshness signals must be considered when consuming
the graph-size gauges: a successful HTTP scrape alone does not prove fresh counts.
Caller attribution uses the normalized X-Quipu-Client header (falling back
to User-Agent, then unattributed) and is capped at 32 identities; overflow
folds into other rather than creating unbounded Prometheus cardinality:
quipu_http_client_requests_total{client,endpoint}— request count;quipu_http_client_request_seconds_total{client,endpoint}— wall time;quipu_store_wait_seconds_total{client,endpoint}— time waiting to acquire a store connection;quipu_store_held_seconds_total{client,endpoint}— store capacity consumed.
/query load is also counted by the caller’s DECLARED X-Quipu-Agent, so a
read-heavy client such as seeds can be split by the crew member it acts for.
The value is caller-declared and unauthenticated. It is normalised like the
client label and capped at 32 agents; a missing or unusable value is
undeclared and overflow folds into other. There is no task or endpoint
dimension, so the series count stays at most clients x 32:
quipu_http_agent_query_requests_total{client,agent}— completed/queryrequests (including dropped futures, like the client counter);quipu_http_agent_query_seconds_total{client,agent}— their wall time.
Writers declare structured provenance in five headers: X-Quipu-Agent,
X-Quipu-Harness, X-Quipu-Model, X-Quipu-Session and X-Quipu-Host. Their
values are not metric labels, except X-Quipu-Agent on /query above. On write routes the server classifies how
completely they were declared, and counts each COMMITTED write transaction once,
at commit. A refused or rolled-back request counts nothing, and commits the
engine makes on a request’s behalf (verdicts, reasoner materialization,
migration) are not counted as that client’s writes:
quipu_write_provenance_total{client,endpoint,provenance}—provenanceiscomplete(agent, harness and host, plus session and model when the harness isclaudeorcodex),partial, orabsent(none of the five headers);quipu_write_provenance_missing_total{client,field}— commits missing a required field (agent,harness,host,session,model).
A header that is present but blank counts as missing. Coverage per client is
rate(...{provenance="complete"}[1h]) / rate(...[1h]); the counters reset when
the process restarts, so use rates rather than raw values.
The server also writes one-line JSON request events to stderr for journald/Loki.
request_start makes a request that never completes visible. request_complete
adds status, duration_ms, and the actual auth_outcome; /query responses
also add query_shape and result_size. Logs contain normalized attribution
and bounded metadata, never the Authorization header or response body. Slow or
failed query text retains its existing separate diagnostic line.
Requests may declare a finite command source with X-Quipu-Source and an
underlying SDK operation with X-Quipu-Operation. Start and terminal logs retain
these separately as declared_source and declared_operation; missing,
unsupported, or duplicate values are null (UNKNOWN). Supported command sources
use st- or sd- plus anchor, plate, inbox, stop_policy, stop_event,
haul, tend, work, other, show, list, ready, where, search,
count, info, stats, create, update, close, or comments.
The component sources seeds and shantytown remain distinguishable from
command attribution. Operations are query, health, graphs, show, list, ready, where,
search, count, info, stats, tend, ready-count, create, update,
close, or comments. These declarations contain no arguments, paths, message
text, or credentials. They do not authenticate a caller or add metric labels.
An attributed census additionally requires a reader that retains these fields
and proves log coverage; an endpoint alone does not identify a command.
Dropping a polled request future also emits one request_complete with
completion_outcome: "cancelled", synthetic status: 499 and elapsed time.
No HTTP 499 response is delivered. Authorization remains pending and result
metadata is absent because no response established those values. Normal responses
use completion_outcome: "response". This records abandonment of the HTTP
future, including cancellation during shutdown; it does not prove that a blocking
worker stopped or that a write did not commit. Response-body streaming after the
handler returns is outside this measurement.
Completed /search, /hybrid-search, and /search-query pipelines exceeding 500 ms also emit
search_phase_slow. This anonymous record reports pipeline_ms, combined
admission_dispatch_ms, and phase_ms for embedding, reader acquisition,
tool execution, SQLite scan/decode/scoring, sorting, and survivor metadata.
Tool time contains the SQLite phases; these overlapping values must not be
summed. Admission includes the asynchronous permit wait and blocking-pool
dispatch, which this record cannot separate. The pipeline excludes HTTP body
parsing and response serialization and therefore differs from request duration.
Missing phases are null, including embedding for supplied-vector requests.
On Linux, thread_logical_read_bytes and thread_physical_read_bytes are
counter deltas from the same blocking thread, including embedding and retrieval.
They are null with thread_io_known=false if unavailable or inconsistent;
physical reads of zero can mean cached reads. They are not process totals and
do not count work performed on other threads. Records contain no query text,
entity names, caller labels, embeddings, or headers. Errors retain the phases
that ran and set ok=false. Cancellation before blocking work starts emits
no phase record; panics are not completion records. Library and CLI vector
calls outside these HTTP handlers do not emit records. This is diagnostic
evidence for individual slow requests, not proof of a historical cause.
UI assets (not documented individually)
GET / and GET /ui serve the built-in web UI; GET /quipu-components.js,
GET /graph-canvas.js, GET /datalinks.js and
GET /vendor/three.module.min.js serve its static assets, vendored so the UI
renders on an air-gapped deploy. They are part of the UI, not the API surface.
Export
GET|HEAD|PUT|POST|DELETE /rdf-graph-store
SPARQL 1.1 Graph Store HTTP Protocol using indirect graph identification.
Select exactly one target with ?graph=<absolute-IRI> or ?default. GET
returns the graph in the RDF syntax requested by Accept; HEAD returns the
same status and headers without a body. PUT replaces the graph, POST merges
the payload, and DELETE removes its contents (and a named graph’s registry
entry). Writes use the same bearer-token and read-only enforcement as Quipu’s
native write APIs.
curl localhost:3030/rdf-graph-store?graph=http%3A%2F%2Fexample.org%2Fg \
-X PUT -H 'Content-Type: text/turtle' --data-binary '@graph.ttl'
curl localhost:3030/rdf-graph-store?graph=http%3A%2F%2Fexample.org%2Fg \
-H 'Accept: application/n-triples'
Supported request and response syntaxes are Turtle (text/turtle) and
N-Triples (application/n-triples). Unsupported request media types return
415; unsupported response types return 406; unknown named graphs return 404.
POST /export
Export deterministic RDF from ROOT, one named graph, one episode provenance
group, or a SPARQL CONSTRUCT/DESCRIBE result. graph, group_id, and
construct are mutually exclusive. The handler uses the read pool, so
serializing a large export does not hold Quipu’s writer lock.
curl -s localhost:3030/export -X POST \
-H "Content-Type: application/json" \
-d '{"graph": "http://example.org/graphs/derived", "format": "turtle"}'
| Field | Required | Description |
|---|---|---|
graph | No | Named-graph IRI (omit for ROOT; unknown IRI → 400) |
group_id | No | ROOT entities attributed through prov:wasGeneratedBy to episodes in this group, plus those episode resources |
construct | No | SPARQL CONSTRUCT or DESCRIBE query to export |
format | No | turtle (default) or ntriples |
Returns the RDF document itself with the matching content-type, not JSON. N-Triples output is lexically sorted and duplicate-free. Blank-node dataset canonicalization belongs to the share-bundle layer; raw export preserves blank node labels.
Share Import and Composition
POST /import
Verify a v1 share manifest and its exact export.nt and shapes.ttl payloads,
then stage the resolved triples in a per-share named graph. Exact canonical-name
matches are rewritten to local IRIs; fuzzy matches are returned as review
candidates and never merged automatically. Local loaded shapes remain the
authority: bundled shapes are evidence only. Off-vocabulary or non-conforming
data is retained in a quarantine graph and is not eligible for promotion.
The request fields are manifest, export_ntriples, shapes_turtle, source,
and optional actor. The response reports staged, quarantined, or
unchanged, the stable import and graph IRIs, accepted/quarantined counts,
resolution candidates, the SHACL report, and promotion blockers. This is an
authenticated write endpoint.
POST /import/promote
Explicitly copy an eligible staging graph into ROOT. The body is
{"share_id":"sha256:...","actor":"optional"}. Quarantined shares have no
eligible staging graph and are refused. Importing never promotes implicitly.
Promotion preserves exact-fact ROOT retractions: replaying a snapshot cannot
restore a locally removed fact. suppressed_retractions reports withheld facts;
triples counts eligible snapshot facts, including already-present duplicates.
An explicit local reassertion can restore a fact. Source-graph membership remains
available as provenance even when a ROOT fact was removed. Foreign transaction
anchors are never compared with local transaction IDs.
Registries
These mirror their MCP tools (see the MCP reference) —
action-style managers where an unknown action errors rather than falling
through to list.
POST /ontology
Manage OWL ontologies: {"action": "load"|"materialize"|"list"|"remove", "name", "turtle", "timestamp"} (mirrors quipu_load_ontology). Registered
even without the owl feature — a build without it answers with an explicit
error naming the missing feature rather than a 404, so “not compiled in”
and “no such route” stay distinguishable.
materialize re-derives entailments from the ontologies already loaded,
without loading anything. It is the endpoint a scheduler calls, and it exists
because on a deployment with [quipu.owl] reactive_materialize = false there
is otherwise nothing that ever runs the reasoner: ReactiveOwl is not
registered, so the write path never materialises, and OWL entailment is dark
for every family rather than merely delayed (aegis-v3gf6u).
curl -s localhost:3030/ontology -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $QUIPU_AUTH_TOKEN" \
-d '{"action": "materialize"}'
# {"action":"materialize","ontologies":7,
# "materialized":{"same_as_inferences":42, ..., "total":118}}
It materialises the combined ontology, matching what the write gate
reasons over: an axiom in one document over a class declared in another is
invisible to either alone. A store with nothing loaded answers "ontologies": 0 with a null materialized and a note, rather than reporting a successful
run of zero — a scheduler has to be able to tell “ran, derived nothing” from
“there was nothing to derive from”.
The REST materialisation action copies current ROOT and companion facts through a read-pool connection, then derives on a private temporary SQLite database. SQLite removes this database when its connection closes; its page cache is limited to 8 MiB and its term cache to 32,768 entries. Provide a writable, disk-backed SQLite temporary directory with enough space for the current facts and indexes; a RAM-backed temporary filesystem defeats the memory saving. Derivation still allocates premise and proposal vectors. It does not hold ordinary write admission or the live writer during that work. Publication takes write admission in batches of at most 64 assertions, allowing other writes between batches. A server without a read pool refuses this action rather than falling back to a full scan under the writer lock. Concurrent materialisation requests are rejected; a disconnected client cannot admit another derivation while its blocking work is still running.
Successful responses include complete: true, snapshot_tx, elapsed_ms, and
applied_proposals. Completion means the captured snapshot was closed, not
that facts arriving afterward have been processed. Concurrent additions are
allowed; a retraction of a premise or a change to the loaded ontologies aborts
publication. Earlier committed batches can remain as partial historical
entailments after an error or interruption, but no new freshness marker is
published. Retry on the next scheduled run. Consumers must inspect freshness;
the companion graph does not automatically hide historical entailments.
Input snapshots are limited to three million current facts, and derivation is limited to 200,000 new assertions and 64 passes. Exhausting a budget is an error, not successful partial materialisation. These are work-size limits, not a hard wall-clock or memory guarantee: choose the cadence and service resource limits from a rehearsal of the actual ontology and workload. The retraction fence uses an index created at store startup; allow for its initial build on existing stores. Entailments remain in the companion inferred graph, never ROOT. The synchronous library/CLI materialiser is unchanged; this lock discipline belongs to the REST scheduling path.
POST /subscriptions
Event-push subscription registry: {"action": "create"|"list"|"delete", ...}
— register an HTTP endpoint to receive graph-change events pushed by the
server’s delivery worker (mirrors quipu_subscriptions).
POST /datasets
Named dataset registry: {"action": ..., "name", "members", ...} — declare a
named set of graphs queryable as one unit via FROM <dataset> or the graph
query param (mirrors quipu_datasets).
POST /queries
Stored named-query registry: {"action": "load"|"list"|"get"|"remove", "name", "template", "params", ...} — competency questions callable through
/ask alongside the compiled-in catalog; definitions are validated at load
and versioned (mirrors quipu_queries).
Governance
The REST half of the governance gate; each mirrors its MCP tool, where the semantics are documented in full.
POST /policy/check
Committed-tier evaluation of a governance Policy against a target: returns a
Verdict — outcome ∈ satisfied | unsatisfied | unknown bound to a
reproducible evidence_hash — signed when the store has a signing identity.
curl -s localhost:3030/policy/check -X POST \
-H "Content-Type: application/json" \
-d '{"policy": "http://example.org/policy/has-owner", "target": "http://example.org/svc"}'
| Field | Required | Description |
|---|---|---|
policy | One of policy/claim | Policy IRI whose aegis:claim to evaluate |
claim | One of policy/claim | Inline SPARQL ASK |
target | Yes | Target IRI bound to $target |
predicate_id | No | Recorded predicate id for inline claims |
evidence_probe | No | ASK for “does the evidence exist?” → unknown |
valid_at | No | ISO-8601 point-in-time |
POST /verifier/authorized
{"verifier", "predicate"} → {"authorized": bool}: may this verifier
attest this predicate, per the Phase-0 verifier registry?
POST /verdict/verify
Verify a signed Verdict against the Phase-0 root of trust:
{"predicate_id", "target_ref", "outcome", "evidence_hash", "tier"?, "verifier", "signature", "verdict"?, "signed_at"?, "tx"?} →
{"signature_valid", "verifier_registered", "verifier_authorized", "trusted", "as_of"} — trusted is the conjunction to gate on. The registry is read as of
the signature: pass verdict (the stored verdict IRI) to use the instant the
store recorded it.
Overlays
Scratch layers over the committed graph (bind-once to a parent branch):
hypotheses go in the overlay, the committed layer stays untouched. Mirror
the quipu_overlay_* MCP tools.
POST /overlay/create
{"overlay": "<iri>", "parent_branch": "<iri>"?} → {"g", "parent_branch"}.
Omitted parent_branch binds to ROOT.
POST /overlay/write
{"overlay", "op": "assert"|"retract"|"tombstone", "subject", "predicate", "object", "timestamp"?} → {"tx_id"}. tombstone masks the parent’s fact
in the composed view.
POST /overlay/compose
{"overlay": "<iri>"} → {"triples": [{subject, predicate, object}], "count"}: the resolved view over [overlay > parent-branch-root],
asserted-and-not-tombstoned, nearest wins.
Provenance Analytics
POST /cooccurrence
{"work_item": "<iri>", "valid_at"?, "tx"?} → the other work-items sharing
at least one touched code entity, via Bead ←implements− GitCommit −modifies→ entity, ordered by overlap strength (mirrors
quipu_cooccurrence).
Events
The durable graph-change event log (at-least-once delivery; consumers dedup by offset).
GET /events
Feed reads (/events, /changes, /transactions) use the WAL read pool when
available, so a writer holding the store mutex does not block them. An empty
read pool falls back to the writer. POST /events/commit remains a write.
The quipu_store_wait_seconds_total and quipu_store_held_seconds_total metrics
attribute each feed read to its normalized X-Quipu-Client and endpoint,
including requests that return an error. An event page and its lag gauge are
separate reads: a concurrent commit may increase the reported lag without
changing the page’s next_offset cursor.
Pull a batch of events in offset order.
| Param | Effect |
|---|---|
since=<offset> | start after this offset |
consumer=<id> | omit since to resume from this consumer’s committed offset |
limit=<n> | batch size, clamped to 1..=10_000 (default 100) |
types=<a,b> | filter by event type |
group=<g> | filter by provenance group |
Returns {events, next_offset, lag, committed_offset?}; pass next_offset
back as since (or commit it) to page forward — polling is a fixpoint, not a
rewind.
GET /events/stream and GET /changes/stream
Read-only Server-Sent Events replay of the corresponding durable feed. Supply a
nonnegative since or Last-Event-ID; if both appear they must agree. Event
frames have event type quipu.events, a delivered event-offset ID, and the
ordinary /events page as JSON data. Change frames use quipu.changes, a
transaction-ID cursor and the ordinary /changes page as JSON data.
Event streams accept types and group; change streams accept graph,
including a registered named graph. Unrelated filter kinds and invalid resume
cursors return HTTP 400. Exhausted stream capacity returns HTTP 503.
Delivery does not acknowledge a consumer. Commit applied data and its cursor
atomically, then reconnect from that cursor after a disconnect. A transaction
ID is not an event offset and must never be sent to /events/commit.
Fifteen-second heartbeat comments do not read the store; commit hints are
checked against the durable feed after crossing the writer barrier. An hourly
backstop covers writes from other connections. A quipu.error event has no ID
and terminates a failed or oversized page; keep the prior applied cursor.
The process admits up to 64 streams, reads one transaction/event per page and limits output frames to 1 MiB. One large transaction may require more memory before the output check; this is not a total heap bound. See the committed-feed protocol.
POST /events/commit
{"consumer_id", "offset"} — durably record a consumer’s cursor. Any offset
≥ 0 is accepted, including a lower one: that is the explicit replay knob.
Refusal events (write.refused)
A refused write never enters the graph, so the event log is where the attempt
is recorded (camayoc-0d3 — the incident-rate denominator: how many writes were
attempted and refused, by which gate). Every write-gate refusal — SHACL on the
episode and /knot paths, and the policy, authority, OWL and placement gates
in transact — appends a write.refused event after the refused write’s
savepoint has rolled back, so the event survives the rollback that the refusal
caused. A failure to record never masks the refusal error itself.
Payload: {gate, graph, actor, source, reason, refused_datums} where gate
is one of shacl | policy | authority | owl | placement, graph is the
destination graph IRI, reason is the gate’s own terse text (shape/policy id,
constraint name; truncated), and refused_datums counts what was refused.
Deliberately not recorded: the refused datum bodies. Refused payloads can be junk or sensitive — the event stores identifying metadata only.
Refusals inside speculate (counterfactual writes) are not recorded: the
whole speculation rolls back by design, so a hypothetical write’s refusal is
not a real one.
Query via GET /events?types=write.refused, or count by gate with the CLI:
quipu events refusals.
Linked-Data Surface
Standards-flavoured read endpoints for semantic-web tooling.
GET /changes
Returns fact-level change records after the optional since transaction.
capture selects new_values, old_and_new_values, or new_row; graph
optionally scopes the feed to one graph IRI. The response includes next_tx
and a watermark so consumers can distinguish an idle feed from a stalled one.
GET /entity/{iri}
Content-negotiated entity page: Accept: application/ld+json → JSON-LD,
text/turtle → Turtle, anything else → the web UI’s HTML page for the
entity. GET /entity/{iri}/json, GET /entity/{iri}/ttl and
GET /entity/{iri}/html pin the format in the path instead of the header.
For a full IRI containing path separators or a fragment, use the equivalent
query form: GET /entity?iri=https%3A%2F%2Fexample.org%2Fresource%231.
JSON-LD is compacted by default with a generated @context derived from the
loaded shape prefixes. Add ?expanded=1 for full IRIs and no compact
context.
POST /spotlight
DBpedia-Spotlight-style annotation: {"text", "confidence"?} → mentions of
known entities found in the text, with offsets and IRIs. The labeled-entity
list it scans against is generation-cached, so a burst pays the expensive
fetch once. The fetch reads current ROOT labels and asserted types; named
graphs and attached packs are excluded. Cold fills stream indexed rows,
retain the existing query deadline and row limits, and avoid intermediate
SPARQL binding tables.
GET /fragments
Triple Pattern Fragments: ?subject=&predicate=&object=&page=&pageSize=
selectors, each optional — a paged triple-pattern read for TPF clients.
POST /reconcile
OpenRefine Reconciliation API: a body without queries returns the service
manifest; {"queries": {...}} runs the batch and returns candidates per
query, scored the way /resolve scores.
RDF /knot loads accept blank_node_scope: distinct IDs separate repeated identical input; the same ID shares blank nodes across graphs only for byte-identical input. By default, whole-document bytes and destination graph define scope.
POST /search_query
Search first, then evaluate SELECT from at most100 distinct seed IRIs. Keyword mode defaults and needs no embeddings; hybrid fuses successful lexical and semantic retrieval with reciprocal rank fusion. Seed ranks and explicit bounded completeness accompany the ordinary query result. See the request and scope contract.
POST /graph/create
Register a committed graph before graph-scoped knot writes or labeling.
Input: {"graph":"urn:example:jobs","actor":"urn:example:agent","timestamp":"2026-10-14T12:00:00Z"}.
Only graph is required; timestamp defaults to current UTC and supplied times
must be RFC 3339. Returns graph, g, created and creation: {timestamp, actor}.
A repeat is idempotent and preserves the original creator/time; an interned
term alone is not registration. Actor is caller-declared provenance, never
permission. Older graphs retain a null actor instead of fabricated attribution.
An overlay cannot be reclassified as committed. This is a protected write,
sharing the server’s normal bearer/read-only guards and MCP handler.
POST /graph/label
Declare versioned graph labels. Required: graph, timestamp; optional:
actor, trust: {iri, chain, rank}, freshness, durability, policy token
array, kind, valid_to. At least one axis is required. Unregistered graphs,
unknown axis values, and empty labels are errors. Create first via
POST /graph/create or MCP quipu_graph_create.
GET /graphs returns each row’s creation together with class/lifecycle/labels.
The MCP-only recipe exercises the same handlers
without a REST client.
Versioned file memory
File memory stores exact UTF-8 source text separately from extracted knowledge. It preserves BOMs, line endings and links. An owner is an exact deployment, project and crew IRI tuple. Names, account labels and caller actor fields do not establish authenticated ownership.
The feature is disabled by default. A deployment must register the graph and
set [quipu.memory_files] with enabled = true, graph, deployment and
project. No graph or credential is created by these endpoints. Existing graph
authority, vocabulary, SHACL and write-policy admission still apply.
HTTP operations
All operations accept JSON and return bounded JSON envelopes:
POST /memory/read: owner tuple; current immutable manifest or null, withcoverage = "this_repository_owner_records".POST /memory/export: owner tuple; manifest and exact identified file revisions. Cross-crew reads retain the deployment’s inherited read boundary.POST /memory/projection: owner tuple; same export plusauthenticated_owner. Requires an existing verified named credential whose principal exactly matches the owner. A shared bearer cannot prove this. This endpoint reads data; it does not write native files.POST /memory/receipt: the exact original intent; returns its verified receipt or null without retrying the write. Requires the matching named principal.POST /memory/commit: one file intent with owner, relative path, UTF-8text, expected snapshot and file revision, idempotency key, harness and opaque account label. A null text is an explicit tombstone. Requires the matching named credential and the normal writable-server policy. A stale revision returns HTTP 409 without changing the owner head.
Clients must retain the exact intent until its receipt is verified. Repeating that intent and idempotency key returns its original receipt even after the head advances. Reusing a key with different content refuses. A new stale intent conflicts even when its bytes equal the current file. Timeouts are indeterminate; clients must not silently create a new key or replace local files.
An owner snapshot contains at most 128 file entries and 4 MiB of live text; each file is at most 256 KiB. Tombstones remain in the manifest. Invalid UTF-8, traversal paths and ambiguous stored records refuse. The complete manifest is conditional on the owner head inside the ordinary transaction savepoint.
Generic RDF mutations cannot edit reserved file-memory subjects or predicates, even when caller-supplied actor/source fields resemble a memory writer. The shared transaction pipeline requires a private capability for the validated protocol’s exact rows. This also protects retractions and duplicate source claims. Current memory records must be changed through the memory protocol; privileged offline database restore remains filesystem authority.
Records use the existing base Artifact class with a memory profile, rather
than minting new ontology classes. Owner, revision, snapshot and receipt records
carry distinct kinds and explicit owner/project/deployment references. Their
source JSON is versioned data, not an extracted factual assertion. The public
repository release selector excludes memory subjects, predicates and references,
including memory attached to an otherwise public subject. This export boundary
does not introduce per-crew read secrecy.
Local CLI workflow
The local CLI uses the same core, but its authority is the filesystem principal that can open the database. It cannot isolate crews from a user who can modify that database. Every invocation requires an explicit database and graph:
qp memory read owner.json --db fixture.db --graph urn:example:memory
qp memory commit request.json --db fixture.db --graph urn:example:memory --local-authority
qp memory receipt request.json --db fixture.db --graph urn:example:memory
qp memory export owner.json --db fixture.db --graph urn:example:memory
The graph must already be registered. owner.json contains the three exact IRI
fields. A first request uses null expected_snapshot and expected_file; later
requests use the returned snapshot and file revision. The account field is an
opaque selected-account identifier, never a credential. Help opens no store.
This export is a file-data envelope, not the older RDF export/share/import
contract and not an instruction to overwrite a native memory directory. RDF
group_id selection denotes provenance, not owner authorization.
Filesystem and harness guarantees
A consumer must verify authenticated own identity, manifest coverage, each revision ID, owner, path, byte count and SHA-256 before staging native data. A safe generic consumer creates an exclusive retained generation. A lock or a last hash check cannot make replacement safe against a noncooperating editor. Native root selection is a separate harness-specific operation; staging alone does not prove a running model consumed the files. Unsupported adapters must remain explicit and preserve the previous memory bridge.
Atomic source acquisition
POST /acquire returns complete canonical N-Triples for explicitly selected
named graphs, plus a separate quipu-acquisition/v1 source receipt. A consumer
must recognize that contract in /version.read_contracts before using it.
Historical acquisition through /share is unsupported.
{
"source_store_id": "the-known-source-lineage-id",
"graphs": [
"https://example.org/project/board"
],
"tx": 42,
"max_bytes": 262144
}
The expected store identity and exact required graph IRIs come from
trusted consumer configuration. The endpoint requires 1–16 distinct registered
committed graphs. It never expands a dataset, includes ROOT implicitly, follows
an overlay, accepts arbitrary queries, or pages its response. An unknown graph
is refused rather than represented as an empty successful acquisition. A graph
IRI that also names a dataset alias is refused: /query would expand that alias,
which could mislabel another graph’s data as the requested source graph.
tx must be a positive signed 64-bit JSON integer naming an existing committed
transaction. Null, strings, fractions, negative values, unknown transactions,
and future transactions are refused. Omit tx to capture present state.
Unknown fields, including valid_at, fork, and per-graph clocks, are refused.
Attached stores are unsupported by this contract, including for present
acquisition: a main-file transaction cannot establish another file’s generation.
Ordinary unpinned /query reads remain available on attached stores.
The producer opens one SQLite read transaction before reading its identity and
head. Every graph is read at applied_tx, and the transaction remains open
through canonicalization and receipt construction. A concurrent writer can
commit, but that commit cannot alter part of the acquisition. The complete
response is capped at 8 MiB; max_bytes can only lower that ceiling. Query row
ceilings and deadlines also apply. An oversized, truncated, timed-out, or refused
read produces an error and no complete receipt. Consumers must treat errors,
missing fields, and incomplete transport bodies as UNKNOWN, never empty success.
The response has three members:
graphs: a map from each exact graph IRI to its canonical N-Triples string;source_receipt: the source identity, transaction selection, build, and scope;source_receipt_sha256: SHA-256 of compact UTF-8 JSON of the receipt, with recursively sorted object keys and no trailing newline. Strings retain UTF-8 characters rather than ASCII escapes; quotes and control characters use JSON escaping. This matches the producer’s deterministic share-manifest encoding.
The receipt contains schema, source_store_id, source_head_tx,
requested_tx, applied_tx, history, history_min_tx, canonicalization, producer_version,
producer_git_sha, producer_git_dirty, graphs, and complete. Its graph map
records each graph’s triple count and SHA-256 of the exact N-Triples bytes.
canonicalization is source-labels-sorted-ntriples/v1: triples are deduplicated
and sorted by their serialized bytes, preserving the source’s blank-node labels
across graphs. Independently renaming blank nodes within each graph could break
cross-graph identity. Consumers must import these labels as one acquisition
document scope, preserving identities across its graph members. This is not an
RDFC-1.0 graph-isomorphism digest.
requested_tx is null for a present-state acquisition; applied_tx then equals
the captured source_head_tx. For a historical request, applied_tx equals the
validated requested transaction. Both modes use the same pin for every graph.
history is file-local-bitemporal-eavt; the retained transaction-log range is
history_min_tx through source_head_tx. Existence is checked separately, since
an integer inside that range need not identify a committed transaction. The
source head is a captured transaction generation, not a substitute for a
requested pin or a content hash. The receipt hash binds the build, source,
generation, exact graph scope, and graph content identities together.
Store identity records lineage: a file copy shares its source store ID. The receipt does not certify a unique physical host, an operator’s authority over an endpoint, or a trusted producer signature. Hashes detect altered graph bytes or an altered receipt against its expected digest; they cannot authenticate a hostile producer that replaces both. Consumers must retain their endpoint trust and expected source identity and inspect the producer build separately.
Native consumers can use quipu::acquisition::Payload::verify with their original
request before importing any bytes. Verification checks the recognized contract,
completeness, expected source/pin/scope, receipt digest, canonical graph bytes,
content digests, and triple counts. Retain the source receipt and its digest
unchanged beside any generated local artifact. That artifact’s local store ID,
scope, and transaction anchor describe the artifact; rewriting them to resemble
the source would discard the distinction the receipt preserves.
/query also validates its tx field without coercion. A transaction pin cannot
be combined with valid_at or fork at this remote boundary. This restriction
does not change the internal bitemporal evaluator’s independent time axes.
Composed service acquisition
POST /acquire/composed uses the separate
quipu-acquisition/composed-v1 contract. /acquire retains its blanket
attachment refusal. Consumers must gate on the new /version.read_contracts
entry and retain the returned source receipt unchanged.
This first composed profile selects only registered, committed MAIN graphs.
Other providers remain mounted and appear in a complete, bounded census, with
outside-qualified-main-scope exclusions. Required unknown or attached graphs,
dataset aliases, overlays, frozen selections, overlapping graph ownership, and
selected identities affected by composed term aliases refuse. The direct-main
reader explicitly differs from composed /query resolution: its advertised
reader is qualified-main-committed/v1. The ownership digest binds the full
selected graph label cache and configured floor. This first profile supports
cache-only, timeless label-floor evaluation; expiring labels or trust-derived
floor inputs refuse rather than borrowing unbound meta-graph history.
The request adds expected_generation to the v1 fields. It is optional for
present capture, but required with any explicit tx: capture the admitted
generation first, then submit that token with the MAIN pin. Equal transaction
integers cannot identify a composition. The response
contains graphs, source_receipt, and source_receipt_sha256. The receipt
contains its own schema, complete, a nested main receipt with schema
quipu-acquisition/main-direct-v1, and composition. The main receipt’s
applied_tx applies only to MAIN. Composition binds the connection incarnation,
selected ownership and policy digest, and every mounted provider’s alias,
lineage, opaque physical identity, byte hash, manifest hash, local head, term
space, graph inventory, and exclusion. It is not a global transaction clock.
The server serializes topology access on its canonical store handle and holds one MAIN read transaction. On Linux x86-64 and AArch64, read leases retain the provider files during census, hashing, selected reads, and final checks. The lease fence also refuses an existing SIGIO handler or blocked/pending notification on its synchronous thread. Existing writers, a lease break, mutable journal content, replacement, or a change in physical file identity refuse. Read-only mount configuration alone is insufficient. Other platforms refuse attached composed acquisition while still allowing the attachment-free direct-main profile.
Historical admission starts at the connection’s first successful observation
of that scope and fenced topology. The composition’s history_min_tx records
this floor separately from retained MAIN transaction history. Changing topology
or policy resets the floor; restarting creates a new incarnation. Runtime
mount, unmount, or synchronized remount invalidates the incarnation before the
transition, even if the same file returns or a transition fails. An old MAIN
pin cannot retroactively use a newly attached pack. A previously observed pin
can be reused under its unchanged generation after ordinary MAIN data writes.
An expected-generation mismatch refuses rather than silently resealing data.
Limits are 16 selected graphs, 16 mounted providers, 512 named graphs per
provider, 256 MiB of aggregate provider identity reads, 100,000 selected rows
per graph, an 8 MiB response, and a ten-second operation ceiling. A shorter
request deadline also applies. Caller max_bytes only lowers the response
ceiling; graph bytes consume one aggregate budget during reading rather than
accumulating a separate full allowance per graph. Unsupported RDF values, including raw byte values, refuse. No error
returns a complete receipt or falls back to an empty or present-state export.
Profiles must name their actual required scope. Committed comments may reside in the project graph itself; do not invent a separate comments graph. A committed-project profile that excludes derived ephemerals does not satisfy a full SDK snapshot requiring them. Unknown required sidegraphs refuse. Producer source tests and a version advertisement do not establish configured adapter acceptance, installed publication, or runtime activation.
Eligibility observation
POST /acquire/eligibility (contract quipu-eligibility/v1) reports, for 1 to
16 exact named graphs, whether each would pass direct-MAIN acquisition admission
and the store’s configured label floor at one observed head:
{"source_store_id": "urn:uuid:...", "graphs": ["https://example.org/project/board"],
"expected_head_tx": 4210}
The response carries observed_head_tx, a sanitized policy identity (a
SHA-256 of the floor and the names of the configured axes, never their values),
the producer identity, and per graph eligible, refused (scope admission or
policy, with the reason) or unknown (the check failed; never eligibility).
expected_head_tx, when given, refuses unless the head is exactly that one, and
the head and policy are re-read after the checks; a change refuses.
It reads graph metadata, terms, the dataset registry, transaction ids and store identity in one read transaction, and nothing else: no facts, no writes. It holds the writer connection through a dedicated guard that never settles a signed attestation and never synchronizes or remounts providers, and it refuses a signed request outright. A configured trust- or time-derived floor refuses as unsupported before any label read, exactly as acquisition does.
An observation is not an acquisition receipt, an applied-pin policy proof,
or authority to acquire. It carries no applied_tx, and a later acquisition
can still refuse.
Physical scope measurement
quipu acquire scope (contract quipu-acquisition-scope/v1) measures, for 1
to 16 exact named graphs at one applied pin, the physical DISTINCT(e,a,v)
rows a direct-MAIN acquisition reads, using the acquisition’s own row
predicate, and the bytes of their values:
quipu acquire scope --db artifact.db --source-store-id urn:uuid:... \
--graph https://example.org/project/board --tx 4210 \
--max-vm-steps 50000000 --deadline-ms 60000
Each graph is first admitted exactly as acquisition admits it; a refused graph
is reported and not read. A graph with more than 100,000 rows is reported as
exceeds_cap with no count. The VM-step and wall budgets are required: SQLite
counts steps in strides of 1,000 per statement, and exhausting either budget
aborts the whole run with the phase that was running and no partial result.
The store is opened read-only with no migration, and a missing store is never created. The measurement returns counts only, never fact content. Running it against a production store or a copy of one is a read-cost decision for that store’s owner; the command does not authorize itself.
Explicit substring search
An optional SQLite FTS5 trigram index accelerates current, local subject-only projections of plain-string substring matches. Queries do not create or backfill the index. Unsupported expressions, historical reads and composed stores retain the ordinary evaluator.
Prepare an owned, quiescent database copy with the matching binary:
quipu search-index backfill --kind substring --batch-size 10000 --db COPY.db
quipu search-index status --kind substring --db COPY.db
Each invocation processes one bounded batch; an operator decides whether to run
another. The CLI retains its existing protected ingest-minute refusal. status
opens the store through the maintenance path; it is not a promise of a file-free
observation. A complete, supported schema is required for ready: true.
The derived tables and transaction triggers track current inserts, retractions and rollbacks. They are omitted from logical exports; receivers prepare their own index. Exact RDF equality is checked after fingerprint collisions. Language and typed literals retain ordinary SPARQL semantics.
Clients requiring this path send X-Quipu-Require-Substring: v6 on /query.
The server checks completeness before evaluation, pins readiness and facts in
one SQLite read snapshot, and echoes X-Quipu-Substring-Index: v6 on success.
Missing or incompatible indexes refuse; they do not silently trigger a scan.
This contract excludes historical, federated and attached-provider reads.
Its connection-local SQLite cache is capped at 8 MiB, preserving smaller caller
budgets; cache and mmap policies are restored after success, error or unwind.
This bounds that cache, not the whole query’s memory or result size.
To remove the optional index, first stop dependent full-field consumers and quiesce the database, then use the matching binary:
quipu search-index drop --kind substring --db COPY.db
Remove the derived triggers before rolling back to a binary that lacks their codecs. Facts remain intact, and requiring clients then refuse until preparation is explicitly restored. Production adoption is a separate operator decision.
See the synthetic before/after demo.
Python Client
quipu-client is a thin, typed Python client for the REST API,
living in python/ in the main repository. Standard library only — urllib
all the way down, zero runtime dependencies, Python >= 3.11. It is a
wrapper, not a re-implementation: request shapes, auth, and error surfaces are
kept honest against the REST reference, and anything not listed here is a call
away with curl.
Installation
# from a quipu checkout
pip install ./python
Quick start
from quipu_client import QuipuClient, QuipuError
q = QuipuClient("http://localhost:3030", token="secret-for-writes")
q.health() # {"status": "ok"}
q.stats() # {"facts": ..., "entities": ..., "predicates": ...}
q.version() # {"version", "git_sha", "git_dirty", "features"}
Reads are open; writes need a bearer token — the client mirrors that
contract exactly. token is attached as Authorization: Bearer <token> on
write calls only, and a client constructed without a token sends no
Authorization header at all. The optional client_id / task_id
constructor arguments become the X-Quipu-Client / X-Quipu-Task
attribution headers.
Reads
| Method | Endpoint | Returns |
|---|---|---|
health() | GET /health | dict |
stats() | GET /stats | dict |
version() | GET /version | dict |
query(sparql, graph=, valid_at=, tx=, fork=, include_kinds=, federated=) | POST /query | dict |
validate(shapes, data) | POST /validate | dict |
search(embedding=, query=, limit=, valid_at=, group_ids=, entity_type=) | POST /search | dict |
hybrid_search(sparql, embedding, limit=) | POST /hybrid_search | dict |
context(query, max_entities=) | POST /context | dict |
ask(name=, params=) | POST /ask | AskResult (dict when listing the catalog) |
export(graph=, group_id=, construct=, format=) | POST /export | str — the RDF document itself, not JSON |
Unset optionals are omitted from the request body entirely, never sent as
null — to /query, absent and null are different things (silence never
widens scope).
Writes
| Method | Endpoint | Returns |
|---|---|---|
knot(turtle, shapes=, timestamp=, valid_from=, actor=, source=, graph=, ...) | POST /knot | KnotResult(tx_id, count, conforms, valid_from) |
episode(name, nodes=, edges=, replace_snapshot=, source=, timestamp=) | POST /episode | EpisodeResult(outcome, count, tx_id) |
set(entity, predicate, value, timestamp=, actor=) | POST /set | SetResult(tx_id, retracted, asserted, ...) |
retract(entity, predicate=, value=, timestamp=, actor=) | POST /retract | RetractResult(retracted) |
Every result dataclass keeps the full decoded body in .raw.
Two REST-doc contracts worth restating because the types encode them:
- Branch on
EpisodeResult.outcome, never oncount.unchangedis a successful idempotent retry — re-posting under a new name becausecount == 0looked like failure is how duplicate entities get minted. - An edge value is
{"iri": ...}, a bare string is a literal.setandretractpassvaluethrough untranslated, so the server’s loud 400 for a bare IRI-shaped string reaches you as aQuipuErrorwith the guidance attached.
Errors: QuipuError
Every non-2xx answer raises QuipuError — a refusal is never silent, and
never swallowed:
try:
q.knot(turtle)
except QuipuError as e:
e.status # HTTP status code
e.body # decoded JSON body (or raw text when not JSON)
e.reason # the stable machine-readable field, e.g.
# "missing_or_invalid_bearer_token" — None if absent
A SHACL refusal’s feedback payload — which shape, which constraint, which
focus node — arrives intact in e.body and in str(e), because that
feedback is the entire point of the refusal.
Tests
The suite runs against a stdlib http.server stub asserting method, path,
headers, and body per call — no network, no live Quipu:
python3 -m pytest python/tests -q
MCP Tools
Quipu exposes its API as MCP (Model Context Protocol) tools for agent
integration, from its own MCP server: quipu mcp --db <path> over stdio, or
quipu-server at /mcp over HTTP. (Bobbin embeds Quipu but serves its own
knowledge_* tools, not these.)
The registry (tool_definitions()) exposes 77 tools in a default build, or
79 when built with the owl feature (which adds quipu_load_ontology and quipu_explain).
(The counts are pinned by tests in src/mcp/tests.rs, which also check this
page and the README against the manifest.)
Connect directly
The server includes the native mcp feature. Build the CLI and its companion:
cargo build --release --features full --bin quipu --bin quipu-server
claude mcp add quipu -- /absolute/path/to/target/release/quipu mcp --db /absolute/path/to/store.db
quipu mcp starts the sibling quipu-server --mcp-stdio with the same configuration
and store initialization as HTTP mode. It opens no network listener. On Unix it
replaces the CLI process, so signals and EOF reach the server directly. Install
both binaries together. Protocol output uses stdout; diagnostics use stderr.
For an already running server, register its /mcp URL as streamable HTTP. Calls
are stateless: a server restart does not leave stale MCP sessions. The HTTP caller
supplies the same service-specific bearer used for REST writes. For stdio with
protected writes, pass --mcp-token-file /absolute/path/to/private-token; the file
must be regular, at most 4096 bytes, and private (0600 or 0400 on Unix). Credentials
are never accepted in tool arguments or forwarded from a claimed actor field.
Unconfigured local stores retain the CLI’s existing local authority; configuring
write authentication applies to stdio too. Read-only mode refuses writes even
with a valid credential. No existing shared credential or open REST read changes.
Every tool dispatches through the existing REST application in process, sharing
its authentication, dataset selection, read pools, validation, and committed
transaction attribution. The schemas come from tool_definitions(); the native
transport does not maintain a second schema catalogue. quipu_graph_list preserves
its query filters. Tool failures are MCP error results with the REST error body.
Natural-language quipu_search still requires the configured embedding provider;
a precomputed embedding works without one. The transport does not download models.
Browser MCP requests require an Origin explicitly present in
quipu.server.cors_allowed_origins; no Origin is normal for native agent clients.
The REST origin policy remains unchanged. Tool requests are limited to 64 MiB.
Bobbin’s knowledge_* tools and existing Homelab quipu_* proxies remain supported
compatibility surfaces. Native MCP lets an installation use Quipu without either
proxy. The Homelab proxy continues to call REST; Bobbin keeps its existing library
integration. Register the intended server explicitly to avoid ambiguous names;
this addition does not remove an existing MCP entry or its credentials.
Run just mcp test for HTTP and stdio protocol acceptance against isolated stores,
including concurrent named/shared credentials and read-only refusal. Set
QUIPU_MCP_TEST_MODEL_DIR to a model directory containing onnx/model.onnx and
tokenizer.json, plus ORT_DYLIB_PATH as needed, to test natural-language search
with a real local embedding provider. Otherwise the test checks vector search and
the explicit missing-provider error separately.
Tool Reference
quipu_query
Execute a SPARQL SELECT query.
| Parameter | Required | Description |
|---|---|---|
query | Yes | SPARQL query string |
valid_at | No | ISO-8601 timestamp for time-travel |
tx | No | Transaction ID for time-travel |
graph | No | Named-graph IRI or dataset name scoping the default graph (unknown IRI → empty default graph, never ROOT) |
fork | No | Fork name to read (see quipu fork); unknown/dropped forks are refused; mutually exclusive with graph |
include_kinds | No | dataKind tokens (e.g. ["archive"]) that widen the default graph set with every graph declaring one of them. Absent/empty = unchanged scope; malformed tokens are refused; mutually exclusive with fork; a FROM in the query text still overrides |
verbose | No | Return full IRIs instead of the default CURIE-compacted values |
Query results use prefixes declared by the loaded shape sets for compact CURIE values by default. IRIs in unknown namespaces remain full IRIs.
quipu_rule_query
Read one grounded claim using independent positive and explicit-negative evidence. The result preserves supported, refuted, both and neither, with a separate admitted judgment disposition. Failed, capped or incomplete reads return UNKNOWN; answers never grant action authority. See rule answers for strict request fields, evidence scope and retained judgment requirements.
| Parameter | Required | Description |
|---|---|---|
subject | Yes | Absolute subject IRI |
positive_predicate | Yes | Absolute predicate for explicit positive support |
negative_predicate | Yes | Distinct absolute predicate for explicit negative support |
object | Yes | One strict IRI or plain-string value object |
judgment_policy | Yes | Expected admitted judgment policy |
witness_limit | No | Evidence row bound per side, 1–256 (default 64) |
quipu_export
Export deterministic RDF from ROOT, a named graph, an episode provenance group, or a SPARQL graph query. Scope parameters are mutually exclusive.
| Parameter | Required | Description |
|---|---|---|
graph | No | Named-graph IRI to export (omit for ROOT; unknown IRI is an error) |
group_id | No | Export ROOT entities attributed to this episode group |
construct | No | SPARQL CONSTRUCT or DESCRIBE query whose graph is exported |
format | No | turtle (default) or ntriples |
quipu_align_propose
READ. Proposes candidate cross-graph alignments between two named graphs as a
scored SSSOM mapping set, and returns the expected_version that
quipu_align_apply requires.
Refuses an unknown graph IRI rather than returning zero candidates: align::enumerate
returns an empty enumeration for an IRI it cannot look up, so a typo would otherwise
come back as 0 candidates — indistinguishable from two graphs that genuinely share
nothing.
quipu_align_decide
READ. Applies operator accept / negate verdicts to a proposed mapping set.
Touches no store. Returns the decided set and the expected_version to carry into
quipu_align_apply.
quipu_align_apply
WRITE. Materialises decided alignments as owl:sameAs / quipu:distinctFrom in an
alignment graph derived from the two source graphs, creating that graph if needed.
expected_version is required and must be carried from the decision being applied.
It is not computed here: set_version hashes the mapping set itself, so deriving the
version at apply time would hash the set about to be written, always match, and silently
discard a concurrent operator’s decision.
These are three separate tools rather than one with a mode because an MCP client judges
a tool by its annotation. A moded tool would carry a single, necessarily destructive
annotation, and every read-only call — including propose, the entry point — would be
refused under a no-approval policy.
quipu_merge_decisions
READ. Lists the conflicts of merging an incoming share into ROOT: one row per slot
with the base / ours / theirs values, the slot’s max_count, and each side’s
provenance. The rows are bound to ROOT’s graph hash and the incoming share id. With
propose: true, each row also gets a mechanical proposal (choose plus evidence).
It never sets a decision and writes nothing.
incoming and base are shares inline, in the shape /import takes (manifest,
export_ntriples, shapes_turtle). Both are verified by hash, and base must be the
incoming share’s parent_share. A server path is never accepted. REST:
POST /merge/decisions. CLI: quipu merge <share-dir> --emit-decisions <file> [--propose].
quipu_merge_apply
WRITE. Finishes the merge from the decisions file with each row’s decision set
({"choose": "ours"|"theirs"|"base"} or {"values": [<N-Triples terms>]}). It commits
the clean part of the merge plus every decided slot in one transaction. The
transaction’s source records both parents, the reviewer, and the SHA-256 of the
decisions.
It refuses, and writes nothing, on an undecided row, stale decisions (ROOT or the share
moved since they were emitted), more values than the slot’s sh:maxCount, or a value
that is not an RDF term. It also refuses a field merge-decisions/v1 does not
define. dry_run: true runs every check and returns the counts it would write,
without writing. REST: POST /merge/apply. CLI: quipu merge <share-dir> --decisions <file> --reviewer <who> [--dry-run].
quipu_knot
Assert facts from Turtle data, with optional SHACL validation.
| Parameter | Required | Description |
|---|---|---|
turtle | Yes | RDF Turtle data |
blank_node_scope | No | Explicit document identity for blank nodes; same ID shares only byte-identical input across graphs, distinct IDs separate repeat loads |
timestamp | No | Transaction-time: when this store came to believe the facts (defaults to now) |
valid_from | No | Valid-time: when the facts became true of the world. RFC 3339, normalised to UTC Z. Omit to reuse timestamp |
actor | No | Who is asserting |
source | No | Where the facts came from |
shapes | No | SHACL Turtle for validation gate |
graph | No | Registered committed-graph IRI to write into; unknown IRIs error, overlays refused; omit for ROOT |
replace_snapshot | No | Replace this producer’s prior facts (diffed), scoped to the target graph |
snapshot | No | Stable producer key required by replace_snapshot |
Returns: transaction ID, fact count, the normalised valid_from actually
stored, and whether validation passed.
timestamp and valid_from are the store’s two time axes and they answer
different questions — valid_from is queried with valid_at, timestamp with
tx/as_of_tx. A commit authored in March and ingested tonight wants
valid_from in March and timestamp tonight; passing only timestamp
collapses both, which is what this surface did before aegis-sb8of5.
Valid-time is compared as text, so valid_from is normalised to
YYYY-MM-DDTHH:MM:SSZ: a UTC offset is applied (git’s %aI emits the author’s
local offset, and 2026-09-07T00:30:00+01:00 would otherwise sort after
2026-09-06T23:45:00Z, though it is earlier) and sub-second precision is
dropped (.5Z sorts before Z). A malformed value is refused before the
vocabulary gate, the SHACL pass, or any transaction — nothing is written.
replace_snapshot retractions are not back-dated: a replaced fact’s
valid_to is the transaction stamp, because “these stopped being true in March”
is a different claim from “we replaced them tonight”.
When replacement deletes an entity, a reference from another producer preserves
its label, but not its stale type membership. Code snapshot keys
code:<repo>:<partition> share a producer boundary with code:<repo> and all
partitions of that repository. Their sibling references do not preserve a deleted
label: a promote replaces keys in separate transactions, so sibling references
can still be stale. References from another repository or producer still preserve
the label. Other snapshot keys retain exact-key ownership semantics.
quipu_cord
List entities with optional filtering.
| Parameter | Required | Description |
|---|---|---|
type | No | Filter by rdf:type IRI |
predicate | No | Filter by relationship |
limit | No | Max results (default: 100) |
quipu_unravel
Time-travel query: view facts at a past state.
| Parameter | Required | Description |
|---|---|---|
tx | No | Transaction ID |
valid_at | No | ISO-8601 timestamp |
At least one of tx or valid_at must be provided.
quipu_validate
Dry-run SHACL validation without writing.
| Parameter | Required | Description |
|---|---|---|
shapes | Yes | SHACL shapes as Turtle |
data | Yes | Data to validate as Turtle |
Returns: conforms boolean, plus arrays of violations, warnings, and informational issues.
quipu_shapes
Manage persistent SHACL shapes that auto-validate writes.
| Parameter | Required | Description |
|---|---|---|
action | Yes | load, list, or remove |
name | For load/remove | Shape set identifier |
turtle | For load | SHACL Turtle content |
timestamp | No | Timestamp for load |
quipu_retract
Retract ROOT/default-graph facts for an entity. Named-graph facts are untouched;
supplying graph, including null, is refused before mutation.
| Parameter | Required | Description |
|---|---|---|
entity | Yes | Entity IRI to retract |
predicate | No | Only retract this predicate |
timestamp | No | Retraction timestamp |
actor | No | Who is retracting |
quipu_set
Atomically set (entity, predicate) to exactly one value: retracts every
current object on that predicate and asserts the new one in a single
transaction — the supersede primitive. Single-value semantics: to add without
removing, assert via quipu_knot.
This operation affects only ROOT/default-graph facts. Named-graph facts are
untouched, and supplying graph, including null, is refused before mutation.
| Parameter | Required | Description |
|---|---|---|
entity | Yes | IRI of the entity (must exist) |
predicate | Yes | Predicate IRI to set (may be new) |
value | Yes | Bare string = literal; {"iri": …} for an edge; typed forms for int/float/bool/lang/datatype |
timestamp | No | ISO-8601 valid-time for the supersede |
actor | No | Who is performing the set |
quipu_retract_episode
Episode-scoped logical retraction (POST /episode/retract). Retracts the
facts an episode’s ingest contributed (activity node, entities, edges, reified
statements) by closing valid_to — logical, not physical, so time-travel history
is preserved. Entities and other episodes’ facts (even about shared IRIs) are
untouched. Idempotent.
By default (on_orphan: "preserve") it does not retract every currently-active
fact: it keeps rdfs:label / rdf:type alive for nodes that other episodes still
reference, so scope retraction cannot leave a node visible to predicate queries but
invisible to label/type scans.
| Parameter | Required | Description |
|---|---|---|
episode | Yes | Episode name to retract (aliases: episode_id, name) |
timestamp | No | Retraction timestamp |
actor | No | Who is retracting |
on_orphan | No | preserve (default) | refuse (reject if it would orphan identity) | allow (retract everything). Alias: orphan_policy |
Response: tx_id, retracted, episode, statements, plus identity accounting —
on_orphan, identity_preserved (+identity_preserved_statements), and
identity_orphans (+identity_orphan_entities).
Retraction is a more sensitive write than assertion. The endpoint honours read-only mode and bearer auth today; when per-principal scopes (hq-azs) and crew identity (hq-otm) land it should require an authorized principal.
quipu_retract_source
Identical facts asserted by different sources retain independent ownership.
Repair removes only the selected source’s claims in the selected graph; a fact
remains visible until its last owner retracts it. Repeating an assertion under
the same source is idempotent. The reported retracted count is the number of
source-owned statements processed, not the number that disappeared from the
logical graph. Historical assertions remain in the audit log.
The ownership fix is forward-only for newly recorded claims. Older versions skipped identical assertions from later producers, so those historical claims cannot be reconstructed. Reassert all relevant producer snapshots before retracting old shared facts; this release does not backfill ownership.
Retract-only repair of facts owned by a legacy transaction source
(POST /retract/source).
Retraction in the store is source-scoped: it closes every currently-live fact
whose transaction source equals a given string. quipu_knot composes its own
tag as snapshot:<key>, so a producer can only ever clear what it wrote under
that scheme — facts written under any other source string (a free-form producer
string, a hand-run CLI promote) were unreachable by any retraction, permanently.
This tool names the source explicitly and clears it.
It is deliberately not a raw source-tag input on quipu_knot. There, one tag
stamps a transaction that carries both retractions and new assertions, so a raw
tag would let any caller write facts attributed to any producer. Here there is no
turtle parameter at all, so the transaction cannot carry an assertion and
impersonation is impossible by construction rather than by discipline.
| Parameter | Required | Description |
|---|---|---|
source | Yes | EXACT transaction source string to retract — matched literally, never by prefix or pattern |
repair | Yes | Ticket or reason; stamped on the retraction transaction as repair:<ticket>, never the source being cleared |
apply | No | Default false = plan only, nothing is written |
expect | With apply | The planned count you are confirming; a mismatch is refused |
graph | No | Registered committed-graph IRI; absent targets ROOT (same rules as quipu_knot) |
timestamp | No | Retraction timestamp |
actor | No | Who is performing the repair |
Response: source, graph, planned, entities, applied, tx_id,
retracted, repair_source, sample (+ sample_truncated), and — on an
applied call — remaining, a fresh read of the post-state rather than an
echo of the request.
Two properties worth knowing before using it:
planned: 0is a real answer. It means the named source owns no live facts.quipu_knotreportsreplaced: true, count: 0both for a retraction that deletes nothing and for one that empties a graph. Readplannedto distinguish those cases.- Re-keying order is retract FIRST, then re-promote. The store dedups an identical triple to one row carrying one source, and the existence check ignores the transaction source — so asserting canonically first is skipped as a duplicate, every row keeps its legacy source, and the retraction then removes everything.
quipu_episode
Ingest structured agent knowledge as an episode.
| Parameter | Required | Description |
|---|---|---|
name | Yes | Episode identifier |
episode_body | No | Natural language description |
source | No | Source agent/system |
group_id | No | Provenance label for the episode (not an isolation boundary — see Episodes) |
nodes | No | Array of {name, type, description, properties} |
edges | No | Array of {source, target, relation} |
quipu_search
Semantic vector search over entity embeddings. Supply either a natural-language
query (auto-embedded when an EmbeddingProvider is attached) or a pre-computed
embedding vector. At least one is required.
| Parameter | Required | Description |
|---|---|---|
query | No | Natural-language query (auto-embedded; alternative to embedding) |
mode | No | semantic (default) or opt-in keyword (SQLite FTS5 BM25; requires query text, enabled and backfilled index; no embedding provider) |
embedding | No | Float array (query vector); takes precedence over query |
limit | No | Max results (default: 10) |
ranking | No | semantic (default) preserves cosine order; opt-in content demotes contentless repository artifacts |
valid_at | No | Temporal filter |
graph | No | Registered graph IRI, or all; omitted scope searches ROOT |
graphs | No | Nonempty list of registered graph IRIs; search their union |
all_graphs | No | Search ROOT and all registered named graphs, excluding graph metadata |
verbose | No | Return full entity IRIs instead of the default CURIE-compacted values |
Requires an embedding provider when called with query and no embedding;
without one it errors naming the missing [quipu.embedding] configuration.
The response carries an embeddings block (configured, embedded_entities)
so zero results are distinguishable from an unembedded store — see
Embeddings and Semantic Search.
| group_ids | No | Best-effort filter to entities from these provenance groups (episode-scoped label, not an isolation boundary; /knot facts are ungrouped and dropped from a group scope) |
| entity_type | No | Restrict to entities of this rdf:type IRI |
Results include raw similarity, adjusted score, and ranking_reason.
Graph selectors are mutually exclusive; unknown graphs are refused. Explicit
scope results carry graph and graphs. Semantic scope selects entity
membership before the result limit on the built-in SQLite backend. Other
vector backends use bounded best-effort oversampling. Embeddings remain per entity: ROOT text
is retained for entities with ROOT facts; other entities combine their
named-graph facts. This is membership scoping, not per-graph embedding text.
Explicit graph scope supports semantic and keyword ranking; ROOT content and
anchor reranking are refused with it.
Explicit graph selection in semantic and hybrid search requires
[quipu.search] named_graphs = true. The default is false. Prepare named-only
vectors with bounded backfill before enabling it; the flag also gates automatic
named-only embedding text. Keyword mode does not need it: the lexical index
records each assertion’s graph, so keyword search accepts an explicit scope
whenever keyword = true and the index is complete. Hybrid with alpha = 0
is pure keyword ranking and follows the keyword rule.
Keyword mode returns score (positive relevance, higher first), raw SQLite
bm25 (lower first), and ranking_reason: "keyword". It indexes literal terms
and quoted phrases from labels, alternate labels, descriptions including full
episode bodies, other literal attributes, type names, and entity IRI local-name
tokens. Phrase/term conjunctions currently match one fact document; structured
cross-attribute expressions belong to the structured-query stage. The default
semantic response and scores are unchanged. Keyword mode rejects embeddings,
anchors and content ranking rather than silently ignoring them.
Activate [quipu.search] keyword = true, then run explicit bounded
quipu search-index backfill --batch-size 500 --db <path> calls until status
reports complete: true. Search refuses an incomplete index. Each call commits
one batch and releases the writer; the CLI refuses UTC minutes 10 through 20
to protect scheduled ingestion. See keyword index.
See search ranking for content criteria, exact-name
exceptions, temporal behavior, and bounded candidate recall.
Anchored search (anchor, max_hops, anchor_mode, decay, via,
direction, explain) roots the search on one entity and ranks by hop
distance. It is off unless the server sets [quipu.search] anchored = true. See
anchored search.
quipu_hybrid_search
Combined SPARQL filtering + vector ranking. Supply either a natural-language
query (auto-embedded) or a pre-computed embedding; the sparql pre-filter is
optional.
| Parameter | Required | Description |
|---|---|---|
query | No | Natural-language query (auto-embedded; alternative to embedding) |
embedding | No | Float array (query vector); takes precedence over query |
sparql | No | SPARQL pre-filter query (enables predicate pushdown) |
limit | No | Max results (default: 10) |
valid_at | No | Temporal filter |
Requires an embedding provider when called with query and no embedding;
without one it errors naming the missing [quipu.embedding] configuration.
The response carries an embeddings block (configured, embedded_entities)
so zero results are distinguishable from an unembedded store — see
Embeddings and Semantic Search.
For the built-in SQLite backend, an explicit SPARQL scope is scored before selecting the top results. Narrow scopes to at most 1,000 candidate rows, resolved entity IDs, and eligible embedding rows; larger scopes return an error. Historical ranking returns the text from the exact embedding version scored. Query and stored vectors must contain finite values with matching dimensions; the SQLite scoped path accepts up to 16,384 dimensions. An empty eligible scope returns no results. SPARQL candidates use the requested valid time and a bounded query budget. Unscoped ranking and delegated backend ranking retain their existing implementation; candidate-before-top-K is a SQLite guarantee. Scoring reads the local SQLite vector table, including rows keyed by composed alias IDs; it does not scan vector tables in attachments. The candidate, scoring, and text reads do not provide one snapshot across concurrent writes.
quipu_graph
Project the knowledge graph into a render-ready node-link payload in one response: nodes (IRI, label, type, degree), index-addressed edges, and a type census. Episode/provenance scaffolding is excluded by default; nodes are ranked by degree and capped, and the response states what was dropped.
| Parameter | Required | Description |
|---|---|---|
limit | No | Max nodes, ranked by degree (default 250, hard max 2000) |
type | No | Restrict to nodes of this rdf:type IRI |
include_episodes | No | Include prov:Activity episode nodes (default false) |
quipu_project
Graph projection and algorithms.
| Parameter | Required | Description |
|---|---|---|
algorithm | No | stats, in_degree, pagerank/ppr, components, louvain, or shortest_path (default: stats) |
type | No | Restrict projection to this rdf:type IRI |
predicate | No | Restrict projection to edges with this predicate IRI |
graph | No | Project one named graph’s own facts instead of ROOT — cheap against a small derived layer even when the episode log is large |
limit | No | Max results for in_degree/pagerank (default: 20) |
seeds | No | Seed entity IRIs for personalized PageRank (non-empty switches pagerank to PPR) |
damping | No | PageRank damping factor (default: 0.85) |
max_iters | No | PageRank max iterations (default: 100) |
tolerance | No | PageRank convergence tolerance (default: 1e-6) |
from / to | No | Source/target entity IRIs for shortest_path |
persist | No | louvain: persist quipu:memberOfCommunity facts; pagerank (global runs only — a seeded run refuses): persist quipu:pageRank scores. Both supersede any prior derivation (default: false). Communities are emergent clustering, not an access boundary. |
The louvain algorithm runs deterministic modularity-based community detection
and returns { communities: [{ community, entities, size }], modularity }.
Read-only unless persist: true.
quipu_context
Unified knowledge context pipeline.
| Parameter | Required | Description |
|---|---|---|
query | Yes | Search query string |
max_entities | No | Max entities (default from pipeline config) |
expand_links | No | Follow relationships to linked entities |
ppr_rerank | No | Re-order candidates by Personalized PageRank seeded at the direct hits before truncation (default: false) |
The summary includes an embeddings block (configured,
embedded_entities) reporting whether semantic retrieval was possible.
quipu_report
Live graph report — graphify’s GRAPH_REPORT.md equivalent, but queryable.
Read-only.
| Parameter | Required | Description |
|---|---|---|
type | No | Restrict the projection to this rdf:type IRI |
predicate | No | Restrict the projection to edges with this predicate IRI |
hubs | No | Number of top hubs to return (default: 10) |
surprises | No | Number of surprising connections to return (default: 10) |
questions | No | Number of suggested questions to return (default: 8) |
Returns three sections:
hubs— “god-nodes”: the most central entities by PageRank, each with itsin_degreeas a secondary signal.surprising_connections— low-prior edges that bridge two otherwise-separate Louvain communities. Rarer bridges (fewer edges crossing between the same two communities —bridge_rarity) rank first; ties break toward bridges touching higher-PageRank endpoints.suggested_questions— deterministic, template-generated prompts seeded by the hubs and bridges above.
Plus a graph summary (nodes, edges, communities, modularity).
Communities here are emergent clustering for surfacing, not an access
boundary.
quipu_policy_check
Committed-tier evaluation of a governance Policy over the graph of record.
Evaluates the policy’s aegis:claim (a SPARQL ASK, optionally with a $target
placeholder) and returns a Verdict — outcome ∈ satisfied | unsatisfied | unknown bound to a reproducible evidence_hash. Deterministic: any verifier
re-running the same ASK over the same committed evidence gets the same verdict
(checked, not trusted). The verdict is returned unsigned unless the store
has a signing identity attached.
Stored claim and evidenceProbe properties are read under both vocabulary
namespaces. Identical alias values collapse; conflicting values are refused.
The stored query text, target identity, and signature inputs are preserved.
| Parameter | Required | Description |
|---|---|---|
policy | One of policy/claim | Policy IRI whose aegis:claim to evaluate |
claim | One of policy/claim | Inline SPARQL ASK claim |
target | Yes | Target IRI bound to the $target placeholder |
predicate_id | No | Predicate identifier recorded in the verdict (inline claims; default inline) |
evidence_probe | No | Inline ASK for “does the evidence exist?” — false yields unknown |
valid_at | No | ISO-8601 point-in-time for valid-time evaluation |
quipu_verdict_verify
Verify a signed Verdict against the Phase-0 root of trust: the signature must
be valid under the verifier’s registered public key, and the verifier must
be authorized to attest the predicate. trusted is the conjunction — the
property a consumer should gate on.
Registration classes and verifier/key predicates are read under both vocabulary namespaces. Conflicting registered keys are refused, rather than choosing one; duplicate aliases carrying the same key are accepted.
| Parameter | Required | Description |
|---|---|---|
predicate_id | Yes | Predicate the verdict attests |
target_ref | Yes | Target the verdict is about |
outcome | Yes | Verdict outcome |
evidence_hash | Yes | Evidence hash the signature seals |
tier | No | Evidence tier (default: committed) |
verifier | Yes | Verifier IRI whose registered key verifies the signature |
signature | Yes | Signature over the verdict message: hex for ed25519; base64url assertion signature for webauthn-*; the armored SSH SIGNATURE for sshsig-sk-ed25519 |
verdict | No | IRI of the stored verdict carrying this signature: verify as of when the store recorded it (use this for trust decisions) |
signed_at | No | Explicit valid-time instant (a what-if query; default now) |
tx | No | Explicit transaction to verify as of (a what-if query; default latest) |
scheme | No | ed25519 (default), webauthn-es256, webauthn-eddsa, or sshsig-sk-ed25519 |
authenticator_data | WebAuthn | base64url authenticatorData |
client_data_json | WebAuthn | base64url clientDataJSON |
The registry is read as of the signature (signing-plane S1): a key that
has since been rotated still verifies what it signed while registered, and
cannot verify anything recorded after it was closed. The result’s
as_of.basis is recorded, caller-supplied or now. A caller-supplied
instant is a what-if: trusted is then always false, and the answer is in
would_verify_as_of_supplied_instant. Otherwise naming a transaction from
before a revocation would make a revoked key read as trusted.
Hardware schemes are off by default ([quipu.governance] hardware_verdict_schemes); while off, a verdict naming one is refused. A
hardware verdict verifies only against a registration declaring the same
aegis:signatureScheme, and is trusted only when that same registration
attests the predicate. WebAuthn registrations carry the base64url COSE key in
aegis:publicKey plus aegis:webauthnRpId and aegis:webauthnOrigin; SSHSIG
registrations carry an OpenSSH sk-ssh-ed25519@openssh.com public key line.
The WebAuthn challenge is derived by quipu from the verdict message, never
taken from the caller, and SSHSIG must use namespace quipu-verdict. The
response’s sign_count is the authenticator counter to record as the
registration’s aegis:signCount. Once a nonzero counter is recorded, a
signature whose counter does not exceed it (including 0) is refused as a
possible clone; a credential that has only ever reported 0 is accepted.
WebAuthn requires user verification (UV); SSHSIG requires user presence and
reports UV without requiring it.
See src/verdict_schemes/mod.rs for the full rule list.
The as-of rule binds every scheme: a hardware verdict is checked against
the registrations, grants and recorded aegis:signCount in effect at the
same instant, and a caller-supplied instant leaves it untrusted too.
quipu_verifier_authorized
Check the Phase-0 verifier registry: may this verifier attest this predicate? The discovery half of the governance gate. Registration classes and each verifier/attests predicate may independently use the legacy or Quechua vocabulary.
| Parameter | Required | Description |
|---|---|---|
verifier | Yes | Verifier IRI |
predicate | Yes | Predicate IRI to attest |
signed_at | No | Valid-time instant to check at (default now) |
tx | No | Transaction to check as of (default latest) |
quipu_cooccurrence
Deterministic, auditable work-item co-occurrence: given a work-item (Bead)
IRI, returns the other work-items that share at least one touched code entity
via the provenance chain Bead ←implements− GitCommit −modifies→ entity.
A graph query over typed provenance edges, ordered by overlap strength. Each
implements/modifies edge may use the legacy or Quechua vocabulary independently;
duplicate aliases do not inflate shared-entity counts.
| Parameter | Required | Description |
|---|---|---|
work_item | Yes | Work-item (Bead) IRI |
valid_at | No | ISO-8601 point-in-time for valid-time filtering |
tx | No | Maximum transaction ID to consider |
quipu_overlay_create
Register an overlay-class named graph bound (bind-once) to a committed parent branch. Overlays are scratch layers over the committed graph: write hypotheses into an overlay, read the composed view, and the committed layer stays untouched.
| Parameter | Required | Description |
|---|---|---|
overlay | Yes | Overlay graph IRI to register |
parent_branch | No | Committed parent-branch IRI (omit for ROOT) |
quipu_overlay_write
Write one overlay primitive: assert, retract, or tombstone a triple in an
overlay graph. Tombstone masks the parent branch’s fact in the composed view
without touching the committed layer.
| Parameter | Required | Description |
|---|---|---|
overlay | Yes | Overlay graph IRI |
op | Yes | assert, retract, or tombstone |
subject | Yes | Subject IRI |
predicate | Yes | Predicate IRI |
object | Yes | Object value (IRI string, literal, or typed JSON value) |
timestamp | No | ISO-8601 valid-time (default: now) |
quipu_overlay_compose
Resolve an overlay’s composed view over [overlay > parent-branch-root].
Read-only. Two precedence modes: nearest (default, the scratch-layer read —
asserted-and-not-tombstoned, nearest wins) and governed (the
quarantine-plane read — the parent’s facts always win: an overlay value on a
same-subject-same-predicate slot the parent claims is suppressed, and an
overlay tombstone cannot mask a parent fact; it only masks the overlay’s own
contributions).
| Parameter | Required | Description |
|---|---|---|
overlay | Yes | Overlay graph IRI |
precedence | No | nearest (default) or governed |
quipu_search_nodes
Search for entities by natural-language query (text matching on names, labels,
and values). Replaces Graphiti’s search_nodes.
| Parameter | Required | Description |
|---|---|---|
query | Yes | Natural-language search query |
group_ids | No | Best-effort filter to entities from these provenance groups (episode-scoped label; /knot facts are ungrouped) |
max_results | No | Max results (default: 10) |
entity_type_filter | No | Filter by rdf:type IRI |
verbose | No | Return full IRIs instead of the default CURIE-compacted values |
quipu_search_facts
Search for relationships/edges by natural-language query (matches predicate or
value). Replaces Graphiti’s search_memory_facts.
| Parameter | Required | Description |
|---|---|---|
query | Yes | Natural-language search query |
group_ids | No | Best-effort filter to facts from these provenance groups (episode-scoped label; /knot facts are ungrouped) |
max_results | No | Max results (default: 10) |
verbose | No | Return full IRIs instead of the default CURIE-compacted values |
quipu_episodes_complete
Graphiti-compatible flat episode ingestion: accepts name, body text, group, and source, then converts to a Quipu episode and ingests.
| Parameter | Required | Description |
|---|---|---|
name | Yes | Episode name/identifier |
episode_body | No | Natural-language body of the episode |
group_id | No | Provenance label for the episode (not an isolation boundary — see Episodes) |
source_description | No | Who/what produced this episode |
timestamp | No | ISO-8601 timestamp |
quipu_impact
Impact analysis: walk downstream from an entity. With remove=true,
speculatively retracts the entity first (counterfactual). The store is never
mutated.
| Parameter | Required | Description |
|---|---|---|
entity | Yes | Entity IRI to analyse |
remove | No | Speculatively retract before walking (default: false) |
hops | No | Max edge hops to follow (default: 5) |
predicates | No | Restrict walk to these predicate IRIs (empty = all) |
rank_by_ppr | No | Order the reached set by Personalized PageRank seeded at the root — each entry gains a ppr score (default: false) |
timestamp | No | Timestamp for the speculative retraction (used when remove=true) |
quipu_path_cone
Golden paths: compute the provenance cone of a trajectory — which steps did
its falsifier-gated verified result depend on? Per-step verdicts are
in-cone (load-bearing; pruning needs a human Decision), out-of-cone
(mechanically prunable), or cannot-evaluate (no derivation edges recorded —
never silently prunable). Refuses trajectories with no steps or no
falsifier-gated verification. See the
golden-paths design.
| Parameter | Required | Description |
|---|---|---|
trajectory | Yes | IRI of the Trajectory to analyse |
via | No | Derivation predicate IRIs to walk, in addition to verifiedBy (always followed) |
hops | No | Depth bound for the derivation walk (default: 8) |
base_ns | No | Vocabulary namespace override (default: the store’s base_ns) |
quipu_path_backtest
Golden paths: backtest a pruned candidate (exemplar trajectory minus omitted
steps) over recorded history — which past trajectories with a shared
work-item topic would have conformed under gp-grammar/1, and how did their
work items close? Distinguishes 0 matches from cannot-evaluate, and refuses a
pattern it cannot compile.
| Parameter | Required | Description |
|---|---|---|
exemplar | Yes | IRI of the exemplar Trajectory |
omit | No | Step IRIs the candidate omits |
base_ns | No | Vocabulary namespace override (default: the store’s base_ns) |
quipu_unified_search
Unified knowledge search for Bobbin integration: combines text and optional
vector search, returning results tagged source="knowledge" with normalized
0–1 scores.
| Parameter | Required | Description |
|---|---|---|
query | Yes | Natural-language search query |
embedding | No | Pre-computed query embedding (else auto-embedded when provider attached) |
limit | No | Max results (default: 10) |
expand_links | No | Expand results via graph links (default: true) |
max_facts_per_entity | No | Max facts per entity (default: 10) |
quipu_ask
Run a curated, parameterized named query by name instead of hand-writing
SPARQL. The catalog is self-describing: call with no name (or name="list")
to list every query, its parameters, and their types.
| Parameter | Required | Description |
|---|---|---|
name | No | Named query to run; omit (or "list") to list the catalog |
params | No | Parameter map for the named query (names/types from the catalog) |
Catalog:
| Query | Parameters | Returns |
|---|---|---|
entity_facts | entity (iri), limit (int, 100) | All facts asserted about an entity |
service_deps | entity (iri), limit (int, 50) | Outgoing entity references (dependencies / links) |
references_to | entity (iri), limit (int, 50) | Entities that reference the given entity (incoming) |
entities_of_type | type (iri), limit (int, 100) | All entities of a given rdf:type |
labeled_like | text (text), limit (int, 50) | Entities whose rdfs:label contains text (case-insensitive) |
The provenance queries brief_ground, brief_related, entity_work, and
cochanged_with read both legacy vocabulary predicates and their Quechua
counterparts under https://scbrown.github.io/quechua/ns#. Each edge can use
either spelling independently. Duplicate aliases do not inflate returned paths
or shared-item counts. Entity IRIs and dataset selection stay unchanged; these
queries do not require an equivalence reasoner.
The group scope in quipu_search, quipu_search_nodes, and
quipu_search_facts accepts either vocabulary’s groupId predicate on the
provenance episode. Duplicate aliases do not duplicate search results. Other
groups, unrelated predicates with the same local name, and nodes without an
episode remain outside a requested group scope.
Parameters are validated and escaped by type before substitution, so values are
safe against SPARQL injection. The response includes the resolved sparql, the
result columns, and rows.
Example — service dependencies of an entity:
{ "name": "service_deps", "params": { "entity": "http://example.org/traefik" } }
quipu_queries
Manage stored named queries — competency questions a consumer ships with its
domain, callable through quipu_ask alongside the compiled-in catalog.
Definitions are validated at load and versioned (re-loading a name closes the
prior version rather than overwriting it).
| Parameter | Required | Description |
|---|---|---|
action | No | load, list (default), get, or remove |
name | For load/get/remove | Query name |
description | For load | What the query answers |
template | For load | SPARQL template with {param} placeholders |
dataset | No | Dataset IRI this query is scoped to |
params | No | Ordered param specs {name, type, required, default, description} |
timestamp | No | ISO-8601 timestamp |
quipu_graph_list
List registered named graphs with class, source, storage lifecycle, and labels
(freshness / durability / trust / policy / kind). The read half of the
graph-kinds surface, and the consumer capability probe: a store that does
not serve this tool (or GET /graphs) predates the kind axis, which a
consumer must treat as “cannot tell” — never as “no graphs”.
| Parameter | Required | Description |
|---|---|---|
graph | No | Exact registered absolute IRI; excludes kind/lifecycle; no materialization enrichment. Same count/graphs envelope, absent count zero, failures are errors. Attached source must support creator index v1. |
kind | No | Only graphs declaring this dataKind token (e.g. operational, archive) |
lifecycle | No | Only graphs in this storage lifecycle state (frozen) |
quipu_graph_freeze
Deep-freeze a named graph: export its full history (retracted rows and
transactions included) into a read-only archive pack, verify the copy by
content hash, delete the local rows, and re-attach the pack — the graph stays
addressable at the same IRI. Compose frozen graphs back in with FROM <iri>,
FROM <urn:quipu:dataset:frozen>, or include_kinds: ["archive"]. Known
cost: as_of_tx time travel is refused while any archive is attached
(pre-existing rule for attachments); valid-time queries survive.
| Parameter | Required | Description |
|---|---|---|
graph | Yes | IRI of the committed graph to freeze |
out_dir | No | Directory for the archive pack (default: beside the store file) |
timestamp | Yes | ISO-8601 timestamp |
actor | No | Who is freezing |
quipu_graph_thaw
Thaw a frozen graph: verify its archive pack, detach it, restore the full history into the local store under the same IRI, and reopen the graph for writes. The pack file is kept on disk; the freeze registry row is closed, never deleted.
| Parameter | Required | Description |
|---|---|---|
graph | Yes | IRI of the frozen graph |
timestamp | Yes | ISO-8601 timestamp |
actor | No | Who is thawing |
quipu_datasets
Manage named datasets — a reusable name for an arbitrary set of graphs, so it
can be labelled, governed and handed to another agent. FROM <dataset-iri>
then means FROM over its members.
| Parameter | Required | Description |
|---|---|---|
action | No | create, list (default), show, or remove |
name | For create/show/remove | Dataset IRI |
members | For create | Graph IRIs, or {"graph": …, "ord": N} for a declared ordering |
timestamp | No | ISO-8601 timestamp |
actor | No | Who is creating the dataset |
quipu_propose_schema_change
Submit a schema-evolution proposal (shape, class, property, or ontology change). Proposals require explicit acceptance before taking effect.
| Parameter | Required | Description |
|---|---|---|
kind | Yes | shape, ontology, class, or property |
target | Yes | Shape name, class IRI, or property IRI being changed |
diff | Yes | Turtle fragment or JSON patch describing the change |
proposer | Yes | Identity of the proposing agent |
rationale | No | Why this change is needed |
trigger_ref | No | Validation-failure ref or bead id that triggered this |
timestamp | No | ISO-8601 timestamp |
quipu_list_proposals
List schema-evolution proposals, optionally filtered by status.
| Parameter | Required | Description |
|---|---|---|
status | No | pending, accepted, or rejected (default: all) |
quipu_accept_proposal
Accept a pending schema proposal. Shape proposals are validated before writing.
| Parameter | Required | Description |
|---|---|---|
id | Yes | Proposal ID to accept |
decided_by | Yes | Identity of the approver |
note | No | Optional acceptance note |
timestamp | No | ISO-8601 timestamp |
quipu_reject_proposal
Reject a pending schema proposal with a reason.
| Parameter | Required | Description |
|---|---|---|
id | Yes | Proposal ID to reject |
note | Yes | Reason for rejection |
decided_by | Yes | Identity of the rejector |
timestamp | No | ISO-8601 timestamp |
quipu_resolve_entity
Check for existing near-duplicate entities before writing, using vector similarity and canonical-name matching (Jaro-Winkler). Returns candidates with similarity scores and match explanations.
| Parameter | Required | Description |
|---|---|---|
name | Yes | Canonical name of the proposed entity |
properties | No | Key-value properties (used for embedding context) |
top_k | No | Max candidates to return (default: 3) |
threshold | No | Similarity threshold 0.0–1.0 (default: 0.85) |
quipu_load_ontology (requires owl feature)
Manage OWL ontologies: load (parse + materialize entailments), list, or
remove. Only registered when Quipu is built with the owl feature.
| Parameter | Required | Description |
|---|---|---|
action | No | load, list, or remove (default: list) |
name | For load/remove | Ontology name |
turtle | For load | OWL ontology in Turtle format |
timestamp | No | ISO-8601 timestamp |
Hybrid fusion parameters on quipu_search: mode: "hybrid", alpha (semantic
weight, 0..1), fusion (weighted or rrf), positive rrf_k, and explain.
Server activation and the ready lexical index are required for intermediate
weights. Alpha endpoints retain exact pure responses. See the REST search
reference for candidate bounds, scope compatibility, snippets and rollback.
quipu_graph_create
quipu_graph_create takes graph (required), optional RFC 3339 timestamp
(default current UTC), and optional non-empty actor. It registers a committed
named graph and returns graph, g, created, and creation with timestamp
and actor. Repeats preserve original provenance. An actor is a declaration,
not authenticated identity; missing historical attribution is null.
quipu_graph_label
quipu_graph_label takes required graph and timestamp, optional actor,
trust: {iri, chain, rank}, freshness, durability, policy tokens, kind,
and valid_to. At least one label axis must be supplied. Register the graph
first. Label declarations are versioned. Invalid axes, empty declarations and
unregistered graphs are refused.
quipu_graph_list includes each graph’s creation alongside class and labels.
Follow the complete MCP-only named graph recipe
for create, label, new-class shapes, write and scoped query.
Portable graph workflows
These tools call the existing guarded REST handlers. Bearer authentication,
read-only mode, graph selectors and validation have the same authority. New
workflow tools reject unknown or wrong-typed top-level fields; pass an exact
scope to shares. None accepts a server-local file path.
quipu_share
Produce a bounded portable share payload, including canonical manifest and member bytes. Read-only; no server-local output path. Pass the exact scope. Internal-only export exemptions are not accepted over this transport.
Fields: scope (required), shapes, no_shapes, queries, alignments_turtle, parent_share, turtle_view, max_bytes.
quipu_import
Stage a portable share with hash, vocabulary and attestation gates. Copy member contents from quipu_share; this is not a server file path. Exact-name merges default off; review before promotion.
Fields: manifest (required), export_ntriples (required), shapes_turtle (required), queries_turtle, alignments_turtle, query_namespace, replace_queries, source (required), actor, accept_exact, attestation.
quipu_import_promote
Promote a staged share only when existing promotion gates permit it. Does not bypass quarantine or resolution review.
Fields: share_id (required), actor.
quipu_reason
Run stored or inline Datalog rules to fixpoint and persist derivations. Omitted graph means ROOT; pass graph to scope premises. Derivations persist in that graph’s reserved companion inferred graph, not alongside asserted facts.
Fields: rules, prefix, graph, timestamp.
quipu_update
Execute SPARQL 1.1 Update through the guarded update endpoint. Use explicit GRAPH clauses for named graphs; absent scope has standard SPARQL default-graph semantics. Actor/source are declared provenance, not authorization.
Fields: update (required), actor, source.
quipu_events
Read a bounded event batch without committing a consumer cursor. since overrides consumer. types is a comma-separated event-type filter; group is an episode group, not a graph selector.
Fields: since, limit, types, group, consumer.
quipu_events_commit
Durably set a consumer cursor. Lower offsets explicitly rewind; delivery alone never commits.
Fields: consumer_id (required), offset (required).
quipu_changes
Read whole transaction change pages. graph scopes an exact graph; use next_tx for the next page. No cursor write.
Fields: since, limit, capture, graph.
quipu_subscriptions
Manage persistent push subscriptions (create/list/delete). Creates may deliver externally; list is still conservatively write-classified like REST.
Fields: consumer_id, webhook_url, sparql_ask, action, types, mode, batch_size, batch_window_s.
quipu_entity
Dereference an exact IRI as JSON-LD. IRIs are encoded as query values, including slashes and fragments; expanded includes label expansion.
Fields: iri (required), expanded.
quipu_entity_history
Read the existing entity-history endpoint for an exact IRI. Its current MAIN-history semantics do not certify composed/named-graph audit completeness.
Fields: iri (required).
quipu_transactions
Read bounded transaction metadata after since; does not change a cursor.
Fields: since, limit.
quipu_acquire
Acquire complete bounded registered named graphs from an expected MAIN store lineage. Unknown graphs, unsupported attachments/pins and overflow refuse.
Fields: source_store_id (required), graphs (required), tx, max_bytes.
quipu_acquire_composed
Acquire qualified MAIN graphs with a fenced attachment census. expected_generation is required with an explicit main tx; provider clocks are never a global transaction.
Fields: source_store_id (required), graphs (required), expected_generation, tx, max_bytes.
quipu_embed_backfill
Backfill missing ROOT entity embeddings using the configured provider. May consume external embedding resources and persist vectors.
Fields: none.
quipu_embed_backfill_graph
Backfill a bounded number of named-graph entity embeddings; does not rewrite ROOT entity text.
Fields: graph (required), max_entities.
quipu_search_query
Fuse ranked search seeds with a SPARQL query. Existing mode-specific validation and query budgets apply.
Fields: mode, query, seed_variable, sparql, valid_at, entity_type, embedding, seed_limit, query_options, group_ids.
quipu_fragments
Read a bounded triple-pattern fragment. page and pageSize select a page; absent terms are wildcards.
Fields: subject, predicate, object, page, pageSize.
quipu_reconcile
Get the reconciliation service manifest or resolve keyed query objects without merging entities.
Fields: queries.
quipu_spotlight
Annotate text with known entities, read-only. A busy cold cache may return no annotations; that is not an absence proof.
Fields: text (required), confidence.
quipu_knot_stage
Stage one immutable numbered snapshot part. Supply exact sha256 hashes and upload identity; replay is idempotent, changed bytes refuse. No file paths.
Fields: upload_id (required), snapshot (required), content_hash (required), part_hash (required), payload (required), timestamp, actor, source, graph, total_parts (required), total_bytes (required), part_number (required).
quipu_knot_promote
Start or read back atomic promotion of a staged snapshot upload. pending=true is not completion; poll the same upload_id. Existing validation gates remain authoritative.
Fields: upload_id (required).
quipu_workflow_capabilities
Read the direct-workflow enforcement capability before guarded updates. No
arguments. A successful read reports enabled, signature version and scope;
it does not grant write authority.
To move a portable share, call quipu_share with
{"scope":{"kind":"graph","value":"urn:example:graph"}}. Its result includes
manifest and files. Pass that manifest, files["export.nt"] as
export_ntriples, files["shapes.ttl"] as shapes_turtle, and a provenance
source to quipu_import on the destination. Include queries_turtle and
files["alignments.ttl"] as alignments_turtle when present; alignments are
sealed evidence, never applied mappings. Review quarantine/resolution output before quipu_import_promote.
An import result or pending upload result is not promotion acceptance.
For quipu_update, provide a SPARQL update string, with explicit GRAPH
clauses for named scope. The adapter form-encodes the exact string and optional
actor/source; the existing update handler applies structural, vocabulary,
workflow and attribution gates. A declared actor is not authenticated identity.
quipu_events returns next_offset without committing. Commit only after
processing with quipu_events_commit. quipu_changes returns next_tx and
supports exact graph filtering. Both are bounded pull APIs; they do not
promise a persistent SSE connection. Subscription creation can trigger external
webhook delivery and retains the REST write classification even for list.
quipu_stats
Read serving-store statistics with no arguments. Unlike CLI stats --db, this
operates on the server’s store, not a caller-selected local path.
quipu_health
Read server health with no arguments. A positive liveness control does not certify target coverage or graph completeness.
quipu_version
Read serving version, source commit and dirty-build identity without REST. No arguments; version identity does not replace behavioral acceptance.
Reasoner Reference
Complete reference for the Quipu reasoner: rule syntax, CLI, Rust API, error catalogue, and current limitations.
Placement (quipu-0b6, 2026-08-27). Derivations are written to the premise graph’s companion inferred graph (
<graph>#inferred; ROOT’s isurn:quipu:graph:root#inferred), never beside their premises. Premises are read from the graph plus its companion, so closure feeds further derivation. Compose reads withFROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>; migrate a pre-regime store withquipu db migrate-inferred.
Reactive graph scope
Each committed transaction carries its authoritative graph in the observer
Delta. Reactive rules read that graph and its companion inferred graph;
derivations and withdrawals stay in that companion. A multi-graph batch
notifies each graph separately. A change in one project cannot update the
same rule’s derivations in another project or in ROOT.
This applies only when the reactive reasoner is enabled and its rules are
loaded. Explicit evaluate_in_graph remains available for a reconciliation
or backfill; it is not a substitute for current reactive evaluation.
Rule Syntax
Rules are written in standard Turtle files using the rule: vocabulary. The
reasoner reads any resource typed rule:Rule and ignores everything else, so
rules can live alongside SHACL shapes in the same file.
Namespace
@prefix rule: <http://quipu.local/rule#> .
| Property | Type | Required | Description |
|---|---|---|---|
a rule:Rule | type | yes | Marks this resource as a rule |
rule:id | string | yes | Stable identifier used in provenance (source = "reasoner:<id>") |
rule:head | string | yes | Head atom: predicate(?var1, ?var2) |
rule:body | string | yes | Body atoms: p(?x, ?y), q(?y, ?z) |
rule:prefix | string | no | Per-rule IRI prefix for bare predicate names |
RuleSet Container
An optional rule:RuleSet resource sets defaults for all rules in the file:
ex:my_rules a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
| Property | Type | Description |
|---|---|---|
a rule:RuleSet | type | Marks this resource as a ruleset |
rule:defaultPrefix | string | Default IRI prefix for all rules in this file |
Prefix Resolution Order
When the reasoner encounters a bare predicate name like dependsOn inside a
head or body string, it resolves the full IRI using this precedence:
- Per-rule
rule:prefixproperty (highest priority) - Ruleset
rule:defaultPrefixproperty - Fallback
http://quipu.local/default/(lowest priority)
Atoms and Terms
An atom is predicate(arg1, arg2). Arguments can be:
| Term | Syntax | Example | Notes |
|---|---|---|---|
| Variable | ?name | ?svc | Bound by body atoms, projected into head |
| Bare name | name | dependsOn | Expanded with prefix resolution |
| Full IRI | <http://...> | <http://ex.org/p> | Used as-is, no expansion |
| String | "value" | "active" | Allowed in head only (constants) |
Body Syntax
Body atoms are comma-separated. Whitespace is flexible:
dependsOn(?a, ?b), dependsOn(?b, ?c)
Negation uses the not keyword:
reachable(?x, ?y), not blocked(?y)
Negation is stratified negation-as-failure (since 2026-08-27, quipu-923): a negated atom filters out rows matching the predicate’s tuples from lower strata. Every variable in a negated atom must be bound by a positive atom (unsafe negation is rejected), and NAF is evaluated over the graph’s materialized state — an open-world caveat to keep in mind when a predicate is only partially recorded.
Supported Rule Shapes
Since 2026-08-27 (quipu-923) rules compile to a general left-deep join pipeline, so the old 1-atom/2-atom shape caps are gone:
-
Any number of body atoms — each further positive atom joins into the accumulated bindings on the tuple of its shared variables.
rule:head "path3(?a, ?d)" ; rule:body "edge(?a, ?b), edge(?b, ?c), edge(?c, ?d)" . -
Any number of shared variables between atoms — including zero (a cross join) and both columns (an intersection).
-
Repeated variables within an atom —
p(?x, ?x)is an equality selection over reflexive tuples. -
Constants in body atoms — a selection; an un-interned constant makes the rule unsatisfiable (derives nothing) rather than erroring.
-
Stratified negation —
not q(?x, ?y)antijoins against the negated predicate’s lower-stratum tuples.
Constraints that remain:
- Head and body atoms must have exactly 2 arguments (binary predicates)
- At least one positive body atom
- Every head variable and every negated-atom variable must be bound by a positive atom
- No string literals in atoms (facts are reference triples)
Complete Example
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://aegis.gastown.local/rules/> .
@prefix aegis: <http://aegis.gastown.local/ontology/> .
ex:aegis a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
# Transitive dependency closure (positive recursion)
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "depends_on(?a, ?c)" ;
rule:body "depends_on(?a, ?b), depends_on(?b, ?c)" .
# Host-level runs_on closure
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runs_on(?svc, ?host)" ;
rule:body "runs_on(?svc, ?container), runs_on(?container, ?host)" .
Safety Checks
The parser enforces these safety properties at load time:
- Range restriction: every variable in the head must appear in at least one positive body atom. A head variable that only appears under negation is rejected.
- Non-empty body: rules with empty bodies are rejected.
- Valid syntax: malformed head or body strings produce errors that name the specific rule and the parse location.
CLI: quipu reason
Run the reasoner against a store.
quipu reason [--rules <file.ttl>] [--reactive] [--db <path>]
Flags
| Flag | Default | Description |
|---|---|---|
--rules <file> | shapes/aegis-rules.ttl | Path to Turtle file containing rules |
--reactive | off | Register a ReactiveReasoner observer after evaluation (requires reactive-reasoner feature) |
--db <path> | config default | Store database path |
Output
reasoner: 2 rules across 1 strata — asserted 5, retracted 0
per-rule contributions:
depends_on_transitive 3
runs_on_transitive 2
The report shows:
- Total rules and strata evaluated
- Aggregate asserted/retracted counts
- Per-rule breakdown of new assertions
Examples
Run with the default aegis rules:
quipu reason --db homelab.db
Run with custom rules:
quipu reason --rules my-rules.ttl --db homelab.db
Run and keep derived facts fresh going forward:
quipu reason --reactive --db homelab.db
Rust API
Parsing
#![allow(unused)]
fn main() {
use quipu::reasoner::{parse_rules, RuleSet};
let turtle = std::fs::read_to_string("rules.ttl")?;
let ruleset: RuleSet = parse_rules(&turtle, None)?;
// Or with a fallback prefix:
let ruleset = parse_rules(&turtle, Some("http://my.org/"))?;
}
RuleSet contains:
rules: Vec<Rule>— rules in source orderdefault_prefix: String— resolved default prefix
Evaluation
#![allow(unused)]
fn main() {
use quipu::reasoner::{evaluate, EvalReport};
use quipu::store::Store;
let mut store = Store::open("homelab.db")?;
let report: EvalReport = evaluate(&mut store, &ruleset, "2026-04-04T12:00:00Z")?;
println!("asserted: {}, retracted: {}", report.asserted, report.retracted);
for (rule_id, count) in &report.per_rule {
println!(" {}: {}", rule_id, count);
}
}
EvalReport fields:
| Field | Type | Description |
|---|---|---|
asserted | usize | Total new derived facts |
retracted | usize | Total retracted derived facts |
strata_run | usize | Number of non-empty strata executed |
per_rule | Vec<(String, usize)> | Per-rule assertion counts |
Historical edition evaluation (native library)
The native library exposes a separate historical path. The timestamp argument
of evaluate stamps a current evaluation; it does not select historical
premises or a historical rule edition. The file-based CLI above keeps its
existing behavior. No historical CLI flags are provided by this library API.
Use the following sequence for an independently retained raw SQLite artifact:
- Verify a detached
SignedHistoryAttestationwithhistory_attestation::verify_history_attestation. The caller supplies a trustedHistoryCoverageGrant, including the external policy revision, reviewed evidence bytes and digest, issuer key, exact artifact digest, source lineage, catalogue and premise graphs, and both coverage intervals. A key or completeness assertion supplied by the manifest itself is not authority. Missing, expired, ambiguous or mismatched evidence refuses. - Call
raw_history::admit_raw_historywith those verified terms, the exact retained bytes, aRawHistoryRequest { valid_at, as_of_tx }, configuration, the caller’s policy store, and the actual admission time. It verifies the digest, SQLite integrity, lineage, real transaction boundaries and graph registration, and refuses attachments and active frozen providers. The reader owns a private immutable copy; it does not snapshot a live service. ROOT is explicitlyurn:quipu:graph:root, reserved graph zero; an unknown named graph never falls back to ROOT. - Call
governed::evaluate_governed_history(&source, ruleset_iri). Catalogue membership is read at the transaction cut, then the edition is selected independently by its valid-time applicability. Premises are read from the admitted premise graph at both pins. Current inferred companions and current shape sets do not participate. Evaluation occurs in isolation without writing derivations into the retained source.
GovernedHistoricalAnswer returns the selected edition identity, executable
Turtle digest and applicability interval, the exact signed source terms,
transaction cut, original admission time and sorted derived IRI tuples. It is
an illustrative model answer, not a legal judgment or a captured derivation
proof. Rule parsing alone does not establish that a vocabulary is loaded or
that primary text supports the model.
The selector refuses absent, overlapping, malformed or digest-mismatched editions. Its bounded profile permits at most 64 editions, 4,096 catalogue triples and 1 MiB of catalogue text; execution permits at most 64 uniquely identified rules. Premise evaluation permits at most 10,000 tuples and 64 predicates. A smaller positive configured SPARQL output ceiling remains in force, with a reserved sentinel row: reaching the ceiling is a capacity refusal, not a truncated successful answer. Zero means no configured output ceiling, while these profile bounds still apply. Query deadlines and join budgets remain enforced for premise extraction. The same absolute deadline also covers derivation. Historical derivation streams one binding at a time, refuses before inserting a sentinel-sized derived relation, and shares one candidate-work budget across rules, strata and fixed-point rounds (at most 1,000,000 candidate steps, reduced by a smaller positive caller row cap or configured join cap). It admits at most 64 body atoms per rule. Capacity or deadline exhaustion refuses the whole answer; it never returns a successful partial closure. This bounded historical evaluator does not change the legacy file evaluator.
A matching artifact digest, store ID or known transaction cannot prove history completeness. That authority must come from the independently reviewed coverage grant, including pre-window facts needed by the catalogue and premises. This API supplies no producer certificate, issuer registration, live schema activation or automatic trust-policy loader. Preserve UNKNOWN when those prerequisites are unavailable. Retaining an old receipt does not extend its admission validity.
Bounded standards mapping
This profile makes a conceptual mapping to LegalRuleML 1.0, sections 4.3.1, 4.3.5 and 4.4. LegalRuleML separates legal entry, efficacy and applicability and supports source associations and temporal parameters for parts of rules. Here, one immutable executable edition has one applicability interval and separately linked primary sources. That interval is not its assertion time, execution time, event time, legal entry or efficacy. This is not LegalRuleML XML interchange or a conformance claim.
The catalogue reuses DCMI isVersionOf and source, PROV value, SPDX checksum
terms and OWL-Time interval/instant
relationships. Half-open applicability intervals, whole-second UTC and exact
executable-byte SHA256 are application profile choices; OWL-Time does not
mandate these choices.
Unsupported constructs include temporal associations below edition granularity, legal authority and jurisdiction reasoning, deontic operators, defeasible priorities, penalties and reparations, legal completeness, and civil calendar arithmetic. Synthetic transition facts are precomputed inputs, not calculated legal deadlines. A reviewed teaching-case oracle is required before presenting any jurisdiction-specific example as an accepted model result.
Reactive Evaluation
Requires the reactive-reasoner feature.
#![allow(unused)]
fn main() {
use quipu::reasoner::reactive::ReactiveReasoner;
use std::sync::Arc;
let observer = Arc::new(ReactiveReasoner::new(ruleset));
store.add_observer(observer.clone());
// Now any transact() call triggers automatic re-derivation.
store.transact(&new_facts, timestamp, Some("agent"), Some("discovery"))?;
// Derived facts are already updated.
// Check stats:
let stats = observer.stats();
println!("triggers: {}, asserted: {}", stats.triggers, stats.total_asserted);
}
ReactiveStats fields:
| Field | Type | Description |
|---|---|---|
triggers | usize | Number of times the observer fired |
total_asserted | usize | Cumulative assertions across all triggers |
total_retracted | usize | Cumulative retractions across all triggers |
The reactive reasoner:
- Skips transactions with
sourcestarting with"reasoner:"(prevents loops) - Computes the transitive closure of affected rules via dependency analysis
- Re-evaluates only the affected strata, not the entire ruleset
Speculate
#![allow(unused)]
fn main() {
let result = store.speculate(&hypothetical_datums, timestamp, |store| {
evaluate(store, &ruleset, timestamp)
})?;
// result is the EvalReport from inside the closure
// store is unchanged — the hypothetical was rolled back
}
The closure receives a &Store with the hypothetical facts applied. When
the closure returns, all changes are rolled back via ROLLBACK TO SAVEPOINT.
Core Types
#![allow(unused)]
fn main() {
// A term in an atom's argument list
pub enum Term {
Var(String), // Variable (without leading ?)
Iri(String), // Full IRI
Str(String), // String literal
}
// A predicate application: pred(arg1, arg2)
pub struct Atom {
pub predicate: String, // Full IRI after expansion
pub args: Vec<Term>,
}
// A body literal
pub enum BodyAtom {
Positive(Atom),
Negative(Atom), // Parsed but not yet evaluated
}
// A Horn clause rule
pub struct Rule {
pub id: String, // Provenance identifier
pub head: Atom,
pub body: Vec<BodyAtom>,
}
}
Error Reference
All errors are variants of ReasonerError.
Turtle
rule Turtle parse error: <details>
The Turtle file itself failed to parse as valid RDF. Check for unclosed strings, missing prefixes, or invalid IRIs.
MissingProperty
rule "R1" is missing required property head
A resource typed rule:Rule lacks a required property. Every rule needs
rule:id, rule:head, and rule:body.
BadSyntax
rule "R1" head: expected 'predicate(args)' but got 'foo bar'
A head or body string couldn’t be parsed as atoms. Check for missing parentheses, unbalanced commas, or invalid variable syntax.
UnboundHeadVariable
rule "R1" head variable ?z is not bound in the body
The head references a variable that doesn’t appear in any positive body atom. Every head variable must be range-restricted — it must appear in at least one positive body literal so the reasoner knows what values to bind.
UnstratifiableCycle
rule set is not stratifiable: negation cycle through ["p", "q"]
The ruleset contains a cycle through negation: rule A negates predicate P
which rule B produces, and rule B negates predicate Q which rule A produces
(or a self-negation like p :- not p). Break the cycle by restructuring
your rules so negation only flows “downward” between strata.
Unsupported
rule "R1" uses unsupported feature: non-binary body atom
The rule parsed and stratified successfully, but uses a shape the evaluator doesn’t handle. Current unsupported features (the 3-atom, negation, constant, and repeated-variable rejections were lifted 2026-08-27, quipu-923):
| Feature | Message |
|---|---|
| Non-binary atoms | non-binary head/body atom |
| Purely negative body | body needs at least one positive atom |
| Unsafe negation | unsafe negation: variable not bound by a positive atom |
| Unbound head variable | head variable not bound by a positive body atom |
| Unknown head IRI | head references an IRI that has never been interned |
| String literals | string constant in head atom / string constant in body atom |
Store
store error: <sqlite error details>
A store operation failed during evaluation (reading facts, writing derivations). This typically indicates a database problem, not a rule problem.
Provenance Tags
Every derived fact is written with structured provenance:
| Field | Value |
|---|---|
source | reasoner:<rule-id> (e.g., reasoner:depends_on_transitive) |
actor | reasoner |
You can query or filter by these tags. To find all facts derived by a specific rule:
quipu read "SELECT ?e ?a ?v WHERE {
?e ?a ?v .
}" --db homelab.db | grep "reasoner:depends_on_transitive"
Or from Rust, filter the source field on returned Fact structs.
Limitations
Binary predicates only. Head and body atoms must have exactly 2
arguments. This covers the vast majority of RDF-style relations
(subject predicate object) but can’t express ternary or higher-arity
relations directly.
Full re-derivation. Each evaluation pass re-derives all facts for every rule in the affected strata, then diffs against the old state. This is correct and fast at the target scale (~50K facts), but would need incremental truth maintenance for much larger workloads.
No aggregation. There’s no COUNT, SUM, MIN/MAX in rules. Use SPARQL for aggregation over derived facts.
Rust API
Quipu’s public API is organized into modules. All types are re-exported
from the crate root via quipu::*.
Store (quipu::store::Store)
The core fact log store backed by SQLite.
#![allow(unused)]
fn main() {
use quipu::store::Store;
// Open or create a store
let mut store = Store::open("quipu.db")?;
let mut store = Store::open_in_memory()?;
// Term dictionary
let id = store.intern("http://example.org/alice")?;
let iri = store.resolve(id)?;
let maybe_id = store.lookup("http://example.org/alice")?;
// Write facts
let tx_id = store.transact(&datums, "2026-04-04", Some("actor"), Some("source"))?;
// Read facts
let facts = store.current_facts()?;
let entity = store.entity_facts(entity_id)?;
let history = store.attribute_history(entity_id, attr_id)?;
// Time-travel
let past = store.facts_as_of(&AsOf { tx: Some(5), valid_at: Some("2026-01-01".into()) })?;
// Contradiction detection
let conflicts = store.detect_contradictions(entity_id, attr_id)?;
}
RDF (quipu::rdf)
Parse and serialize standard RDF formats.
#![allow(unused)]
fn main() {
use quipu::rdf::{ingest_rdf, export_rdf};
use oxrdfio::RdfFormat;
// Ingest from any RDF format
let (tx_id, count) = ingest_rdf(&mut store, reader, RdfFormat::Turtle,
None, "2026-04-04", None, None)?;
// Export to any RDF format
let bytes = export_rdf(&store, RdfFormat::NTriples)?;
}
Supported formats: Turtle, N-Triples, N-Quads, RDF/XML, JSON-LD, TriG.
SPARQL (quipu::sparql)
Execute SPARQL queries (SELECT, ASK, CONSTRUCT, DESCRIBE).
#![allow(unused)]
fn main() {
use quipu::sparql;
// SELECT
let result = sparql::query(&store, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }")?;
for row in result.rows() {
println!("{:?}", row.get("name"));
}
// ASK
let result = sparql::query(&store, "ASK { ?s a <http://ex.org/Person> }")?;
// CONSTRUCT
let result = sparql::query(&store, "CONSTRUCT { ?s a <http://ex.org/Known> } WHERE { ?s ?p ?o }")?;
}
Episode Ingestion (quipu::episode)
Structured write path for agent-extracted knowledge.
#![allow(unused)]
fn main() {
use quipu::episode::{Episode, ingest_episode, ingest_batch, episode_provenance};
let episode: Episode = serde_json::from_str(json_str)?;
// The trailing `base_ns` is the base namespace used to mint entity IRIs.
let (tx_id, count) = ingest_episode(&mut store, &episode, "2026-04-04", "http://example.org/")?;
// Query provenance (same base_ns used at ingest time)
let entities = episode_provenance(&store, "my-episode", "http://example.org/")?;
}
Context Pipeline (quipu::context)
Unified knowledge context for agent consumption.
#![allow(unused)]
fn main() {
use quipu::context::{ContextPipeline, ContextPipelineConfig};
let pipeline = ContextPipeline::new(&store, ContextPipelineConfig::default());
let ctx = pipeline.query("traefik")?;
println!("{} entities, {} facts", ctx.summary.total_entities, ctx.summary.total_facts);
}
Graph Projection (quipu::graph)
Materialize subgraphs for algorithms.
#![allow(unused)]
fn main() {
use quipu::graph::{project, in_degree, connected_components, shortest_path};
let pg = project(&store, None, None)?;
let ranked = in_degree(&pg);
let components = connected_components(&pg);
let path = shortest_path(&store, &pg, "http://ex.org/a", "http://ex.org/z")?;
}
Federation (quipu::provider)
Virtual graph federation across multiple sources.
#![allow(unused)]
fn main() {
use quipu::provider::{FederatedProvider, LocalProvider};
let mut federation = FederatedProvider::new();
federation.add(Box::new(LocalProvider::new(&store, "local")));
let result = federation.query_all("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")?;
}
Vector Search (quipu::store::Store)
Embedding storage and similarity search.
#![allow(unused)]
fn main() {
// Store embedding
store.embed_entity(entity_id, "description text", &embedding_vec, "2026-04-04")?;
// Search
let matches = store.vector_search(&query_embedding, 10, None)?;
for m in &matches {
println!("{} (score: {:.3})", m.text, m.score);
}
}
Reasoner (quipu::reasoner)
Stratified Datalog engine that derives facts from rules over the EAVT log.
#![allow(unused)]
fn main() {
use quipu::reasoner::{parse_rules, evaluate, EvalReport, RuleSet};
use quipu::store::Store;
// Parse rules from Turtle
let turtle = std::fs::read_to_string("rules.ttl")?;
let ruleset: RuleSet = parse_rules(&turtle, None)?;
// Evaluate (full re-derivation)
let mut store = Store::open("quipu.db")?;
let report: EvalReport = evaluate(&mut store, &ruleset, "2026-04-04T12:00:00Z")?;
println!("{} asserted, {} retracted", report.asserted, report.retracted);
// Reactive evaluation (auto-derive on every transact)
#[cfg(feature = "reactive-reasoner")]
{
use quipu::reasoner::reactive::ReactiveReasoner;
use std::sync::Arc;
let observer = Arc::new(ReactiveReasoner::new(ruleset));
store.add_observer(observer.clone());
// Derived facts now update automatically on every commit.
}
// Counterfactual queries
let result = store.speculate(&hypothetical_datums, timestamp, |s| {
evaluate(s, &ruleset, timestamp)
})?;
// Store is unchanged — hypothetical was rolled back.
}
See Reasoner Reference for rule syntax, error catalogue, and supported rule shapes.
SHACL Validation (quipu::shacl)
Schema enforcement at write time (requires shacl feature).
#![allow(unused)]
fn main() {
use quipu::shacl::Validator;
let validator = Validator::from_turtle(shapes_turtle)?;
let feedback = validator.validate(data_turtle)?;
if feedback.conforms {
// Safe to write
} else {
for issue in &feedback.results {
let message = issue.message.as_deref().unwrap_or("(no message)");
println!("{}: {} at {}", issue.severity, message, issue.focus_node);
}
}
}
Types (quipu::types)
Core data structures used across modules:
Value– typed value (Ref, Str, Int, Float, Bool, Bytes)Fact– a single EAVT fact entryOp– Assert (1) or Retract (0)Term– dictionary entry (id + IRI)Transaction– recorded transaction metadataVectorMatch– vector search result with score
Sharing & Federation
The primitive: a Quipu store can hand its knowledge to another store, and compose another store’s knowledge, without either one having to trust the other by default. Every step is explicit, hash-verified, and labelled with where it came from — so you never absorb someone else’s knowledge by accident.
That is the whole idea. The rest of this page is how it works, and what proves it.
Try the receiving half in your browser — Quipu releases can include a knowledge pack of this repository, and that page imports it with Quipu compiled to WebAssembly. You watch the manifest get verified, the bundled shapes get adopted, and the graph get staged and promoted, and then you query it — and then edit it and export a pack that declares this one as its parent, which is the lineage half of the same story. It is the transcript below, with you as the receiver.
Follow the contributor constellation walkthrough to explore the vision, a design decision, and the code it governs on a phone.
Embed the browser playground
Use the same released engine and repository pack in an iframe:
<iframe id="quipu-playground" title="Quipu playground"
src="https://scbrown.github.io/quipu/explore/?embed=1&theme=intigraph"
style="width:100%;height:860px;border:0"></iframe>
embed=1 opens Map first and hides navigation, the loading-ceremony hero,
and the take-away cards. Ask, Map, the node inspector, and Try a bad write
still use the real engine. Narrow screens place the map before starter questions.
After one successful embedded load, the browser can show a saved map preview
while the worker verifies a fresh copy. The cache is bounded and keyed to the
exact pack bytes, engine revision and compiled features. It contains the map’s
real query rows from a pristine imported pack. The preview is labelled; Ask and
writes stay disabled until verification finishes. A verification refusal hides
and removes it. Pack or engine changes, unavailable storage and invalid cache
entries take the normal cold-load path.
The under-five-second warm target measures when that saved map appears. Full verification and interactive query readiness take longer; the page reports that transition explicitly. Loading still verifies and imports the complete pack on every visit.
theme=intigraph selects Intigraph’s palette, Inter and JetBrains Mono, with
its light variant following the system color scheme. The normal page’s Theme
picker also offers Intigraph. System, Light and Dark remain available.
The fonts load from Google Fonts only when Intigraph is selected; system fonts
remain the fallback if that service is unavailable.
Embedded pages send {type: "quipu:resize", height: <pixels>} to their parent
when content height changes, including after tab selection. A host can resize
the iframe after checking both the sender origin and frame identity:
const frame = document.querySelector("#quipu-playground");
window.addEventListener("message", (event) => {
if (event.origin !== "https://scbrown.github.io"
|| event.source !== frame.contentWindow
|| event.data?.type !== "quipu:resize") return;
const height = event.data.height;
if (Number.isInteger(height) && height > 0 && height < 20000) {
frame.style.height = `${height}px`;
}
});
One run, end to end
This transcript is not illustrative output. It is
examples/sharing-demo/expected.txt,
included here verbatim. just sharing-demo creates two fresh stores and checks
that a new run still matches it; the required CI Build job runs the same
check.
The demo loads an explicit identifier-policy catalogue before its outward share. Its policy lives in a separate named graph, so it does not become widget data in the shared ROOT graph.
1. SHARE A scope=root facts=2 shapes=1
2. IMPORT B outcome=quarantined admitted=0 quarantined=2 blocker=off_vocabulary
3. ADOPT + REIMPORT outcome=staged admitted=2 quarantined=0 promotion_eligible=true
4. PROMOTE B outcome=promoted triples=2
5. COMPOSE B root_names=shared
6. DIVERGE A_adds=a-only B_adds=b-only
7. STATUS diverged=true ours_added=1 theirs_added=1 conflicts=0
8. RECONNECT outcome=merged asserted=1 retracted=0 provenance_parents=2
9. CONVERGED B root_names=a-only,b-only,shared
10. BOUNDARY provider federation unions labelled query results; it does not merge store histories
The receiver first quarantines the intact bundle because the type is not in
its local vocabulary. It then adopts the bundled shape deliberately, re-imports,
and promotes with a named actor. After A and B make independent additions,
status proves both histories moved and merge records two provenance parents.
The demo intentionally stops at the current boundary: provider federation
unions labelled query results but does not merge store histories. The broader
evaluation and claims belong to the
arXiv submission source for the shape-aware merge paper,
not to this ten-line walkthrough.
Every claim below names the command, symbol, or verbatim message that backs it, so you can check it rather than believe it. Citations are file plus symbol, not line numbers, because line numbers rot and a page that cites them stops being true without anyone editing it. Quoted messages are exact — grep for them.
Two things most graph databases do, Quipu deliberately does not. It does not merge on receipt, and it does not take a peer’s word for how trustworthy that peer is.
flowchart LR
subgraph A["Store A — producer"]
AR[("ROOT")]
end
subgraph B["Store B — consumer"]
BQ[["quarantine graph<br/>urn:quipu:import:quarantine:…"]]
BR[("ROOT")]
end
AR -->|"quipu share --output dir/"| S["share bundle<br/>export.nt · shapes.ttl · manifest.json"]
S -->|"quipu import dir/<br/>hashes verified"| BQ
BQ -->|"quipu import promote <id><br/>an operator's explicit act"| BR
BR -.->|"quipu status dir/"| D{{"diverged?"}}
D -.->|"quipu merge dir/<br/>conflict ⇒ exit 2"| BR
Prepare an outward share
New local shares stamp pack_dir: "pendant" in their manifest. Use
--pack-dir knowledge to keep an explicit repository layout. Older manifests
without this field still resolve to qpack; importing them does not rewrite
their identity or silently move their deltas. HTTP shares do not assert a
repository directory on a caller’s behalf.
Outward sharing checks the outgoing bytes against the store’s block-tier identifier-policy catalogue. Load your reviewed policy before the first share; loading only your data’s SHACL shapes does not supply these rules.
For a minimal example, save this shape as identifier-policy.shapes.ttl:
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix aegis: <http://aegis.gastown.local/ontology/> .
<urn:demo:IdentifierPolicyShape> a sh:NodeShape ;
sh:targetClass aegis:InternalIdentifierPattern ;
sh:nodeKind sh:IRI .
Save its rule as identifier-policy.ttl:
@prefix aegis: <http://aegis.gastown.local/ontology/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
# The demo's explicit identifier policy. Stored in its own named graph so the
# shared payload and its quarantine/admission transcript remain unchanged.
<urn:demo:private-identifiers> a aegis:InternalIdentifierPattern ;
rdfs:label "Demo private identifiers" ;
aegis:regex "private[.]example" ;
aegis:enforcementTier "block" .
This demonstration rule only detects private.example. For real publication,
replace it with a reviewed catalogue covering the identifiers your organization
needs to exclude. The checker evaluates the supplied rules; a non-empty
catalogue does not establish that your policy covers every private identifier.
Load the policy into the same database that will produce the share:
quipu shapes load identifier-policy identifier-policy.shapes.ttl --db my.db
quipu knot identifier-policy.ttl --graph urn:quipu:identifier-policy --db my.db
quipu share --output graph-share --db my.db
Also load the shapes governing your application data before ingesting it. The
catalogue graph is separate from ROOT, the default share scope; its location
does not change which application facts travel. Each rule’s type, label,
regular expression and block tier must be together in ROOT or one named graph.
Fragments split across graphs do not form a usable rule.
The CLI exits 2 if there is no block-tier catalogue, 1 if a rule matches,
and 0 after checking a clean payload. A refusal creates no partial output
directory. --no-shapes does not bypass the catalogue check. Use
--destination internal only for an intended internal transfer; it skips the
outward check and stamps the resulting artifact accordingly.
Release publication rollout
Native releases can ship before the trusted repository-share producer is enabled.
While the repository variable TRUSTED_REPOSITORY_SHARE_ENABLED is unset or not
true, the release summary explicitly records that no new repository pendant was
published. The release may therefore have no graph artifact for the explorer.
Enable that variable only after the managed trusted producer is installed and its scheduled execution has been observed. Once enabled, the release job requires the pendant, checksum and source/binary provenance receipt; missing or invalid artifacts fail the job. The first enabled release must separately prove published artifact delivery. Hosted runners never receive the private policy catalogue.
The repository pack also carries a separate, public receiver catalogue from
docs/knowledge/publication-policy.ttl, with its SHACL constraints. It contains
generic access-token, private-key and user-directory patterns plus a fictional
domain control. It does not publish the trusted producer’s private rules or
claim to cover every organization’s private identifiers. Load stricter local
rules when your destination requires them. The producer’s fresh private-policy
check remains mandatory and is not replaced by this public catalogue.
Before emitting a release pack, the builder imports it into a fresh receiver and proves outward sharing and re-import without injecting policy afterward. Acceptance pins all four public rule IRIs and their exact labels, regexes and block tiers; missing one rule or changing any of those values fails the check. Missing receiver policy and a planted forbidden value must still refuse without output, and native SHACL must refuse an incomplete policy rule. Carrying shapes does not establish that a particular browser binary has compiled SHACL; the explorer reports that capability from the engine build.
What a share is
A share is a directory you can commit to git, attach to an email, or publish
as a release asset. quipu share --output <dir> writes it deterministically
(src/cli_pack.rs, cmd_share), and it holds exactly three files — the same
three quipu import reads back (cmd_import):
| File | What it carries |
|---|---|
export.nt | the facts, as N-Triples |
shapes.ttl | the SHACL shapes those facts were validated against |
manifest.json | hashes, producer name and version, and the lineage link |
queries.ttl | only when the store has stored queries registered against the scope: the competency questions a receiver can quipu_ask, sealed by the manifest |
The manifest’s parent_share field (src/share.rs, ShareOptions) is what
makes a share a link in a chain rather than a loose dump: it names the share
this one descends from, which is what later lets quipu merge find a common
base.
Shares carry their shapes on purpose. A receiving store is never asked to guess what the sender meant by a predicate — it gets the constraints alongside the facts.
Pendants: what the artifact is called
On a khipu, pendant cords hang from a primary cord, and each pendant carries a self-contained record. Quipu names its share artifact after them:
| Khipu | Quipu |
|---|---|
| Primary cord | a store |
| Pendant cord | a pendant: a shareable graph with its shapes and stored queries — the manifest, the payload (export.nt), shapes.ttl, and stored queries where the share carries them |
| Subsidiary cord | a delta share, hanging off the pendant it amends |
| Tying a pendant onto a primary cord | composing an imported pendant into a store |
| Knots between cords | relationships — edges join cords; they are not pendants |
“Share” still names the protocol — quipu share, quipu import, share deltas.
“Pendant” names the thing that travels. Packed for release, a pendant is a
deterministic tar archive named *.pendant.tar.gz.
Renamed from “qpack”. Artifacts used to be called qpacks and carried a
.qpackextension. For one release, anything named.qpack,.qpack.tar.gzor.qpack.dbstill loads unchanged, with a deprecation warning; release pages publish the repository pendant under both asset names. New artifacts are written only as.pendant.
How repository pendants connect
A repository pendant describes that repository and points back to its source. Load Quipu, Bobbin, Yupana and Camayoc pendants into the same receiving store to query their relationships together. Each import still passes verification and quarantine; composition follows explicit promotion, not merely downloading or opening an archive. The cords below illustrate that composition, not a claim that every repository already publishes a complete pendant.
An idea, interface or symbol can carry a repository-qualified entity IRI and a file pointer. The pointer identifies where to read the definition; it does not copy the file’s text into Quipu. For example:
| Repository | Source pointer to inspect |
|---|---|
| Quipu | Import verification and resolution |
| Bobbin | The knowledge import MCP handler |
| Yupana | CLI entry point |
| Camayoc | Native WorkItem backfill adapter |
These are navigable source references, not embedded copies or four equivalent
interfaces. A released pendant’s source revision and provenance receipt identify
the version to inspect; the links above follow each repository’s main branch.
Bobbin’s knowledge_import handler calls Quipu’s /import endpoint, so a
cross-repository depends-on edge can describe that interface dependency.
It does not say that the two interfaces are the same entity.
The repository content contract requires the following alongside code and documentation pointers. This table specifies what a producer must represent; it does not certify those contents in every currently released artifact.
| Required knowledge | What to connect |
|---|---|
| High-level ideas | Repository concepts to the interfaces, symbols and source files that implement them |
| Public interfaces | CLI commands, MCP tools, REST routes, configuration keys and file formats to defining files; cross-repository dependencies to their target interfaces |
| Stored queries | Queries the repository answers, with their definitions in queries.ttl |
| Decisions | Decision status and supersession, with links to the evidence and affected source |
| Freshness | reviewAfter / maxAge information on knowledge facts, so missing or expired review remains visible |
| Start-here knowledge | A glossary linked to Quechua terms, plus source files to read first |
| Shapes | Validation of ideas, interfaces and mappings; a prose claim is not a shape |
| Cross-repository mappings | The existing shareable alignment record, identifying the two IRIs and the reviewed relationship |
Chunk nodes, where retained for navigation, carry pointer metadata and relationships. Chunking and source-text retrieval belong to Bobbin; this contract does not ask Quipu to duplicate chunk bodies or line spans. Missing producer coverage is unknown, not evidence that a relationship does not exist. Embedding model metadata and vector rebuilding are separate load capabilities; composing two graphs does not itself prove either one.
Composition is not an identity decision
- The same exact IRI names the same RDF subject in the composed data.
Repository-qualified identifiers prevent unrelated local names such as
configfrom colliding. The diagram deliberately uses a generic repository namespace rather than declaring a new permanent namespace. - Different IRIs remain different by default, even when their labels match.
The current import resolver reports exact canonical-name matches as proposals;
it preserves the foreign IRI unless the caller explicitly enables
accept_exact. That compatibility option rewrites IRIs and does not create a retractable alignment record. - A dependency is not identity. Bobbin calling Quipu is a relationship between interfaces, not permission to merge their names.
- A proposed mapping is not accepted alignment. Use the existing
propose → decide → apply workflow. Its SSSOM record carries
subject_id,object_id,predicate_idandmapping_justification; the decision records the reviewer. Applying the decided version can derive accepted identity into a separate alignment graph, leaving both source graphs recoverable. A close match, undecided row or rejected identity does not authorizeowl:sameAs.
The two differently named ideas in the diagram are an illustrative mapping candidate, not a published cross-stack alignment dataset. Share a reviewed alignment record with the pendants when one exists; do not manufacture mappings from a picture or a common label. See Aligning concepts across graphs for the expected-version guard and rejection history.
Receiving: verify, quarantine, promote
Import is two verbs, and the split is the point.
quipu import ./their-share --source https://example.org/share --actor alice
quipu import promote <share-id> --actor alice
quipu import verifies before storing anything. It recomputes the payload hash
and refuses a mismatch (src/share_import.rs, verify_share):
share graph hash mismatch: manifest=… actual=…
What survives verification lands in a quarantine graph named
urn:quipu:import:quarantine:<hash> (staging_graph) — present in the store,
queryable, and not part of ROOT. The result reports an ImportCounts split of
admitted versus quarantined triples.
quipu import promote is the separate, actor-attributed step that moves a staged
share into ROOT (cmd_import, the promote arm). Nothing reaches your ROOT
because a file arrived; it reaches ROOT because a named person ran the second
command.
Identity across stores
Two stores will call the same thing by different names. Quipu handles that in two places, and today one of them is stronger than the other.
quipu knot writes real owl:sameAs edges, so the claim “these two IRIs are one
entity” is itself a fact in the graph — visible, queryable, and retractable like
any other fact. That is the model the project is built around.
Import proposes; it does not merge. On an exact canonical-name match
(src/share_import.rs, resolve_and_rewrite: score == 1.0 && matched_on == "canonical_name:exact"), the incoming foreign IRI is kept. The match is reported
as an exact_merges entry carrying its score and matched_on, so a caller can accept
the exact hits in one action — but nothing is applied to the triples, and the foreign
identity survives the import intact.
Sub-exact matches are reported the same way, as candidates for review. The difference between exact and sub-exact is now the confidence attached to a proposal, not whether it is applied behind you.
accept_exact restores the older behaviour explicitly: exact matches are applied as IRI
rewrites at import time. It defaults to false. Use it only where you control the naming
in both stores, and note that a rewrite is still not recorded as an owl:sameAs — it
remains the one path on which identity is not queryable and not retractable.
Accepting a proposal is knot’s job, and that is the point of the split: an accepted
alignment becomes an owl:sameAs fact — visible, queryable, retractable — rather than a
silent edit to someone else’s identifiers.
What travels: facts, graphs, whole repositories
A share is the portable graph artifact. Its scope may be a slice of facts, a
whole graph, or a repository graph; a release .pendant.tar.gz is a deterministic
archive of the same text bundle, not a SQLite database. The bundle contains a
canonical RDF payload, SHACL shapes, and JSON plus PROV-O/DCAT/SPDX RDF
manifests (src/share.rs: share_payload, manifest_turtle).
Import accepts a directory, archive, or HTTP(S) release artifact. URL and archive
inputs are bounded, verified, and loaded into a fresh in-memory store without a
user-visible download (src/share_transport.rs: read_reference,
import_in_memory). Verification is unchanged because bytes arrived over the
network: graph, shapes, and envelope hashes must agree before the store is
opened.
For a modified copy, --since writes a parent-bound delta using the deliberately
restricted, interoperable part of SPARQL 1.1 Update:
quipu share --out child --since <parent-share>
quipu import delta <parent-share> child
The delta contains only ground DELETE DATA and INSERT DATA. Its manifest
names and hashes the immediate parent and the materialized result; the importer
rejects a wrong parent, a changed update, any other Update operation, or a result
digest mismatch (src/share_delta.rs: write_delta, materialize). These are
local artifact writes and reads: they do not send a delta to a remote store or
grant ROOT admission.
Pointers: graphs a share names but does not carry
A share can also name graphs it does not contain. A repository that only points at knowledge ships a share with no facts:
quipu share --output pointers --pointers pointers.toml --no-shapes
[[pointer]]
graph = "https://example.org/homelab"
kind = "endpoint" # a live graph, reached by federation
endpoint = "https://quipu.example.org/sparql"
[[pointer]]
graph = "https://example.org/reference"
kind = "pack" # a pinned pack, fetched on first use
url = "https://example.org/reference.pendant.tar.gz"
sha256 = "<64 lowercase hex digits of the archive>"
Pointers are part of the share’s identity, like the internal destination
marker. Removing or editing one changes the hash, so verify_share refuses the
share, and the verifier applies the producer’s own rules, so a hand-edited
manifest cannot carry what quipu share would refuse:
- an IRI graph;
- an
http(s)URL with no credentials; - a 64-hex
sha256; - one pointer per graph.
A share without pointers is byte-identical to one produced before the field existed. An older quipu refuses a share that has pointers, because it recomputes a different identity; it never silently drops them.
quipu compose reports every input’s pointers, each with the share that
declared it, and never follows them. Loading a pointer is the consumer’s trust
decision (caboodle packs trust). Following kind = "pack" pointers on first
use, and joining kind = "endpoint" pointers to the federation list, are
separate steps.
The older SQLite pack commands remain an internal/archive compatibility surface:
quipu pack <graph-iri> --out <file> # export a graph as an attachable pack
quipu pack --verify <file> # check one before trusting it
quipu unpack <file> [--into <graph-iri>]
(src/cli_pack.rs: cmd_pack, cmd_unpack.) --verify exists so that “is this
pack intact?” is answerable before you load it rather than after. SQLite packs
are no longer the published repository interchange artifact.
For archives, quipu graph freeze|thaw|list is the deep freeze surface
(src/cli_graph.rs), producing read-only full-history graphs. See
Graph Kinds & Deep Freeze and
Knowledge Packs.
Querying across stores
A FederatedProvider composes members behind one query (src/provider/mod.rs),
and two properties are what make its answers honest.
Trust is declared locally. A member’s DeclaredLabel carries only trust
and freshness — in the source’s words, “the axes an operator can honestly
declare about a peer” — and it is declared by the local operator, never read
from the member itself (src/provider/label.rs). A remote that declares
nothing does not quietly pass a configured floor: an undeclared value fails a
configured trust or freshness floor, which the source describes as “fail-safe at
enforcement, honest at reporting”. And because durability, policy and kind cannot
be honestly declared about a remote, a remote member degrades a dataset’s
coverage on those axes to partial — “the conservative reading, not an
omission”.
A merge is never silent. FederatedQuery returns one ProviderOutcome per
member (src/provider/mod.rs), so a partial answer is reported as a partial
answer instead of arriving as a short one. And when a share has diverged,
quipu merge performs a genuine three-way reconnect against the common base
located through parent_share (src/share_merge.rs, locate_base — which
refuses outright with “incoming share has no parent_share; three-way merge has no
base”). Conflicts are detected against the SHACL cardinalities (max_counts); on
conflict Quipu keeps the base value, emits a DecisionRecord, and the CLI
exits 2 (src/cli_pack.rs, cmd_merge). It would rather stop and tell you
than pick a winner.
Federation and SPARQL SERVICE
Quipu supports SPARQL SERVICE, restricted to endpoints the operator has
configured. Queries are parsed by spargebra (src/sparql/mod.rs) and the
SERVICE pattern is evaluated in src/sparql/pattern.rs under the remote
feature — which server implies and the shipped full build includes
(Cargo.toml).
It is narrower than SPARQL 1.1’s open federation, by design:
- Variable endpoints are refused — “variable SERVICE endpoints are refused; use a configured endpoint IRI”. A query cannot compute its target at runtime.
- Unconfigured hosts are unreachable — with no configured remotes an endpoint
fails with “SERVICE endpoint ‘…’ is unavailable: no configured remotes”, or
returns the seed row under
SILENT. - Every returned row is labelled —
_provideralways, plus_trustand_freshnesswhere you declared them. A federated answer cannot arrive anonymous.
The pinned W3C federated-query ledger scores all seven approved SERVICE
cases. Quipu implements SERVICE as a query-planned remote subquery path using
the same operator-configured declarations and labels as RemoteProvider; it is
not the GraphProvider whole-query fanout path, and it is not open federation.
The variable-endpoint case is a deliberate policy
deviation because query data cannot widen the operator’s remote allowlist.
See SPARQL 1.1 conformance for the measured
score and named verdicts.
Endpoint pointers and Caboodle consent
A pendant can declare a kind = "endpoint" pointer without carrying that
remote graph. Register the local declaring pendant and its repository remote
in the operator’s federation configuration:
[[quipu.federation.pendants]]
path = "/path/to/repo/knowledge/declaration.pendant"
repo_remote = "https://github.com/example/project.git"
On each SERVICE <exact-endpoint-url> use, Quipu verifies the pendant’s share
identity and invokes caboodle packs trust check-endpoint <repo-remote> <url>.
That command requires Tambo/Caboodle with endpoint decision support. Missing,
refused, invalid or timed-out policy checks fail before network access; the error
names the endpoint and pointing repository. Consent is rechecked after policy
changes, including revocation. A foreign archive allow alone does not admit its
endpoint. Use Caboodle’s packs trust allow-endpoint or owner policy to grant
consent; Quipu keeps no second trust policy or persistent admission cache.
An admitted pointer contributes a credential-free, unlabelled endpoint only to
the current SPARQL SERVICE plan. Its _provider is the declared graph IRI.
It does not import triples or shapes, promote to ROOT, forward local credentials,
follow redirects, infer trust labels, or enable REST whole-query fanout. The
URL must match exactly, including its trailing slash: consent does not extend
to an appended /query URL. Existing explicit federation remotes retain their
operator-defined credentials and labels.
The server and MCP use their federation configuration. To opt into strict project/user configuration from the native CLI, use:
quipu query 'SELECT ?s WHERE { SERVICE <https://example.org/sparql> { ?s ?p ?o } }' --federation
Without the remote build feature, SERVICE remains unsupported. Local
filesystem declarations and the native Caboodle policy process are not a browser
API; browser pack resolution is a separate host interface.
The whole stack speaks it
A primitive is only first-class if the tools around it use it, rather than reaching past it. The same share format and the same verify → quarantine → promote discipline appear at every layer:
| Layer | What speaks the format |
|---|---|
| CLI | quipu share / import / import promote / status / merge |
| MCP | quipu_export, quipu_import, quipu_import_promote |
| Bobbin | bobbin:src/knowledge/share_contract.rs plus its quipu-share-v1 fixtures |
The MCP layer is worth a second look, because the discipline is in the tool
contract, not merely in the documentation. quipu_import’s own description reads
“Stage a v1 share bundle into a per-source named graph. Never promotes.”, and
promotion is a separate tool. An agent working through MCP therefore gets the same
two-step admission an operator gets at the CLI — without having to know to ask for
it, and without a way to skip it by accident.
Bobbin carries the canonical fixture set — export.nt, manifest.json, shapes.ttl
and the import request/response pair — so “does this bundle conform?” is a test in
another repository rather than a claim made in this one.
Built vs designed
Everything described above is built and reachable from the CLI today. The gaps are stated here rather than left to be inferred from careful wording.
| Capability | Status |
|---|---|
share / URL import / import promote / status / merge | ✅ Built (src/cli_pack.rs, src/share_transport.rs) |
| Parent-bound SPARQL Update delta write/materialize | ✅ Built (src/share_delta.rs) |
SQLite pack / unpack / pack --verify, deep freeze | ✅ Internal compatibility/archive surface (src/cli_pack.rs, src/cli_graph.rs) |
knot — owl:sameAs across stores | ✅ Built |
| Provider model, declared trust/freshness, per-member outcomes | ✅ Built (src/provider/) |
SPARQL SERVICE to operator-configured endpoints | ✅ Built (feature remote) |
MCP share tools — quipu_export / quipu_import / quipu_import_promote | ✅ Built and provisioned |
Bobbin share contract + quipu-share-v1 fixtures | ✅ Built (bobbin:src/knowledge/share_contract.rs) |
| Bobbin runtime adapter — producing and consuming bundles live | 🔶 Designed, not built. The contract and fixtures exist; the adapter does not yet. |
| External share attestation — proving who produced a share | ✅ Built, in three tiers — see below. Hash verification proves a share is intact; an attestation proves who signed it, and only a producer registered out of band reaches attested. |
| Re-runnable two-store transcript | ✅ Built (examples/sharing-demo/run.sh), checked in CI, and embedded above from expected.txt |
Producing the repository release share
The repository release producer runs on a trusted host that can reach the live
identifier-policy authority. Configure QUIPU_POLICY_SERVER there; use
QUIPU_POLICY_TOKEN_FILE when authentication is required. Keep both the authority
configuration and catalogue on that host. A missing, unreachable, empty,
truncated, or malformed catalogue refuses production. There is no fixture or
internal-destination fallback for a public release.
just contributor pack <output> uses this same boundary. The producer projects
policy into a temporary copy of the index, scrubs the outward share, and proves
its import into a fresh receiver before making the output directory available.
The source index retains no policy projection. Failure leaves no share output.
For a release, check out the exact release tag in a clean source clone and use its checksum-verified native binary. On the trusted host:
bash scripts/publish-repository-share.sh --prepare "$TAG" \
"$QUIPU_BIN" "$BOBBIN_BIN" "$SOURCE_REPO" "$NEW_OUTPUT"
--prepare creates the text pendant archive, checksum and provenance receipt
locally. --publish additionally uploads those three explicit files to the
existing release. It refuses to replace existing assets. The receipt binds the
archive to the release tag, source revision and native binary hash; it includes
no policy content or authority address.
The hosted release job uploads native binaries first. A separately managed
trusted-host trigger must then run the producer. The hosted job waits for its
three assets and verifies the receipt against its own source and binary. It
fails if the producer cannot deliver within the deadline. Provision and prove
that trigger before enabling this workflow; a local --prepare pass alone does
not establish release automation.
These receipt checks establish byte and build correspondence under the release
uploader’s authority. They are not a cryptographic producer attestation and do
not upgrade an import from transport to attested; those tiers are described
below. Neither the catalogue nor the producer’s private logs are release assets.
What an import proves about its producer
Hash verification proves a share is intact. It says nothing about who made it. Those are different claims, and since aegis-tadzdf every import reports which one it reached.
| tier | what it means | what it does NOT mean |
|---|---|---|
transport | No attestation supplied. The payload hashes verify, so the bytes are intact. | Anything about authorship. |
claimed | A signature verifies against the key the share itself carried. The bundle is unaltered since signing and its identity fields are bound together. | That the key belongs to whoever it names — nobody here vouched for it. Replay is not defended at this tier. |
attested | The signature verifies against a session binding registered out of band on this store. | — |
A first import from an unknown producer reports claimed, and that is correct rather than a
failure. Reaching attested requires someone to decide that this key is that producer, using
quipu attest register with a key obtained some other way.
Importing a share never registers its producer. That is the design, not an omission: a key that vouches for the bundle it arrived in vouches for nothing, and an attacker replacing the whole bundle would replace the key along with it. The governance plane states the same rule for itself — quipu never self-registers.
Automated callers should require attested. Accepting claimed is defensible, but it should be a
caller’s deliberate choice rather than the effect of a tier that merely does not read as failure.
Command reference: CLI — sharing.
Import and attestation metrics
The server’s /metrics endpoint exposes process-local counters at the import and
signature-verification decisions:
| Counter | Labels | Meaning |
|---|---|---|
quipu_share_import_total | outcome, tier | One completed or failed library import attempt. |
quipu_attestation_verify_total | binding, result | One verification decision for the write or share signed domain. |
Import outcomes are staged, quarantined, unchanged, and error. Tiers are
transport, claimed, attested, and unverified. A failure before trust has
been established uses unverified, even if the request supplied an envelope.
A failure after verification retains the verified tier. Malformed HTTP JSON and
HTTP authentication refusals do not enter the library import path; see the HTTP
request counters for those failures. An import is staging, not ROOT promotion.
Verification results are ok, badsig, replay, revoked, unbound, skew,
invalid, and error. invalid covers malformed envelopes and binding/domain
mismatches; error covers inability to consult protected registry/replay state.
skew includes expired or not-yet-valid registered sessions. No unsigned import
increments verification success. A claimed signature can verify successfully
without proving the producer’s identity: use the import tier for that claim.
The counters also cover library/CLI callers within their own processes; only the
server’s own observations appear in its scrape.
Labels never contain keys, signatures, nonces, session identities, or error text.
Series appear after observation; family declarations exist before the first
attempt. Counters reset with the process, so compare increments within one
process_start_time_seconds interval or use reset-aware Prometheus functions.
A zero or absent verification-success series is not evidence that an import has
proved authorship; verify a real operation and its corresponding counter delta.
Quarantine review events
Verified share imports append receiver-local lifecycle events to the existing
GET /events cursor feed: import.quarantined, import.staged,
import.adopted, import.promoted, import.rejected, import.expired, and
import.reopened. State and its event commit together; identical retries do not
announce a transition again. Import integrity failures produce no import event.
import.adopted means an earlier quarantine became eligible on re-import after
local validation; it does not install any bundled shape. Promotion stays explicit.
Events contain the share ID, source, caller and separately claimed actor, unknown types, blocking reasons, shapes hash, triple counts, byte size and waiting age. Old imports without review metadata gain that metadata when re-imported; this is observation time, not a reconstructed historical arrival. Composition inspection datasets are separate from the share-import review lifecycle.
The pending registry survives pruning of the event delivery log. Review pages
are bounded, ordered by share ID and return has_more and next_share_id.
Pass the cursor with --after to inspect the next page. These local commands
operate on the receiver selected by --db:
quipu import review pending --limit 100 --db receiver.db
quipu import review notify --age-seconds 86400 --route reviewer --db receiver.db
quipu import review rejected <share-id> --actor alice --reason 'not trusted' --db receiver.db
quipu import review expired <share-id> --actor alice --reason 'review policy expired' --db receiver.db
quipu import review reopen <share-id> --actor alice --reason 'reconsider authority' --db receiver.db
notify emits import.review_due after the explicit threshold, once per share
for the current threshold/route policy. Changing that policy deliberately permits
another notice. Consumers poll the existing feed and commit their offsets using
POST /events/commit; they decide whether and how to send a nudge. No timer,
webhook, external message, or automatic shape adoption is installed. A scheduled
consumer must page through pending reviews, specify its policy and verify its
own observed run and delivery. This library/CLI provides the event producer.
Rejection and expiry require an actor and reason and retain quarantine contents. They prevent re-import and promotion until an explicit reopening; eligible or already promoted packs cannot be closed through the quarantine-review command. Receiver-local review metadata stays in the receiver SQLite database across restart and event retention. It is excluded from portable packs and fact shares: a new receiver records its own decisions. Event offsets follow the existing retention/replay contract.
Aligning concepts across graphs
Sharing moves a graph. It does not move an opinion about what the graph’s concepts are.
Import a colleague’s graph and you may now hold two nodes for one thing — their
bobbin-release and your Bobbin_release-artifact. Nothing in the import can decide they are
the same, because nothing in the import knows. quipu align is the step that closes that gap:
it proposes candidate pairs with evidence, the operator decides, and accepted pairs are
recorded as owl:sameAs in a dedicated alignment graph.
Three properties hold throughout, and they are the reason the verb is split in three:
- Nothing is applied on a score. A candidate is a proposal until a human accepts it.
- The record lives outside both source graphs, so an imported graph stays byte-recoverable against its own share hash.
- Rejections are remembered, so the same pair is not proposed at you again.
propose
quipu align propose <graph-a> <graph-b> [--out <set.tsv>] [--db <path>]
Enumerates both graphs and emits candidate pairs as a SSSOM mapping set — a TSV with a YAML metadata header, the standard interchange format for ontology mappings, so the artifact is diffable, shareable and readable by tools that have never heard of quipu.
Over REST the same call returns the set inline:
$ curl -s http://quipu.example/align/propose -H 'content-type: application/json' \
-d '{"graph_a":"…/plane/crew/records","graph_b":"urn:shuttle:graph:identity"}'
{
"candidates": 0,
"set_aside": 0,
"summary": "0 candidate(s); 0 entity(ies) set aside as ambiguous",
"expected_version": "sha256:ce548369e02ee10e…",
"set_tsv": "#curie_map:\n…"
}
The summary reports both numbers on purpose. set_aside counts entities excluded because
they carry more than one label — alignment never guesses which label is the one to match on. A
caller that prints only the candidate count hides a graph it could not read.
Reading a zero
0 candidates has more than one cause, and they need different actions:
| cause | what you see | what to do |
|---|---|---|
| the graph IRI is not in this store | an error naming the IRI | fix the IRI — a namespace prefix is the usual culprit |
entities exist but carry no rdfs:label | 0 concepts, non-zero unlabelled | alignment matches on labels; ask the publisher to share labels |
| both graphs enumerate fine, nothing matches | 0 candidates, unlabelled 0 | a real zero: there is nothing to align |
Candidates need more than a shared label. Two entities with identical rdfs:label and no
rdf:type in common produced 0 candidates on a live run; adding a shared type to both
produced 1. If a pair you expect is missing, check the types before the labels.
An absent graph is a question the store cannot answer, so it is refused rather than reported as an empty result. An empty graph is a legitimate answer of zero and is returned as one.
decide
quipu align decide <set.tsv> --decisions <rows.tsv> --reviewer <who> [--out <set.tsv>]
Applies the operator’s accept/reject rows to the proposed set and stamps who reviewed it.
Rejections are written as SSSOM negative mappings (predicate_modifier=Not) rather than
dropped, which is what makes them survive the next import.
decide prints the set’s version:
$ quipu align decide set.tsv --decisions rows.tsv --reviewer you --out decided.tsv
expected-version: sha256:99d5c39c476e4a2d… <- pass this to `align apply`
wrote decided.tsv
⚠️ propose prints a version too, and it is not the one to use. Deciding changes the set,
so the two differ — measured on one run: sha256:27eb4ae7… from propose,
sha256:99d5c39c… from decide. Carrying propose’s version to apply fails the concurrency
check, which is the correct outcome and an annoying way to learn it.
apply
quipu align apply <set.tsv> --graph-a <iri> --graph-b <iri> \
--expected-version <sha> [--actor <who>] [--db <path>]
Writes the accepted pairs as owl:sameAs through the existing knot primitive, into an
alignment graph derived from the two source IRIs — not into either source.
Two contracts worth knowing before you script it:
--expected-version is required and is never recomputed. It is the version you read before
you started deciding. If the set changed underneath you, apply refuses and writes nothing.
Recomputing it here would hash the set being written, always match, and silently void the check —
so a lost decision would look like a success.
The derived alignment graph is created for you; a graph you name is not. The derived IRI is computed by quipu and never handed back, so requiring you to pre-create it would be asking for a name you are not told. Any other graph IRI you pass must already exist — otherwise a typo would mint a new empty graph and report a successful write that nobody can find.
What the record is
An alignment is an ordinary, queryable fact:
SELECT ?a ?b WHERE { ?a owl:sameAs ?b }
Visible, retractable, and attributable — rather than a silent edit to someone else’s identifiers. Because it is a graph like any other, it is itself shareable: publish the alignment and a colleague can apply your judgements to their own copy, or disagree with them in the open.
EAVT Fact Log
Implementation status (2026-07-23, kelly): ✅ Implemented (the schema + value-tag tables were corrected in this commit). Core is real and shipped:
facts/terms/transactionswith theidx_eavt/idx_aevt/idx_vaet/idx_txindexes, bitemporalvalid_from/valid_to, current-stateop=1 AND valid_to IS NULL, and the term dictionary (src/schema.rs,src/store/mod.rs). Drift, three items: the realfactstable also has ag(graph) column +idx_geav(named-graph support), absent from the doc’sCREATE TABLE; theopdiscriminant also has 2 = Tombstone (doc shows only 1/0); and the value-encoding table omits tag 6 = Lang and tag 7 = Typed (src/types.rs) — the schema + value-tag tables below are now corrected to match.
The core of Quipu is an immutable, bitemporal fact log stored in SQLite. Every fact is an append-only entry that is never deleted, only superseded.
Schema
CREATE TABLE facts (
e INTEGER NOT NULL, -- entity (dictionary-encoded IRI)
a INTEGER NOT NULL, -- attribute (dictionary-encoded IRI)
v BLOB NOT NULL, -- value (tagged encoding)
tx INTEGER NOT NULL, -- transaction ID
valid_from TEXT NOT NULL, -- when fact became true
valid_to TEXT, -- when fact stopped being true (NULL = current)
op INTEGER NOT NULL, -- 1 = assert, 0 = retract, 2 = tombstone (overlay absence)
g INTEGER NOT NULL DEFAULT 0, -- named graph (0 = default/root graph)
PRIMARY KEY (e, a, v, tx)
);
Alongside the idx_eavt / idx_aevt / idx_vaet covering indexes, a
graph-scoped idx_geav ON facts(g, e, a, v, valid_from) supports named-graph
reads (the g column; graph 0 is the default/root graph — writes target it
unless transact_to_graph names another). op = 2 (tombstone) marks a specific
(e, a, v) absent in an overlay’s composed view, distinct from a retract.
Term Dictionary
IRIs are stored once in the terms table and referenced by integer ID
everywhere else. This keeps the fact table compact and makes integer
comparisons fast.
CREATE TABLE terms (
id INTEGER PRIMARY KEY,
iri TEXT NOT NULL UNIQUE
);
Transactions
Every write is wrapped in a transaction with metadata:
CREATE TABLE transactions (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
actor TEXT, -- who made the change
source TEXT -- provenance (episode, file, etc.)
);
Index Permutations
Four indexes support the standard Datomic-style access patterns:
| Index | Use Case |
|---|---|
| EAVT | “What are all facts about entity X?” |
| AEVT | “What entities have attribute Y?” |
| VAET | “What entities reference value Z?” (reverse lookup) |
| TX | “What changed in transaction T?” |
Bitemporal Model
Every fact has two time axes:
- Transaction time (
tx): when the fact was recorded in the system - Valid time (
valid_from,valid_to): when the fact was true in the world
This enables:
- Current state:
WHERE op = 1 AND valid_to IS NULL - Time-travel:
WHERE tx <= ? AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) - Contradiction detection: overlapping valid-time intervals on the same entity+attribute
Value Encoding
Values are stored as tagged BLOBs with a single-byte type discriminant:
| Tag | Type | Encoding |
|---|---|---|
| 0 | Ref | i64 term ID (little-endian) |
| 1 | Str | UTF-8 bytes |
| 2 | Int | i64 (little-endian) |
| 3 | Float | f64 (little-endian) |
| 4 | Bool | single byte (0/1) |
| 5 | Bytes | raw bytes |
| 6 | Lang | language tag + lexical form (Value::Lang { lexical, lang }) |
| 7 | Typed | datatype IRI + lexical form (Value::Typed { lexical, datatype }) |
This preserves type fidelity across round-trips without external schema lookups.
Change Feed
Implementation status (2026-08-31): ✅ Implemented (quipu-2ae, from the Spanner investigation in
docs/design/spanner-capabilities.md§4.4).src/store/changes.rs—changes_afterwith the three capture modes — served byquipu changesandGET /changes.
The append-only fact log has been a change stream all along; the change feed
gives it a consumer contract, modeled on Spanner’s change streams but
adapted to a pull surface. It is fact-level and lossless where the
event log (/events) is semantic and
taxonomized: use /events to hear “an entity changed”, use /changes to
mirror exactly which facts did.
The contract
- Records are
(tx, sequence, op, graph, entity, attribute, value), derived directly from fact rows — never a second log that can drift from the first.opisassert,retract, ortombstone. - The cursor is a transaction id, and pages end on transaction boundaries. Every cursor is therefore a consistent prefix of commit history: a reader never observes half a transaction.
- Ordering: per entity, records arrive in commit order (transaction, then write order within it). Across entities there is no ordering promise.
- Watermark instead of heartbeat: every page carries
watermark_txandwatermark_timestamp— the newest committed transaction. An empty page with an advancing watermark means the store is idle (or your graph scope is quiet); a watermark that never moves means check the writer. - Cursors never expire. Spanner retains change records for 1–30 days; quipu’s fact log is permanent, so a consumer can resume from any transaction id it ever held, including 0.
Value capture modes
| Mode | A record carries |
|---|---|
new_values (default) | Asserts carry value; a retract identifies (entity, attribute) but withholds the ended value. |
old_and_new_values | A retract also carries the value it ended, as old_value. |
new_row | old_and_new_values, plus the entity’s full state as of that record’s transaction under row — so consumers skip the read-back that a bare notification would force. |
The row snapshot uses the same as-of-transaction predicate the fork
snapshot uses, so the two surfaces cannot disagree about what “live at tx N”
means.
Reading it
quipu changes --db homelab.db # from genesis, new_values
quipu changes --from 42 --capture new_row --limit 50 # page after tx 42
quipu changes --graph "http://example.org/graph/tenant-a"
GET /changes?since=42&capture=old_and_new_values&limit=100
GET /changes?graph=http://example.org/graph/tenant-a
Both return one page:
{
"records": [
{
"tx": 43, "sequence": 0, "timestamp": "2026-04-03", "actor": "ingest",
"source": "episode:...", "op": "assert", "graph": "ROOT",
"entity": "http://example.org/korimako",
"attribute": "http://example.org/cpuCores", "value": 8
}
],
"next_tx": 43,
"watermark_tx": 43,
"watermark_timestamp": "2026-04-03",
"capture": "new_values"
}
Pass next_tx back as the cursor (--from / since). On an empty page it
stays put, so polling is a fixpoint, not a rewind. Ref values resolve to
{"ref": "<iri>"} so a consumer can tell an edge from a string that happens
to look like one; bytes report a length, not a body — the feed is a
notification surface, and blob bodies belong on the entity read path.
First consumers
The intended first consumer is incremental index/embedding maintenance (bobbin): re-embed exactly the entities whose facts changed rather than rescanning, using per-entity ordering to apply changes safely. The event-push delivery worker’s semantic events remain the right surface for workflow triggers; the change feed is for mirrors.
Committed feed streams
GET /events/stream replays the existing event log. GET /changes/stream
replays complete transaction pages from the fact change feed, including named
graph writes. Both are read-only SSE endpoints. Changes use transaction IDs;
events use event offsets. Never submit a transaction ID to /events/commit.
Pass a nonnegative since, or Last-Event-ID. If both are supplied they must
agree. Each quipu.events or quipu.changes frame contains the corresponding
ordinary feed response as JSON; its SSE ID is the delivered page cursor.
Delivery does not acknowledge a consumer. Persist the applied data and cursor
atomically, and reconnect from that durable cursor after any failure.
SQLite commit hooks supply wake hints only. The stream crosses the writer barrier and reads the committed feed before delivery; a rolled-back savepoint or failed commit does not authorize an event. A watch channel coalesces hints; read-after-subscribe prevents a missed commit during subscription. An hourly backstop also checks for external writers. Fifteen-second heartbeat comments perform no database reads.
There are at most 64 active streams and one transaction/event per read page.
Frames larger than 1 MiB terminate with an error event without acknowledging a
cursor. This is an output budget, not a bound on the memory needed to read one
large transaction. Consumers must refuse incomplete frames and retain the
last applied cursor on errors, with bounded reconnect backoff. Event filters
use types and group; transaction filters use graph. Combining unrelated
filter kinds is refused.
Streaming is a source capability. Enabling a consumer, replaying production history, changing subscriptions or claiming scheduled acceptance requires its separate operational decision and measurements.
RDF Data Model
Implementation status (2026-07-23, kelly): ✅ Implemented (a stale Language-Tags section was corrected in this commit). The capability is shipped:
ingest_rdf/export_rdffor all 6 formats (Turtle, N-Triples, N-Quads, RDF/XML, JSON-LD, TriG) via oxrdfio, blank-node round-trip, and XSD→Valuemapping (src/rdf.rs,src/types.rs). Drift (corrected in this commit): the “Language Tags” section (and its type-mapping row) is OBSOLETE — it claimsrdf:langStringis stored asValue::Str("text@lang")and re-split on@, but the code uses a dedicatedValue::Lang { lexical, lang }variant (src/types.rs, tag 6); reconstructing a lang tag by splitting aStron@was a fixed bug. The Language Tags section + type-map row are now corrected to match.
Quipu bridges standard RDF types with the EAVT fact log via the rdf module.
This layer handles conversion between oxrdf types and the integer-encoded
term dictionary.
Type Mapping
RDF terms map to Quipu’s Value type based on XSD datatype:
| RDF Type | XSD Datatype | Quipu Value |
|---|---|---|
| Named node | – | Value::Ref(term_id) |
| Blank node | – | Value::Ref(term_id) (stored as _:name) |
| xsd:integer, xsd:long, xsd:int | Integer types | Value::Int(i64) |
| xsd:double, xsd:float, xsd:decimal | Float types | Value::Float(f64) |
| xsd:boolean | Boolean | Value::Bool |
| xsd:string | String | Value::Str |
| rdf:langString | Language-tagged | Value::Lang { lexical, lang } |
other ^^<datatype> literals | Typed literal | Value::Typed { lexical, datatype } |
Ingestion
Parse any RDF format and write to the fact log in a single transaction:
#![allow(unused)]
fn main() {
use quipu::{Store, ingest_rdf};
use oxrdfio::RdfFormat;
let mut store = Store::open_in_memory().unwrap();
let turtle = r#"
@prefix ex: <http://example.org/> .
ex:alice ex:name "Alice" ; ex:age "30"^^xsd:integer .
"#;
let (tx_id, count) = ingest_rdf(
&mut store,
turtle.as_bytes(),
RdfFormat::Turtle,
None, // base IRI
"2026-04-04T00:00:00Z", // timestamp
Some("crew/braino"), // actor
Some("entity-file.ttl"), // source
).unwrap();
// tx_id: transaction ID, count: 2 triples ingested
}
Supported formats: Turtle, N-Triples, N-Quads, RDF/XML, JSON-LD, TriG.
Export
Serialize current facts back to any RDF format:
#![allow(unused)]
fn main() {
use quipu::export_rdf;
use oxrdfio::RdfFormat;
let ntriples = export_rdf(&store, RdfFormat::NTriples).unwrap();
let turtle = export_rdf(&store, RdfFormat::Turtle).unwrap();
}
Blank Nodes
Blank nodes are stored in the term dictionary with a _: prefix.
They round-trip correctly through ingestion and export.
Language Tags
Language-tagged literals (rdf:langString) are stored as a dedicated
Value::Lang { lexical, lang } variant — the lexical form ("hello") and the
language tag ("en") held in separate fields, never concatenated. On export
the tag is reattached from the lang field. (A previous design concatenated them
as "text@lang" in a Value::Str and re-split on @; that is a fixed bug — a
lexical form may legitimately contain @, so the tag lives in its own field.)
Datatyped literals with a non-standard ^^<datatype> use the parallel
Value::Typed { lexical, datatype }, preserving the lexical form byte-for-byte.
SPARQL Engine
Implementation status (2026-07-23, kelly): ✅ Implemented (code is AHEAD of this doc). SELECT/ASK/CONSTRUCT/DESCRIBE, BGP/JOIN/UNION/OPTIONAL/FILTER/BIND, DISTINCT/ORDER/LIMIT/GROUP/HAVING, aggregates (COUNT/SUM/AVG/MIN/MAX/SAMPLE/ GROUP_CONCAT), FILTER builtins, RDFS subclass inference, and bitemporal current-state filtering are all live (
src/sparql/). Note the doc UNDER-claims: it marks “Property paths: Planned,” but they are fully implemented insrc/sparql/property_path.rs(Reverse/Sequence/Alternative/ZeroOrMore/OneOrMore) — the table row below is now corrected.
Quipu includes a custom SPARQL evaluator that compiles queries directly against the SQLite fact log. No separate triple store or graph database is needed.
How It Works
- Parse: SPARQL string -> AST via spargebra
- Evaluate: Walk the AST, executing each graph pattern against SQLite
- Return: Variable bindings as
HashMap<String, Value>rows
#![allow(unused)]
fn main() {
use quipu::store::Store;
use quipu::sparql;
let result = sparql::query(&store,
"SELECT ?name WHERE { ?s <http://example.org/name> ?name }"
).unwrap();
for row in result.rows() {
println!("{:?}", row.get("name"));
}
}
Query Forms
| Form | Description | Example |
|---|---|---|
| SELECT | Return variable bindings | SELECT ?s ?p ?o WHERE { ... } |
| ASK | Boolean existence check | ASK { ?s a ex:Person } |
| CONSTRUCT | Build new triples | CONSTRUCT { ?s a ex:Result } WHERE { ... } |
| DESCRIBE | Return all facts about an entity | DESCRIBE <http://example.org/alice> |
Supported Features
Graph Patterns
| Pattern | Status | Example |
|---|---|---|
| Basic Graph Pattern (BGP) | Supported | ?s ?p ?o |
| JOIN | Supported | Multiple BGP patterns |
| UNION | Supported | { ... } UNION { ... } |
| FILTER | Supported | FILTER(?age > 30) |
| OPTIONAL (LeftJoin) | Supported | OPTIONAL { ?s ex:email ?e } |
| PROJECT | Supported | SELECT ?name |
| DISTINCT / REDUCED | Supported | SELECT DISTINCT ?type |
| LIMIT / OFFSET | Supported | LIMIT 10 OFFSET 5 |
| ORDER BY | Supported | ORDER BY DESC(?age) |
| GROUP BY | Supported | GROUP BY ?type |
| HAVING | Supported | HAVING(COUNT(?s) > 2) |
| EXTEND (BIND) | Supported | Computed variables |
| VALUES | Supported | VALUES ?x { "a" "b" }, multi-column, UNDEF (src/sparql/values.rs) |
| Property paths | Supported | Reverse ^, Sequence /, Alternative |, *, + (src/sparql/property_path.rs) |
Aggregates
| Function | Example |
|---|---|
| COUNT | SELECT (COUNT(?s) AS ?n) WHERE { ... } |
| SUM | SELECT (SUM(?age) AS ?total) ... |
| AVG | SELECT (AVG(?age) AS ?mean) ... |
| MIN / MAX | SELECT (MIN(?age) AS ?youngest) ... |
An ungrouped, non-distinct COUNT(*) or COUNT of a bound variable over
one triple pattern can use a scalar SQL count. This avoids retaining a binding
for every matching triple. Composed stores canonicalize subject, predicate and
reference-object aliases before counting, so duplicate assertions across
attached dictionaries still count once. The query’s graph and temporal scope
remain in force.
During the scalar statement, file mappings are disabled and an oversized main page-cache budget is reduced to 8 MiB. Each provider’s mapping policy and the main cache policy are restored afterward, including on errors. Smaller cache budgets are preserved. Query deadlines still apply; unsupported aggregate forms and joins retain their existing complexity limits.
FILTER Expressions
| Expression | Example |
|---|---|
| Equality | ?name = "Alice" |
| Comparison | ?age > 30, ?age <= 50 |
| AND / OR / NOT | ?age > 20 && ?age < 40 |
| BOUND | BOUND(?name) |
| Regex | regex(?name, "Ali") |
| CONTAINS | CONTAINS(STR(?s), "traefik") |
| LCASE / STR | LCASE(STR(?name)) |
| isIRI | FILTER(isIRI(?o)) |
| IN / NOT IN | FILTER(?name IN ("Alice", "Bob")) |
RDFS Inference
Quipu supports RDFS subclass inference for rdf:type queries. If you define:
ex:Engineer rdfs:subClassOf ex:Person .
ex:alice a ex:Engineer .
Then SELECT ?s WHERE { ?s a ex:Person } will return ex:alice through
transitive subclass reasoning.
Temporal Awareness
The SPARQL engine automatically filters to current state
(op = 1 AND valid_to IS NULL). Time-travel is supported via the
unravel command:
# See the world as it was at a specific transaction
quipu unravel --tx 5 --db my.db
# See the world as it was at a specific time
quipu unravel --valid-at "2026-03-15T00:00:00Z" --db my.db
Via the MCP tool:
{
"tool": "quipu_query",
"input": {
"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
"valid_at": "2026-03-15T00:00:00Z"
}
}
The In-Memory Read Model
Quipu stores facts in SQLite and answers SPARQL by compiling each triple pattern into SQL. The read model is an optional in-memory index over the same facts, built so that joins do not have to go back to SQL for every row.
It is on by default for multi-pattern queries. This page explains what it is, what it deliberately cannot answer, and when you might turn it off.
What it holds
Three permutation indexes over one graph’s currently-valid asserted facts, plus an object index:
| Index | Answers |
|---|---|
spo | <s> ?p ?o — everything about a subject |
pso | ?s <p> ?o — everything using a predicate |
pos | ?s <p> <o> and ?s a <Type> |
osp | ?s ?p <o> — everything pointing at an object |
Joins are hash joins: each pattern is evaluated once and joined on the variables it shares with the rows so far, rather than re-evaluated once per accumulated row.
Everything is keyed by term id. The id ↔ IRI dictionary lives on the store
itself and is shared with every other read path rather than duplicated here.
What it deliberately cannot answer
The model is built from currently-valid, asserted facts in one graph. That is a strict subset of what SPARQL can ask, and the difference is not a gap to paper over — it is the point.
| Query | Served from |
|---|---|
| Current facts in the ROOT graph | the model |
valid_at / as_of_tx time travel | SQL |
GRAPH <iri>, GRAPH ?g, FROM | SQL |
| Overlays and tombstones | SQL |
| A store with attached databases | SQL |
| Anything, while a write holds an open transaction | SQL |
| A graph past the size budget | SQL |
A guard checks every one of these before the model is consulted, and anything outside its scope falls through to the SQL path unchanged. A query that time travels is not slower because an optimization is missing — it is a different question, asked of data the model does not hold.
What it costs, and what it buys
Measured on stores of synthetic episodes, SQL path → read model:
| Episodes | Point lookup | Type scan | 2-hop join |
|---|---|---|---|
| 1,000 | 0.11 → 0.14 ms | 4.6 → 5.4 ms | 1,016 → 38 ms |
| 4,000 | 0.10 → 0.13 ms | 18.8 → 18.5 ms | 26,233 → 225 ms |
| 10,000 | 0.16 → 0.12 ms | 56.2 → 46.8 ms | 173,803 → 560 ms |
27× to 310× on joins, which are also linear now rather than quadratic, and no measured regression on the other shapes.
Three design choices are what make that true, and each was a measured failure before it was a choice:
- Only multi-pattern queries use it. A single pattern is what SQL is already fast at — a bound-subject lookup is a tenth of a millisecond against an index. Routing those through the model made them pay to build one, which measured as 0.12 ms → 320 ms.
- Writes maintain the model rather than dropping it, so a write-then-read loop does not pay a rebuild every time.
- Size is bounded at one million triples (roughly 320 MB), checked with a
COUNTso an oversized store never pays a build to discover it is oversized. Past that ceiling queries use SQL — slower on joins, but exactly the behaviour they had before.
Turning it off
#![allow(unused)]
fn main() {
store.set_read_model_enabled(false); // SQL for everything
store.set_read_model_max_triples(200_000); // or just lower the ceiling
}
Worth considering if your process is memory-constrained, or if your workload is almost entirely single-pattern lookups on a large store — there the model is built for joins that never come.
Correctness
Both paths share one binding implementation, so a triple becomes the same
Value either way — including the subtleties, like a subject that resolves to a
blank node binding as a string rather than a reference.
Beyond that, a differential test runs every pattern shape through both paths and asserts the answers match, and the entire test suite has been run with the model forced on.
Two bugs that surfaced from doing so are worth knowing about, because they show what this kind of index has to get right:
- The write-time policy guard runs queries inside an open transaction, against rows that are staged but not committed. A model built there and left resident after a denied write would hold facts the database had rolled back.
- Conversely, a model cached before a write is missing that write’s staged rows, so the guard would judge a write against a store lacking the very facts that made it valid.
The first fix dropped the model at both points. It worked, and it forced a rebuild after every write — which is why the fast path could not be the default at first.
The structural fix is to suspend the model for the duration of a write instead: the guard uses SQL, so the model never observes staged rows, and there is nothing to poison on rollback. That is also what makes maintenance possible, because the model is still there when the commit lands.
The general rule either way: the model is never consulted across a transaction boundary it did not observe.
See also
docs/design/in-memory-read-model.md— the full design, measurements, and phase plan.- EAVT Fact Log — the storage the model is built from.
- SPARQL Engine — the evaluator it plugs into.
SHACL Validation
Implementation status (2026-07-23, kelly): ✅ Implemented.
src/shacl.rs(backed byrudof_lib, a default feature) providesValidator::from_turtle/validate/validate_or_rejectandValidationFeedback/ValidationIssueexactly as documented; write-time enforcement is wired via[quipu.shacl] validate_on_write(src/config.rs→src/server.rs), with/shapesand/validateREST routes. No gaps found.
Quipu enforces strict schema at write time via SHACL (Shapes Constraint Language), powered by rudof.
How It Works
- Define SHACL shapes in Turtle format
- Create a
Validatorfrom those shapes - Validate proposed data before writing to the fact log
- Get structured feedback on failures
#![allow(unused)]
fn main() {
use quipu::{Validator, validate_shapes};
let shapes = r#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
sh:property [
sh:path ex:name ;
sh:datatype xsd:string ;
sh:minCount 1 ;
] .
"#;
let data = r#"
@prefix ex: <http://example.org/> .
ex:alice a ex:Person ; ex:name "Alice" .
"#;
let feedback = validate_shapes(shapes, data).unwrap();
assert!(feedback.conforms); // true -- data is valid
}
Validation scope and pending post-state
The gates have different scopes. Do not describe all write validation as a whole-graph pending-post-state check:
quipu knot --shapesandvalidate_shapesvalidate the submitted data.- Contextual write-time SHACL starts with that payload verdict. If it fails, a bounded second pass adds referenced types and required properties already stored in the destination graph and ROOT. It can remove payload violations; it cannot introduce new violations found only in that context. A conforming payload takes the fast path. This preserves incremental-write compatibility and is not whole-graph conformance certification.
- Write-savepoint admission also checks the stored values of touched focuses
for maximum count, datatype, node kind, class, pattern, length, numeric bounds,
enumeration, language, and nested-node constraints. This catches a value written
before the type or edge that targets it. Referenced-node context stays in the
destination graph (ROOT contributes types only). Scalar constraints do not
walk references; class checks add only referenced types, and nested-node
checks follow declared direct-predicate node paths, not arbitrary links.
Optional context has fact/node work budgets, not graph-size admission limits.
Exhaustion discards partial value context and falls back to the pre-existing
payload/maxCount path; it does not reject a valid write or certify its stored
value constraints. Existing maxCount context limits still apply. These budgets
are not measurements of the largest production graph. It does not turn context nodes into
additional write-owned focuses. Changes to a child recheck its bounded
incoming dependents on declared direct-predicate
sh:nodepaths. Other incoming dependencies are not a whole-graph revalidation guarantee. This does not add a new minimum-count requirement to an incomplete incremental write. Existing deactivation and unsupported cardinality path behavior remain unchanged. - With
governance.enforce_on_writeenabled, an applicable action-boundary policy evaluates its claim for touched targets against the transaction’s pending post-state. A parent can require evidence on its linked child in the same transaction or already committed in the store. Evidence promised in a later transaction does not satisfy the current write: refusal rolls back the parent and link. This guarantee is for applicable touched targets, not automatic revalidation of every incoming dependency of a changed node.
tests/pending_poststate.rs pins the distinction with positive and negative
controls. A linked child’s missing required name fails SHACL when the child
is typed in the submitted payload. If its incomplete type exists only in
store context, the contextual repair may accept a parent reference: the
new child violation is outside the original payload ceiling. Conversely, a
policy requiring linked evidence accepts a complete same-transaction write
and refuses the missing-evidence write before that evidence exists.
just test --locked --test pending_poststate
Use the policy guarantee for transactional cross-entity requirements. Use a separate full-store or explicitly scoped validation for stored SHACL conformance; incremental acceptance alone does not establish it. Neither this documentation nor the tests enable a gate or change its runtime policy.
Agent-Friendly Feedback
When validation fails, the ValidationFeedback struct provides structured
details that agents can act on:
#![allow(unused)]
fn main() {
if !feedback.conforms {
for issue in &feedback.results {
println!("Severity: {}", issue.severity);
println!("Focus node: {}", issue.focus_node);
println!("Component: {}", issue.component);
if let Some(path) = &issue.path {
println!("Path: {}", path);
}
if let Some(msg) = &issue.message {
println!("Message: {}", msg);
}
}
}
}
This is the core of Quipu’s “strict but helpful” philosophy – validation doesn’t just reject, it tells the agent exactly what’s wrong and where.
Supported Constraints
Through rudof, Quipu supports the full SHACL Core specification:
- Cardinality:
sh:minCount,sh:maxCount - Value type:
sh:datatype,sh:class,sh:nodeKind - Value range:
sh:minInclusive,sh:maxInclusive,sh:minExclusive,sh:maxExclusive - String:
sh:minLength,sh:maxLength,sh:pattern - Property pair:
sh:equals,sh:disjoint,sh:lessThan - Logical:
sh:and,sh:or,sh:not,sh:xone - Shape-based:
sh:node,sh:property,sh:qualifiedValueShape - Other:
sh:closed,sh:ignoredProperties,sh:hasValue,sh:in
Reusable Validators
Create a Validator once and validate multiple data payloads:
#![allow(unused)]
fn main() {
let validator = Validator::from_turtle(shapes)?;
// Validate multiple payloads
let result1 = validator.validate(data1.as_bytes())?;
let result2 = validator.validate(data2.as_bytes())?;
// Or use the convenience reject method
validator.validate_or_reject(data.as_bytes())?;
}
Code Entity Shapes
Quipu ships SHACL shapes for Bobbin’s code entity model in
shapes/code-entities.ttl. These enforce the knowledge-aware bundles
ontology at write time:
| Shape | Target Class | Required Properties |
|---|---|---|
CodeModuleShape | CodeModule | filePath, repo, language |
CodeSymbolShape | CodeSymbol | name, definedIn (class CodeModule) |
DocumentShape | Document | filePath |
SectionShape | Section | heading, headingDepth (integer) |
BundleShape | Bundle | rdfs:label, contains (min 1) |
CodeSymbolShape constrains symbolKind to an enumerated set of values
(function, method, class, interface, enum, struct, variable, constant,
module, property, field, constructor, type_alias).
BundleShape requires contains members to be instances of CodeModule,
CodeSymbol, Document, or Section.
Load the shapes via the REST API:
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d "{\"action\": \"load\", \"name\": \"code-entities\", \
\"turtle\": \"$(cat shapes/code-entities.ttl)\"}"
Episode Ingestion
Implementation status (2026-07-23, kelly): ✅ Implemented.
src/episode/mod.rs—ingest_episode,ingest_episode_with_resolution,ingest_batch, theEpisodestruct withgroup_id/shapes,prov:Activity/prov:wasGeneratedByprovenance, and edge-confidencereification (bare triple when absent;rdf:Statement+quipu:confidencefor enum/numeric grades — tested insrc/episode/tests.rs). SHACL episode + persistent shape gates and thequipu episodeCLI are wired. Verified by grep + tests.
Episodes are the structured write path for agent-extracted knowledge. When an agent observes something – a deploy, an incident, a configuration change – it packages that knowledge as an episode with typed nodes and edges.
Anatomy of an Episode
{
"name": "korimako-rebuild-2026-03-29",
"episode_body": "Korimako was rebuilt on kotare after disk failure",
"source": "aegis/crew/ellie",
"group_id": "aegis-ontology",
"nodes": [
{
"name": "korimako",
"type": "ProxmoxNode",
"description": "Proxmox host, rebuilt after failure",
"properties": { "hostname": "korimako.example", "status": "recovered" }
},
{
"name": "kotare",
"type": "ProxmoxNode",
"description": "Primary Proxmox host"
}
],
"edges": [
{
"source": "korimako",
"target": "kotare",
"relation": "rebuilt_on"
}
]
}
Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Episode identifier (becomes IRI) |
episode_body | No | Natural language description |
source | No | Agent or system that produced this |
group_id | No | Provenance label for the episode (see note below) |
nodes | No | Entities to create |
edges | No | Relationships between entities |
shapes | No | SHACL Turtle for validation gate |
replace_snapshot | No | Replace this episode’s prior facts atomically (default: false) |
Ordinary episodes are additive: changing an episode does not retract entities
that were generated by its earlier version. A producer publishing a complete
current inventory should set replace_snapshot: true. Quipu then retracts facts
missing from the new inventory and asserts new facts in one transaction. An
empty nodes/edges payload therefore represents an empty snapshot, and
readers never observe a retract-then-repost gap.
group_idis a label, not a partition. It is recorded as a singleaegis:groupIdliteral on the episode’sprov:Activity; nodes inherit it transitively viaprov:wasGeneratedBy. All groups share one graph — there is no isolation boundary, and thegroup_idsfilter on search is best-effort. Facts asserted directly via/knot(raw Turtle, not through an episode) carry no group at all and are invisible togroup_idsfiltering. Usegroup_idfor provenance/organization, not as a multi-tenant security boundary.
Node Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Entity identifier |
type | No | rdf:type (e.g., “ProxmoxNode”) |
description | No | rdfs:comment |
properties | No | Key-value pairs as typed literals |
Edge Fields
| Field | Required | Description |
|---|---|---|
source | Yes | Source entity name |
target | Yes | Target entity name |
relation | Yes | Relationship name (becomes predicate) |
confidence | No | Confidence qualifier for the generated triple (see below) |
Edge confidence. Supply
confidenceto grade how trustworthy an AUTO-extracted edge is — either an enum ("EXTRACTED","INFERRED","AMBIGUOUS") or a0–1number. The bare triple is always asserted; when a confidence is present the statement is additionally reified (rdf:Statementwithrdf:subject/rdf:predicate/rdf:object) and qualified withquipu:confidence, so agents can filter or flag uncertain facts via SPARQL. Omitting it (the common case) leaves an unqualified triple — fully back-compatible.# All edges an agent flagged as merely inferred SELECT ?s ?p ?o WHERE { ?stmt rdf:subject ?s ; rdf:predicate ?p ; rdf:object ?o ; quipu:confidence "INFERRED" . }
How Ingestion Works
- Episode JSON is converted to RDF Turtle
- If
shapesis provided, SHACL validation runs first – rejects on failure - Turtle is ingested via the standard RDF pipeline in a single transaction
- Episode node gets
prov:Activitytype with provenance links - Each entity gets
prov:wasGeneratedBylinking back to the episode
Provenance Tracking
Every entity created by an episode carries a provenance link:
SELECT ?entity WHERE {
?entity <http://www.w3.org/ns/prov#wasGeneratedBy>
<http://aegis.gastown.local/ontology/episode/korimako-rebuild-2026-03-29>
}
The episode_provenance() function returns all entities and their facts
for a given episode name.
Description Revisions
A current entity carries one rdfs:comment. When an ordinary (additive)
episode supplies a different description for an existing node, the
superseded text is not silently discarded and not left to pile up on the
entity: in the same transaction, each superseded comment is retracted from the
entity and re-asserted on the episode entity whose prov:wasGeneratedBy
assertion shared its original transaction (src/episode/descriptions.rs).
Last write wins on the entity; the history stays lossless because every prior
description remains readable on the episode that produced it.
Two edges of that rule:
- Unattributable history refuses the whole write. If a current comment has
no same-transaction
prov:wasGeneratedByattribution, the ingest errors before the transaction opens rather than discarding provenance. One revision migrates every attributable legacy comment it supersedes. - The reconciliation is per
(entity, new text)— a node listed more than once in an episode (for example under multiple types) produces one revision, not duplicate retractions.
SHACL Validation Gate
Episodes can include a shapes field with SHACL constraints. If provided,
the episode data is validated before writing – invalid episodes are rejected
with structured feedback explaining exactly what failed.
{
"name": "new-service",
"shapes": "@prefix sh: ... (SHACL Turtle)",
"nodes": [{ "name": "myapp", "type": "WebApplication" }]
}
CLI Usage
# From file
quipu episode deploy.json --db my.db
# From stdin (pipe from another tool)
echo '{"name": "test", "nodes": [...]}' | quipu episode - --db my.db
Batch Ingestion
Multiple episodes can be ingested sequentially. Processing stops on the first error, so earlier episodes are committed while later ones may not be.
#![allow(unused)]
fn main() {
use quipu::episode::ingest_batch;
let results = ingest_batch(&mut store, &episodes, ×tamps)?;
for (tx_id, count) in results {
println!("tx={tx_id}, triples={count}");
}
}
Vector Search
Implementation status (2026-07-23, kelly): 🟡 Implemented (SQLite); LanceDB framing overstated. The SQLite backend is fully shipped —
vectorstable, brute-force cosine,embed_entity/vector_search(query, k, valid_at), bitemporal exclusion,VectorMatch, plus real hybrid search (tool_hybrid_search,/hybrid_search, oversample-and-post-filter) insrc/vector.rs/src/mcp/. Gap: the doc presents LanceDB as a runtime-selectable “optional backend” for ANN + predicate pushdown, but it is inert in the shipped binaries:vector.backend = "lancedb"is set-but-not-read (src/config.rsunwired_warnings()warns so), and the only activation path,Store::set_local_vector_backend, has zero callers in-repo — it is embedder-only. Seelancedb.md(also 🟡).
Quipu stores vector embeddings alongside facts and supports cosine similarity search with temporal awareness. Two backends are available: the default SQLite backend (brute-force) and an optional LanceDB backend with approximate nearest neighbor search and predicate pushdown.
How It Works
Each entity can have an associated embedding – a 384-dimensional float
vector that captures its semantic meaning (compatible with
all-MiniLM-L6-v2). Both backends implement the KnowledgeVectorStore
trait, so calling code is backend-agnostic.
The default SQLite backend stores embeddings in a vectors table with
bitemporal validity (same model as the fact log):
vectors(entity_id, text, embedding, valid_from, valid_to)
Search computes cosine similarity between a query vector and all current embeddings, returning the top-N matches ranked by score. For larger datasets, the LanceDB backend provides ANN search with predicate pushdown.
Storing Embeddings
#![allow(unused)]
fn main() {
use quipu::store::Store;
let store = Store::open("my.db").unwrap();
// Generate embedding externally (e.g., all-MiniLM-L6-v2)
let embedding: Vec<f32> = model.encode("Traefik reverse proxy");
// Store it
store.embed_entity(entity_id, "Traefik reverse proxy", &embedding, "2026-04-04T00:00:00Z").unwrap();
}
Searching
#![allow(unused)]
fn main() {
let query_embedding = model.encode("web proxy");
let results = store.vector_search(&query_embedding, 10, None).unwrap();
for m in &results {
println!("{} (score: {:.3})", m.text, m.score);
}
}
Each VectorMatch contains:
| Field | Description |
|---|---|
entity_id | The matched entity’s term ID |
text | The text that was embedded |
score | Cosine similarity (0.0 to 1.0) |
valid_from | When this embedding became active |
valid_to | When it expired (None = current) |
Hybrid Search
The quipu_hybrid_search tool combines SPARQL filtering with vector ranking:
- Extract pushdown filter – simple type patterns (
?s a <Type>) are converted to a filter string for the vector backend - Vector search with filter – LanceDB applies the filter during ANN search; built-in SQLite scores the bounded SPARQL candidate set before top-K
- Cross-filter with SPARQL – full SPARQL query runs independently, results intersected for consistency
{
"tool": "quipu_hybrid_search",
"input": {
"sparql": "SELECT ?s WHERE { ?s a <http://example.org/WebApp> }",
"embedding": [0.1, 0.2, ...],
"limit": 5
}
}
This lets you narrow by type or relationship first (SPARQL), then rank by semantic meaning (vector) – combining structured and unstructured search. With LanceDB, the type filter is pushed down into the vector index for O(log n) filtered search. See LanceDB Vector Backend for details.
Temporal Vector Search
Pass valid_at to search embeddings as they existed at a past point in time:
#![allow(unused)]
fn main() {
let results = store.vector_search(&query, 10, Some("2026-03-01T00:00:00Z")).unwrap();
}
Expired embeddings (where valid_to is set) are automatically excluded
from current searches.
LanceDB Vector Backend
Selectable from config. The backend is fully built and trait-conformant —
LanceVectorStorebehind#[cfg(feature="lancedb")](src/vector_lance.rs), allKnowledgeVectorStoremethods includingonly_if()predicate pushdown, theVectorSearchDelegatewrapper, andquipu migrate-vectors(src/migration.rs). The binaries readvector.backendand install it at open (src/config/vector_backend.rs;src/cli_open.rsfor everyquipusubcommand,src/server/base.rsforquipu-server).Store::vector_store()already preferred a local backend over the built-in table, so selecting it is all search, resolution, auto-embed and the MCP/REST search tools needed.Two things to know before turning it on:
- The shipped release binaries are NOT built with the feature.
lancedbis deliberately outside thefullbundle — protoc plus the whole datafusion tree is a real cost for a backend most deployments do not use. A binary built without it refusesbackend = "lancedb"at startup, naming the rebuild, rather than falling back to the SQLite table: a deployment that has runquipu migrate-vectorswould otherwise have every search answered out of the store it migrated away from. Build withcargo build --features full,lancedbto ship it.- It needs a Tokio runtime.
quipu-serveris#[tokio::main]; the CLI enters one for the whole dispatch when the configured backend requires it.
Quipu supports two vector storage backends: the default SQLite backend and
an optional LanceDB backend for production workloads. Both implement the
KnowledgeVectorStore trait.
Dual-Backend Architecture
┌──────────────────────────┐
│ KnowledgeVectorStore │
│ (trait) │
└────────┬─────────────────┘
│
┌──────────────┴──────────────┐
│ │
┌────────┴────────┐ ┌──────────┴──────────┐
│ SQLite (default)│ │ LanceDB (optional) │
│ Brute-force │ │ ANN + pushdown │
│ cosine sim │ │ Arrow columnar │
└─────────────────┘ └─────────────────────┘
| Aspect | SQLite | LanceDB |
|---|---|---|
| Storage format | f32 BLOB in vectors table | Arrow RecordBatch columns |
| Search algorithm | Brute-force cosine similarity | Approximate nearest neighbor |
| Predicate pushdown | No (5x oversampling fallback) | Yes (only_if() clause) |
| Complexity | O(n) scan | O(log n) with filter |
| Metadata columns | entity_id, text, valid_from, valid_to | + entity_type, source_episode |
| Async requirement | None | Tokio runtime required |
| Feature flag | Always available | lancedb feature |
Enabling LanceDB
Two steps, and both are required — the feature compiles the backend, the config key selects it.
1. Build with the feature.
cargo build --features full,lancedb
2. Select it in .bobbin/config.toml.
[quipu.vector]
backend = "lancedb"
lancedb_path = ".bobbin/quipu/quipu-vectors"
Moving existing embeddings across first is one command:
quipu migrate-vectors --from sqlite --to lancedb --dry-run # see the count
quipu migrate-vectors --from sqlite --to lancedb
Selecting the backend on a directory that has never been written creates the empty table, so a fresh deployment does not have to migrate first.
As a library dependency
Add the lancedb feature flag:
[dependencies]
quipu = { git = "https://github.com/scbrown/quipu", features = ["lancedb"] }
Or build from source:
cargo build --features lancedb
The KnowledgeVectorStore Trait
Both backends implement this trait (defined in src/vector.rs):
#![allow(unused)]
fn main() {
pub trait KnowledgeVectorStore {
fn embed_entity(&self, entity_id: i64, text: &str,
embedding: &[f32], valid_from: &str) -> Result<()>;
fn close_embedding(&self, entity_id: i64, valid_to: &str) -> Result<()>;
fn vector_search(&self, query: &[f32], limit: usize,
valid_at: Option<&str>) -> Result<Vec<VectorMatch>>;
fn vector_search_filtered(&self, query: &[f32], limit: usize,
filter: Option<&str>,
valid_at: Option<&str>) -> Result<Vec<VectorMatch>>;
fn vector_count(&self) -> Result<usize>;
}
}
The Store::vector_store() method returns &dyn KnowledgeVectorStore,
so calling code is backend-agnostic.
Delegated Vector Search
When Quipu is used as a Bobbin dependency, embeddings are rebuildable derived
data that belong in the index layer (Bobbin), not the durable knowledge layer
(Quipu). The VectorSearchDelegate trait enables this separation:
#![allow(unused)]
fn main() {
pub trait VectorSearchDelegate: Send + Sync {
fn vector_search(&self, query: &[f32], limit: usize,
valid_at: Option<&str>) -> Result<Vec<VectorMatch>>;
fn vector_search_filtered(&self, query: &[f32], limit: usize,
filter: Option<&str>,
valid_at: Option<&str>) -> Result<Vec<VectorMatch>>;
fn text_search(&self, query: &str, limit: usize,
valid_at: Option<&str>) -> Result<Vec<VectorMatch>>;
fn vector_count(&self) -> Result<usize>;
}
}
When a delegate is set via Store::set_vector_search_delegate():
- Search forwards to delegate:
vector_store()returns a wrapper that routes all search calls to the delegate - Auto-embedding is skipped: the transact hook does not generate embeddings (Bobbin owns the embedding lifecycle)
- Write methods are no-ops:
embed_entity()andclose_embedding()on the delegated store silently succeed without writing
When no delegate is set (standalone mode), Quipu falls back to its own SQLite or LanceDB vectors as before.
Hybrid Search with Predicate Pushdown
The quipu_hybrid_search tool uses a three-phase approach:
Phase 1 – Extract pushdown filter. Simple SPARQL type patterns
(?s a <TypeIRI>) are converted to a SQL filter string like
entity_type = 'TypeIRI'.
Phase 2 – Vector search with filter. The filter is passed to
vector_search_filtered():
- LanceDB: applies the filter during ANN search (
only_if()clause), so only matching vectors are scanned - SQLite: ignores the filter and oversamples by 5x, relying on post-filtering
Phase 3 – Post-filter by SPARQL candidates. The full SPARQL query executes independently, and vector results are intersected with SPARQL results for consistency.
SPARQL: SELECT ?s WHERE { ?s a <Person> }
│
├─► Extract type filter: entity_type = 'Person'
│
├─► Vector search with pushdown (LanceDB)
│ or oversample 5x (SQLite)
│
└─► Post-filter: intersect with SPARQL candidates
│
▼
Ranked results
Embedding Dimensions
All backends use 384-dimensional float32 vectors, compatible with the
all-MiniLM-L6-v2 model. When running as a Bobbin subsystem, the shared
ONNX embedding pipeline provides vectors automatically.
Temporal Awareness
Both backends track valid_from and valid_to for each embedding:
- Current embeddings have
valid_to = NULL - Expired embeddings are excluded from searches unless
valid_atis specified - Time-travel queries (
valid_at) return embeddings active at that timestamp
Context Pipeline
Implementation status (2026-07-23, kelly): ✅ Implemented.
src/context/mod.rs—ContextPipeline/ContextPipelineConfig(defaultsmax_entities:20,expand_links:true,link_depth:1),KnowledgeContext/KnowledgeEntity, theDirect/Linked/Semanticrelevance enum (Semantic from the hybrid vector path), andquery(). Exposed as thequipu_contextMCP tool and the/contextREST route. Verified by grep.
The context pipeline blends knowledge graph facts with code context, producing unified results for agent consumption. It’s the integration surface between Quipu and Bobbin.
How It Works
When an agent asks for context about a topic:
- Text search – SPARQL
FILTER(CONTAINS(...))on entity IRIs and literal values to find direct hits - Link expansion – follow outgoing and incoming relationships from direct hits to discover related entities
- Rank and truncate – sort by relevance score, trim to budget
The output is a KnowledgeContext shaped for Bobbin to merge with its
code search results.
Output Shape
KnowledgeContext
{
"query": "traefik",
"entities": [ ... ],
"summary": {
"total_entities": 4,
"total_facts": 18,
"direct_hits": 1,
"linked_additions": 3
}
}
KnowledgeEntity
Each entity includes its label, types, relevance, and all its facts:
{
"iri": "http://example.org/traefik",
"label": "Traefik",
"types": ["http://example.org/WebApplication"],
"relevance": "Direct",
"score": 1.0,
"facts": [
{ "predicate": "http://example.org/runsOn", "value": "http://example.org/kotare", "value_type": "Entity" },
{ "predicate": "http://example.org/port", "value": "443", "value_type": "Literal" }
]
}
Relevance Types
| Relevance | Score | Description |
|---|---|---|
| Direct | 1.0 | Found via text search match |
| Linked | 0.5 | Discovered by following relationships from direct hits |
| Semantic | varies | Found via vector similarity search |
Configuration
| Option | Default | Description |
|---|---|---|
max_entities | 20 | Maximum entities to return |
max_facts_per_entity | 20 | Maximum facts per entity |
expand_links | true | Follow relationships from direct hits |
link_depth | 1 | How many hops to follow (1 = immediate neighbors) |
MCP Tool
{
"tool": "quipu_context",
"input": {
"query": "traefik reverse proxy",
"max_entities": 10,
"expand_links": true
}
}
REST API
curl -s localhost:3030/context -X POST \
-H "Content-Type: application/json" \
-d '{"query": "traefik", "max_entities": 10}'
Rust API
#![allow(unused)]
fn main() {
use quipu::context::{ContextPipeline, ContextPipelineConfig};
let config = ContextPipelineConfig {
max_entities: 10,
expand_links: true,
..Default::default()
};
let pipeline = ContextPipeline::new(&store, config);
let ctx = pipeline.query("traefik").unwrap();
for entity in &ctx.entities {
println!("{} ({:?}): {} facts",
entity.label.as_deref().unwrap_or(&entity.iri),
entity.relevance,
entity.facts.len());
}
}
Graph Projection
Implementation status (2026-07-23, kelly): ✅ Implemented.
src/graph.rs—project,in_degree,connected_components(Kosaraju SCC), andshortest_path(A*), each matching the documented algorithm and JSON output, dispatched by thequipu_projectMCP tool. (PageRank/PPR + Louvain also ride this Projection API — seedocs/design/pagerank.md.) Verified by grep.
Quipu can materialize its fact store into an in-memory directed graph (via petgraph) for running graph algorithms that aren’t expressible in SPARQL.
How It Works
The project() function scans entity-to-entity relationships in the
store and builds a petgraph::DiGraph:
- Nodes = entities (term IDs)
- Edges = relationships where the object is also an entity (
Value::Ref) - Edge weight = predicate ID
Optional filters narrow the projection:
| Filter | Description |
|---|---|
type_filter | Only include entities of a given rdf:type |
predicate_filter | Only include edges with a given predicate |
graph | Project one named graph’s own facts instead of ROOT |
Scoping to a named graph (project_in_graph / the tool’s graph parameter)
reads the same scope a GRAPH <iri> { … } query sees, so projecting a small
derived layer stays cheap even when the ROOT episode log is large. Projections
are also memoized on the store (project_cached): repeat calls with the same
shape return the resident projection until any transaction commits, using the
latest_tx_id change stamp — an unchanged graph is never re-scanned.
Available Algorithms
Stats
Basic graph metrics: node count and edge count.
In-Degree Centrality
Rank entities by how many incoming relationships they have. Useful for finding “hub” entities that many things depend on.
quipu read "..." # Not expressible in SPARQL -- use the MCP tool instead
{
"tool": "quipu_project",
"input": {
"algorithm": "in_degree",
"type": "http://example.org/Service",
"limit": 10
}
}
Returns:
{
"results": [
{ "entity": "http://example.org/traefik", "in_degree": 12 },
{ "entity": "http://example.org/postgres", "in_degree": 8 }
]
}
Connected Components
Find clusters of entities that are connected to each other (strongly connected components via Kosaraju’s algorithm).
{
"tool": "quipu_project",
"input": { "algorithm": "components" }
}
Shortest Path
Find the shortest path between two entities (A* algorithm).
{
"tool": "quipu_project",
"input": {
"algorithm": "shortest_path",
"from": "http://example.org/traefik",
"to": "http://example.org/postgres"
}
}
Returns the path as an ordered list of entity IRIs, or null if unreachable.
Rust API
#![allow(unused)]
fn main() {
use quipu::graph::{project, in_degree, connected_components, shortest_path};
// Project all entities and relationships
let pg = project(&store, None, None).unwrap();
println!("Nodes: {}, Edges: {}", pg.node_count(), pg.edge_count());
// Find most-connected entities
let ranked = in_degree(&pg);
for (id, degree) in ranked.iter().take(5) {
println!("{}: {} incoming", id, degree);
}
// Find clusters
let components = connected_components(&pg);
println!("Found {} connected components", components.len());
// Find a path
let path = shortest_path(&store, &pg, "http://ex.org/a", "http://ex.org/z").unwrap();
}
Entity Resolution
Implementation status (2026-08-12): ✅ Built. The resolver lives in
src/resolution.rs(embedding + Jaro-Winkler matching, dedup by IRI,top_k);[quipu.resolution]config is parsed and applied; episode ingest returnsresolution_hints(src/episode/mod.rs); thequipu_resolve_entityMCP tool (src/mcp/resolution.rs) and thePOST /resolveprobe are wired, withstrict_modeenforced on ingest. Seedocs/design/entity-resolution.md.
Independent ingests mint independent entities. Two agents describing the same
service — one as example-service, one as Example Service — produce two
IRIs, and every fact after that fragments across them. Entity resolution is
the countermeasure: before (or instead of) a write, it asks the graph “does
something like this already exist?” and returns scored candidates.
Scoring
resolve_entity in src/resolution.rs runs two matchers and merges their
results:
- Embedding similarity — the proposed name and properties are joined into
one text, embedded, and searched against the existing vector index (SQLite
or LanceDB — the same index
/searchuses, no separate store). Only runs when an embedding provider is configured. - Canonical name matching — the name is compared against every current
rdfs:labelvalue. A case-insensitive exact match scores1.0; otherwise Jaro-Winkler string similarity applies, which catches typos and case variations that embeddings miss.
Candidates above the threshold from both phases are merged, deduplicated by
IRI (highest score wins), sorted descending, and truncated to top_k. Each
candidate carries an explanation:
{
"iri": "http://example.org/ontology/example-service",
"score": 0.92,
"matched_on": "canonical_name:jaro_winkler:0.92"
}
matched_on is one of canonical_name:exact,
canonical_name:jaro_winkler:<score>, or embedding:<score> — agents use it
to decide how much to trust the match.
Two surfaces
On-demand probe
POST /resolve (handler tool_resolve_entity, also exposed as the
quipu_resolve_entity MCP tool) answers “what would resolution say?” without
writing anything — no transaction, no vectors, guaranteed by test rather than
by signature. It works even when [quipu.resolution].enabled = false and
needs no bearer token:
curl -s localhost:3030/resolve -X POST \
-H "Content-Type: application/json" \
-d '{"name": "example-service", "properties": {"type": "DatabaseService"}}'
The response is has_matches, candidates, and count. Optional top_k and
threshold default to the [quipu.resolution] config, so the probe and the
ingest path agree by construction.
Ingest-time hints
When resolution is enabled, episode ingest
(ingest_episode_with_resolution) resolves each node before writing. Matches
do not block the write — they ride along in the response as
resolution_hints, one entry per node with candidates:
{
"tx_id": 42,
"count": 3,
"resolution_hints": [
{
"node": "example-service",
"candidates": [
{ "iri": "http://…/example-service", "score": 0.91, "matched_on": "embedding:0.91" }
]
}
]
}
The same field appears on POST /episode and the episode MCP tools, which
share one handler.
What it does not do
Resolution proposes; it never merges. Advisory mode (the default) writes
the new entity anyway and leaves the reuse-or-keep decision to the caller.
With strict_mode = true, ingest goes further: a write whose node matches an
existing entity is rejected outright, and the error names the top candidate —
the caller must reuse the existing IRI, or assert quipu:distinctFrom to
record that the entities are intentionally separate. There is no automatic
dedup, no silent IRI rewriting, and no background merge job.
quipu:distinctFrom excuses exactly the pairing it names, and is stored as a
durable fact, so it is declared once rather than on every re-ingest:
{ "name": "alice_smith", "type": "Person",
"distinct_from": ["http://aegis.gastown.local/ontology/Alice"] }
That holds for contention too. When two nodes of one write claim the same
existing entity, the response says so in resolution_contentions — but it does
not pick a winner. Assigning contested entities would be a judgment made from a
similarity score the caller can see and quipu cannot justify, and quipu leaves
judgments to the reader.
{
"resolution_contentions": [
{ "iri": "http://aegis.gastown.local/ontology/Alice",
"claimants": [ {"node": "alice_smith", "score": 0.94},
{"node": "a_smith", "score": 0.88} ] }
]
}
What resolution can see
On a store with attached layers the two halves have different reach. The
canonical-name half reads the composed fact source, so it sees entities defined
in an attached knowledge pack. The embedding half does not: vectors is a
per-database table and a pack may carry a different embedding model, so unioning
the indexes could turn a working search into a dimension-mismatch error.
Every result therefore carries vector_scope — {"kind": "whole_store"} when
there is nothing attached, {"kind": "local_only", "attached_layers": N} when
the embedding half left N layers unsearched. Without it, an empty candidate list
means either “no duplicates” or “the layer your duplicate is in was never
searched”, and the caller cannot tell which.
Configuration
[quipu.resolution]
enabled = true # ingest-time hints (default: false)
threshold = 0.85 # similarity floor, 0.0–1.0 (default: 0.85)
top_k = 3 # max candidates per entity (default: 3)
strict_mode = false # reject near-duplicate writes (default: false)
Resolution is off by default, so existing write workflows are unaffected; the
/resolve probe answers regardless. At threshold = 0.99 it is effectively
exact-match only.
See also
- REST API —
POST /resolve - MCP tools —
quipu_resolve_entity - Design doc:
docs/design/entity-resolution.md
Multi-DB Composition
Implementation status (2026-08-12): 🟩 Composed reads are built. Term spaces and the space-aware allocator (
src/store/mod.rs),quipu db respace(src/store/respace.rs), ATTACH mounting, graph registration and the composed facts source (src/store/attach.rs), and term aliases (src/store/alias.rs— quipu #76, with one deviation: the alias table is TEMP and rebuilt at open, not persisted).GRAPH ?granges attached graphs end-to-end. Not built: the full fail-loud cross-DB limit surface (quipu #77) and the blob sidecar (design §7, consumer-gated). Mounting is a library API today —Store::open_with_attachments— no config or CLI surface yet. Seedocs/design/multi-db-composition.md.
A quipu store is one SQLite file. Composition mounts several of those files — a shared read-only reference layer beside a per-tenant memory store, a knowledge pack beside both — as one queryable store, without merging them. Each layer keeps its own lifecycle: it ships, versions and swaps independently, it is distributed as a single file, and read-only mounting is enforced by SQLite at the file level, not by query rewriting.
ATTACH, and two kinds of alias
Composition is SQLite ATTACH: the store’s one connection mounts each extra
file under a schema alias (validated ^[a-z][a-z0-9_]*$, read-only via
file:…?mode=ro), and a single query planner sees every file — so composed
queries get real joins and index pushdown, which result-merging
federation cannot offer.
#![allow(unused)]
fn main() {
let store = Store::open_with_attachments(
"tenant.db",
&[Attachment::read_only("shared", "reference.db")],
)?;
}
The design’s §1.2 alias is a different thing: a term alias. The same IRI
interned independently in two files gets two ids — an alias, not a collision.
At open, a TEMP term_alias table is built by joining the files’ terms
tables on IRI; lookups return every id an IRI denotes (lookup_all), query
predicates match all of them, and result bindings are canonicalised toward the
local id after SQL DISTINCT, so an entity present in both files is one row,
not two.
Term spaces
Every term id in a store — facts.e/a/g, the graph registry, even ids
embedded inside Value::Ref blobs — is an integer assigned per file. Unioned
naively, two files’ ids collide silently. Term spaces make ids globally
unique by construction: each database owns a space s and allocates ids
from s · 2^40 + k (SPACE_SIZE = 2^40 — about 10¹² terms per space,
millions of spaces). The allocator in src/store/mod.rs reads the store’s
space from the term_spaces registry and allocates within that half-open
range.
A legacy store’s ids are 1..n — exactly space 0 — so existing stores need
no rewrite, and new stores still allocate from space 0 unless configured. The
constraint that falls out: at most one space-0 database per composition;
verify_attached_schema refuses a colliding attach with a message naming the
fix.
Respace
That fix is quipu db respace: rewrite a database into a chosen term space so
it can be attached beside another. The remap is paid once, offline. The source
is opened read-only and copied with VACUUM INTO; every rewrite happens in
the new file, and the original stays byte-identical. Respace derives its work
from the live schema — every column is classified as term-id-bearing or not,
and an unclassified column makes it refuse before writing anything, because a
missed column produces a store that opens, answers, and is wrong.
What composes, and what does not
An attachment contributes named graphs, nothing else. Each attached graph
registers in the local graphs table (with a source column naming the
attachment), so GRAPH <iri> resolution and graph labels are uniform over
local and attached graphs. The attachment’s own default graph and label
meta-graph are per-database and are not contributed. Two guarantees follow:
- Attaching changes no existing query’s result. The default dataset stays the local default graph alone; a layer is visible only to a query that names one of its graphs. With no attachments, the generated SQL is byte-identical to an unattached store’s.
- Quipu never writes to an attached database. Read-only mounting is enforced by SQLite; writing a local fact into an attached graph is refused at the Rust layer.
Permanently out, per the design’s §6: cross-DB writes and cross-DB
transactions (SQLite’s multi-file atomic commit does not work in WAL mode,
and quipu is WAL). Transaction ids are file-local with no cross-file
ordering, so as_of_tx over a composed store is refused with an error rather
than answered wrongly — valid-time travel works (valid_from/valid_to
are portable ISO strings), transaction-time does not cross files. The event
log stays local. The remaining fail-loud refusals are tracked as quipu #77.
Operating it
quipu db respace --into 7 --out shared-s7.db --db shared.db
--out is required — respace writes a fresh file and never overwrites — and
the report prints the space moved from and to, and the rows touched. A store
whose space fills up gets the same advice: the allocator’s exhaustion error
names respace. Knowledge packs (quipu pack) are attachable artifacts too: a
pack declares its term space in its manifest, and attaching verifies the two
agree.
See also
- CLI reference —
quipu db respace,quipu pack, andquipu graph import(the copy-based fallback to attaching) - Named graphs — the substrate; an attachment is a source of named graphs
- Federation — the remote half; composition sits below the provider seam, federation above it
docs/design/multi-db-composition.md— the full design, including the consumer-gated blob sidecar (§7)
Federation
Federation and Git-native shares are complementary boundaries. The
[[quipu.federation.remotes]] configuration drives live read fan-out through
federated_from_config; it does not silently publish local facts or bypass the
share scrub gate. Durable exchange uses a canonical share followed by explicit
import, quarantine, and promotion. This keeps remote availability and local
publication policy independent: adding a read peer cannot turn it into an
outbound replication target.
Implementation status (2026-08-25): ✅ Built. In
src/provider/(with tests): theGraphProvidertrait,ProviderStatus,LocalProvider,FederatedProviderwith outcome-reportingquery_all, and — behind theremotefeature —RemoteProviderplusfederated_from_config()(re-exported fromlib.rs).quipu-serverhealth-checks every configured remote at startup, andPOST /querywith"federated": truefans the query out through the federated provider per request (quipu-tkh). Since quipu-fd1, remotes carry an operator-declared trust/freshness label (src/provider/label.rs) and configured[quipu.labels]floors refuse a federated result exactly as a local one. Seedocs/design/federation-remote-provider.md.
Quipu defines federated queries across multiple graph providers through
the GraphProvider trait, so that a host embedding quipu can query a local store
and its own remote providers in a single operation.
The GraphProvider Trait
#![allow(unused)]
fn main() {
pub trait GraphProvider {
fn name(&self) -> &str;
fn query(&self, sparql: &str) -> Result<QueryResult>;
fn entities(&self, type_filter: Option<&str>, limit: usize) -> Result<JsonValue>;
fn health(&self) -> ProviderStatus;
}
}
Any data source that implements this trait can participate in federated queries.
Built-in Providers
LocalProvider
Wraps a local Quipu Store:
#![allow(unused)]
fn main() {
use quipu::provider::LocalProvider;
let provider = LocalProvider::new(&store, "local");
let result = provider.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5").unwrap();
}
FederatedProvider
Aggregates multiple providers and merges their results:
#![allow(unused)]
fn main() {
use quipu::provider::{FederatedProvider, LocalProvider};
let mut federation = FederatedProvider::new();
federation.add(Box::new(LocalProvider::new(&store, "local")));
// Add remote providers as they become available
// Query all providers; the outcome reports who answered.
let fq = federation.query_all("SELECT ?s ?p ?o WHERE { ?s ?p ?o }");
assert!(fq.complete, "every member contributed: {:?}", fq.providers);
// Health check all
let statuses = federation.health_all();
for s in &statuses {
println!("{}: healthy={}, facts={:?}", s.name, s.healthy, s.fact_count);
}
}
query_all never aborts because one member is down — a dead peer must not
deny the whole result — but it never hides it either: the returned
FederatedQuery carries the merged rows plus a ProviderOutcome per member
(row count, or the failure reason), and complete is the one-field answer to
“can I trust this result set as exhaustive?”. A member that errors, answers a
non-SELECT shape, or disagrees on the variable list is a reported failure,
never a silent merge.
RemoteProvider
Behind the remote feature (a default of the shipped binaries): another
quipu-server, reached over its REST API — POST /query, POST /cord, and
GET /stats as the health probe.
Configuration
[[quipu.federation.remotes]]
name = "prod"
url = "http://quipu.example:3030"
auth_token = "…" # optional; sent as `Authorization: Bearer …`
timeout_ms = 5000 # optional; default 5000
# The label this remote's rows carry, DECLARED by you, the local operator
# (quipu-fd1). Never read from the remote itself — a remote asserting its own
# trustworthiness would defeat the trust boundary. All optional; trust needs
# all three fields (a rank means nothing outside its chain) and a partial
# declaration is refused at startup and on every federated query.
trust = "urn:trust:partner"
trust_chain = "https://quipu.dev/ontology/defaultTrustChain"
trust_rank = 30
freshness = "fresh" # fresh | recomputing | stale
quipu-server builds the federated provider from these at startup and
health-checks every remote (reported on stderr, with each remote’s declared
label — or undeclared), so a dead peer, a wrong token, or a missing label
is visible without waiting for a federated query to be issued.
Trust labels at the federation edge
A remote’s rows enter your composed result set, so they must enter your label
lattice — and the label is declared by the local operator, never inferred
and never read from the remote (the SARC trust boundary, surfaced at the
federation edge — see docs/design/multi-db-composition.md §5).
- Rows are stamped. Beside
_provider, rows from a declared remote carry_trust(the trust IRI; rank and chain ride the per-memberprovidersentry) and_freshness. Rows from an undeclared member simply lack the binding — undeclared is absent, never fabricated. ProviderStatuscarries the label. Health reports (startup stderr, and thelabelfield on eachprovidersentry) show what each member’s rows are declared as;null/undeclaredmeans exactly that.- The composed label folds remotes in as members. The federated response’s
labelskey is the local dataset fold with each remote’s declared label met in — trust and freshness by meet, so composition never widens; the axes a remote cannot declare (durability, policy, kind) degrade coverage topartial. - Configured floors apply. With
[quipu.labels]floors set, a federated query is refused when a local member fails the floor (same check as the local path) or when a remote’s declared label is below it — and the refusal names the remote. An undeclared remote fails a configured freshness or trust floor, exactly as an unlabelled local graph does: fail-safe at enforcement, honest at reporting. With no floor configured, nothing changes.
Federated queries over REST
POST /query with "federated": true fans the whole query text out to the
local store and every configured remote:
{ "query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", "federated": true }
The response carries the merged rows — each tagged with a _provider field —
plus the per-member account:
{
"variables": ["s", "p", "o", "_provider", "_trust", "_freshness"],
"rows": [
{ "s": "ex:traefik", "p": "ex:port", "o": "443", "_provider": "local" },
{ "s": "ex:nginx", "p": "ex:port", "o": "80", "_provider": "prod",
"_trust": "urn:trust:partner", "_freshness": "fresh" }
],
"count": 2,
"providers": [
{ "name": "local", "ok": true, "rows": 1, "error": null, "label": null },
{ "name": "prod", "ok": true, "rows": 1, "error": null,
"label": { "trust": { "iri": "urn:trust:partner",
"chain": "https://quipu.dev/ontology/defaultTrustChain",
"rank": 30 },
"freshness": "fresh" } }
],
"complete": true,
"labels": null
}
_trust/_freshness columns appear only when at least one member declares
that axis; labels is the composed dataset label (local members’ fold with
every remote met in), null when nothing local or remote declared anything.
Whole-query federation only: every member gets the same query text and the
results are unioned, not joined across members. The temporal/graph parameters
(valid_at, tx, graph, row_labels) shape the local evaluator’s
context and are refused on a federated query rather than silently meaning
something different per member. SPARQL 1.1 SERVICE and write federation are
deliberately out of scope (design §7).
Impact Analysis
Recipes for answering “what breaks if X goes down?” using SPARQL property paths and graph projection.
Direct Dependencies
Find everything that directly depends on a specific service:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?dependent ?label
WHERE {
?dependent ont:dependsOn <http://aegis.gastown.local/ontology/postgres> .
?dependent rdfs:label ?label .
}
Transitive Blast Radius
Follow the full dependency chain with property path +:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT DISTINCT ?affected ?label
WHERE {
?affected ont:dependsOn+ <http://aegis.gastown.local/ontology/postgres> .
?affected rdfs:label ?label .
}
This traverses one or more dependsOn hops — if A depends on B, and B
depends on postgres, then A appears in the results.
Host-Level Impact
“Everything that breaks if korimako goes down” — services running on korimako plus anything that depends on them:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT DISTINCT ?affected ?label ?reason
WHERE {
{
?affected ont:runsOn <http://aegis.gastown.local/ontology/korimako> .
BIND("runs on korimako" AS ?reason)
}
UNION
{
?affected ont:dependsOn+/ont:runsOn <http://aegis.gastown.local/ontology/korimako> .
BIND("depends on service on korimako" AS ?reason)
}
?affected rdfs:label ?label .
}
| ?label | ?reason |
|---|---|
| traefik | runs on korimako |
| pihole | runs on korimako |
| grafana | runs on korimako |
The BIND clause annotates each row with the reason it appears.
Impact Count by Host
Which hosts are single points of failure?
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?hostLabel (COUNT(DISTINCT ?affected) AS ?blastRadius)
WHERE {
?host a ont:Host .
?host rdfs:label ?hostLabel .
{
?affected ont:runsOn ?host .
}
UNION
{
?affected ont:dependsOn+/ont:runsOn ?host .
}
}
GROUP BY ?hostLabel
ORDER BY DESC(?blastRadius)
Graph Projection for Visual Analysis
For more complex analysis, project the dependency graph and run algorithms:
curl -s localhost:3030/project -X POST \
-H "Content-Type: application/json" \
-d '{
"predicate_filter": "http://aegis.gastown.local/ontology/dependsOn"
}'
The projection returns nodes and edges suitable for:
- In-degree centrality: Most-depended-upon services
- Connected components: Independent failure domains
- Shortest path: How are two services connected?
In-Degree: Most Critical Services
{
"tool": "quipu_project",
"input": {
"predicate_filter": "http://aegis.gastown.local/ontology/dependsOn"
}
}
Services with the highest in-degree are your most critical dependencies.
Materialised Impact via the Reasoner
The SPARQL property path approach above re-derives transitive chains at query time. For graphs that change infrequently but are queried often, you can materialise the transitive closure using the reasoner — derived facts sit in the store alongside raw facts and are queryable without property paths.
Set Up Rules
Create impact-rules.ttl:
@prefix rule: <http://quipu.local/rule#> .
@prefix ex: <http://aegis.gastown.local/rules/> .
ex:impact a rule:RuleSet ;
rule:defaultPrefix "http://aegis.gastown.local/ontology/" .
ex:depends_on_transitive a rule:Rule ;
rule:id "depends_on_transitive" ;
rule:head "dependsOn(?a, ?c)" ;
rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .
ex:runs_on_transitive a rule:Rule ;
rule:id "runs_on_transitive" ;
rule:head "runsOn(?svc, ?host)" ;
rule:body "runsOn(?svc, ?container), runsOn(?container, ?host)" .
Run the Reasoner
quipu reason --rules impact-rules.ttl --db homelab.db
Now transitive edges are first-class facts. The blast radius query simplifies to a flat lookup:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT DISTINCT ?affected ?label
WHERE {
?affected ont:dependsOn <http://aegis.gastown.local/ontology/postgres> .
?affected rdfs:label ?label .
}
No + operator, no property paths — the reasoner has already closed the
chain. This is faster for repeated queries and simpler for agents to
consume (they don’t need to understand property path syntax).
Keep It Fresh
Enable reactive evaluation so derived facts update automatically when base facts change:
quipu reason --reactive --rules impact-rules.ttl --db homelab.db
Now every transact() that touches dependsOn or runsOn triggers
re-derivation of the affected transitive edges.
Property Paths vs Reasoner: When to Use Which
| Approach | Best for |
|---|---|
Property paths (dependsOn+) | Ad-hoc exploration, one-off queries, small graphs |
| Reasoner rules | Repeated queries, agent consumption, cross-predicate joins, counterfactual analysis |
The two approaches are complementary. Property paths work on any graph without setup. The reasoner requires writing rules up front but pays back on every subsequent query.
Counterfactual Impact
The reasoner’s speculate() API lets you test hypothetical changes
without committing them:
#![allow(unused)]
fn main() {
// "What if postgres goes down?"
let report = store.speculate(&retractions, timestamp, |s| {
evaluate(s, &ruleset, timestamp)
})?;
println!("{} derived facts would be retracted", report.retracted);
}
See The Rule Builder tutorial for a complete worked example.
Temporal Impact: What Changed?
Compare the dependency graph before and after a change:
# Snapshot before (transaction 5)
quipu read "PREFIX ont: <http://aegis.gastown.local/ontology/>
SELECT ?svc ?dep WHERE { ?svc ont:dependsOn ?dep }" --db my.db --tx 5
# Current state
quipu read "PREFIX ont: <http://aegis.gastown.local/ontology/>
SELECT ?svc ?dep WHERE { ?svc ont:dependsOn ?dep }" --db my.db
Diff the two result sets to see which dependencies were added or removed.
Incident Correlation
Recipes for linking incidents to infrastructure, code, and time.
Record an Incident
Ingest an incident as an episode with edges to affected services:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "inc-2026-04-02-dns",
"source": "pagerduty-agent",
"group_id": "incidents",
"nodes": [
{
"name": "inc-dns-outage",
"type": "Incident",
"description": "DNS resolution failures across all services",
"properties": {
"severity": "P1",
"started": "2026-04-02T14:30:00Z",
"resolved": "2026-04-02T16:00:00Z",
"root_cause": "pihole OOM after update"
}
}
],
"edges": [
{"source": "inc-dns-outage", "target": "pihole", "relation": "causedBy"},
{"source": "inc-dns-outage", "target": "traefik", "relation": "affected"},
{"source": "inc-dns-outage", "target": "grafana", "relation": "affected"}
]
}'
Query: What Caused This Incident?
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?incident ?cause ?description
WHERE {
?incident a ont:Incident .
?incident ont:causedBy ?cause .
?incident rdfs:comment ?description .
}
Query: What Has Pihole Caused?
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?incident ?desc
WHERE {
?incident ont:causedBy <http://aegis.gastown.local/ontology/pihole> .
?incident rdfs:comment ?desc .
}
Query: Incident History for a Service
“Show me every incident that affected grafana”:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?incident ?description ?cause
WHERE {
?incident ont:affected <http://aegis.gastown.local/ontology/grafana> .
?incident rdfs:comment ?description .
OPTIONAL { ?incident ont:causedBy ?c . ?c rdfs:label ?cause . }
}
Correlate Incidents with Deployments
If you track deployments as episodes (see Agent Builder), you can correlate:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX prov: <http://www.w3.org/ns/prov#>
SELECT ?incident ?deploy
WHERE {
?incident a ont:Incident .
?incident ont:causedBy ?svc .
?deploy a ont:Deployment .
?deploy ont:deploys ?svc .
?incident rdfs:label ?incLabel .
?deploy rdfs:label ?deploy .
}
Time-Travel: State Before the Incident
“What did the infrastructure look like before things broke?”
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{
"query": "PREFIX ont: <http://aegis.gastown.local/ontology/> PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> SELECT ?svc ?host WHERE { ?svc ont:runsOn ?host }",
"valid_at": "2026-04-02T14:00:00Z"
}'
Compare this with the state at incident time to see what changed.
Provenance: Which Agent Reported This?
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?entity ?episode ?source
WHERE {
?entity prov:wasGeneratedBy ?ep .
?ep rdfs:label ?episode .
?ep <http://aegis.gastown.local/ontology/source> ?source .
}
ORDER BY ?source
Pattern: Incident Dashboard Query
A monitoring agent can run this periodically to build a dashboard:
PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?svc ?label (COUNT(?inc) AS ?incidentCount)
WHERE {
?inc ont:affected ?svc .
?svc rdfs:label ?label .
}
GROUP BY ?svc ?label
ORDER BY DESC(?incidentCount)
Services with the most incidents are your reliability hotspots.
Knowledge Ingestion
Recipes for loading data into Quipu — from single triples to batch imports.
Turtle Files (Bulk Load)
The fastest way to load structured data:
quipu knot infrastructure.ttl --db knowledge.db
With SHACL validation:
quipu knot infrastructure.ttl --db knowledge.db --shapes shapes/infra.shapes.ttl
With a specific timestamp (for valid-time):
quipu knot infrastructure.ttl --db knowledge.db --timestamp 2026-04-01
Via REST:
curl -s localhost:3030/knot -X POST \
-H "Content-Type: application/json" \
-d '{
"turtle": "@prefix hw: <http://example.org/homelab/> .\nhw:korimako a hw:Host ; hw:hostname \"korimako.example\" .",
"timestamp": "2026-04-01",
"actor": "bulk-import"
}'
Episodes (Agent Observations)
Episodes are the structured write path for agents. Each episode is a transaction with nodes, edges, and provenance:
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "discovery-run-42",
"source": "prometheus-sd",
"group_id": "monitoring",
"episode_body": "Periodic service discovery sweep",
"nodes": [
{"name": "redis", "type": "Service", "description": "Cache layer"},
{"name": "memcached", "type": "Service"}
],
"edges": [
{"source": "redis", "target": "korimako", "relation": "runsOn"},
{"source": "memcached", "target": "palau", "relation": "runsOn"}
]
}'
Episode Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Episode identifier (becomes rdfs:label) |
nodes | Yes | Array of entities to create |
edges | Yes | Array of relationships |
source | No | Agent/system that produced this |
group_id | No | Logical grouping (e.g., “monitoring”) |
episode_body | No | Human-readable description |
shapes | No | Inline SHACL shapes for validation |
replace_snapshot | No | Atomically replace prior facts from this episode name; use for complete inventories |
Node Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Entity name (used to generate IRI) |
type | No | RDF type (e.g., “Service”, “Host”) |
description | No | Human-readable description (rdfs:comment) |
properties | No | Key-value map of additional properties |
Edge Fields
| Field | Required | Description |
|---|---|---|
source | Yes | Source entity name |
target | Yes | Target entity name |
relation | Yes | Predicate: a bare name lands in aegis: (e.g. "runsOn"); a declared prefix (owl:sameAs, rdfs:seeAlso, rdf:, skos:, prov:, quipu:, xsd:, sh:) or a full <http://…> IRI is emitted verbatim; anything else is a 400, never a silent rewrite. See REST API → edge relation. |
Aliases / entity dedup. owl:sameAs is the convention, and it is writable from
/episode. Reuse existing node names byte-for-byte, and check retrievability with
the reader’s own query rather than trusting count > 0; the full recipe is in the
REST API reference.
Graphiti-Compatible Ingestion
For systems already using the Graphiti API format:
curl -s localhost:3030/episodes/complete -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "flat-episode",
"episode_body": "Ingested via Graphiti compat endpoint",
"entity_nodes": [
{"name": "svc-1", "entity_type": "Service", "summary": "A service"}
],
"episodic_edges": [
{"source_node_name": "svc-1", "target_node_name": "host-1", "relation_type": "runsOn"}
]
}'
Batch Patterns
Multiple Turtle files
for f in data/*.ttl; do
echo "Loading $f..."
quipu knot "$f" --db knowledge.db
done
Episodes from a JSON array
# episodes.json contains an array of episode objects
cat episodes.json | jq -c '.[]' | while read -r episode; do
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d "$episode"
done
Idempotent ingestion
Episodes with the same entity names update existing entities rather than creating duplicates. The entity IRI is derived from the name, so repeated ingestion is safe.
Validated Ingestion Pipeline
For production use, always validate:
-
Load shapes first:
quipu shapes load --name infra --file infra.shapes.ttl --db knowledge.db -
Dry-run validate:
quipu validate --shapes infra.shapes.ttl --data new-data.ttl -
Ingest with shapes:
quipu knot new-data.ttl --db knowledge.db --shapes infra.shapes.ttl
If validation fails, the write is rejected and no facts enter the log.
MCP Tool Ingestion
For agents using MCP tools:
Assert triples
{
"tool": "quipu_knot",
"input": {
"turtle": "@prefix hw: <http://example.org/homelab/> .\nhw:korimako a hw:Host .",
"shapes": "@prefix sh: ... optional validation ..."
}
}
Ingest episode
{
"tool": "quipu_episode",
"input": {
"name": "agent-observation",
"source": "my-agent",
"nodes": [{"name": "x", "type": "Thing"}],
"edges": []
}
}
Retraction (Removing Facts)
Retract all facts about an entity:
quipu retract "http://example.org/homelab/old-host" --db knowledge.db
Retract a specific predicate:
quipu retract "http://example.org/homelab/korimako" \
--predicate "http://example.org/homelab/cpuCores" --db knowledge.db
Via REST:
curl -s localhost:3030/retract -X POST \
-H "Content-Type: application/json" \
-d '{
"entity": "http://example.org/homelab/old-host",
"timestamp": "2026-04-04",
"actor": "cleanup-agent"
}'
Retractions don’t delete data — they close the valid-time window. The original facts remain in the log for audit and time-travel.
Bulk loads and migrations: share → import → promote
Use this path whenever you have a lot of data at once:
- a first load;
- a backfill;
- moving a dataset from one store to another;
- migrating a board or a catalogue.
Do not use a loop of thousands of quipu knot calls, /knot requests or
SPARQL Update writes for this.
| situation | path |
|---|---|
| live, incremental facts arriving one at a time | quipu knot, /knot, /episode |
| a bulk or one-time load, or a migration | share → import → promote (this page) |
Why
- Validated before it touches your graph.
quipu importstages the whole dataset in its own staging graph and checks it against your shapes there. Non-conforming data is quarantined, and import never writes ROOT. - One transaction.
quipu import promoteadmits the staged data into ROOT in a single transaction. Thousands of small writes give you thousands of transactions, each paying the write path’s per-write cost, and a half-finished loop leaves a half-loaded graph. - One rollback. A promotion is one transaction, so undoing it is one step (see Rollback).
- Integrity. The share carries canonical payload hashes, and import refuses a share whose bytes do not match its manifest.
The three steps
1. Build a share from the data
Load the data into a scratch store, with the shapes you will validate against, and write a share from it:
quipu shapes load items shapes.ttl --db scratch.db
quipu knot data.ttl --shapes shapes.ttl --db scratch.db
quipu share --output share1 --db scratch.db --destination internal
share1/ now holds export.nt (the canonical facts), shapes.ttl and the
manifests. --destination internal is for a migration inside your own
infrastructure. Without it, quipu share runs the outward identifier scrub, which
needs an identifier-policy catalogue.
2. Import: stage and validate
The target validates the data against its own shapes, not the ones the share carries, so load the same shape set there first:
quipu shapes load items shapes.ttl --db target.db
quipu import share1 --db target.db --actor you --destination internal
The JSON result is the review:
{
"outcome": "staged",
"share_id": "sha256:1a3581b0…",
"staging_graph": "urn:quipu:import:staging:1a3581b0…",
"triples": { "accepted": 4000, "quarantined": 0 },
"validation": { "conforms": true, "blocking": false },
"promotion": { "eligible": true, "blockers": [] }
}
- ROOT is unchanged at this point.
- A share stamped
internalmust be imported with--destination internaltoo, or import refuses it. - If the data breaks your shapes, the result says so, and nothing can be promoted:
{ "outcome": "quarantined",
"triples": { "accepted": 0, "quarantined": 5 },
"promotion": { "eligible": false, "blockers": ["shacl_nonconforming"] } }
- If the target has no shapes for a class the share uses, every typed triple is quarantined as off-vocabulary. The data is neither dropped nor admitted. Load the shapes and import again:
{ "outcome": "quarantined",
"triples": { "accepted": 0, "quarantined": 4000 },
"validation": { "off_vocabulary": ["http://example.org/Item"] },
"promotion": { "eligible": false, "blockers": ["off_vocabulary"] } }
3. Promote: admit it into ROOT
quipu import promote sha256:1a3581b0… --db target.db --actor you
{ "outcome": "promoted", "tx_id": 3, "triples": 4000, "suppressed_retractions": 0 }
Read back by count, not by the success message:
quipu read 'SELECT (COUNT(?s) AS ?n) WHERE { ?s a <http://example.org/Item> }' --db target.db
Promoting the same share again re-asserts the same facts and adds no duplicates.
Rollback
Before promoting, note the store’s current transaction. To undo a promotion, fork ROOT as of the transaction just before it, check the diff, and promote the fork:
quipu fork 2 --name pre-import --db target.db # 2 = the tx before the promotion
quipu fork diff main pre-import --db target.db # expect -<the promoted triples>
quipu fork promote pre-import --db target.db # one transaction retracts them
Read the diff first. It is the safety check: the fork also undoes any write
made after that transaction. History is kept, so quipu read … --tx 2 still shows
the pre-promotion state.
On a running server
The same steps exist over HTTP: POST /import stages a share and
POST /import/promote admits it. See the REST API.
Measured
These are the numbers from the example above, measured with a release build of quipu 0.11.0: 2,000 entities and 4,000 triples moved from a scratch store into a target that already held its own data and had the same shapes loaded.
| step | result |
|---|---|
import, target without the shapes | quarantined: 4,000 triples, blocker off_vocabulary |
import, target with the shapes | staged, 4,000 accepted, 0 quarantined; ROOT unchanged (1 entity) |
import promote | one transaction (tx_id 3), 4,000 triples, 0.12 s |
| read-back | 2,001 entities (1 existing + 2,000 promoted) |
| re-promote | same 4,002 current facts, no duplicates |
| fork rollback | retracted 4000 in one transaction; back to 1 entity |
See also: CLI: sharing, import and legacy packs and Sharing & Federation.
Public benchmarks
Read this before quoting any number from this section. Every benchmark class below is scored separately and is never combined into a single figure. A blended score would hide exactly the classes that are unimplemented, unrun, or measuring somebody else’s system. Unrun classes are published as NOT RUN rather than omitted — a page that lists only what went well flatters by silence.
This page is an index, not a ledger. Numbers produced by Quipu’s own runners are published here and re-derivable from this repository. Numbers produced elsewhere in the stack stay in the repository that produced them and are linked with the commit that published them — they are not copied into a table here, because a copied number rots silently while its source moves on.
| Class | What it measures | Published by | Status |
|---|---|---|---|
| SPARQL 1.1 conformance | Quipu’s query engine against the W3C RDF Tests at a pinned revision | this repository | published, re-derivable |
| Extraction → ingress | a governed RML write of frozen upstream extractions into a disposable Quipu | caboodle 0a1b169 | published, with the boundary below |
| Bulk ingest | Quipu’s own load rate for a pinned WatDiv dataset | this repository (benchmark/public/watdiv_ingest.py) | published, re-derivable |
| WatDiv 1M diagnostic | one-off query latency and process memory on a pinned 1M dataset | this repository (docs/design/persistence-evidence/watdiv-1m-20260914/) | MEASURED, CONTROL-INVALID — diagnostic only |
| Separate-process memory | memory residency by phase, Quipu SQLite versus Oxigraph RocksDB | this repository, published evidence | published, limited to the stated memory observations |
| Performance | WatDiv / LUBM query latency against Oxigraph | — | NOT RUN |
Extraction → ingress (Text2KGBench)
Caboodle publishes a pinned Text2KGBench run at
evaluations/text2kgbench/results/2026-09-03/report.json
(commit 0a1b169). Read it there; this page deliberately does not restate its
score table.
What it is a measurement of, in the report’s own words (evaluation_scope):
separate boundary measurements; upstream artifact is not graph-extract output
That sentence is the whole reason this section exists rather than a row of F1 numbers on Quipu’s trust page:
- The extraction half is a
frozen_upstream_replayof the dataset’s own Vicuna-13B baseline responses, hash-pinned and re-scored. It measures a third-party model on 25 cases (selection.method: first_n_in_gold_file_order, so a fixed prefix, not a random sample). It is not a measurement of Quipu, of Caboodle, or ofgraph-extract, and quoting its F1 as one would be wrong in both directions: it is neither our credit nor our fault. - The ingress half is ours and is the number this project can stand behind:
127 input triples materialised to 635 quads, all conforming
(
ingress.write.conforms: true,count: 635), through a governed Camayoc RML write into a disposable store, with the mapping and source both hash-pinned.
So the honest one-line summary is: the pipeline ingests a third party’s extractions into a governed graph without dropping or mangling any of them. The quality of the extractions themselves is the upstream baseline’s, and improving on it is a separate, unrun benchmark.
Bulk ingest (WatDiv)
This is NOT the Oxigraph comparison. It measures one thing: how fast Quipu loads a pinned third-party dataset into a fresh store. No other engine is involved, and nothing here says anything about query latency. The comparison class below remains NOT RUN.
The measurement
Quipu ingested a 10,916,457-triple WatDiv dataset in 2,848.9 s — 3,831.8 live facts per second
— into a 3,227,811,840-byte store (295.7 bytes per fact). Release build, single process,
--chunk 50000, on an idle-to-moderately-loaded 20-core host with 66 GB RAM, store on ext4.
The storage medium is part of the number and is worth ~45x — see below.
The population appears in the same sentence as the rate deliberately: WatDiv’s “10M” archive contains 10,916,457 triples, not 10,000,000, and a rate quoted “at 10M” would be wrong by 9% before anyone checked anything else.
| dataset | WatDiv 10M archive, sha256 1d0a8a47…; extracted N-Triples sha256 7cfe0341… |
| triples | 10,916,457 declared, 10,916,460 live facts after load |
| wall time | 2,848.9 s |
| rate | 3,831.8 live facts/s |
| store | 3,227,811,840 B = 295.7 B/fact |
| build | release |
| storage | ext4 on a shared device (see The device this was measured on, below) |
Why the fact count exceeds the triple count by exactly 3: a declared ingest writes three completion markers (declared count, source digest, completion) into the graph. That identity is the load’s own anti-vacuity check — a silently truncated load cannot produce it.
Throughput is a before/after delta of live facts read from the store, never the loader’s parse count. The two differ: the parse count reports triples the parser saw, and a re-ingest of identical content parses everything and writes nothing.
What this number does NOT support
- It is not a per-triple constant. Rate varies ~10x within a single run (below), so a figure taken from part of a load is not the load’s rate. Only end-to-end figures are quoted here.
- It does not extrapolate. See the refuted hypothesis below.
- It is not a comparison. Quipu and Oxigraph share the SPARQL parser and RDF data model
(
spargebra,oxrdf), so any future comparison measures storage and evaluation layers, never independent engines, and must say so.
The device this was measured on, and why load average could not see it
The rate above is an ext4-on-a-shared-device number. The store lived on the host’s root filesystem, which is also the working disk for roughly twenty concurrent agent sessions. Measured 2026-09-16 on the same host, same binary, same pinned 1M artifact, changing only the store’s device:
| store device | device busy (3h baseline, node_disk_io_time_seconds_total) | ingest |
|---|---|---|
| dedicated measurement volume | 0.4 % | 1,078,688 facts in 94.1 s |
| root filesystem (shared) | 20.8 % | 667 facts/s sustained — ~27 min for the same load |
That is a 21.6x difference from the device alone, with the engine, the dataset and the host held fixed. So the published figure characterises Quipu ingesting while sharing a disk, not Quipu’s ingest ceiling, and a reader reproducing it on a quiet volume should expect to beat it substantially.
Why the contention check below does not cover this. The next section correlates rate against host load average and finds essentially none (-0.09). That result stands — and it cannot speak to this, because load average measures CPU runnable work, not disk saturation. The two come apart routinely: during the measurement above the host sat at a load average around 4 while the root device was 78 % busy. A store starved for I/O on an otherwise unbusy machine is invisible to the instrument that section used.
This does not explain the within-run variation described below, and it is not offered as an explanation — it is an untested candidate that the published correlation was structurally unable to detect. Stated so that the absence of a load-average correlation is not read as the absence of contention.
Rate is NOT constant within a load, and the obvious explanation is wrong
Instrumented per committed chunk, the first quarter of a 10,916,457-triple load runs about 10x slower than the rest (first-quartile median ~1,099 facts/s; later quartiles at or above the instrument’s resolution). Correlation with host load average is -0.09 across 2.1-8.7 — that is, essentially none — and with position in the run +0.49, over 161 commit intervals.
The obvious reading is that the transition happens after a fixed number of facts. That is refuted. A 108,997,714-triple load of the same dataset family, same binary, same chunk size, was 14x slower at the same absolute commit count (commits 41-55: 1,263 facts/s, against 18,333 facts/s at those commits in the smaller load). Whatever causes the speed-up, it is not “N facts ingested”.
Two explanations remain untested and are recorded rather than chosen: a proportional effect (the transition at some fraction of the dataset) and working-set residency (the smaller store is 3.2 GB and caches readily; the larger is 32.3 GB). They are not equivalent — the first says the cost never amortises, the second says it amortises whenever the store fits in memory.
⚠ Every rate here is a STORAGE figure — the medium is worth ~45x
Measured on this host, same source prefix, same binary, same --chunk 50000, differing only in
where the store file sits:
| store on | facts | wall | rate |
|---|---|---|---|
| tmpfs (RAM) | 8,100,003 | 187 s | 43,388 facts/s |
| ext4 (SSD) | 8,100,003 | ~2.2 h | ~1,000 facts/s |
So the published 3,831.8 facts/s is a property of the disk at least as much as of Quipu, and the same load in memory is roughly 45x faster. Roughly 98% of wall time on ext4 is durability rather than work.
This is why every figure on this page names its host shape, build profile and storage medium together. A throughput number quoted without all three is not reproducible and not comparable: a reader on different storage will not come close, and will have no way to know why.
It also bounds what a comparison against another engine could mean here. Two engines measured on this host would be measured mostly on its disk.
Rate is not monotonic: it halves between 4.4M and 6.8M facts
A second, larger load was instrumented per committed chunk at one-second resolution and stopped deliberately at 8,100,000 of 108,997,714 triples, because the shape had become the result. Rolling ten-commit rate:
| facts ingested | rate | median host load |
|---|---|---|
| 800,000 | 1,035/s | — |
| 2,300,000 | 1,085/s | — |
| 4,400,000 | 1,650/s | peak |
| 5,300,000 | 1,479/s | — |
| 6,800,000 | 705/s | — |
| 8,100,000 | 761/s | 3.94 |
The rate rises to a peak at ~4.4M facts and then halves, and it does so while the host gets
QUIETER (median load 5.33 → 3.94 across the final bands). No figure from this load is published
as a result: it is an incomplete run, its ledger row is marked valid_result: false, and a rate
quoted from 7% of a dataset invites a division nobody should perform.
Three explanations were tested and all three are refuted:
- A fixed number of facts ingested. The 10,916,457-triple load was ~14× faster at the same absolute commit count. Two scales were required to test this; one cannot.
- Host load. Correlation of rate with one-minute load average is +0.068 across a 5.2× range (2.47–12.91) over 162 commit intervals — that is, none.
- IRI cardinality, the most mechanistic candidate: interning cost rising as distinct IRIs accumulate. Measured directly — the 108,997,714-triple dataset carries 284,093 distinct subjects in its first 2,000,000 triples against 396,970 for the 10,916,457-triple one. It has fewer, so interning predicts the opposite of what was observed.
Why the smaller dataset is faster at matched fact count is unexplained, and is published as unexplained. Three candidates are dead; proposing a fourth after seeing the data would not be a finding.
Re-deriving it
python3 benchmark/public/watdiv_ingest.py --scale 10M \
--archive <watdiv.10M.tar.bz2> --quipu <release quipu> \
--db <scratch>.db --output benchmark/public/results/watdiv-ingest.jsonl \
--pins benchmark/public/results/watdiv-pins.tsv
The archive is fetched once from the published WatDiv site; the runner pins its digest on first sight and verifies it afterwards, aborting on a mismatch rather than benchmarking bytes nobody pinned. The source is streamed from the archive and never unpacked — at the 100M scale the extracted form is ~15.6 GB, which would double the footprint of a run designed to leave nothing behind.
Guards that decide whether a row may be quoted, each covered by a test in
benchmark/public/test_watdiv_ingest.py:
- a non-zero exit or a contended host marks the row
valid_result: falsewith the reason named — the row is still written, because an unlabelled fast number is the hazard, not a labelled slow one; - an unreadable store reads as UNKNOWN rather than a zero baseline, which would otherwise inflate the delta by whatever the store already held;
- an archive with no
.ntmember is refused rather than silently benchmarking the first file it finds.
WatDiv 1M diagnostic checkpoint
MEASURED, and CONTROL-INVALID. This is published because the rule at the top of this page applies to invalid results exactly as it applies to unrun ones: a class that was attempted and failed its own admission controls is published as such, never quietly dropped. Do not quote any number in this section as a performance result.
Receipts: docs/design/persistence-evidence/watdiv-1m-20260914/
— 140 per-request observations in verified-requests.json, plus cgroup, count-validation
and provenance records.
Why the controls are invalid
The host was outside the protocol’s admission envelope for the whole run: root disk began at 94% and rose to 96% against an 80% ceiling, swap was occupied, other workloads were active, the ten-minute thermal/frequency admission was not performed, and a temperature sensor read 86 °C during two arms. A run that violates a control is invalid, not noisy — so these figures cannot support a ranking, a throughput claim, or an admission-green claim.
The completed ingest also ran on tmpfs, after a disk attempt was terminated at 150.386 s with 50,000 facts committed. A tmpfs figure is not a persistence figure.
What was observed
| Concurrency | Requests | HTTP 200 | HTTP 408 | Ready RSS / PSS (KiB) | Sampled peak RSS / PSS (KiB) |
|---|---|---|---|---|---|
| 1 | 40 | 34 | 6 | 15,808 / 11,303 | 506,848 / 502,343 |
| 4 | 100 | 81 | 19 | 15,992 / 11,446 | 1,611,788 / 821,736 |
All 20 top-level WatDiv v0.6 templates were included; C2, C3 and F3 timed out in both arms and S7 timed out at concurrency four, leaving 17 templates with any successful result. Successful counts and scalar-row hashes were consistent across those 17. The tmpfs ingest completed in 26.789 s over 22 transactions, producing a 338,128,896-byte database.
Scope boundary — read before quoting anything above
- No comparison was run. There is no Oxigraph arm and no other engine here. Nothing in this section is a between-engine statement of any kind.
1Mis a scale name, not a denominator. The pinned artifact is 152,195,750 bytes, SHA-256c158998c66e11b33bc56cf7fa3cbc9e69c1c36bf9bdd1bab447d8a64e2d8da75, and its 1,091,718 parsed triples contain 13,033 identical duplicate lines — 1,078,685 distinct source triples plus three ingest metadata facts give 1,078,688 live facts.- The generator was unseeded, so the ARTIFACT is the pin, never the process. Regenerating at the same scale factor does not reproduce this dataset.
- RSS includes file mappings and must not be read as private heap size. Concurrent requests overlap, so their memory samples are aggregate process observations and are not attributable to a single request.
- No correctness oracle was run. Zero-row responses and matching hashes do not establish semantic correctness.
- No percentiles, and no long-tail or cache-cold claims. Each template received one serial warmup and one measured wave; this is not the peer protocol’s thousands of warm repetitions.
Performance (WatDiv/LUBM)
NOT RUN. No WatDiv or LUBM query latency figures exist against Oxigraph or anything else, and none should be quoted from anywhere until a pinned runner produces them here. The bulk-ingest section above is a different class and is not a substitute: a load rate says nothing about how fast either engine answers a query.
This row exists so the absence is visible. The rule for this section is that a class with no result is published as NOT RUN and kept in the list, because the alternative — leaving it out until it looks good — is how a benchmark page stops being evidence and becomes marketing.
The separate-process memory comparison below is published, but does not satisfy this query-latency row. The diagnostic checkpoint above is likewise not a substitute: it has no second engine in it at all.
Note also that the in-process oxi_compare harness in this repository cannot
fill this row. It deliberately shares the parser and data model between arms, so
what it measures is storage and evaluation, never engine versus engine — and with
one process there is no separate resident set to compare.
Separate-process memory comparison
The September 16 run-4 evidence and phase table are published with machine-readable results. Each engine ran in its own process on the pinned WatDiv artifact. Read the whole phase table, including the anonymous/file-backed split and the query-completion gap: raw RSS favours different engines at different phases, so no overall engine ranking follows from these observations.
This is one dataset, one machine, one run per engine, with concurrency one and four. Timing figures in the receipts are not admissible as a comparison because the host was shared; store-device effects were not independently controlled. These are historical memory observations, not a measurement of the current release. The WatDiv/LUBM query-latency comparison remains NOT RUN.
The rules this section is held to
Inherited from the benchmark programme, and stated here so a future page cannot quietly drop one:
- Classes stay separately scored. Never blended into one compliance percentage.
- Every number comes from a version-pinned, checked-in runner that exits
non-zero on regression.
just conformance-checkenforces this for the conformance page: the committed ledger and the rendered page must agree. - Unsupported and unrun cases stay in the denominator, each with a named reason. NOT RUN is published as NOT RUN.
- Cross-repository results are indexed and linked, never copied. A copy is a number with no owner: it cannot be re-derived from the page that shows it, and it goes stale without anyone editing it.
- A result carries the boundary of what it measured. The extraction section above is the worked example — the same figures, published without their scope, would assert something about Quipu that nobody measured.
SPARQL 1.1 conformance
Claim boundary — read this before quoting any number on this page. Quipu does not pass every approved W3C SPARQL 1.1 Query, Update, Protocol and Results test at rdf-tests
369a90d: query syntax 86/86, query evaluation 167/168, update 93/93, protocol 34/34, result format 10/10, SPARQL 1.0 query 199/242. Exceptions, each named below: federated query (SERVICE) passes 6/7, with 1 refused by policy (variable endpoints); entailment regimes are scored separately (35/70 passed, 0 failing, 35 declared non-goals); SHACL-SPARQL, OWL, RIF and D entailment are declared non-goals. What these counts are. Working Group–approved tests only. The query-evaluation manifests list 225 tests, and the 168 approved ones are scored; the 57 Proposed or unclassified are not run. The update-syntax suites are not run yet. The SPARQL 1.0 query tests are scored separately. The SPARQL 1.1 manifests hold what 1.1 added; the 1.0 tests (rdf-testssparql/sparql10) also bear on SPARQL 1.1 Query conformance. Quipu passes 199/242 of the approved ones, with 33 failing, 0 errors and 10 not comparable; see SPARQL 1.0 query tests. This score is fitted to this suite. Quipu’s failures here were found by running this suite and fixed against it, case by case, so a perfect score is partly a record of that work rather than an independent sample. Other stores measured with the same harness were not tuned to it. Every class below is scored separately and is never combined into a single compliance percentage, because a blended figure would hide exactly the classes that are not implemented at all.
These results come from the W3C RDF Tests suite at a pinned revision, run against throwaway stores by a checked-in runner. You can re-derive every number on this page yourself — the commands are below.
For what Quipu does with a graph once it is correct — handing it to another
store, and composing another store’s without trusting it — see
Sharing & Federation. That page states its own claim
boundary for SERVICE, including the configured-endpoint policy deviation scored below.
What was measured
| Field | Value |
|---|---|
| W3C RDF Tests revision | 369a90d1a60c021b746df2e411da0ff36258a758 |
| Quipu revision (evaluation) | 8fb57af0472af4bb8038247ccb54f9a4b2afe689 |
| Quipu revision (syntax) | 8fb57af0472af4bb8038247ccb54f9a4b2afe689 |
| Quipu version | quipu 0.15.0 |
| Store isolation | one temporary SQLite store per executable test |
| Test selection | Working Group–approved tests only |
Results by class
unsupported means the harness cannot execute the test at all — the capability
is missing, not merely wrong. Those cases stay in the denominator and each one
carries a named reason further down this page.
| Class | Passed | Failed | Error | Unsupported | Approved cases |
|---|---|---|---|---|---|
| query syntax | 86 | 0 | 0 | 0 | 86 |
| query evaluation | 167 | 1 | 0 | 0 | 168 |
federated query (SERVICE) | 6 | 0 | 0 | 1 | 7 |
| result format | 10 | 0 | 0 | 0 | 10 |
| protocol | 34 | 0 | 0 | 0 | 34 |
| update | 93 | 0 | 0 | 0 | 93 |
| entailment | 35 | 0 | 0 | 35 | 70 |
| all classes | 431 | 1 | 0 | 36 | 468 |
The final row is an arithmetic total, not a score. It is here so the class rows can be checked against the ledgers, not so it can be quoted as a percentage.
Other stores, same harness
The same discovery, test selection and result comparison, run against other
stores at the same rdf-tests revision (369a90d1). Scores use RDF
term equality, the rule quipu is held to. “Same value” counts failures whose answer
had the right values in a different lexical form; they stay failures and are
shown separately, so a design choice is not presented as a wrong answer.
| System | Version | Query evaluation | Of those failures, same value | Update |
|---|---|---|---|---|
| quipu | quipu 0.15.0 | 167/168 | — | 93/93 |
| RDF4J | 6.1.0 | 162/168 | 5 | 87/93 |
| Oxigraph | 0.5.11 | 159/168 | 8 | 93/93 |
| Jena Fuseki | 6.2.0 | 155/168 | 12 | 93/93 |
| rdflib | 7.6.0 | 154/168 | 9 | 69/93 |
The quipu row is this page’s own ledger. Quipu’s runner compares exact labels and has no same-value tag, so that cell is empty rather than zero.
Disclosure. Quipu parses SPARQL with spargebra and models RDF with oxrdf, both
from the Oxigraph project. Where the two agree, part of that agreement is shared code.
Quipu’s score is fitted to this suite. Its failures were found by running this suite and fixed against it, case by case. The other stores were not tuned to this harness.
Pinned versions, the fairness rules, every competitor deviation checked by hand, and
the per-case ledgers are in
benchmark/competitors.
RDF syntax
The W3C RDF 1.1 and RDF 1.2 syntax suites at the same rdf-tests revision
(369a90d1). Every manifest case is counted, including cases
the manifests have not marked approved.
| Format | RDF 1.1 | RDF 1.2 |
|---|---|---|
| Turtle | 313/313 | not supported (0/106) |
| N-Triples | 70/70 | not supported (0/70) |
| N-Quads | 0/87 (87 unsupported) | not supported (0/68) |
| TriG | 0/357 (357 unsupported) | not supported (0/61) |
RDF 1.2 is measured and not supported. Quipu is built without RDF 1.2, so it cannot parse a triple term. The RDF 1.2 cases are enumerated from the pinned manifests and not run: a loader that rejects all RDF 1.2 input would “pass” every negative-syntax case, and those passes would read as partial support. No RDF 1.2 case is scored as a pass until the support exists.
Known deviation: literal lexical identity
The current loader canonicalizes integer and double literals: for example,
"01"^^xsd:integer is stored and exported as "1"^^xsd:integer.
It also converts an ill-typed boolean such as "z"^^xsd:boolean to false,
and rejects some ill-typed numeric literals during loading. These are known
deviations, not the intended storage contract; affected cases remain failures.
The chosen contract is to preserve lexical form and datatype as RDF term
identity, including ill-typed literals, and derive numeric values separately.
Distinct terms such as "01" and "1" with the same integer datatype must
coexist even though their numeric values compare equal. This follows
RDF 1.1 literal term equality.
An expression’s effective boolean value must not replace the stored term.
The preservation fix is not implemented yet. Existing data cannot recover discarded spellings; a future compatibility plan must preserve legacy term identity, exact retraction and history without inventing missing lexical forms.
Ledgers: rdf11-syntax.json
and rdf12-syntax.json.
SPARQL 1.0 query tests
The approved W3C SPARQL 1.0 query-evaluation tests (sparql/sparql10) at the same
rdf-tests revision (369a90d1), run by the same runner: 199/242 pass.
Most SPARQL 1.0 answers are RDF result-set graphs (rs:ResultSet). They are read with
rdflib, pinned, with literal normalisation off, so "01"^^xsd:integer stays 01.
Quipu never reads its own expected answers. Where the answer numbers its solutions
(rs:index), order is compared, not just the multiset. The unsupported cases have
RDF/XML answers, which the runner does not read.
| Family | Passed | Failed | Error | Unsupported | Cases |
|---|---|---|---|---|---|
expr-builtin | 12 | 12 | 0 | 0 | 24 |
open-world | 5 | 12 | 0 | 0 | 17 |
sort | 3 | 0 | 0 | 10 | 13 |
distinct | 6 | 5 | 0 | 0 | 11 |
expr-equals | 10 | 2 | 0 | 0 | 12 |
reduced | 1 | 1 | 0 | 0 | 2 |
regex | 3 | 1 | 0 | 0 | 4 |
algebra | 14 | 0 | 0 | 0 | 14 |
ask | 4 | 0 | 0 | 0 | 4 |
basic | 27 | 0 | 0 | 0 | 27 |
bnode-coreference | 1 | 0 | 0 | 0 | 1 |
boolean-effective-value | 7 | 0 | 0 | 0 | 7 |
bound | 1 | 0 | 0 | 0 | 1 |
cast | 7 | 0 | 0 | 0 | 7 |
construct | 5 | 0 | 0 | 0 | 5 |
dataset | 12 | 0 | 0 | 0 | 12 |
expr-ops | 7 | 0 | 0 | 0 | 7 |
graph | 11 | 0 | 0 | 0 | 11 |
i18n | 5 | 0 | 0 | 0 | 5 |
optional | 7 | 0 | 0 | 0 | 7 |
optional-filter | 4 | 0 | 0 | 0 | 4 |
solution-seq | 13 | 0 | 0 | 0 | 13 |
triple-match | 4 | 0 | 0 | 0 | 4 |
type-promotion | 30 | 0 | 0 | 0 | 30 |
Some failures are the lexical-form design choice described above (a number is stored
by value, so "01" reads back as 1); others are engine defects being fixed.
Every case, with its diagnostic, is in
sparql10-evaluation.json.
Named SPARQL 1.0 deviations
These are the current ledger’s non-passing cases, including loader errors and runner limitations. Listing a case does not claim its engine defect is fixed.
| W3C test | Status | Diagnostic |
|---|---|---|
:open-eq-03 | failed | actual result differs from expected multiset |
:open-eq-04 | failed | actual result differs from expected multiset |
:open-eq-06 | failed | actual result differs from expected multiset |
:open-eq-07 | failed | actual result differs from expected multiset |
:open-eq-08 | failed | actual result differs from expected multiset |
:open-eq-10 | failed | actual result differs from expected multiset |
:open-eq-11 | failed | actual result differs from expected multiset |
:open-eq-12 | failed | actual result differs from expected multiset |
:date-2 | failed | actual result differs from expected multiset |
:date-3 | failed | actual result differs from expected multiset |
:open-cmp-01 | failed | actual result differs from expected multiset |
:open-cmp-02 | failed | actual result differs from expected multiset |
:dawg-str-2 | failed | actual result differs from expected multiset |
:dawg-isBlank-1 | failed | actual result differs from expected multiset |
:dawg-datatype-2 | failed | actual result differs from expected multiset |
:dawg-lang-1 | failed | actual result differs from expected multiset |
:dawg-lang-2 | failed | actual result differs from expected multiset |
:dawg-isURI-1 | failed | actual result differs from expected multiset |
:dawg-isIRI-1 | failed | actual result differs from expected multiset |
:dawg-langMatches-4 | failed | actual result differs from expected multiset |
:lang-case-insensitive-eq | failed | actual result differs from expected multiset |
:sameTerm-simple | failed | actual result differs from expected multiset |
:sameTerm-eq | failed | actual result differs from expected multiset |
:sameTerm-not-eq | failed | actual result differs from expected multiset |
:eq-2-1 | failed | actual result differs from expected multiset |
:eq-2-2 | failed | actual result differs from expected multiset |
:dawg-regex-003 | failed | actual result differs from expected multiset |
:no-distinct-1 | failed | actual result differs from expected multiset |
:distinct-1 | failed | actual result differs from expected multiset |
:no-distinct-2 | failed | actual result differs from expected multiset |
:no-distinct-9 | failed | actual result differs from expected multiset |
:distinct-9 | failed | actual result differs from expected multiset |
:dawg-sort-1 | unsupported | |
:dawg-sort-2 | unsupported | |
:dawg-sort-3 | unsupported | |
:dawg-sort-4 | unsupported | |
:dawg-sort-5 | unsupported | |
:dawg-sort-6 | unsupported | |
:dawg-sort-7 | unsupported | |
:dawg-sort-8 | unsupported | |
:dawg-sort-9 | unsupported | |
:dawg-sort-10 | unsupported | |
:reduced-2 | failed | actual result differs from expected multiset |
SPARQL 1.2 query tests
The W3C SPARQL 1.2 query tests (sparql/sparql12) at the same rdf-tests revision
(369a90d1). No SPARQL 1.2 case is Working Group–approved yet; every one is
counted anyway. 3 of 269 pass, and 263 are not run.
Most of SPARQL 1.2 needs grammar or terms Quipu does not have: triple terms, the VERSION
declaration, base direction, new codepoint escapes. Those cases are listed and never run,
because a parser that rejects all SPARQL 1.2 input would “pass” every negative case. The
few cases that need nothing new are run by the same runner as everything above.
| Part | Passed | Failed | Not run | Cases | Why not run |
|---|---|---|---|---|---|
codepoint-escapes | 0 | 0 | 14 | 14 | SPARQL 1.2 codepoint-escape grammar is not implemented; its negative cases would pass by rejection |
eval-triple-terms | 0 | 0 | 41 | 41 | needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk) |
expression | 0 | 1 | 4 | 5 | needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk) |
grouping | 0 | 2 | 0 | 2 | run |
lang-basedir | 0 | 0 | 11 | 11 | RDF 1.2 base direction (rdf:dirLangString) is not implemented |
rdf11 | 3 | 0 | 0 | 3 | run |
syntax | 0 | 0 | 6 | 6 | not wired: the syntax runner scores the 1.1 syntax manifest only |
syntax-triple-terms-negative | 0 | 0 | 65 | 65 | needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk) |
syntax-triple-terms-positive | 0 | 0 | 113 | 113 | needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk) |
version | 0 | 0 | 9 | 9 | the SPARQL 1.2 VERSION declaration is not implemented |
Ledger: sparql12.json.
Query evaluation, by feature family
The family is the pinned suite’s own directory for each manifest, so this grouping is re-derivable by anyone holding the same suite revision. It is the map of where the work is: the largest failing families are where conformance moves the most per fix.
| Family | Passed | Failed | Error | Unsupported | Cases |
|---|---|---|---|---|---|
functions | 57 | 0 | 0 | 0 | 57 |
property-path | 24 | 0 | 0 | 0 | 24 |
aggregates | 21 | 1 | 0 | 0 | 22 |
subquery | 14 | 0 | 0 | 0 | 14 |
negation | 11 | 0 | 0 | 0 | 11 |
bind | 10 | 0 | 0 | 0 | 10 |
bindings | 10 | 0 | 0 | 0 | 10 |
project-expression | 7 | 0 | 0 | 0 | 7 |
exists | 5 | 0 | 0 | 0 | 5 |
construct | 4 | 0 | 0 | 0 | 4 |
grouping | 4 | 0 | 0 | 0 | 4 |
The not-yet-passing tests, named
Every test that does not pass is listed here with its W3C identifier, so a claim of progress can be checked against a specific case rather than a count.
aggregates — 1 of 22 not passing
| Test | Name | Status | Reason |
|---|---|---|---|
:agg-min-02 | MIN with GROUP BY | failed | actual result differs from expected multiset |
Federated query (SERVICE)
Quipu passes 6/7 approved W3C Basic Federated Query cases.
SERVICE is a query-planned subquery path using the same declarations and labels as RemoteProvider; it is separate from GraphProvider whole-query fanout and is not open federation.
The variable-endpoint case is a deliberate policy deviation because query data cannot widen the configured remote allowlist.
| Test | Name | Status | Reason |
|---|---|---|---|
:service1 | SERVICE test 1 | passed | |
:service2 | SERVICE test 2 | passed | |
:service3 | SERVICE test 3 | passed | |
:service4a | SERVICE test 4a with VALUES clause | passed | |
:service5 | SERVICE test 5 | unsupported | variable SERVICE endpoints are deliberately refused; endpoints must be operator-configured |
:service6 | SERVICE test 6 | passed | |
:service7 | SERVICE test 7 | passed |
Why a class is unsupported
Grouped by the reason the runner recorded.
| Why it is unsupported | Cases | Classes |
|---|---|---|
| OWL-Direct entailment is a deliberate non-goal pending a design (aegis-b5moll): it needs a real DL reasoner, and this store’s OWL layer is a write gate with no axioms – no amount of RDFS closure reaches it | 18 | entailment |
| OWL-RDF-Based entailment is a deliberate non-goal pending the same design (aegis-b5moll): it needs an RL rule set or an external reasoner, not an extension of the RDFS closure | 11 | entailment |
| RIF entailment is a deliberate non-goal: RIF is a rule-interchange format, not a semantics asked of this store | 4 | entailment |
| D entailment (datatype entailment) is a deliberate non-goal: no consumer asks for datatype entailment beyond simple and RDFS | 2 | entailment |
| variable SERVICE endpoints are deliberately refused; endpoints must be operator-configured | 1 | federated query (SERVICE) |
Corrections
Kept on the page so a changed number never changes silently.
- 2026-09-24: update was 37 of 93. Until this date the page reported update
37/37. The runner discovered only 37 of the 93 approved update tests: the
delete,delete-data,delete-insert,delete-where,clearanddropmanifests declare their tests with Turtle’sarather thanrdf:type, and the parser matched only the latter. On the full 93, quipu passes 93/93. The runner now pins the approved count per class at the pinned suite revision and refuses a run that discovers a different number.
Re-derive these numbers
git clone https://github.com/w3c/rdf-tests /tmp/rdf-tests
git -C /tmp/rdf-tests checkout 369a90d1a60c021b746df2e411da0ff36258a758
cargo build --release --bin quipu --bin quipu-server --features shacl,onnx,server
QUIPU_BIN="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')/release/quipu"
SUITE="/tmp/rdf-tests/sparql/sparql11"
python3 benchmark/public/sparql11_syntax.py --suite "$SUITE/syntax-query" \
--quipu "$QUIPU_BIN" --output /tmp/sparql11-syntax.json
python3 benchmark/public/sparql11_evaluation.py --suite "$SUITE" \
--quipu "$QUIPU_BIN" --output /tmp/sparql11-evaluation.json
python3 benchmark/public/sparql11_federated.py --suite "$SUITE" \
--quipu "$QUIPU_BIN" --output /tmp/sparql11-federated-query.json
A nonzero exit from an incomplete runner is expected while any test fails: it writes the complete ledger first, then reports that not everything passed. Use the regression gate below to tell worse than committed apart from not yet perfect.
Per-class reproduction commands are embedded in the ledger itself, under
reproduce.per_class.
The regression gate
python3 benchmark/public/check_regression.py \
--baseline benchmark/public/results/sparql11-evaluation.json \
--candidate /tmp/sparql11-evaluation.json
It exits nonzero when a class’s pass count drops or when a test that passed in the committed ledger stops passing, and it names the tests. Improvements exit zero and print the tests that newly pass — a prompt to refresh the baseline. This is what runs in CI on every release, so a published number cannot silently get worse.
Full ledgers
benchmark/public/results/sparql11-syntax.jsonbenchmark/public/results/sparql11-evaluation.jsonbenchmark/public/results/sparql11-federated-query.json
Each row records its class, test identifier, manifest, query and result paths, status, and diagnostic or unsupported reason.
W3C SHACL conformance
Quipu passes 98/98 manifest-reachable SHACL Core cases. SHACL-SPARQL remains a deliberate non-goal: 0/22 pass and 22 are unsupported. Quipu does not advertise SHACL-SPARQL support; the upstream SPARQL validator is incomplete, and support requires the full 22-case manifest gate rather than a partial claim. This score uses the context-free native validator on whole graphs; the write path is measured below.
Pinned W3C Data Shapes revision: 9c863967bceaef1a87c24e4dd761eda763823120.
The pinned manifest exposes 120 approved cases (98 Core + 22 SHACL-SPARQL).
nodeValidator-001.ttl exists in the checkout but is not manifest-reachable and is not scored.
| SHACL class | Passed | Failed | Error | Unsupported | Cases |
|---|---|---|---|---|---|
core-complex-misc | 7 | 0 | 0 | 0 | 7 |
core-node | 32 | 0 | 0 | 0 | 32 |
core-property | 38 | 0 | 0 | 0 | 38 |
core-path | 13 | 0 | 0 | 0 | 13 |
core-targets | 7 | 0 | 0 | 0 | 7 |
core-validation-reports | 1 | 0 | 0 | 0 | 1 |
shacl-sparql | 0 | 0 | 0 | 22 | 22 |
Write-path conformance
The same 98 Core cases are also written through the real /knot handler, whole and split across writes (subject halves, one write per subject, one write per triple, each in both orders). 90/98 agree with the W3C verdict under every split; 7 diverge under at least one; 1 could not be judged under at least one split (the write gate answered cannot verify instead of a SHACL verdict).
A divergence is a case whose final graph violates (or conforms) while the write gate accepted (or refused) every write. These are known defects, listed in the ledger with the splits that expose them; the CI gate fails on any new divergence and on any entry that no longer diverges, so this number can only fall.
| Constraint component | Cases using it | Agree | Diverge | Unjudged |
|---|---|---|---|---|
sh:and | 4 | 3 | 1 | 0 |
sh:class | 19 | 13 | 6 | 0 |
sh:closed | 4 | 3 | 1 | 0 |
sh:datatype | 28 | 27 | 1 | 0 |
sh:disjoint | 3 | 2 | 1 | 0 |
sh:equals | 3 | 2 | 1 | 0 |
sh:hasValue | 5 | 4 | 1 | 0 |
sh:in | 5 | 3 | 2 | 0 |
sh:languageIn | 3 | 2 | 1 | 0 |
sh:lessThan | 3 | 2 | 1 | 0 |
sh:lessThanOrEquals | 2 | 1 | 1 | 0 |
sh:maxCount | 13 | 11 | 1 | 1 |
sh:maxExclusive | 3 | 2 | 1 | 0 |
sh:maxInclusive | 3 | 2 | 1 | 0 |
sh:maxLength | 3 | 2 | 1 | 0 |
sh:minCount | 20 | 19 | 1 | 0 |
sh:minExclusive | 4 | 3 | 1 | 0 |
sh:minInclusive | 4 | 3 | 1 | 0 |
sh:minLength | 3 | 2 | 1 | 0 |
sh:node | 5 | 4 | 1 | 0 |
sh:nodeKind | 6 | 4 | 1 | 1 |
sh:not | 4 | 3 | 1 | 0 |
sh:or | 5 | 4 | 1 | 0 |
sh:pattern | 6 | 5 | 1 | 0 |
sh:property | 55 | 51 | 3 | 1 |
sh:qualifiedValueShape | 4 | 3 | 1 | 0 |
sh:uniqueLang | 3 | 2 | 1 | 0 |
sh:xone | 3 | 2 | 1 | 0 |
Ledger: shacl-write-path.json.
Entailment-regime commitments
2 of 6 regimes are goals (RDF, RDFS): 35/35 of their cases pass. The remaining 4 are deliberate non-goals.
Ledger re-derived 2026-10-21T15:07:06Z by CI run, from quipu 8fb57af0472a.
Local RDFS and OWL extensions beyond a goal regime are not standards-regime claims.
Do not read the goal-regime fraction as “nearly done”. The two numbers have different characters. Most RDF-regime cases are
bind*tests answerable under simple entailment, so they pass without any additional inference — a high RDF score is not evidence of an entailment engine. The RDFS score DOES reflect one: an RDFS closure (rdfs2/3/5/7/9/11) is materialised into the graph’s companion inferred graph and composed into the default graph when the regime is in force, which is what a query likeSELECT ?x WHERE { ex:a ?x ex:c }needs — its predicate is a variable, so the entailed triple has to EXIST and cannot be produced by rewriting the pattern. What remains failing is not more of the same closure: it is container and axiomatic shapes beyond those six rules, and OWL-flavoured cases filed under RDFS.
| Regime | Cases | Passed | Commitment |
|---|---|---|---|
| D | 2 | 0 | deliberate non-goal |
| OWL-Direct | 18 | 0 | deliberate non-goal |
| OWL-RDF-Based | 11 | 0 | deliberate non-goal |
| RDF | 16 | 16 | goal |
| RDFS | 19 | 19 | goal |
| RIF | 4 | 0 | deliberate non-goal |
Machine ledgers: shacl-core.json and sparql11-entailment.json.
CI/CD and Releases
Implementation status (2026-07-23, kelly): ✅ Implemented.
.github/workflows/ci.ymlhas correctness, invariant, and housekeeping jobs;release.ymlusesrelease-plz-action@v0.5behind the exact-SHA correctness aggregate;docs.ymlbuilds the mdBook and deploys to Pages.release-plz.toml,cliff.toml,.pre-commit-config.yaml, andjustfileall present. (Achangelog-check.ymlguard now exists too — additive.) Verified by reading the workflows.
Quipu uses GitHub Actions for continuous integration and release-plz for automated versioning and changelog generation.
CI Pipeline
Every push to main and every pull request triggers the CI workflow
(.github/workflows/ci.yml). Jobs run in parallel:
| Job | What it does |
|---|---|
| fmt | cargo fmt --check |
| clippy | Linting with multiple feature combinations (default, SHACL) |
| test | Test suite across feature matrix |
| build | Full compilation check |
| check | Pre-commit hooks on all files |
| source-size | Source-size policy ratchet |
| shapes | Static ontology-shape invariants |
| release-correctness | Aggregate release gate over tests, clippy, builds, pre-commit, source size, and shapes |
| lint-markdown | markdownlint-cli2 on documentation |
All jobs use cargo caching for fast iteration.
Release Automation
Pushes to main trigger the release workflow (.github/workflows/release.yml).
Before release-plz runs, it waits for CI’s Release correctness check on the
exact pushed SHA. Formatting and Markdown lint remain visible CI checks but do
not block release. The workflow then uses
release-plz to:
- Analyze conventional commits since the last release
- Bump the version in
Cargo.toml - Generate a changelog from commit messages
- Create a GitHub release with a git tag
If the wait budget expires while correctness is queued, running, absent or
unreadable, the waiter reports DEFERRED, emits ready=false, and skips
release-plz. This is neither a code failure nor permission to publish. A genuine
completed non-success check still fails the waiter. release-ci-ready.yml
observes both CI and Release completion, then rearms only an initially deferred
Release whose exact SHA is still main and whose genuine correctness check is
terminal. It retries once, using the existing gate again. Superseded commits,
pull requests, foreign repositories and already retried runs are never rearmed.
If that one retry remains unready, inspect the recorded reason before an operator
reruns it; no deadline or completion event substitutes for a successful check.
Conventional Commits
Commit messages drive changelog categories:
| Prefix | Category |
|---|---|
feat: | Added |
fix: | Fixed |
refactor: | Changed |
doc: | Documentation |
test: | Testing |
chore: | Miscellaneous |
ci: | CI/CD |
Commits with security in the body get a Security category.
Configuration
- release-plz.toml – enables git releases and tags, disables crates.io publishing
- cliff.toml – git-cliff template for changelog formatting with GitHub links and commit SHAs
Documentation Deployment
The docs workflow (.github/workflows/docs.yml) builds the mdbook and
deploys to GitHub Pages on pushes to main:
- Build:
mdbook build docs/book - Deploy: upload to GitHub Pages (main branch only)
Pre-commit Hooks
Local development uses pre-commit hooks (.pre-commit-config.yaml):
- Trailing whitespace and end-of-file fixes
- YAML and JSON validation
- Merge conflict detection
- Large file checks
- Markdown linting (markdownlint-cli2)
- File size limits (warn at 400 lines, error at 500 for Rust source)
Install hooks with:
just setup
Quality Gate
Before pushing, run the full quality gate:
just check # All pre-commit hooks
just docs check # Markdown lint + mdbook build
Contributing
See CONTRIBUTING.md in the repository root for development setup and guidelines.
Quick Start
git clone https://github.com/scbrown/quipu
cd quipu
just setup # Install pre-commit hooks
just check # Run all quality checks
just test # Run tests
just lint # Run clippy
Architecture
Quipu is organized as a single Rust crate with four core modules:
store– SQLite-backed EAVT fact logrdf– RDF data model bridge (oxrdf)sparql– SPARQL query evaluator (spargebra)types– shared data structures
Related work
Two recent preprints address evidence and maintenance in agent-accessible knowledge graphs. The descriptions below are limited to their abstracts; their evaluations are not benchmarks of Quipu.
- Vishisht Choudhary, Keep Your Receipts: Trustworthy Cleanup of Agent-Built Knowledge Graphs (2026), studies the consequences of withdrawing source evidence. PolyGraph records alternative evidence bundles for derived artifacts and audits those receipts by re-derivation. Its results motivate checking downstream dependencies rather than assuming that deleting directly extracted facts repairs every affected answer. This complements Quipu’s temporal record and SHACL-governed reconciliation: a merge’s structural validity does not establish the fidelity of its evidence receipts.
- Akash Raj, Plant Memory: Technical Language Processing and a Verified Maintenance Ontology for Agent-Accessible Failure Knowledge from SAP PM Work Orders (2026), describes rule-first extraction, optional LLM review, reified facts with confidence, evidence and verification state, technician review and MCP access. Its reported evaluation uses a synthetic 400-work-order dataset. It provides an application-specific example of reviewed agent-accessible knowledge; Quipu’s store and merge work instead concerns a general RDF graph governed by explicit shapes. The abstract does not establish a comparison of their temporal or withdrawal semantics.
Official arXiv BibTeX: Keep Your Receipts and Plant Memory.
Docs map
Every document in this repository that is not a page of this book, with one line on what it is. Design notes record how a feature was decided and may describe an earlier state; the book pages are the current description.
Design notes
- Conformance grammar: the versioned step-matching contract (
docs/design/conformance-grammar.md) - Cross-graph concept alignment (
docs/design/cross-graph-alignment.md) - Datalinks: a spatial explorer for Quipu graphs (
docs/design/datalinks-3d.md) - A Unified Entailment Regime — Plan (
docs/design/entailment-regime.md) - Entity Resolution (
docs/design/entity-resolution.md) - Episode-Scoped Logical Retraction (
docs/design/episode-retraction.md) - Design:
RemoteProvider— reaching remote Quipu instances (docs/design/federation-remote-provider.md) - Flagship Reasoning Use Cases — Plan (
docs/design/flagship-use-cases.md) - Design: Fork-at-any-event — persistent named forks (
docs/design/fork-at-any-event.md) - Golden-path blessing: from a verified trajectory to a governed path (
docs/design/golden-paths-blessing.md) - Design: Graph kinds and deep freeze — a data-kind axis on the label lattice, and cold storage that stays composable (
docs/design/graph-kinds-and-deep-freeze.md) - Design: Graph Labels — freshness, trust and policy as a lattice over named graphs (
docs/design/graph-labels.md) - Design: Group Isolation / Multi-Tenant Partitioning (
docs/design/group-isolation.md) - Design: In-Memory Read Model — query in memory, write to SQLite (
docs/design/in-memory-read-model.md) - Design: Knowledge Packs — a graph, its shapes, its queries, and its retrieval policy as one artifact (
docs/design/knowledge-packs.md) - Design: Multi-DB Composition — term spaces, ATTACH, and the blob sidecar (
docs/design/multi-db-composition.md) - Design: Named Graphs (Quads) — the
graph × valid-time × tx-timemodel (docs/design/named-graphs.md) - PageRank & Personalized PageRank — Specification (
docs/design/pagerank.md) - Design: The defaults comparison and the Governed Store principles (
docs/design/paper-principles.md) - Design: Quipu paper plan — a governed bitemporal knowledge graph store (
docs/design/paper.md) - Persistence review evidence (
docs/design/persistence-evidence/README.md) - Separate-process persistent-engine memory measurement (
docs/design/persistence-evidence/separate-process-1m-20260914/README.md) - Separate-process memory comparison — quipu (SQLite) vs Oxigraph (RocksDB), run 4 (
docs/design/persistence-evidence/separate-process-1m-20260914/run4/README.md) - WatDiv 1M diagnostic checkpoint — 2026-09-14 (
docs/design/persistence-evidence/watdiv-1m-20260914/README.md) - Persistence without whole-graph residency (
docs/design/persistence-layer.md) - Policy by example: from an observed edit to a governed rule (
docs/design/policy-by-example.md) - Performant edit hooks for policy (
docs/design/policy-edit-hooks.md) - Quipu UI: Knowledge Graph Visualization & Exploration (
docs/design/quipu-ui.md) - Quipu Reasoner — Incremental Datalog on the Bitemporal Fact Log (
docs/design/reasoner.md) - Reasoning Engine Fixes — Plan (
docs/design/reasoning-engine-fixes.md) - Semantic, entity-grounded edit policies (
docs/design/semantic-grounded-edit-policies.md) - Semantic Reasoning Support — Gap Inventory (
docs/design/semantic-reasoning-gaps.md) - Design: Shape Versioning — a bitemporal registry for shapes and ontologies (
docs/design/shape-versioning.md) - Design: The Signing Plane — governing the trust root like everything else (
docs/design/signing-plane.md) - Design: Spanner-class capabilities over any structured data (
docs/design/spanner-capabilities.md) - Standard share artifacts (
docs/design/standard-share-artifact.md) - Design: statement identity, edge properties, and bounded paths (
docs/design/statement-identity.md) - Test Fixtures: Seed Data for UI Development and Demos (
docs/design/test-fixtures.md) - Quipu: AI-Native Knowledge Graph — Vision (
docs/design/vision.md) - Design: WebAssembly Support — running Quipu without a server (
docs/design/wasm-support.md)
Papers
- The paper source (
docs/paper/README.md) - The merge paper source (
docs/paper-merge/README.md) - arXiv submission card (
docs/paper-merge/SUBMISSION.md)
Release and operations
- Releasing (
docs/RELEASING.md) - Private-key checks (
docs/private-key-checks.md)
Direct workflow processing
Direct workflows use the existing Workflow, WorkflowDefinition,
WorkflowStep, WorkflowRun and TransitionEvent entities. A definition is
one entity typed as both Workflow and WorkflowDefinition; a declared step
is typed as both Step and WorkflowStep. Labels retain the existing shapes.
No local journal is the authority for this processing path.
enforce_workflows in [quipu.governance] defaults to false. Enabling it is a
separate deployment decision: legacy definitions and active runs must be
migrated before enrollment. A client must check GET /workflow/capabilities
and refuse writes unless processing is enabled and signature version 2 is
supported. The read exposes processing capability, not keys or configuration.
Source availability alone does not establish an enabled guard.
In the same graph, a definition declares initialState, terminalState, and
hasStep. Each step declares one fromState and toState. The combination of
step name and starting state must be unambiguous. A terminal state cannot have
an outgoing step. Definitions and steps cannot change their committed process;
use a new identity for a revised plan. Existing shapes keep optional fields for
legacy records, while enrolled processing requires them.
A run starts at the definition’s initial state with currentRevision zero.
An advance must atomically write a new signed event and replace both
currentState and currentRevision. Its fromRevision must equal the current
revision, and the resulting run revision must be exactly one higher. The event
must name a step declared by that run’s definition and match its states.
Committed events are immutable. Steps, definitions and runs from another graph
cannot substitute for those in the authoritative graph. Identity registrations
retain their existing authority and key-rotation rules.
For enrolled processing, signatures bind the expected revision:
shuttle-transition-v2|{run}|{step}|{from}|{to}|{at}|{agent}|{from_revision}
transitionVersion is integer 2. Revision values use canonical nonnegative
decimal encoding. The revision prevents an old advance being reused when a
loop returns to the same state. Legacy v1 signatures remain supported outside
enrolled processing; the v1 message format does not change. Both client and
server must adopt v2 before enrollment.
Use SPARQL Update on /update: its WHERE clause compares the current state
and revision; its DELETE/INSERT replaces them together with the signed event.
The same native transaction guard covers other write surfaces. A refused
operation leaves the run and event facts unchanged. A retry whose WHERE clause
no longer matches is a no-op. After an indeterminate response, read the exact
event identity and run revision, validate the immutable payload, and do not
mint a new event merely because the response was lost.
The native controls include signatures, illegal steps, missing atomic state changes, exact replay, graph isolation, plan/event immutability, initial state, and a stale advance after a state cycle. Server controls exercise both normal and full-copy SPARQL Update paths, concurrent compare-and-set, lost-response replay, and the capability response. These are source tests, not installed workflow, priority-consumer or scheduled notification acceptance.
Before activation, adopt reviewed source and the shared property catalogue,
verify the installed guard, migrate active workflows and clients, and retain a
named rollback. fromState and toState are shared by declared steps and
observed events, so their catalogue must not materialize a Step as a
TransitionEvent. Do not enable processing while legacy writers still mutate
a run or batch-export v1 transitions as its primary state.
