Quipu
Structured knowledge encoded in knotted strings.
A quipu is the Incan knotted-string recording system. Cords are entities, knots are facts, colors are types, and trained readers interpret the structure. Quipu brings this philosophy to modern knowledge graphs: strict structure, enforced by AI agents.
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 — strict schema enforcement at write time with structured agent-friendly feedback
- Hybrid search — SPARQL + vector similarity in a single query
- Episode ingestion — structured write path for agent-extracted knowledge
- 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
This page holds the long form that used to open the README: sharing, a tour, the comparison, the full feature list, the architecture and the feature matrix. The README now leads with installing it and a three-command first success (the caboodle-stack README standard).
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 .qpack.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 | ❌ | ❌ | ✅ |
| SPARQL 1.1 | ✅ | ❌ | ✅ |
| Datalog reasoner | ❌ | ❌ | ✅ |
| Counterfactual queries | ❌ | ❌ | ✅ |
| Vector similarity search | ❌ | ✅ | ✅ |
| LanceDB ANN + pushdown | ❌ | ❌ | ✅ |
| Agent-friendly feedback | ❌ | ❌ | ✅ |
| Episode provenance | ❌ | ✅ | ✅ |
| Graph algorithms | ❌ | ❌ | ✅ |
| Built-in web UI | ❌ | ❌ | ✅ |
| Embeddable (no server) | ❌ | ❌ | ✅ |
| SQLite-backed | ❌ | ❌ | ✅ |
| Rust / zero dependencies | ❌ | ❌ | ✅ |
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 46 MCP tools for agent integration (48 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 = { 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 │
│ (46 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.
When running as a Bobbin subsystem, agents get 46 MCP tools (48 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 (46; 48 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 = { git = "https://github.com/scbrown/quipu" }
To use SHACL validation (enabled by default):
[dependencies]
quipu = { git = "https://github.com/scbrown/quipu", features = ["shacl"] }
To exclude SHACL (smaller binary, faster compile):
[dependencies]
quipu = { git = "https://github.com/scbrown/quipu", default-features = false }
From Source
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
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 \
--qpack domain.qpack.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 --qpack 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)
When running as a Bobbin subsystem, Quipu tools are available to agents:
{
"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.qpack.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) |
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.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 | false | Validate episode ingest against the stored shapes |
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 |
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 | false | 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.qpack.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.
Not wired into the quipu CLI / quipu-server
Nothing, currently — every documented key above is read by the shipped
binaries. The mechanism is kept rather than deleted: unwired_warnings() still
exists, and any future key that parses but is not acted on must be listed there
so setting it prints a warning: instead of being silently inert.
(Two keys used to sit here. federation.remotes was wired in quipu #47 —
health-checked at startup and queried per-request via federated: true on
POST /query, see Federation. vector.backend
was wired in quipu-lv7, described just above.)
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
Triples and the Knowledge Graph
Everything in Quipu is a triple: a subject, a predicate, and an object.
<koror> <runs> <traefik>
subject predicate object
This triple says “koror runs traefik.” Three triples can encode a complete service dependency:
@prefix hw: <http://example.org/homelab/> .
hw:koror a hw:Host .
hw:koror 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/koror
Prefixes keep things readable. Instead of writing the full IRI every time:
@prefix hw: <http://example.org/homelab/> .
hw:koror hw:runs hw:traefik .
hw:koror expands to http://example.org/homelab/koror.
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:koror hw:runs hw:traefik . # reference → another entity
hw:koror hw:hostname "koror.example" . # literal → a string
hw:koror 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:koror |
| 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:koror a hw:Host ; hw:hostname \"koror.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": "koror", "type": "Host", "properties": {"hostname": "koror.example"}},
{"name": "traefik", "type": "WebApp"}
],
"edges": [
{"source": "koror", "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 koror 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:koror hw:cpuCores "4"^^xsd:integer .
EOF
On April 3 you upgrade to 8 cores and record the change:
quipu retract "http://example.org/homelab/koror" \
--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:koror 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/koror> <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/koror> <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:koror | hw:cpuCores | 4 | 1 | 2026-04-01 | 2026-04-03 | Assert |
hw:koror | hw:cpuCores | 4 | 2 | 2026-04-03 | 2026-04-03 | Retract |
hw:koror | 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
Committed 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.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 }
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.
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. A member naming an unregistered graph contributes nothing.
- 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
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 a conforming union, 2 for a retained,
nonconforming 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. A
nonconforming union stays in the named inspection dataset and is never promoted to
ROOT. The report preserves violation counts and counts by source shape, with at
most 40 individual diagnostics. Types outside the selected authority are reported
separately as off_vocabulary and also quarantine the union.
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.
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, mirroring shacl.validate_on_write: 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.
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.
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. 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).
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, qpack means the text share directory.
The binary .qpack.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. 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.qpack.db --with-vectors
quipu pack urn:example:graph --out domain.qpack.db --name "domain" --version 1.0.0
quipu pack urn:example:graph --out domain.qpack.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 .qpack.db form, because the hash is computed
from canonical content, not from the emitted bytes.
Unpacking
quipu unpack domain.qpack.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.qpack.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 (.qpack.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.
- Read the source opens the repository file; Inspect facts & edit opens its local facts. SPARQL, export and the propose-as-PR flow remain available.
The cluster outlines separate vision, decisions, episodes, code and reading. Positions stay fixed while you explore. 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. 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.
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 qpack 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:koror hw:cpuCores "lots" . # Should be an integer
hw:koror a hw:Host . # Missing required hostname
With SHACL shapes loaded, Quipu catches these problems before they enter the fact log.
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.
Validation in Action
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.
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 ..."
}'
Pre-Built Shapes
Quipu ships with shapes for the Aegis infrastructure ontology in the
shapes/ directory. These 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
Load them with:
quipu shapes load --name aegis --file shapes/aegis-ontology.shapes.ttl --db homelab.db
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
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": "aegis/crew/braino", "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.
- The approver role defaults to
aegis/crew/braino. 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.
Before 2026-09-06 owl:sameAs was not implemented at all: assertions were
accepted and stayed completely inert, so a reader landing on one twin never saw
the other’s facts (aegis-yro9m, filed after the identity had been asserted 191
times on a live store).
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. (Before 2026-08-27 it was one-shot — each
family ran once over base facts, so composed entailments were silently
missing and the recorded workaround was re-encoding OWL axioms as Datalog
rules.) 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. Default off: it is a per-write cost a deployment should
choose.
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
Enforcement is OPT-IN, and was not wired at all before 2026-08-04. This section previously stated flatly that the two constraints below “are enforced at write time”. That was FALSE for the shipped server:
Ontology::validate()implemented both and had no caller — nothing on the write path invoked it, so an ontology could declare a disjointness and every violating write was accepted. The caller landed on 2026-08-04.It is recorded here rather than quietly corrected because the failure mode is the doc, not the code: a capability claim in a manual is not tested, it is BELIEVED, so it stops the reader checking the very thing that is broken.
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> <koror> }
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 koror
A human reads this and concludes “traefik transitively runs on koror.” 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 koror 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
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.
Nothing about semantic retrieval is on by default. This page is the checklist for turning it on, and — just as important — for telling whether it is on.
What knot does and does not do
This asymmetry surprises people, so it is worth stating plainly:
| Write path | Auto-embeds? |
|---|---|
POST /episode / quipu_episode | Yes, when auto_embed = true |
quipu knot / POST /knot (Turtle ingest) | No |
quipu-server --embed-backfill | Yes, all entities, once at startup |
POST /embed_backfill | Yes, all entities, on demand |
A graph loaded from Turtle therefore holds no embeddings. Semantic
retrieval over it returns nothing at all — successfully, with a 200 and an
empty result — until you backfill.
$ quipu knot alphax.ttl --db na.db
knotted 2579 facts from alphax.ttl (tx 1)
$ quipu-server --db na.db --embed-backfill # <- the missing step
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 — with either missing, the server skips provider construction entirely
and starts without embeddings.
Telling whether it worked
Three signals, in the order you will meet them.
At startup, a loaded provider announces itself:
ONNX embedding provider loaded (dim=384, auto_embed=true, deferred)
--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:koror a hw:Host ;
rdfs:label "koror" ;
hw:hostname "koror.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:koror ;
hw:port "443"^^xsd:integer ;
hw:dependsOn hw:pihole .
hw:pihole a hw:Service ;
rdfs:label "pihole" ;
hw:runsOn hw:koror ;
hw:port "53"^^xsd:integer .
hw:grafana a hw:WebApp ;
rdfs:label "grafana" ;
hw:runsOn hw:koror ;
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/koror |
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:koror | 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:koror | 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:koror | 32768 |
hw:palau | 16384 |
OFFSET 1 LIMIT 1 would skip koror and return only palau.
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:koror | 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:koror | 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.
History, so an older reading does not mislead. For a period this page documented the opposite — an
asserted-onlyconstant form announcing"applied": falsewith awithheldTypeslist. That flip was reverted when formal reasoning defaults were enabled, and that marker shape no longer exists. If you are looking forwithheldTypes, you are reading a build that is gone; branch oninference.appliedinstead.
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": false, "inference": {...}}
hw:postgres may be asserted only as hw:DatabaseService — nothing in the
graph says it is a hw:Service, so this now answers false. A boolean gives you
no number to look at twice, so the marker makes the semantic change visible.
CONSTRUCT/DESCRIBE carry it too: their formerly inferred triples are likewise
withheld unless the query uses the explicit path.
What the marker claims. It says the old implicit expansion was withheld from
this query. It does not say the resulting answer necessarily changed: a marked
ASK can still be true about a directly asserted fact. To ask the inferred
question, use the explicit path; 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: withheld: http://example.org/homelab/Service
Same rule: the header is absent when the flip did not affect the query. 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 withheldTypes detail is
one Accept-free request away.
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:koror |
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 koror host?” using reverse traversal:
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc
WHERE {
hw:koror ^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:koror 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:koror
Returns all triples where koror 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 koror 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:koror a hw:Host ;
rdfs:label "koror" ;
hw:hostname "koror.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:koror ;
hw:port "443"^^xsd:integer ;
hw:dependsOn hw:pihole .
hw:pihole a hw:Service ;
rdfs:label "pihole" ;
hw:runsOn hw:koror ;
hw:port "53"^^xsd:integer .
hw:grafana a hw:WebApp ;
rdfs:label "grafana" ;
hw:runsOn hw:koror ;
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 |
|---|---|
| koror | grafana |
| koror | pihole |
| koror | traefik |
| palau | nginx |
| palau | postgres |
| palau | prometheus |
Step 3: Impact Analysis — What Breaks if Koror Goes Down?
This is the killer query. Find all services on koror, 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:koror .
?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 koror.
| ?affected | ?label |
|---|---|
hw:traefik | traefik |
Traefik depends on pihole, which runs on koror. But traefik itself also runs on koror — so if koror 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 koror OR depend on services running on koror:
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:koror .
}
UNION
{
?svc hw:dependsOn+/hw:runsOn hw:koror .
}
?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 koror 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:koror .
?svc rdfs:label ?label .
}
This returns both directly-hosted services AND services that transitively run on koror 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 koror, 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 koror
let report = store.speculate(&koror_retractions, timestamp, |s| {
evaluate(s, &ruleset, timestamp)
})?;
println!("Decommissioning koror 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 koror”. But other agents need to query derived facts — “traefik runs on koror” (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 koror.”
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:koror a hw:Host ;
rdfs:label "koror" .
hw:webproxy a hw:Container ;
rdfs:label "webproxy" ;
hw:runsOn hw:koror .
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:koror .
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:koror ;
hw:dependsOn hw:postgres .
hw:postgres a hw:Database ;
rdfs:label "postgres" ;
hw:runsOn hw:koror .
EOF
Step 1: Your First Rule — Transitive runsOn
Traefik runs on webproxy, and webproxy runs on koror. You want to materialise the fact that traefik runs on koror 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 koror (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 koror
let koror_id = store.lookup("http://example.org/homelab/koror")?.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 koror 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 two lines without loading
configuration or opening a database:
quipu <version>
git_sha: <build-commit>
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.
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 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 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 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 the file nor the parser. And 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.
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 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 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
.qpack.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.qpack.db --name "domain" --version 1.0.0
quipu pack urn:example:graph --out domain.qpack.db --shapes s.ttl --queries q.json --with-vectors
quipu pack urn:example:graph --out domain.qpack.db --space 7
quipu pack --verify domain.qpack.db
quipu pack urn:example:repo --out repo.qpack.db --repo scbrown/example --repo-sha "$BASE_SHA" --model-id all-MiniLM-L6-v2 --model-version 1
quipu unpack repo.qpack.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 .qpack.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
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 .qpack.tar.gz archive. The older pack and unpack commands
remain for local SQLite compatibility, but a .qpack.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] [--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), 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 |
--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 qpack/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.
quipu import — receive a share, into quarantine
quipu import <share-dir|archive|URL> [--source <uri>] [--actor <id>]
[--destination internal] [--db <path>]
quipu import delta <parent-share> <delta-share> [--actor <id>]
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 |
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.
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.
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”.
quipu pack / quipu unpack — legacy SQLite compatibility
quipu pack <graph-iri> --out <file.qpack.db> [--name N] [--version V] [--space N]
[--shapes S]... [--queries Q]... [--with-vectors] [--format turtle]
quipu pack --verify <file.qpack.db>
quipu unpack <file.qpack.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 .qpack.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.qpack | 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> [--db <path>]
quipu attest list [--db <path>]
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.
REST API
The quipu-server binary exposes all Quipu operations over HTTP (Axum).
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
Reads are open; writes need a bearer token. When the server is started with an auth token configured, every write endpoint requires:
Authorization: Bearer <token>
Reads — /query, /search, entity lookups, /health, /version — need no
credential and answer normally.
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.
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 /health
Health check.
curl localhost:3030/health
Response: {"status": "ok"}
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" }'
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.
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": "kota", "relation": "runs_on"}]
}'
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.
Edge relation: which vocabularies /episode can write
/episode used to force every relation into aegis: and then sanitize it, so
"relation": "rdfs:subClassOf" was stored as aegis:rdfs_subClassOf — a predicate
that resembles the intended one, matches nothing, and is inert — behind HTTP 200 with
a healthy count. It no longer does. The policy is now: represent the caller’s
predicate faithfully, or refuse and say which path to use. Never silently rewrite it.
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 — it would be silently renamed |
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).
- 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.
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: ..."}'
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).
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.
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
Vector similarity search. Body: embedding (or query), optional limit,
valid_at, and best-effort scoping by group_ids / entity_type.
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.
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}'
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”.
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": "kota", "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).
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, optional decided_by, note,
timestamp.
POST /proposal/reject
Reject a pending proposal. Body: id, note, optional decided_by,
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.
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"}. 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.
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.
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.
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.
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”.
Cadence must exceed the scan cost: the full pass re-reads every current fact,
measured at ~2.3 s against 641,803 facts. That cost per WRITE is what made the
reactive observer an OOM (aegis-2s6xpb); the same work on a timer is the same
closure without the per-write scan. Entailments land in the companion inferred
graph, so a wrong owl:sameAs pair stays quarantined and re-derivable.
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"} → {"signature_valid", "verifier_registered", "verifier_authorized", "trusted"} — trusted is the conjunction to gate on.
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
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.
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.
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.
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. These tools are available when Quipu runs as a Bobbin subsystem or standalone MCP server.
The registry (tool_definitions()) exposes 46 tools in a default build, or
48 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_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_knot
Assert facts from Turtle data, with optional SHACL validation.
| Parameter | Required | Description |
|---|---|---|
turtle | Yes | RDF Turtle data |
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 facts for an entity.
| 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.
| 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
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 removed nothing and for one that emptied a graph, so this question previously had no answer.- 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) |
embedding | No | Float array (query vector); takes precedence over query |
limit | No | Max results (default: 10) |
valid_at | No | Temporal filter |
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 |
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.
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.
| 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.
| 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 | Hex ed25519 signature over the verdict message |
quipu_verifier_authorized
Check the Phase-0 verifier registry: may this verifier attest this predicate? The discovery half of the governance gate.
| Parameter | Required | Description |
|---|---|---|
verifier | Yes | Verifier IRI |
predicate | Yes | Predicate IRI to attest |
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.
| 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) |
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 |
|---|---|---|
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 | No | 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 | No | 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 |
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.
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 |
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.
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
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 qpack 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 qpack, 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.
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 |
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.
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 .qpack.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.
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.
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 qpack 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.
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/koror",
"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.
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) ... |
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
}
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": "koror-rebuild-2026-03-29",
"episode_body": "Koror was rebuilt on kota after disk failure",
"source": "aegis/crew/ellie",
"group_id": "aegis-ontology",
"nodes": [
{
"name": "koror",
"type": "ProxmoxNode",
"description": "Proxmox host, rebuilt after failure",
"properties": { "hostname": "koror.example", "status": "recovered" }
},
{
"name": "kota",
"type": "ProxmoxNode",
"description": "Primary Proxmox host"
}
],
"edges": [
{
"source": "koror",
"target": "kota",
"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/koror-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; SQLite oversamples 5x and post-filters
- 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
Implementation status (2026-08-25): 🟩 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) — and since quipu-lv7 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.(This banner previously read “code-complete but inert in the shipped binaries” — accurate when it was written, and the shape that rots into a false affordance if left:
set_local_vector_backendhad zero non-test callers andvector.backendwas set-but-not-read, somigrate-vectorsmoved embeddings into a store nothing then selected.)
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/kota", "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 koror goes down” — services running on koror 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/koror> .
BIND("runs on koror" AS ?reason)
}
UNION
{
?affected ont:dependsOn+/ont:runsOn <http://aegis.gastown.local/ontology/koror> .
BIND("depends on service on koror" AS ?reason)
}
?affected rdfs:label ?label .
}
| ?label | ?reason |
|---|---|
| traefik | runs on koror |
| pihole | runs on koror |
| grafana | runs on koror |
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:koror a hw:Host ; hw:hostname \"koror.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": "koror", "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. It was not always: it used to land as the inert aegis:owl_sameAs
behind a 200. 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:koror 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/koror" \
--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.
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 |
| 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.
A comparison is PENDING, and it is not this row. A separate-process memory comparison against a RocksDB-backed Oxigraph on the same pinned dataset is scoped and not yet run. It is a memory residency measurement, not query latency, so it will not satisfy this row when it lands — it will earn its own. 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.
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 passes all Working Group–approved W3C SPARQL 1.1 Query, Update, Protocol and Results tests at rdf-tests
369a90d: query syntax 86/86, query evaluation 168/168, update 93/93, protocol 34/34, result format 10/10. 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. 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) | d6548f887ad808a9a718dd748abf5891899796c8 |
| Quipu revision (syntax) | d6548f887ad808a9a718dd748abf5891899796c8 |
| Quipu version | quipu 0.8.1 |
| 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 | 168 | 0 | 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 | 432 | 0 | 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.8.1 | 168/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 | 306/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.
Ledgers: rdf11-syntax.json
and rdf12-syntax.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 | 22 | 0 | 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.
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; write-gate transaction behavior is tested separately.
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 |
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-09-25T21:50:07Z by CI run, from quipu d6548f887ad8.
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
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
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)
