Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

InterfaceUse case
Rust crateEmbed 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:

PersonaYou want to…Start here
Homelab OperatorModel hosts, services, and dependenciesTutorial
AI Agent BuilderLet agents share structured knowledgeTutorial
Code ArchaeologistUnderstand how a codebase evolvedTutorial
Knowledge GardenerCurate and validate an ontologyTutorial

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.

The Explore page: a provenance table listing the pack's producer version, RDFC-1.0 graph hash, share id, import outcome staged, the triple count accepted with none quarantined, and promoted; below it a type distribution bar chart over Chunk, CodeSymbol, Section, CodeModule and Document

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

Quipu's graph explorer: a force-directed node-link view of an infrastructure knowledge graph, with a type filter sidebar, an entity list, and a legend pairing each entity type with a colour and a shape

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/StardogGraphiti/Mem0Quipu
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 share writes canonical export.nt, shapes.ttl, and a lineage-aware manifest.json; unchanged graph state produces byte-identical files and stable hashes for meaningful git diffs. Remote consumers use read-only POST /share to receive that exact manifest and file set without access to the server filesystem. When the store carries block-tier InternalIdentifierPattern rules, the producer refuses matching outbound bytes before publishing anything and never rewrites entity IRIs.
  • Shape-aware reconnect — quipu status previews base/ROOT/incoming divergence, while quipu merge unions multi-valued RDF and emits structured decisions for sh:maxCount conflicts 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 the urn:quipu:dataset:frozen dataset, or by include_kinds: ["archive"] on a query; quipu graph thaw restores it for writes. GET /graphs lists 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.backend in config selects it in-binary: the CLI and server install the configured backend at open, so choosing LanceDB no longer requires an embedder to call Store::set_local_vector_backend by 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 datafrog with 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 — TransactObserver re-runs affected rules on every write. Delta-aware: only changed predicates trigger re-evaluation. Behind the non-default reactive-reasoner feature; reason --reactive errors 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; only quipu 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 GraphProvider trait for multi-source queries, with a RemoteProvider (behind the remote feature) built from federation.remotes config. The server health-checks every configured remote at startup, and POST /query with "federated": true fans 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 /graph payload (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 the owl feature).

  • “SQLite energy” — single process, no server required, inspect with sqlite3, back up with cp.

  • 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 unrelated quipu crate on crates.io. CI runs fmt, clippy, tests, and markdown lint on every push. /version also 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 via Store::transact() with full provenance.
  • Reactive evaluation — TransactObserver keeps derived facts fresh as base facts change. Delta-aware: only affected rules re-run. Optional reactive-reasoner feature.
  • 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.

FeatureStatusNotes
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 tool
  • target/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.

FieldDefaultDescription
store_path.bobbin/quipu/quipu.dbSQLite database path
base_nsaegis ontology NSBase namespace for minted IRIs (set before first write; --base-ns overrides per CLI call)
server.enabledfalseEnable REST API server
server.bind127.0.0.1:3030Server bind address
server.auth_tokenunsetBearer token required on write endpoints when set
server.previous_auth_tokenunsetPrevious write bearer accepted temporarily during rotation; requires server.auth_token and a positive grace duration
server.previous_auth_token_expires_at_epoch_secsunsetAbsolute UTC Unix epoch expiry for the previous bearer; at most 24 hours away when starting; expired previous bearers are ignored
server.read_onlyfalseRefuse all write endpoints
server.cors_allowed_origins[]CORS allowlist for the UI/API
server.read_pool_size4Read-only connection pool size (0 = all reads take the writer lock)
events.retention_daysunset (keep forever)Prune events older than N days, never past any registered consumer’s committed offset
labels.min_freshnessunsetGraph-label floor: refuse results staler than this
labels.min_trust_rank / labels.min_trust_chainunsetTrust 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_limit10Result limit when the caller passes none
search.max_limit1000Hard cap on requested result limits
search.max_sparql_rows10000Cap on SPARQL result rows
search.query_timeout_ms30000SPARQL evaluation deadline
search.max_join_rows1000000Abort a join once an intermediate exceeds this
search.oversample_factor10Vector-search oversampling before filtering
shacl.validate_on_writefalseValidate episode ingest against the stored shapes
owl.validate_on_writetrueEnforce owl:disjointWith / owl:FunctionalProperty at write time (with functional-property supersede); set false for an explicitly informal deployment
governance.enforce_on_writefalseEvaluate action-boundary policies against every write (the write-time gate)
governance.validate_placementfalseCheck SARC class↔placement rules when a write defines/amends a policy
governance.verify_transitionsfalseRefuse a write landing an aegis:TransitionEvent whose signature is missing or does not verify under a registered key
governance.enforce_authorityfalseMake a supplied principal chain binding for graph writes
resolution.enabledfalseEntity resolution (dedup) on the episode write path
resolution.threshold / top_k / strict_mode0.85 / 3 / falseMatch threshold, candidate count, refuse-on-ambiguity
embedding.auto_embedfalseAuto-embed entities on write (needs model/tokenizer paths)
embedding.model_path / tokenizer_pathunsetONNX model + tokenizer for embeddings
embedding.dimension / max_sequence_length / embed_batch_size384 / 256 / 32Embedding runtime parameters
vector.backendsqlitesqlite 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"
KeyRequiredDescription
aliasYesSQLite schema name the file mounts under, and the source its contributed graphs carry in the graph registry. Must match ^[a-z][a-z0-9_]*$
pathYesPath 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 g column (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 with quipu db respace.
  • The packs you already build are attachable. quipu pack --space N ships 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):

  1. CLI flags (--db, --bind)
  2. Project config (.bobbin/config.toml in working directory)
  3. Global config (~/.config/bobbin/config.toml)
  4. 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:

FieldMeaningExample
E (entity)The subjecthw:koror
A (attribute)The predicatehw:runs
V (value)The objecthw:traefik
T (transaction)When it was writtentx: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

Every fact in Quipu has two time dimensions:

  1. Transaction time — when the fact was recorded in the database
  2. 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_fromvalid_toop
hw:kororhw:cpuCores412026-04-012026-04-03Assert
hw:kororhw:cpuCores422026-04-032026-04-03Retract
hw:kororhw:cpuCores832026-04-03nullAssert

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

DimensionWhat it tracksSet byQueryable via
Transaction timeWhen the database learned about the factSystem (auto-incremented tx ID)--tx flag, as_of_tx parameter
Valid timeWhen the fact was true in realityYou (--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 g column and graphs registry, overlay create/write/compose, GRAPH / FROM / FROM NAMED evaluation, the graph query param, and named datasets, and (since 2026-08-21) a strict graph param on POST /knot that accepts only already-registered committed-class graphs. Deliberately not built: property paths under GRAPH ?g (explicitly refused, never a silent ROOT read). See docs/design/named-graphs.md for 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 = 0 is the reserved ROOT graph — the default committed graph, the source of truth.
  • A named graph’s g is the interned term id of its graph IRI, so resolving GRAPH <iri> is a single term lookup.
  • The graphs registry keeps one row per graph with an enforced class: committed (a durable branch; ROOT is the seeded, self-rooted one) or overlay (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 ?g to each match’s graph IRI. Property paths under GRAPH ?g are 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-unknown FROM set yields an empty default graph — never a fall-through to ROOT.
  • FROM NAMED <g…> restricts which named graphs a GRAPH clause can see. A query with FROM but no FROM NAMED activates no named graphs (per SPARQL 1.1), so GRAPH matches 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: assert and retract are graph-scoped writes into the overlay; tombstone marks 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 the graph query 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:includesGraph facts, 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.
  • 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 in src/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. See docs/design/graph-labels.md for 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-export graph taints the set, and the union of their declared kinds, so a dataset touching an archive graph says so.

The axes

AxisValuesCompose
quipu:freshnessfresh > recomputing > stalemeet
quipu:trustIRIs ranked by a declared chainmeet, within one chain
quipu:policyClassset of obligation tokens (pii, no-export, …)join (union)
quipu:durabilitybacked > reproducible > soleRecordmeet
quipu:dataKindone 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? backed is persisted elsewhere, reproducible is re-derivable from a source that still exists, soleRecord means 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 backed because a backup ran once. Related but distinct: quipu:derivedBy records 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. derivedBy says how to recover a fact; durability says whether you must.
  • Kind is categorical, not ordered. quipu:dataKind declares 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.

Graph Kinds & Deep Freeze

Implementation status: ✅ Built on the sanctioned surfaces — the quipu:dataKind label axis, GET /graphs, include_kinds on /query, and quipu graph freeze|thaw|list. See docs/design/graph-kinds-and-deep-freeze.md for 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):

  1. GRAPH <iri> or FROM <iri> — by name;
  2. FROM <urn:quipu:dataset:frozen> — the auto-maintained dataset of every frozen graph;
  3. "include_kinds": ["archive"] on POST /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 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_omitted into the pack manifest, so an incomplete archive never reads as “this graph had no embeddings”. A thaw in 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. Run quipu migrate-vectors first.
  • 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 through stage_and_guard in src/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 of require-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; $target is bound to the entity under evaluation. Satisfied = good.
  • boundary — action (pre-edit/pre-write) or transition (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 with rdfs:range only — no rdfs:domain, so the same term reads identically on a future TextRule or Directive.

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. Returns signature_valid, verifier_registered, verifier_authorized, and trusted = signature_valid AND verifier_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 the quipu pack / quipu pack --verify / quipu unpack CLI, 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. See docs/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.

  • CLI reference — the quipu pack / quipu unpack flags 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:

  1. In Knowledge constellation, search for vision, then select Quipu vision.
  2. In its card, tap guides: Federation has explicit boundaries.
  3. Read the decision and its source excerpt. Tap governs: src/provider/mod.rs to reach the actual indexed code module.
  4. 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

ConstraintWhat it checks
sh:minCount / sh:maxCountCardinality (how many values)
sh:datatypeValue type (xsd:string, xsd:integer, etc.)
sh:minInclusive / sh:maxInclusiveNumeric ranges
sh:minLength / sh:maxLengthString length
sh:patternRegex match
sh:inAllowed values (enumeration)
sh:classReferenced entity must have rdf:type
sh:nodeNested shape reference
sh:or / sh:and / sh:notLogical constraints
sh:equals / sh:disjointProperty 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 resources
  • SystemdService, WebApplication, Database — services
  • CommandDiskImpactObservation — one privacy-preserving command/filesystem measurement. diskDeltaBytes is 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-safe configPath and contentSha256 facts support exact drift lookups. When present, each is single-valued and the digest must be a full lowercase SHA-256 hexadecimal string.
  • CiJob and LocalCommand — 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

KindDescription
shapeNew or updated SHACL shape (Turtle fragment)
ontologyOWL axiom change (future)
classNew RDF class definition
propertyNew 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:

  1. Validates the Turtle diff is syntactically correct
  2. Verifies it parses as valid SHACL
  3. Writes the shape to the shapes table
  4. 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 proposals table uses a text diff column so that SHACL shape diffs and future OWL axiom diffs can coexist. The kind column 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:

  1. Parses the Turtle and extracts OWL/RDFS axioms
  2. Persists the ontology in SQLite (like SHACL shapes)
  3. Materializes entailments into the fact log

Supported Axioms

AxiomEffect
rdfs:subClassOfTransitive closure: instances of a subclass are also instances of all superclasses
owl:disjointWithWrite-time validation (opt-in): rejects an entity typed with two disjoint classes
rdfs:subPropertyOfMaterialization: a fact under a subproperty is restated under every superproperty (transitive)
owl:inverseOfMaterialization: (a P b) produces (b Q a)
owl:FunctionalPropertyWrite-time validation (opt-in): rejects a second value on a functional property
owl:SymmetricPropertyMaterialization: (a P b) produces (b P a)
owl:equivalentClassMaterialization: instances of A become instances of B and vice versa
owl:TransitivePropertyMaterialization: full closure — (a P b), (b P c) produce (a P c), chained to fixpoint
owl:equivalentPropertyMaterialization: facts under either property are restated under the other
rdfs:domain / rdfs:rangeMaterialization: infers type from property usage
owl:sameAsMaterialization: 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:TransitiveProperty and owl:equivalentProperty were parsed and counted but not materialized before 2026-08-27 — the same silently-dropped shape rdfs:subPropertyOf had 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:sameAs between two PROPERTIES, the identity itself is closed, but facts are not restated under the co-referent property — ex:box ex:hosts ex:svc with ex:hosts owl:sameAs ex:runs does not entail ex:box ex:runs ex:svc. This is OWL 2 RL’s eq-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. Use owl:equivalentProperty instead, 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:

  1. Query-time: write a property path (runsOn+) every time you ask
  2. 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:

  1. Stratify the ruleset (this is cheap — just graph analysis)
  2. Load the current world: read all relevant facts from the store
  3. Evaluate each stratum in order, running rules to fixpoint
  4. Diff each rule’s new derivations against its old ones
  5. 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:

  1. Checks which predicates changed
  2. Finds the rules whose bodies reference those predicates
  3. Follows the dependency chain to find transitively affected rules
  4. Re-evaluates only the affected strata
  5. 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:

FieldExample
sourcereasoner:depends_on_transitive
actorreasoner
valid_from2026-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

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 pathAuto-embeds?
POST /episode / quipu_episodeYes, when auto_embed = true
quipu knot / POST /knot (Turtle ingest)No
quipu-server --embed-backfillYes, all entities, once at startup
POST /embed_backfillYes, 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:

  1. 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.
  2. 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:

configuredembedded_entitiesMeaning
false0No provider. Configure [quipu.embedding].
true0Provider attached, store never embedded. Run a backfill.
true> 0Semantic 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, /context text search, link expansion, SHACL, the reasoner.
  • Empty or refused: vector similarity in /search and quipu_hybrid_search, the Semantic relevance 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:koror8
hw:palau4
hw:yap4

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:koror32768
hw:palau16384

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:traefikhw:pihole
hw:pihole
hw:grafanahw:prometheus
hw:prometheus
hw:minio
hw:nginxhw: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:koror32768
hw:palau16384

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:koror3
hw:palau2
hw:yap1

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
1657344

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:koror3
hw:palau2

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.

QuestionForm
“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-only constant form announcing "applied": false with a withheldTypes list. That flip was reverted when formal reasoning defaults were enabled, and that marker shape no longer exists. If you are looking for withheldTypes, you are reading a build that is gone; branch on inference.applied instead.

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:traefikhw:koror
hw:grafanahw:palau
hw:nginxhw: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

PatternMeaning
?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 ?xAggregate per group
ORDER BY DESC(?n)Sort descending
LIMIT 10 OFFSET 5Paginate
?x hw:a/hw:b ?yPath sequence
?x hw:a+ ?yTransitive closure
?x ^hw:a ?yReverse edge
?x (hw:a|hw:b) ?yEither predicate

What’s Next

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
kororgrafana
kororpihole
korortraefik
palaunginx
palaupostgres
palauprometheus

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

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

  1. Episodes — structured write path for agent observations
  2. MCP tools — native integration for LLM tool-use
  3. 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

ToolPurpose
quipu_queryRun SPARQL queries (SELECT, ASK, CONSTRUCT, DESCRIBE)
quipu_knotAssert Turtle facts with optional SHACL validation
quipu_cordList entities, optionally filtered by type
quipu_unravelTime-travel query (by transaction or valid time)
quipu_episodeIngest a structured episode
quipu_searchVector similarity search
quipu_hybrid_searchCombined SPARQL filter + vector search
quipu_search_nodesNatural language entity search
quipu_search_factsNatural language relationship search
quipu_validateDry-run SHACL validation
quipu_shapesLoad, list, or remove SHACL shapes
quipu_retractRetract 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 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"}
    ]
  }'

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

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

  1. Parse violations
  2. Attempt automated fixes (e.g., add missing labels from entity names)
  3. File issues for violations it can’t fix
  4. 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

GoalQuery Pattern
Orphan nodesEntities with no relationships beyond type/label
Missing typesFILTER NOT EXISTS { ?x a ?type }
Duplicate labelsGROUP BY ?label HAVING(COUNT(?x) > 1)
Stale referencesObject IRI with no type assertion
Type distributionSELECT ?type (COUNT(?x) AS ?n) GROUP BY ?type
Most connectedSELECT ?x (COUNT(?rel) AS ?n) GROUP BY ?x ORDER BY DESC(?n)
Recent additionsTime-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:

  1. Agent writes a new service with runsOn pointing to a container
  2. Reactive reasoner fires, tries to derive the transitive runsOn to a host
  3. If the container doesn’t have its own runsOn edge, no transitive fact is derived
  4. 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

“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:RuleSet with rule:defaultPrefix tells the parser that bare names like runsOn expand to http://example.org/homelab/runsOn.
  • rule:id is the provenance tag — every derived fact will carry source = "reasoner:runs_on_transitive" so you know where it came from.
  • rule:head is what gets derived: runsOn(?svc, ?host).
  • rule:body is the condition: if ?svc runs on ?mid, and ?mid runs on ?host, then ?svc runs 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:

  1. depends_on_transitive — reads and writes dependsOn
  2. runs_on_transitive — reads and writes runsOn
  3. affected_by_package — reads usesPackage and installedIn, writes affectedByPackage

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:

  1. Sees that dependsOn changed
  2. Finds depends_on_transitive uses dependsOn in its body
  3. Re-evaluates that rule
  4. 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

CLI Commands

The quipu binary provides a command-line interface for all operations.

Global Flags

FlagDescription
--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
FlagDescription
--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"
FlagDescription
--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
FlagDescription
--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
FlagDescription
--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
FlagDescription
--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
FlagDescription
--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
FlagDescription
--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-shapesExplicitly create a shapes-free share
--parent-share <id>Record the prior share_id in this lineage
--turtleAdd 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
FlagDefaultDescription
--rules <file>shapes/aegis-rules.ttlTurtle file containing rules
--reactiveoffRegister 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
FlagDescription
--removeCounterfactual: 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
FlagDescription
--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 / --tolerancePageRank parameters
--limit <N>Max results (default: 20)
--from / --toEndpoints 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
FlagDescription
--hubs / --surprises / --questionsHow 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
FlagDefaultDescription
--jsonoffEmit 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 deny that only warned under enforce, 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:importsUntrustedState brings 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: governedAt says 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 no aegis:untrustedOrigin is 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:

gatewhat it askswhy it blocks promotion
livenessdid it ever fire?a rule promoted without firing has been tested by nothing
both outcomesdid it record satisfied and unsatisfied?a one-sided check is vacuous or universal, and neither is distinguishable from broken
in specis it in Σ at all?a rule enforcing outside the specification has nothing to be promoted to
recoverabilityafter a refusal, did work on that target ever succeed?a rule nobody has got past is an outage with a reason attached
new blockshow 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
SubcommandDescription
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
SubcommandDescription
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:

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

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

SubcommandDescription
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
FlagSubcommandsDescription
--via <predicate-IRI>cone, draftDerivation predicate to walk, in addition to verifiedBy (always followed). Repeatable
--hops <N>coneDepth bound for the derivation walk (default: 8)
--omit <step-IRI>backtest, draftStep the candidate omits. Repeatable
--by <decision-IRI>draftThe human Decision authorising the paired --omit. Repeatable
--dead-end <step-IRI>draftMark a step a dead end in the drafted grammar. Repeatable
--name <local-name>draftLocal name for the drafted grammar (required)
--label <text>draftHuman label for the drafted grammar (required)
--jsoncone, backtestEmit 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
FlagDescription
--out <file>Output pack path (required for pack)
--name / --versionManifest 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-vectorsInclude embeddings (refused unless the SQLite vector backend is active)
--format turtleAlso embed a Turtle serialization
--fullA 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 textThe 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-versionAll-or-none provenance for a repository pack. The manifest also carries the Quipu version, build SHA, and pack schema version.
--expect-repo / --head-shaVerify 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
SubcommandDescription
<tx> [--name <n>]Create: materialize ROOT-as-of-<tx> into a new fork (default name fork-<tx>)
listName, 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.

FlagEffect
--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-shapesomit 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
--turtleadditionally write a Turtle view for humans
--destination internalskip 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:

  1. Skips the outward scrub entirely. Not a per-pattern exception, not an allowlist — the check does not run.
  2. Stamps destination: "internal" into manifest.json, and quipu:destination "internal" into manifest.ttl. The exemption travels with the bytes instead of living in the shell history of whoever produced them.
  3. Binds that stamp into share_id. Unlike attestation, 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=…
FlagEffect
--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.

ExitMeaning
0merged
2conflicts — nothing was guessed; the decision records name what needs a human
1error

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>]
FlagEffect
--out <file>destination
--verify <file>check an existing pack instead of writing one
--name / --versionidentify 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-vectorsinclude embeddings
--format turtlecarry 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>]
FlagEffect
--fullpack the whole store losslessly, rather than one graph
--format textrender that whole-store pack as a git-friendly DIRECTORY of text instead of a SQLite file
--forceallow 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.

tierwhat it means
transportNo envelope. The payload hashes verify, so the bytes are intact, but nothing says who produced them.
claimedA 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.
attestedThe 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
FlagDescription
--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 concurrentN=16
read_pool_size = 01.09s, 1.00 cores2.18s, 0.99 cores
read_pool_size = 80.43s, 6.40 cores0.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:

EndpointWhy it is a WRITE
/projectLooks 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.
/shapesGated 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:

HeaderMeaningMissing/invalid value
X-Quipu-ClientStable caller kind, such as query-first or graph-extractFalls back to User-Agent, then unattributed
X-Quipu-TaskWork-item join key, normally a bead id such as aegis-3aybcunattributed; 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:

outcomeMeaningcounttx_id
createdThe episode did not exist; its facts were written.> 0> 0
updatedIt existed with DIFFERENT content; stale activity facts were retracted and the new content written.> 0> 0
unchangedIt already existed with identical content. Nothing was written and nothing needed to be. This is success.00

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:

  • outcome describes THIS episode name. Re-posting the same knowledge under a different name is a new episode and will be created — idempotency is keyed on the name plus content hash, not on meaning.
  • A 200 still is not proof of retrievability. A node filed under a type nobody queries is created and 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.

relationEmitted
runs_onaegis: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:exact and 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. /search or /resolve first, and copy the name out.

  • count > 0 proves 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 0 here on a 200 write 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 /episode names 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 — /set on a typo’d IRI must not mint an unlabelled orphan node. The predicate may be new.
  • The value shape 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.
refuseIf 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.
allowLegacy 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: Store writes through &self methods via interior mutability, so a &Store handler can commit. Several routes registered the same way do write, which is why /overlay/create sits in WRITE_ENDPOINTS despite its signature. What actually holds this route read-only is the explicit assertion in tool_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 a read_only = true server — where POST /episode returns 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 /spotlight and /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, B in a result’s text is NOT valid as /episode input. A multi-typed entity renders as ... type: Feature, Tool, Concept, and that string looks exactly like a type value you could paste back. It is not one — /episode takes type as 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 text is 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.

ParamEffect
(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"}'
FieldRequiredDescription
graphNoNamed-graph IRI (omit for ROOT; unknown IRI → 400)
group_idNoROOT entities attributed through prov:wasGeneratedBy to episodes in this group, plus those episode resources
constructNoSPARQL CONSTRUCT or DESCRIBE query to export
formatNoturtle (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"}'
FieldRequiredDescription
policyOne of policy/claimPolicy IRI whose aegis:claim to evaluate
claimOne of policy/claimInline SPARQL ASK
targetYesTarget IRI bound to $target
predicate_idNoRecorded predicate id for inline claims
evidence_probeNoASK for “does the evidence exist?” → unknown
valid_atNoISO-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.

ParamEffect
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

MethodEndpointReturns
health()GET /healthdict
stats()GET /statsdict
version()GET /versiondict
query(sparql, graph=, valid_at=, tx=, fork=, include_kinds=, federated=)POST /querydict
validate(shapes, data)POST /validatedict
search(embedding=, query=, limit=, valid_at=, group_ids=, entity_type=)POST /searchdict
hybrid_search(sparql, embedding, limit=)POST /hybrid_searchdict
context(query, max_entities=)POST /contextdict
ask(name=, params=)POST /askAskResult (dict when listing the catalog)
export(graph=, group_id=, construct=, format=)POST /exportstr — 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

MethodEndpointReturns
knot(turtle, shapes=, timestamp=, valid_from=, actor=, source=, graph=, ...)POST /knotKnotResult(tx_id, count, conforms, valid_from)
episode(name, nodes=, edges=, replace_snapshot=, source=, timestamp=)POST /episodeEpisodeResult(outcome, count, tx_id)
set(entity, predicate, value, timestamp=, actor=)POST /setSetResult(tx_id, retracted, asserted, ...)
retract(entity, predicate=, value=, timestamp=, actor=)POST /retractRetractResult(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 on count. unchanged is a successful idempotent retry — re-posting under a new name because count == 0 looked like failure is how duplicate entities get minted.
  • An edge value is {"iri": ...}, a bare string is a literal. set and retract pass value through untranslated, so the server’s loud 400 for a bare IRI-shaped string reaches you as a QuipuError with 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.

ParameterRequiredDescription
queryYesSPARQL query string
valid_atNoISO-8601 timestamp for time-travel
txNoTransaction ID for time-travel
graphNoNamed-graph IRI or dataset name scoping the default graph (unknown IRI → empty default graph, never ROOT)
forkNoFork name to read (see quipu fork); unknown/dropped forks are refused; mutually exclusive with graph
include_kindsNodataKind 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
verboseNoReturn 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.

ParameterRequiredDescription
graphNoNamed-graph IRI to export (omit for ROOT; unknown IRI is an error)
group_idNoExport ROOT entities attributed to this episode group
constructNoSPARQL CONSTRUCT or DESCRIBE query whose graph is exported
formatNoturtle (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.

ParameterRequiredDescription
turtleYesRDF Turtle data
timestampNoTransaction-time: when this store came to believe the facts (defaults to now)
valid_fromNoValid-time: when the facts became true of the world. RFC 3339, normalised to UTC Z. Omit to reuse timestamp
actorNoWho is asserting
sourceNoWhere the facts came from
shapesNoSHACL Turtle for validation gate
graphNoRegistered committed-graph IRI to write into; unknown IRIs error, overlays refused; omit for ROOT
replace_snapshotNoReplace this producer’s prior facts (diffed), scoped to the target graph
snapshotNoStable 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.

ParameterRequiredDescription
typeNoFilter by rdf:type IRI
predicateNoFilter by relationship
limitNoMax results (default: 100)

quipu_unravel

Time-travel query: view facts at a past state.

ParameterRequiredDescription
txNoTransaction ID
valid_atNoISO-8601 timestamp

At least one of tx or valid_at must be provided.

quipu_validate

Dry-run SHACL validation without writing.

ParameterRequiredDescription
shapesYesSHACL shapes as Turtle
dataYesData 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.

ParameterRequiredDescription
actionYesload, list, or remove
nameFor load/removeShape set identifier
turtleFor loadSHACL Turtle content
timestampNoTimestamp for load

quipu_retract

Retract facts for an entity.

ParameterRequiredDescription
entityYesEntity IRI to retract
predicateNoOnly retract this predicate
timestampNoRetraction timestamp
actorNoWho 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.

ParameterRequiredDescription
entityYesIRI of the entity (must exist)
predicateYesPredicate IRI to set (may be new)
valueYesBare string = literal; {"iri": …} for an edge; typed forms for int/float/bool/lang/datatype
timestampNoISO-8601 valid-time for the supersede
actorNoWho 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.

ParameterRequiredDescription
episodeYesEpisode name to retract (aliases: episode_id, name)
timestampNoRetraction timestamp
actorNoWho is retracting
on_orphanNopreserve (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.

ParameterRequiredDescription
sourceYesEXACT transaction source string to retract — matched literally, never by prefix or pattern
repairYesTicket or reason; stamped on the retraction transaction as repair:<ticket>, never the source being cleared
applyNoDefault false = plan only, nothing is written
expectWith applyThe planned count you are confirming; a mismatch is refused
graphNoRegistered committed-graph IRI; absent targets ROOT (same rules as quipu_knot)
timestampNoRetraction timestamp
actorNoWho 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: 0 is a real answer. It means the named source owns no live facts. quipu_knot reports replaced: true, count: 0 both 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.

ParameterRequiredDescription
nameYesEpisode identifier
episode_bodyNoNatural language description
sourceNoSource agent/system
group_idNoProvenance label for the episode (not an isolation boundary — see Episodes)
nodesNoArray of {name, type, description, properties}
edgesNoArray of {source, target, relation}

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.

ParameterRequiredDescription
queryNoNatural-language query (auto-embedded; alternative to embedding)
embeddingNoFloat array (query vector); takes precedence over query
limitNoMax results (default: 10)
valid_atNoTemporal filter
verboseNoReturn 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 |

Combined SPARQL filtering + vector ranking. Supply either a natural-language query (auto-embedded) or a pre-computed embedding; the sparql pre-filter is optional.

ParameterRequiredDescription
queryNoNatural-language query (auto-embedded; alternative to embedding)
embeddingNoFloat array (query vector); takes precedence over query
sparqlNoSPARQL pre-filter query (enables predicate pushdown)
limitNoMax results (default: 10)
valid_atNoTemporal 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.

ParameterRequiredDescription
limitNoMax nodes, ranked by degree (default 250, hard max 2000)
typeNoRestrict to nodes of this rdf:type IRI
include_episodesNoInclude prov:Activity episode nodes (default false)

quipu_project

Graph projection and algorithms.

ParameterRequiredDescription
algorithmNostats, in_degree, pagerank/ppr, components, louvain, or shortest_path (default: stats)
typeNoRestrict projection to this rdf:type IRI
predicateNoRestrict projection to edges with this predicate IRI
graphNoProject one named graph’s own facts instead of ROOT — cheap against a small derived layer even when the episode log is large
limitNoMax results for in_degree/pagerank (default: 20)
seedsNoSeed entity IRIs for personalized PageRank (non-empty switches pagerank to PPR)
dampingNoPageRank damping factor (default: 0.85)
max_itersNoPageRank max iterations (default: 100)
toleranceNoPageRank convergence tolerance (default: 1e-6)
from / toNoSource/target entity IRIs for shortest_path
persistNolouvain: 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.

ParameterRequiredDescription
queryYesSearch query string
max_entitiesNoMax entities (default from pipeline config)
expand_linksNoFollow relationships to linked entities
ppr_rerankNoRe-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.

ParameterRequiredDescription
typeNoRestrict the projection to this rdf:type IRI
predicateNoRestrict the projection to edges with this predicate IRI
hubsNoNumber of top hubs to return (default: 10)
surprisesNoNumber of surprising connections to return (default: 10)
questionsNoNumber of suggested questions to return (default: 8)

Returns three sections:

  • hubs — “god-nodes”: the most central entities by PageRank, each with its in_degree as 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.

ParameterRequiredDescription
policyOne of policy/claimPolicy IRI whose aegis:claim to evaluate
claimOne of policy/claimInline SPARQL ASK claim
targetYesTarget IRI bound to the $target placeholder
predicate_idNoPredicate identifier recorded in the verdict (inline claims; default inline)
evidence_probeNoInline ASK for “does the evidence exist?” — false yields unknown
valid_atNoISO-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.

ParameterRequiredDescription
predicate_idYesPredicate the verdict attests
target_refYesTarget the verdict is about
outcomeYesVerdict outcome
evidence_hashYesEvidence hash the signature seals
tierNoEvidence tier (default: committed)
verifierYesVerifier IRI whose registered key verifies the signature
signatureYesHex 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.

ParameterRequiredDescription
verifierYesVerifier IRI
predicateYesPredicate 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.

ParameterRequiredDescription
work_itemYesWork-item (Bead) IRI
valid_atNoISO-8601 point-in-time for valid-time filtering
txNoMaximum 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.

ParameterRequiredDescription
overlayYesOverlay graph IRI to register
parent_branchNoCommitted 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.

ParameterRequiredDescription
overlayYesOverlay graph IRI
opYesassert, retract, or tombstone
subjectYesSubject IRI
predicateYesPredicate IRI
objectYesObject value (IRI string, literal, or typed JSON value)
timestampNoISO-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).

ParameterRequiredDescription
overlayYesOverlay graph IRI
precedenceNonearest (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.

ParameterRequiredDescription
queryYesNatural-language search query
group_idsNoBest-effort filter to entities from these provenance groups (episode-scoped label; /knot facts are ungrouped)
max_resultsNoMax results (default: 10)
entity_type_filterNoFilter by rdf:type IRI
verboseNoReturn 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.

ParameterRequiredDescription
queryYesNatural-language search query
group_idsNoBest-effort filter to facts from these provenance groups (episode-scoped label; /knot facts are ungrouped)
max_resultsNoMax results (default: 10)
verboseNoReturn 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.

ParameterRequiredDescription
nameYesEpisode name/identifier
episode_bodyNoNatural-language body of the episode
group_idNoProvenance label for the episode (not an isolation boundary — see Episodes)
source_descriptionNoWho/what produced this episode
timestampNoISO-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.

ParameterRequiredDescription
entityYesEntity IRI to analyse
removeNoSpeculatively retract before walking (default: false)
hopsNoMax edge hops to follow (default: 5)
predicatesNoRestrict walk to these predicate IRIs (empty = all)
rank_by_pprNoOrder the reached set by Personalized PageRank seeded at the root — each entry gains a ppr score (default: false)
timestampNoTimestamp 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.

ParameterRequiredDescription
trajectoryYesIRI of the Trajectory to analyse
viaNoDerivation predicate IRIs to walk, in addition to verifiedBy (always followed)
hopsNoDepth bound for the derivation walk (default: 8)
base_nsNoVocabulary 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.

ParameterRequiredDescription
exemplarYesIRI of the exemplar Trajectory
omitNoStep IRIs the candidate omits
base_nsNoVocabulary namespace override (default: the store’s base_ns)

Unified knowledge search for Bobbin integration: combines text and optional vector search, returning results tagged source="knowledge" with normalized 0–1 scores.

ParameterRequiredDescription
queryYesNatural-language search query
embeddingNoPre-computed query embedding (else auto-embedded when provider attached)
limitNoMax results (default: 10)
expand_linksNoExpand results via graph links (default: true)
max_facts_per_entityNoMax 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.

ParameterRequiredDescription
nameNoNamed query to run; omit (or "list") to list the catalog
paramsNoParameter map for the named query (names/types from the catalog)

Catalog:

QueryParametersReturns
entity_factsentity (iri), limit (int, 100)All facts asserted about an entity
service_depsentity (iri), limit (int, 50)Outgoing entity references (dependencies / links)
references_toentity (iri), limit (int, 50)Entities that reference the given entity (incoming)
entities_of_typetype (iri), limit (int, 100)All entities of a given rdf:type
labeled_liketext (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).

ParameterRequiredDescription
actionNoload, list (default), get, or remove
nameFor load/get/removeQuery name
descriptionFor loadWhat the query answers
templateFor loadSPARQL template with {param} placeholders
datasetNoDataset IRI this query is scoped to
paramsNoOrdered param specs {name, type, required, default, description}
timestampNoISO-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”.

ParameterRequiredDescription
kindNoOnly graphs declaring this dataKind token (e.g. operational, archive)
lifecycleNoOnly 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.

ParameterRequiredDescription
graphYesIRI of the committed graph to freeze
out_dirNoDirectory for the archive pack (default: beside the store file)
timestampYesISO-8601 timestamp
actorNoWho 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.

ParameterRequiredDescription
graphYesIRI of the frozen graph
timestampYesISO-8601 timestamp
actorNoWho 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.

ParameterRequiredDescription
actionNocreate, list (default), show, or remove
nameFor create/show/removeDataset IRI
membersFor createGraph IRIs, or {"graph": …, "ord": N} for a declared ordering
timestampNoISO-8601 timestamp
actorNoWho 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.

ParameterRequiredDescription
kindYesshape, ontology, class, or property
targetYesShape name, class IRI, or property IRI being changed
diffYesTurtle fragment or JSON patch describing the change
proposerYesIdentity of the proposing agent
rationaleNoWhy this change is needed
trigger_refNoValidation-failure ref or bead id that triggered this
timestampNoISO-8601 timestamp

quipu_list_proposals

List schema-evolution proposals, optionally filtered by status.

ParameterRequiredDescription
statusNopending, accepted, or rejected (default: all)

quipu_accept_proposal

Accept a pending schema proposal. Shape proposals are validated before writing.

ParameterRequiredDescription
idYesProposal ID to accept
decided_byNoIdentity of the approver
noteNoOptional acceptance note
timestampNoISO-8601 timestamp

quipu_reject_proposal

Reject a pending schema proposal with a reason.

ParameterRequiredDescription
idYesProposal ID to reject
noteYesReason for rejection
decided_byNoIdentity of the rejector
timestampNoISO-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.

ParameterRequiredDescription
nameYesCanonical name of the proposed entity
propertiesNoKey-value properties (used for embedding context)
top_kNoMax candidates to return (default: 3)
thresholdNoSimilarity 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.

ParameterRequiredDescription
actionNoload, list, or remove (default: list)
nameFor load/removeOntology name
turtleFor loadOWL ontology in Turtle format
timestampNoISO-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 is urn:quipu:graph:root#inferred), never beside their premises. Premises are read from the graph plus its companion, so closure feeds further derivation. Compose reads with FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>; migrate a pre-regime store with quipu 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#> .
PropertyTypeRequiredDescription
a rule:RuletypeyesMarks this resource as a rule
rule:idstringyesStable identifier used in provenance (source = "reasoner:<id>")
rule:headstringyesHead atom: predicate(?var1, ?var2)
rule:bodystringyesBody atoms: p(?x, ?y), q(?y, ?z)
rule:prefixstringnoPer-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/" .
PropertyTypeDescription
a rule:RuleSettypeMarks this resource as a ruleset
rule:defaultPrefixstringDefault 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:

  1. Per-rule rule:prefix property (highest priority)
  2. Ruleset rule:defaultPrefix property
  3. Fallback http://quipu.local/default/ (lowest priority)

Atoms and Terms

An atom is predicate(arg1, arg2). Arguments can be:

TermSyntaxExampleNotes
Variable?name?svcBound by body atoms, projected into head
Bare namenamedependsOnExpanded 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

FlagDefaultDescription
--rules <file>shapes/aegis-rules.ttlPath to Turtle file containing rules
--reactiveoffRegister a ReactiveReasoner observer after evaluation (requires reactive-reasoner feature)
--db <path>config defaultStore 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 order
  • default_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:

FieldTypeDescription
assertedusizeTotal new derived facts
retractedusizeTotal retracted derived facts
strata_runusizeNumber of non-empty strata executed
per_ruleVec<(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:

FieldTypeDescription
triggersusizeNumber of times the observer fired
total_assertedusizeCumulative assertions across all triggers
total_retractedusizeCumulative retractions across all triggers

The reactive reasoner:

  • Skips transactions with source starting 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):

FeatureMessage
Non-binary atomsnon-binary head/body atom
Purely negative bodybody needs at least one positive atom
Unsafe negationunsafe negation: variable not bound by a positive atom
Unbound head variablehead variable not bound by a positive body atom
Unknown head IRIhead references an IRI that has never been interned
String literalsstring 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:

FieldValue
sourcereasoner:<rule-id> (e.g., reasoner:depends_on_transitive)
actorreasoner

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 entry
  • Op – Assert (1) or Retract (0)
  • Term – dictionary entry (id + IRI)
  • Transaction – recorded transaction metadata
  • VectorMatch – 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 &lt;id&gt;<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):

FileWhat it carries
export.ntthe facts, as N-Triples
shapes.ttlthe SHACL shapes those facts were validated against
manifest.jsonhashes, 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 — _provider always, plus _trust and _freshness where 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:

LayerWhat speaks the format
CLIquipu share / import / import promote / status / merge
MCPquipu_export, quipu_import, quipu_import_promote
Bobbinbobbin: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.

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

tierwhat it meanswhat it does NOT mean
transportNo attestation supplied. The payload hashes verify, so the bytes are intact.Anything about authorship.
claimedA 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.
attestedThe 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:

CounterLabelsMeaning
quipu_share_import_totaloutcome, tierOne completed or failed library import attempt.
quipu_attestation_verify_totalbinding, resultOne 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:

causewhat you seewhat to do
the graph IRI is not in this storean error naming the IRIfix the IRI — a namespace prefix is the usual culprit
entities exist but carry no rdfs:label0 concepts, non-zero unlabelledalignment matches on labels; ask the publisher to share labels
both graphs enumerate fine, nothing matches0 candidates, unlabelled 0a 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/transactions with the idx_eavt/idx_aevt/idx_vaet/idx_tx indexes, bitemporal valid_from/valid_to, current-state op=1 AND valid_to IS NULL, and the term dictionary (src/schema.rs, src/store/mod.rs). Drift, three items: the real facts table also has a g (graph) column + idx_geav (named-graph support), absent from the doc’s CREATE TABLE; the op discriminant 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:

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

TagTypeEncoding
0Refi64 term ID (little-endian)
1StrUTF-8 bytes
2Inti64 (little-endian)
3Floatf64 (little-endian)
4Boolsingle byte (0/1)
5Bytesraw bytes
6Langlanguage tag + lexical form (Value::Lang { lexical, lang })
7Typeddatatype 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_after with the three capture modes — served by quipu changes and GET /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. op is assert, retract, or tombstone.
  • 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_tx and watermark_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

ModeA record carries
new_values (default)Asserts carry value; a retract identifies (entity, attribute) but withholds the ended value.
old_and_new_valuesA retract also carries the value it ended, as old_value.
new_rowold_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_rdf for all 6 formats (Turtle, N-Triples, N-Quads, RDF/XML, JSON-LD, TriG) via oxrdfio, blank-node round-trip, and XSD→Value mapping (src/rdf.rs, src/types.rs). Drift (corrected in this commit): the “Language Tags” section (and its type-mapping row) is OBSOLETE — it claims rdf:langString is stored as Value::Str("text@lang") and re-split on @, but the code uses a dedicated Value::Lang { lexical, lang } variant (src/types.rs, tag 6); reconstructing a lang tag by splitting a Str on @ 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 TypeXSD DatatypeQuipu Value
Named node–Value::Ref(term_id)
Blank node–Value::Ref(term_id) (stored as _:name)
xsd:integer, xsd:long, xsd:intInteger typesValue::Int(i64)
xsd:double, xsd:float, xsd:decimalFloat typesValue::Float(f64)
xsd:booleanBooleanValue::Bool
xsd:stringStringValue::Str
rdf:langStringLanguage-taggedValue::Lang { lexical, lang }
other ^^<datatype> literalsTyped literalValue::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 in src/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

  1. Parse: SPARQL string -> AST via spargebra
  2. Evaluate: Walk the AST, executing each graph pattern against SQLite
  3. 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

FormDescriptionExample
SELECTReturn variable bindingsSELECT ?s ?p ?o WHERE { ... }
ASKBoolean existence checkASK { ?s a ex:Person }
CONSTRUCTBuild new triplesCONSTRUCT { ?s a ex:Result } WHERE { ... }
DESCRIBEReturn all facts about an entityDESCRIBE <http://example.org/alice>

Supported Features

Graph Patterns

PatternStatusExample
Basic Graph Pattern (BGP)Supported?s ?p ?o
JOINSupportedMultiple BGP patterns
UNIONSupported{ ... } UNION { ... }
FILTERSupportedFILTER(?age > 30)
OPTIONAL (LeftJoin)SupportedOPTIONAL { ?s ex:email ?e }
PROJECTSupportedSELECT ?name
DISTINCT / REDUCEDSupportedSELECT DISTINCT ?type
LIMIT / OFFSETSupportedLIMIT 10 OFFSET 5
ORDER BYSupportedORDER BY DESC(?age)
GROUP BYSupportedGROUP BY ?type
HAVINGSupportedHAVING(COUNT(?s) > 2)
EXTEND (BIND)SupportedComputed variables
VALUESSupportedVALUES ?x { "a" "b" }, multi-column, UNDEF (src/sparql/values.rs)
Property pathsSupportedReverse ^, Sequence /, Alternative |, *, + (src/sparql/property_path.rs)

Aggregates

FunctionExample
COUNTSELECT (COUNT(?s) AS ?n) WHERE { ... }
SUMSELECT (SUM(?age) AS ?total) ...
AVGSELECT (AVG(?age) AS ?mean) ...
MIN / MAXSELECT (MIN(?age) AS ?youngest) ...

FILTER Expressions

ExpressionExample
Equality?name = "Alice"
Comparison?age > 30, ?age <= 50
AND / OR / NOT?age > 20 && ?age < 40
BOUNDBOUND(?name)
Regexregex(?name, "Ali")
CONTAINSCONTAINS(STR(?s), "traefik")
LCASE / STRLCASE(STR(?name))
isIRIFILTER(isIRI(?o))
IN / NOT INFILTER(?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:

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

QueryServed from
Current facts in the ROOT graphthe model
valid_at / as_of_tx time travelSQL
GRAPH <iri>, GRAPH ?g, FROMSQL
Overlays and tombstonesSQL
A store with attached databasesSQL
Anything, while a write holds an open transactionSQL
A graph past the size budgetSQL

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:

EpisodesPoint lookupType scan2-hop join
1,0000.11 → 0.14 ms4.6 → 5.4 ms1,016 → 38 ms
4,0000.10 → 0.13 ms18.8 → 18.5 ms26,233 → 225 ms
10,0000.16 → 0.12 ms56.2 → 46.8 ms173,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 COUNT so 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 by rudof_lib, a default feature) provides Validator::from_turtle/ validate/validate_or_reject and ValidationFeedback/ValidationIssue exactly as documented; write-time enforcement is wired via [quipu.shacl] validate_on_write (src/config.rs → src/server.rs), with /shapes and /validate REST routes. No gaps found.

Quipu enforces strict schema at write time via SHACL (Shapes Constraint Language), powered by rudof.

How It Works

  1. Define SHACL shapes in Turtle format
  2. Create a Validator from those shapes
  3. Validate proposed data before writing to the fact log
  4. 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:

ShapeTarget ClassRequired Properties
CodeModuleShapeCodeModulefilePath, repo, language
CodeSymbolShapeCodeSymbolname, definedIn (class CodeModule)
DocumentShapeDocumentfilePath
SectionShapeSectionheading, headingDepth (integer)
BundleShapeBundlerdfs: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, the Episode struct with group_id/shapes, prov:Activity/prov:wasGeneratedBy provenance, and edge-confidence reification (bare triple when absent; rdf:Statement + quipu:confidence for enum/numeric grades — tested in src/episode/tests.rs). SHACL episode + persistent shape gates and the quipu episode CLI 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

FieldRequiredDescription
nameYesEpisode identifier (becomes IRI)
episode_bodyNoNatural language description
sourceNoAgent or system that produced this
group_idNoProvenance label for the episode (see note below)
nodesNoEntities to create
edgesNoRelationships between entities
shapesNoSHACL Turtle for validation gate
replace_snapshotNoReplace 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_id is a label, not a partition. It is recorded as a single aegis:groupId literal on the episode’s prov:Activity; nodes inherit it transitively via prov:wasGeneratedBy. All groups share one graph — there is no isolation boundary, and the group_ids filter on search is best-effort. Facts asserted directly via /knot (raw Turtle, not through an episode) carry no group at all and are invisible to group_ids filtering. Use group_id for provenance/organization, not as a multi-tenant security boundary.

Node Fields

FieldRequiredDescription
nameYesEntity identifier
typeNordf:type (e.g., “ProxmoxNode”)
descriptionNordfs:comment
propertiesNoKey-value pairs as typed literals

Edge Fields

FieldRequiredDescription
sourceYesSource entity name
targetYesTarget entity name
relationYesRelationship name (becomes predicate)
confidenceNoConfidence qualifier for the generated triple (see below)

Edge confidence. Supply confidence to grade how trustworthy an AUTO-extracted edge is — either an enum ("EXTRACTED", "INFERRED", "AMBIGUOUS") or a 0–1 number. The bare triple is always asserted; when a confidence is present the statement is additionally reified (rdf:Statement with rdf:subject/rdf:predicate/rdf:object) and qualified with quipu: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

  1. Episode JSON is converted to RDF Turtle
  2. If shapes is provided, SHACL validation runs first – rejects on failure
  3. Turtle is ingested via the standard RDF pipeline in a single transaction
  4. Episode node gets prov:Activity type with provenance links
  5. Each entity gets prov:wasGeneratedBy linking 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:wasGeneratedBy attribution, 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, &timestamps)?;
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 — vectors table, 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) in src/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.rs unwired_warnings() warns so), and the only activation path, Store::set_local_vector_backend, has zero callers in-repo — it is embedder-only. See lancedb.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:

FieldDescription
entity_idThe matched entity’s term ID
textThe text that was embedded
scoreCosine similarity (0.0 to 1.0)
valid_fromWhen this embedding became active
valid_toWhen it expired (None = current)

The quipu_hybrid_search tool combines SPARQL filtering with vector ranking:

  1. Extract pushdown filter – simple type patterns (?s a <Type>) are converted to a filter string for the vector backend
  2. Vector search with filter – LanceDB applies the filter during ANN search; SQLite oversamples 5x and post-filters
  3. 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.

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 — LanceVectorStore behind #[cfg(feature="lancedb")] (src/vector_lance.rs), all KnowledgeVectorStore methods including only_if() predicate pushdown, the VectorSearchDelegate wrapper, and quipu migrate-vectors (src/migration.rs) — and since quipu-lv7 the binaries read vector.backend and install it at open (src/config/vector_backend.rs; src/cli_open.rs for every quipu subcommand, src/server/base.rs for quipu-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. lancedb is deliberately outside the full bundle — protoc plus the whole datafusion tree is a real cost for a backend most deployments do not use. A binary built without it refuses backend = "lancedb" at startup, naming the rebuild, rather than falling back to the SQLite table: a deployment that has run quipu migrate-vectors would otherwise have every search answered out of the store it migrated away from. Build with cargo build --features full,lancedb to ship it.
  • It needs a Tokio runtime. quipu-server is #[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_backend had zero non-test callers and vector.backend was set-but-not-read, so migrate-vectors moved 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     │
     └─────────────────┘         └─────────────────────┘
AspectSQLiteLanceDB
Storage formatf32 BLOB in vectors tableArrow RecordBatch columns
Search algorithmBrute-force cosine similarityApproximate nearest neighbor
Predicate pushdownNo (5x oversampling fallback)Yes (only_if() clause)
ComplexityO(n) scanO(log n) with filter
Metadata columnsentity_id, text, valid_from, valid_to+ entity_type, source_episode
Async requirementNoneTokio runtime required
Feature flagAlways availablelancedb 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.

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() and close_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_at is 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 (defaults max_entities:20, expand_links:true, link_depth:1), KnowledgeContext/KnowledgeEntity, the Direct/Linked/Semantic relevance enum (Semantic from the hybrid vector path), and query(). Exposed as the quipu_context MCP tool and the /context REST 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:

  1. Text search – SPARQL FILTER(CONTAINS(...)) on entity IRIs and literal values to find direct hits
  2. Link expansion – follow outgoing and incoming relationships from direct hits to discover related entities
  3. 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

RelevanceScoreDescription
Direct1.0Found via text search match
Linked0.5Discovered by following relationships from direct hits
SemanticvariesFound via vector similarity search

Configuration

OptionDefaultDescription
max_entities20Maximum entities to return
max_facts_per_entity20Maximum facts per entity
expand_linkstrueFollow relationships from direct hits
link_depth1How 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), and shortest_path (A*), each matching the documented algorithm and JSON output, dispatched by the quipu_project MCP tool. (PageRank/PPR + Louvain also ride this Projection API — see docs/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:

FilterDescription
type_filterOnly include entities of a given rdf:type
predicate_filterOnly include edges with a given predicate
graphProject 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 returns resolution_hints (src/episode/mod.rs); the quipu_resolve_entity MCP tool (src/mcp/resolution.rs) and the POST /resolve probe are wired, with strict_mode enforced on ingest. See docs/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:

  1. 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 /search uses, no separate store). Only runs when an embedding provider is configured.
  2. Canonical name matching — the name is compared against every current rdfs:label value. A case-insensitive exact match scores 1.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

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 ?g ranges 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. See docs/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, and quipu 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): the GraphProvider trait, ProviderStatus, LocalProvider, FederatedProvider with outcome-reporting query_all, and — behind the remote feature — RemoteProvider plus federated_from_config() (re-exported from lib.rs). quipu-server health-checks every configured remote at startup, and POST /query with "federated": true fans 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. See docs/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-member providers entry) and _freshness. Rows from an undeclared member simply lack the binding — undeclared is absent, never fabricated.
  • ProviderStatus carries the label. Health reports (startup stderr, and the label field on each providers entry) show what each member’s rows are declared as; null/undeclared means exactly that.
  • The composed label folds remotes in as members. The federated response’s labels key 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 to partial.
  • 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
traefikruns on koror
piholeruns on koror
grafanaruns 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

ApproachBest for
Property paths (dependsOn+)Ad-hoc exploration, one-off queries, small graphs
Reasoner rulesRepeated 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

FieldRequiredDescription
nameYesEpisode identifier (becomes rdfs:label)
nodesYesArray of entities to create
edgesYesArray of relationships
sourceNoAgent/system that produced this
group_idNoLogical grouping (e.g., “monitoring”)
episode_bodyNoHuman-readable description
shapesNoInline SHACL shapes for validation
replace_snapshotNoAtomically replace prior facts from this episode name; use for complete inventories

Node Fields

FieldRequiredDescription
nameYesEntity name (used to generate IRI)
typeNoRDF type (e.g., “Service”, “Host”)
descriptionNoHuman-readable description (rdfs:comment)
propertiesNoKey-value map of additional properties

Edge Fields

FieldRequiredDescription
sourceYesSource entity name
targetYesTarget entity name
relationYesPredicate: 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:

  1. Load shapes first:

    quipu shapes load --name infra --file infra.shapes.ttl --db knowledge.db
    
  2. Dry-run validate:

    quipu validate --shapes infra.shapes.ttl --data new-data.ttl
    
  3. 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.

ClassWhat it measuresPublished byStatus
SPARQL 1.1 conformanceQuipu’s query engine against the W3C RDF Tests at a pinned revisionthis repositorypublished, re-derivable
Extraction → ingressa governed RML write of frozen upstream extractions into a disposable Quipucaboodle 0a1b169published, with the boundary below
Bulk ingestQuipu’s own load rate for a pinned WatDiv datasetthis repository (benchmark/public/watdiv_ingest.py)published, re-derivable
WatDiv 1M diagnosticone-off query latency and process memory on a pinned 1M datasetthis repository (docs/design/persistence-evidence/watdiv-1m-20260914/)MEASURED, CONTROL-INVALID — diagnostic only
PerformanceWatDiv / 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_replay of 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 of graph-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.

datasetWatDiv 10M archive, sha256 1d0a8a47…; extracted N-Triples sha256 7cfe0341…
triples10,916,457 declared, 10,916,460 live facts after load
wall time2,848.9 s
rate3,831.8 live facts/s
store3,227,811,840 B = 295.7 B/fact
buildrelease
storageext4 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 devicedevice busy (3h baseline, node_disk_io_time_seconds_total)ingest
dedicated measurement volume0.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 onfactswallrate
tmpfs (RAM)8,100,003187 s43,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 ingestedratemedian host load
800,0001,035/s—
2,300,0001,085/s—
4,400,0001,650/speak
5,300,0001,479/s—
6,800,000705/s—
8,100,000761/s3.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:

  1. 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.
  2. 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.
  3. 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: false with 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 .nt member 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

ConcurrencyRequestsHTTP 200HTTP 408Ready RSS / PSS (KiB)Sampled peak RSS / PSS (KiB)
14034615,808 / 11,303506,848 / 502,343
4100811915,992 / 11,4461,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.
  • 1M is a scale name, not a denominator. The pinned artifact is 152,195,750 bytes, SHA-256 c158998c66e11b33bc56cf7fa3cbc9e69c1c36bf9bdd1bab447d8a64e2d8da75, 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:

  1. Classes stay separately scored. Never blended into one compliance percentage.
  2. Every number comes from a version-pinned, checked-in runner that exits non-zero on regression. just conformance-check enforces this for the conformance page: the committed ledger and the rendered page must agree.
  3. Unsupported and unrun cases stay in the denominator, each with a named reason. NOT RUN is published as NOT RUN.
  4. 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.
  5. 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

FieldValue
W3C RDF Tests revision369a90d1a60c021b746df2e411da0ff36258a758
Quipu revision (evaluation)d6548f887ad808a9a718dd748abf5891899796c8
Quipu revision (syntax)d6548f887ad808a9a718dd748abf5891899796c8
Quipu versionquipu 0.8.1
Store isolationone temporary SQLite store per executable test
Test selectionWorking 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.

ClassPassedFailedErrorUnsupportedApproved cases
query syntax8600086
query evaluation168000168
federated query (SERVICE)60017
result format1000010
protocol3400034
update9300093
entailment35003570
all classes4320036468

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.

SystemVersionQuery evaluationOf those failures, same valueUpdate
quipuquipu 0.8.1168/168—93/93
RDF4J6.1.0162/168587/93
Oxigraph0.5.11159/168893/93
Jena Fuseki6.2.0155/1681293/93
rdflib7.6.0154/168969/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.

FormatRDF 1.1RDF 1.2
Turtle306/313not supported (0/106)
N-Triples70/70not supported (0/70)
N-Quads0/87 (87 unsupported)not supported (0/68)
TriG0/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.

FamilyPassedFailedErrorUnsupportedCases
functions5700057
property-path2400024
aggregates2200022
subquery1400014
negation1100011
bind1000010
bindings1000010
project-expression70007
exists50005
construct40004
grouping40004

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.

TestNameStatusReason
:service1SERVICE test 1passed
:service2SERVICE test 2passed
:service3SERVICE test 3passed
:service4aSERVICE test 4a with VALUES clausepassed
:service5SERVICE test 5unsupportedvariable SERVICE endpoints are deliberately refused; endpoints must be operator-configured
:service6SERVICE test 6passed
:service7SERVICE test 7passed

Why a class is unsupported

Grouped by the reason the runner recorded.

Why it is unsupportedCasesClasses
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 it18entailment
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 closure11entailment
RIF entailment is a deliberate non-goal: RIF is a rule-interchange format, not a semantics asked of this store4entailment
D entailment (datatype entailment) is a deliberate non-goal: no consumer asks for datatype entailment beyond simple and RDFS2entailment
variable SERVICE endpoints are deliberately refused; endpoints must be operator-configured1federated 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, clear and drop manifests declare their tests with Turtle’s a rather than rdf: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

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 classPassedFailedErrorUnsupportedCases
core-complex-misc70007
core-node3200032
core-property3800038
core-path1300013
core-targets70007
core-validation-reports10001
shacl-sparql0002222

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 like SELECT ?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.

RegimeCasesPassedCommitment
D20deliberate non-goal
OWL-Direct180deliberate non-goal
OWL-RDF-Based110deliberate non-goal
RDF1616goal
RDFS1919goal
RIF40deliberate 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.yml has correctness, invariant, and housekeeping jobs; release.yml uses release-plz-action@v0.5 behind the exact-SHA correctness aggregate; docs.yml builds the mdBook and deploys to Pages. release-plz.toml, cliff.toml, .pre-commit-config.yaml, and justfile all present. (A changelog-check.yml guard 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:

JobWhat it does
fmtcargo fmt --check
clippyLinting with multiple feature combinations (default, SHACL)
testTest suite across feature matrix
buildFull compilation check
checkPre-commit hooks on all files
source-sizeSource-size policy ratchet
shapesStatic ontology-shape invariants
release-correctnessAggregate release gate over tests, clippy, builds, pre-commit, source size, and shapes
lint-markdownmarkdownlint-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:

  1. Analyze conventional commits since the last release
  2. Bump the version in Cargo.toml
  3. Generate a changelog from commit messages
  4. Create a GitHub release with a git tag

Conventional Commits

Commit messages drive changelog categories:

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

  1. Build: mdbook build docs/book
  2. 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 log
  • rdf – 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

Papers

Release and operations