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

The Quipu stack is built around a governed, bitemporal knowledge store: shared facts, decisions and work history your agents can query. Quipu can check writes against your rules, records where each fact came from, and answers what was true when.

Quipu is that store. Bitemporal means keeping both when a fact was recorded and, when supplied, when it was true.

A quipu is the Andean knotted-cord record: cords are entities, knots are facts.

See it

Datalinks — a 3D explorer, live in your browser, over Alpha Centauri’s technology tree: 374 entities, 329 prerequisite edges, 17 ranks. Height is longest-path depth, so position is derived and stable rather than emergent. Select a node and the lattice re-lights by personalized PageRank seeded on it. No server — the graph is a baked Quipu export.

Explore this repository’s graph — Quipu’s own code and docs as a knowledge graph, 61k triples shipped with every release, imported and queried by Quipu itself compiled to WebAssembly. The pack is verified against its manifest, its shapes are adopted, and it is staged and promoted in your tab before you can query it: the receiving half of the sharing story below, running rather than described. Then edit it — add, change and retract facts through the real write path — and export the result as a pack that quipu import <archive> --db your.db stages against your local shapes. Still no server.

What is Quipu?

An embeddable Rust library and server for building knowledge graphs with:

  • Immutable bitemporal fact log — time-travel, contradiction detection, full audit trail
  • RDF data model — IRIs, blank nodes, typed literals via oxrdf
  • SPARQL 1.1 query engine — SELECT, CONSTRUCT, ASK, DESCRIBE with property paths, aggregates, RDFS inference
  • SHACL validation — configurable write checks with structured, agent-friendly feedback
  • Hybrid search — SPARQL + vector similarity in a single query
  • Episode ingestion — structured write path for agent-extracted knowledge
  • Bulk import + promote — load or migrate a whole dataset validated in a staging graph, then admit it in one transaction (guide)
  • Graph projection — materialize subgraphs into petgraph for centrality, components, shortest-path algorithms
  • “SQLite energy” — single process, no server required

Three Ways to Use It

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

Quipu stores structured knowledge, validates writes against your rules, and lets agents query and share that knowledge through its CLI, REST API and MCP server.

Sharing & Federation

A Quipu store can hand its knowledge to another store, and compose another store’s knowledge, without either one having to trust the other by default. Every step is explicit, hash-verified, and labelled with where it came from — so you never absorb someone else’s knowledge by accident.

Before exporting, load an identifier-policy catalogue and the shapes governing your data. Outward sharing is the default: a missing block-tier catalogue refuses with exit 2; a matched identifier refuses with exit 1.

quipu share --output ./share            # a git-native bundle: facts + shapes + lineage
quipu import ./their-share              # verifies hashes, lands in QUARANTINE, not ROOT
quipu import promote <share-id>         # a named operator's explicit act
quipu status ./share && quipu merge ./share   # diverged? three-way; conflict exits 2

Quipu does not merge on receipt, and it does not take a peer’s word for how trustworthy that peer is: trust is declared by the local operator, never read from the member itself. Federated rows carry _provider, _trust and _freshness, and a partial answer is reported as partial rather than arriving as a short one.

SPARQL SERVICE is supported, restricted to endpoints the operator has configured — variable endpoints are refused and unconfigured hosts are unreachable. That is narrower than SPARQL 1.1’s open federation by design, and Quipu makes no SERVICE conformance claim.

→ Sharing & Federation — the primitive in full, every claim citing the command or symbol that proves it.

Explore this repository’s graph, in your browser

Every release ships a knowledge pack of this repository — its modules, symbols, documents and sections as RDF — and the book has a page that opens it with Quipu itself compiled to WebAssembly. GitHub cannot run scripts in a README, so here is a picture; the page is one click away.

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 .pendant.tar.gz, built by the same share_payload the CLI uses and declaring the pack it came from as its parent. Import it directly with quipu import <archive> --db your.db to stage it against your database’s loaded shapes; promotion is a separate step. Without --db, archive verification stays in memory. Or download export.nt and diff it — it is line-oriented and canonically ordered, so a change is a reviewable diff.

The bundle is a release asset (quipu-<tag>-wasm.tar.gz), so nothing here is committed and the Pages build needs no Rust.

See It In Action

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❌✅ Graphiti✅
SPARQL 1.1✅❌✅
Datalog reasoner❌❌✅
Counterfactual queries❌❌✅
Vector similarity search✅ Stardog✅✅
LanceDB ANN + pushdown❌❌✅
Agent-friendly feedback❌❌✅
Episode provenance❌✅✅
Graph algorithms❌❌✅
Built-in web UI❌❌✅
Embeddable (no server)❌❌✅
SQLite-backed❌❌✅
Rust, no JVM or Python❌❌✅

Named products in a cell qualify the grouped column: the capability is not being attributed to every member. Graphiti documents explicit bi-temporal tracking, and Stardog’s semantic search compares vector embeddings.

Traditional RDF stores demand too much ceremony. AI-native stores have no structure. Quipu’s thesis: start strict, use agents to bear the cost of strictness.

Features

🏛️ Knowledge Graph Core

  • Immutable bitemporal fact log — every fact has transaction time and valid time. Time-travel to any point. Full audit trail. Contradiction detection.
  • RDF data model — IRIs, blank nodes, typed literals via oxrdf. Import/export Turtle, N-Triples, JSON-LD, RDF/XML. Exports are stably ordered and can be scoped to ROOT, one named graph, an episode provenance group, or a SPARQL CONSTRUCT result; server exports use the read pool rather than blocking writers.
  • Git-native knowledge shares — quipu 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 77 MCP tools for agent integration (79 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 = { package = "quipu-ai", git = "https://github.com/scbrown/quipu" }
#![allow(unused)]
fn main() {
use quipu::store::Store;
use quipu::rdf::ingest_rdf;
use quipu::sparql;
use oxrdfio::RdfFormat;

let mut store = Store::open_in_memory()?;

let turtle = r#"
@prefix ex: <http://example.org/> .
ex:alice a ex:Person ; ex:name "Alice" ; ex:knows ex:bob .
ex:bob a ex:Person ; ex:name "Bob" .
"#;
ingest_rdf(&mut store, turtle.as_bytes(), RdfFormat::Turtle,
           None, "2026-04-04", None, None)?;

let result = sparql::query(&store,
    "SELECT ?name WHERE { ?s a <http://example.org/Person> . ?s <http://example.org/name> ?name }")?;
}

From the Command Line

git clone https://github.com/scbrown/quipu && cd quipu
cargo build --release

# Load, query, explore
quipu knot data.ttl --db my.db
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10" --db my.db
quipu repl --db my.db

# Governance: check an agent's enforcement trace against the policy set
quipu audit trace.jsonl --db my.db          # T ⊨ Σ — exits 1 on a violation
quipu audit inventory --db my.db            # which tool classes are ungoverned
quipu audit namespace --db my.db            # which minted predicates no shape mentions
quipu audit replay trace.jsonl --db my.db   # advise → enforce readiness, per rule

REST API & Web UI

# quipu-server needs `onnx` (the embedding runtime) AND `server` (axum/tokio —
# the HTTP stack is feature-gated so the library does not carry a web server).
# Neither is on by default, and an unmet required-feature SKIPS the binary
# silently rather than erroring, so build the `full` bundle releases ship:
cargo build --release --features full

quipu-server --db my.db --bind 0.0.0.0:3030

# Open the interactive graph explorer in your browser
open http://localhost:3030

# Or use the REST API directly
curl localhost:3030/query -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'

The built-in web UI provides:

  • Graph Explorer — force-directed visualization with type-based coloring, entity search, and detail panel
  • SPARQL Workbench — syntax-highlighted editor with time-travel parameters and tabular/JSON results
  • Episode Timeline — chronological view of ingested episodes with extracted entities
  • Schema Inspector — type distribution, SHACL shape browser, and validation runner

Embeddable web components (<quipu-graph>, <quipu-sparql>, <quipu-entity>, <quipu-timeline>, <quipu-schema>) let you drop Quipu panels into any page:

<script src="http://localhost:3030/quipu-components.js"></script>
<quipu-graph endpoint="http://localhost:3030"></quipu-graph>

Semantic Web APIs for interoperability:

  • Spotlight — entity recognition/disambiguation (POST /spotlight)
  • Triple Pattern Fragments — LDF-compatible pagination (GET /fragments)
  • OpenRefine Reconciliation — data cleaning integration (POST /reconcile)
  • Content Negotiation — GET /entity/{iri} returns JSON-LD, Turtle, or HTML based on Accept header

Reasoner

# Impact analysis — what depends on this entity?
quipu impact http://example.org/gateway --db ops.db

# Counterfactual — what breaks if we remove it?
quipu impact http://example.org/gateway --remove --db ops.db

# Run Datalog rules over the fact log
quipu reason --rules rules.ttl --db ops.db

The reasoner adds forward-chaining inference over the EAVT fact log:

  • Datalog rule engine — rules written in Turtle DSL, evaluated with semi-naive datafrog. Supports stratified negation-as-failure over materialized facts, including derived predicates from lower strata; unsafe negation and negation cycles are rejected. Derived facts written back 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   │
        │ (77 tools) │   │ + Web UI  │   │  (crate)    │
        └─────┬─────┘   └─────┬─────┘   └──────┬──────┘
              └────────────────┼────────────────┘
                               │
     ┌─────────────────────────┼─────────────────────────┐
     │                         │                         │
┌────┴────┐  ┌────┴─────┐  ┌──┴───────┐  ┌──────────────┴──────────────┐
│ SPARQL  │  │  SHACL   │  │ Reasoner │  │   KnowledgeVectorStore      │
│ Engine  │  │ Validator│  │ (Datalog)│  │         (trait)             │
└────┬────┘  └────┬─────┘  └──┬───────┘  └──────┬─────────┬───────────┘
     │             │           │                 │         │
     └─────┬───────┴───────────┘          ┌──────┴───┐ ┌───┴──────┐
           │                              │  SQLite  │ │ LanceDB  │
           │                              │ (default)│ │(optional)│
     ┌─────┴──────────────┐               └──────────┘ └──────────┘
     │   EAVT Fact Log    │
     │   (SQLite)         │
     │                    │
     │  facts + terms +   │
     │  shapes + rules    │
     └────────────────────┘

Bobbin Integration

Quipu is designed as a Bobbin subsystem. Bobbin holds the thread (code context); Quipu ties knots of structured meaning into it.

Through Quipu’s own MCP server (quipu mcp, or quipu-server at /mcp), agents get 77 MCP tools (79 with the owl feature). The two most commonly used for knowledge-aware context:

quipu_context — unified knowledge discovery. Bobbin merges the result with its own code search to give agents both code and knowledge in one response.

{
  "tool": "quipu_context",
  "input": { "query": "gateway reverse proxy", "max_entities": 10 }
}
// Returns ranked entities with facts, types, and relevance scores

quipu_episode — save agent-extracted structured knowledge with full provenance tracking.

{
  "tool": "quipu_episode",
  "input": {
    "name": "deploy-v3",
    "source": "aegis/ellie",
    "nodes": [{"name": "gateway", "type": "WebApplication",
               "properties": {"version": "3.0"}}],
    "edges": [{"source": "gateway", "target": "host-a", "relation": "runs_on"}]
  }
}

Embeddings are shared: Bobbin’s ONNX pipeline (all-MiniLM-L6-v2) provides 384-dimensional vectors to both its code search and Quipu’s knowledge search, enabling hybrid queries that span both domains.

Agent access

When Quipu MCP tools are configured, agents should use them first so structured inputs, validation feedback, and provenance stay in the tool contract. Use the native quipu CLI second for local databases and offline work. Raw HTTP is the portability fallback; endpoint shapes and authentication are in the REST API reference.

Named graphs are first-class query scopes, and named datasets are reusable sets of graphs. ROOT remains the default until a caller explicitly selects a graph or dataset with SPARQL FROM / FROM NAMED, the query graph field, or the query tool’s graph field. This permits an application to ground a request in one declared plane without silently widening into every graph. See Named Graphs for the registration, write, and trust-label rules.

Feature Matrix

Legend: ✅ available in the shipped quipu CLI / quipu-server · 🔩 library primitive only, not reachable from the shipped binaries · 🔜 planned.

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 (77; 79 with owl)✅Agent integration
Python bindings✅quipu-client under python/ — REST client, stdlib-only
Infrastructure
Graph projection (petgraph)✅Centrality, shortest path, etc.
GraphProvider federation trait✅RemoteProvider, startup health checks, federated: true on /query
Bobbin integration✅Namespace, IRI patterns, search
Automated releases (release-plz)✅
Clustering / replication🔜Planned

Installation

As a Rust Dependency

Add to your Cargo.toml:

[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu" }

To use SHACL validation (enabled by default):

[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu", features = ["shacl"] }

To exclude SHACL (smaller binary, faster compile):

[dependencies]
quipu = { package = "quipu-ai", git = "https://github.com/scbrown/quipu", default-features = false }

From Source

qp is the preferred command name; quipu remains supported. Install both names plus the companion server with:

just install
# Optional destination directory:
just install "$HOME/.local/bin"
qp --version
quipu --version

qp is a relative symlink to quipu, so both execute the same binary after upgrades. The installer refuses an existing conflicting qp in the destination or on PATH. It never replaces another tool’s command name. Release archives also carry this relative link beside both binaries.

To add the command name to an existing installation without opening a store:

quipu install-alias --check
quipu install-alias
git clone https://github.com/scbrown/quipu
cd quipu
cargo build --release --features full

This produces two binaries:

  • target/release/quipu – CLI 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

Keep the CLI and server from the same build beside each other for quipu mcp. Both quipu --version and quipu-server --version report git_sha and git_dirty without opening a database or loading configuration. A source archive without Git metadata reports git_sha: unknown; a dirty build reports git_dirty: true. The HTTP /version response retains its existing format.

With Cargo

Cargo installs the executable targets; finish the installation with the alias command to provide both supported names:

cargo install --git https://github.com/scbrown/quipu --locked --features full
quipu install-alias
qp --version

install-alias uses the directory containing the running Quipu executable, including Cargo’s --root destination. It refuses conflicting qp names and verifies the same inode and exact version output. --check only checks for collisions and does not create the link.

just install resolves its root as QUIPU_INSTALL_ROOT, then CARGO_INSTALL_ROOT, then $HOME/.local, and installs into that root’s bin directory. Its optional directory argument overrides this resolution.

The Full Stack (caboodle)

To install Quipu as part of the whole knowledge stack — caboodle interviews, plans, applies, and verifies the installation of every stack tool — use the wrapper script:

# Phase one: install caboodle if absent, write a reviewable plan, STOP.
scripts/install-stack.sh --profile kg

# Phase two: after reviewing caboodle-plan.toml, apply + verify,
# then verify and load knowledge packs into the target store.
scripts/install-stack.sh --profile kg --yes \
  --pendant domain.pendant.db --db my.db

The two-phase gate is deliberate and mirrors caboodle’s own doctrine: nothing installs until the written plan has been reviewed (or --yes given explicitly). Every --pendant is checked with quipu pack --verify before quipu unpack — a content-hash mismatch refuses the pack rather than installing silently corrupted knowledge. --dry-run prints every command the script would run and executes nothing; --profile selects the caboodle profile (default kg).

Python Client

The REST API has a thin, zero-dependency Python client under python/:

pip install ./python

See the Python Client reference.

Requirements

  • Rust 1.85+ (edition 2024)
  • SQLite is bundled via rusqlite – no system dependency needed
  • Python >= 3.11 for the optional Python client (stdlib only)

Quick Start

Rust Library

use quipu::store::Store;
use quipu::rdf::ingest_rdf;
use quipu::sparql;
use oxrdfio::RdfFormat;

fn main() -> quipu::error::Result<()> {
    // Open a persistent store (or use open_in_memory() for testing)
    let mut store = Store::open("my-knowledge.db")?;

    // Ingest Turtle data
    let data = r#"
    @prefix ex: <http://example.org/> .
    @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

    ex:alice a ex:Person ;
        ex:name "Alice" ;
        ex:age "30"^^xsd:integer ;
        ex:knows ex:bob .

    ex:bob a ex:Person ;
        ex:name "Bob" ;
        ex:age "25"^^xsd:integer .
    "#;

    let (tx_id, count) = ingest_rdf(
        &mut store,
        data.as_bytes(),
        RdfFormat::Turtle,
        None,
        "2026-04-04T00:00:00Z",
        Some("demo"),
        Some("quick-start"),
    )?;
    println!("Ingested {count} triples in transaction {tx_id}");

    // Query with SPARQL
    let result = sparql::query(
        &store,
        r#"SELECT ?name ?age WHERE {
            ?s a <http://example.org/Person> .
            ?s <http://example.org/name> ?name .
            ?s <http://example.org/age> ?age .
            FILTER(?age >= 28)
        }"#,
    )?;

    println!("People aged 28+:");
    for row in result.rows() {
        println!("  {:?} age {:?}", row.get("name"), row.get("age"));
    }

    Ok(())
}

CLI

# Build
cargo build --release

# Load data
quipu knot data.ttl --db my.db

# Query
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10" --db my.db

# Interactive REPL
quipu repl --db my.db

REST API Server

# Build the server with the full bundle. quipu-server has
# required-features = ["shacl", "onnx", "server"], and cargo SILENTLY SKIPS a
# binary whose required features are missing — a plain `cargo build --release`
# (or a partial feature list) builds only the `quipu` CLI and you'd never know.
cargo build --release --features full

# Start
quipu-server --db my.db --bind 0.0.0.0:3030

# Health check
curl localhost:3030/health

# Query
curl -s localhost:3030/query -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'

# Ingest an episode
curl -s localhost:3030/episode -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "name": "test-episode",
    "nodes": [
      {"name": "alice", "type": "Person", "description": "Test user"}
    ],
    "edges": []
  }'

MCP (Agent Integration)

Run quipu mcp --db <path> (stdio) or quipu-server (/mcp over HTTP), and agents get Quipu’s tools:

{
  "tool": "quipu_query",
  "input": {
    "query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"
  }
}

See the MCP Tools Reference for all available tools.

Configuration

Quipu is configured via .bobbin/config.toml in your project directory or ~/.config/bobbin/config.toml for global defaults.

Config File

[quipu]
# Path to the SQLite triple store
store_path = ".bobbin/quipu/quipu.db"

# Base namespace new IRIs are minted under (optional; default is the aegis
# ontology namespace). A non-aegis deployment MUST set this before its first
# write — an IRI namespace is data identity and cannot be changed afterwards
# without re-ingesting every episode.
base_ns = "http://example.org/kb/"

[quipu.server]
# Enable the REST API server
enabled = false
# Bind address
bind = "127.0.0.1:3030"
# Require this bearer on write endpoints.
# auth_token = "current-token"
# During a credential rotation only, accept the old bearer for at most 24h.
# Use an absolute deadline so restarting cannot renew the old bearer.
# previous_auth_token = "old-token"
# previous_auth_token_expires_at_epoch_secs = 2000000000

[quipu.events]
# Event-log retention. Unset (the default) keeps every event forever.
# When set, the server hourly deletes events older than this many days —
# but never an event a registered consumer has not yet committed past, so
# a lagging consumer's replay is never broken (its backlog just stays on
# disk). A consumer registering AFTER a prune replays from the retained
# prefix, not from genesis.
# retention_days = 90

# Read-only databases mounted alongside the store. Each block is one layer:
# a knowledge pack, a shared reference database, a frozen archive someone
# handed you. Their named graphs become readable by `GRAPH <iri>` / `FROM <iri>`
# without changing what any existing query returns.
# [[quipu.attachments]]
# alias = "reference"
# path = "/srv/quipu/reference.pendant.db"

Config Fields

Every key below is wired — src/config.rs carries a test (config_knobs_are_wired_or_listed_unwired) that fails if a documented knob stops being read.

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)
server.update_full_copy_max_facts250000Facts an unsliceable /update (open subject AND open predicate) may copy before it is refused with advice; 0 = unbounded
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.keywordfalseOpt-in SQLite FTS5 keyword search; activation installs schema/triggers without a bulk startup migration. Existing facts require explicit bounded backfill; reads refuse until complete.
search.hybridfalseEnable lexical/vector fusion after offline evaluation; no startup backfill.
search.mode"semantic"Default mode for requests omitting it: semantic, keyword or hybrid.
search.alpha0.5Semantic contribution in hybrid mode; 0 and 1 use exact pure endpoints.
search.fusion"rrf"Weighted reciprocal ranks or "weighted" min-max scores.
search.rrf_k60.0Positive RRF denominator offset.
search.named_graphsfalseNamed-graph scope for semantic and hybrid search, and automatic named-only embedding text. Keyword search accepts named scope without it. Prepare existing named-only vectors with bounded backfill before enabling. Disabling retains the ROOT exclusion for prepared vectors.
search.default_graph_scopeunset (stored default)New stores use "all": ROOT plus registered named graphs, minus exclusions. Existing stores retain "root" until explicitly broadened. Set "all" or "root" to override; semantic named scope also requires prepared vectors and named_graphs = true. Does not change SPARQL datasets.
search.exclude_graphs[]Graph IRI exclusion list for all search selections, including explicit ones. Empty selections refuse. Retrieval policy, not authorization.
search.max_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_writetrue (false in builds without the shacl feature)Validate writes against the stored shapes: full validation on episode ingest, and cardinality/value admission on the touched nodes of every write. Set false to accept non-conformant writes
owl.validate_on_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
governance.hardware_verdict_schemesfalseAccept WebAuthn and SSHSIG sk-ssh-ed25519 verdict signatures beside ed25519; while off, those verdicts and registrations declaring them are refused
governance.quarantine.enabledtrueRecord a sealed, digest-only quarantine entry for each verdict of a refused write, outside the governed graph, so quipu audit replay <verdict> can re-derive the refusal
governance.quarantine.retain_full[]Graph IRIs (or "*") whose refused attempts are also kept in full, sealed; purge with quipu audit quarantine purge
resolution.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_embedtrueAuto-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.pendant.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.

Configuration coverage

Every documented key above is read by the shipped binaries. unwired_warnings() reports any configured key that parses but is not acted on, so an inert setting produces a warning.

Priority Order

Configuration is resolved in this order (highest priority first):

  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

Read-only follower serving

A file-level replication operator can serve a changing follower with:

[quipu.server.replica]
primary_url = "https://primary.example"

Start quipu-server --read-only --db /path/to/follower.db from that configuration directory. Keep --read-only in the service invocation: it refuses startup if neither config nor --primary-url URL supplies a valid primary, even if the config file disappears. --primary-url URL overrides the configured primary. This is an opt-in serving role, separate from the older server.read_only HTTP-only access-control flag. The default primary serving role is unchanged. Server startup now refuses invalid or unreadable existing configuration instead of silently falling back to defaults. Replica mode opens only existing SQLite databases read-only, with no schema setup or migration. Every read connection explicitly waits up to one second for short apply locks. It never falls back to opening a writable database. SQLite may create WAL/SHM sidecars; the directory must support the follower’s locking protocol. Do not use immutable=1 for a database that replication updates.

The replica does not initialize a keyword index, materialize OWL, register write observers, load a signing identity, reset/checkpoint WAL, deliver subscriptions, refresh graph-count snapshots, or prune events. Read policies (namespace, search limits, labels and resolution) are still configured. Keyword search requires an index already present on the primary. Attachments and external vector backends currently refuse startup rather than silently dropping configured providers. Graph-count metrics remain unavailable until an independent read-only refresh contract is implemented; they must not be interpreted as healthy zeros.

Mutating HTTP routes return 503 with reason: "read_only_replica" and the configured primary URL. Writes are never forwarded or redirected, including signed writes: the refusal occurs before credential/nonce settlement. SQLite’s read-only handle independently refuses a planted writer that bypasses HTTP. This role does not provision, replicate, promote or fail over a database.

Lag observations

GET /watermark returns the serving store’s store_id and watermark_tx using indexed reads. A replica polls that endpoint on its configured primary every 15 seconds, with a two-second HTTP timeout, no redirects and a bounded response. This is its only background task and performs no database writes.

Responses include X-Quipu-Replica-Lag-Tx. The metrics are:

  • quipu_replica_lag_transactions: primary minus follower transaction IDs; -1 means UNKNOWN.
  • quipu_replica_lag_known: 1 only for a successful observation no older than 30 seconds; otherwise 0.

A failed request, malformed response, mismatched store lineage, primary behind the follower, unavailable local connection, or stale observation gives UNKNOWN, not zero. Values describe the most recent bounded observation, not an atomic cross-host snapshot, elapsed seconds or proof of identical contents. The replication operator must establish the same primary lineage and common-prefix contract before interpreting transaction distance. A primary without /watermark yields UNKNOWN until upgraded; no expensive fallback query runs.

just replica-test /path/to/quipu-server /path/to/quipu checks a scratch follower: 100 positive reads, unchanged main DB hash/counter/mtime across start and stop, 503 write responses, lag failure handling and a pending-subscription trap that must never deliver. Library/server tests also plant a write against the actual read-only connection. The scratch test does not change or probe production.

Private bounded query specimen

server.query_capture is omitted by default. An omitted configuration or enabled = false creates no capture worker, file or capture-state log. An operator can explicitly configure enabled, an exact lowercase query_sha256, query_bytes (1–4096), a unique alphanumeric/underscore/hyphen window_id, and absolute start_epoch_secs/end_epoch_secs. The window is at most five minutes; startup refuses an expired window or a start more than fifteen minutes ahead. A parsed selector/window that fails validation disables the diagnostic without changing normal query results; malformed configuration still follows normal strict configuration-loading errors. There is no HTTP endpoint to arm it.

Only the first matching Seeds-category POST /query request can consume the claim. Eligibility is the intersection of the absolute UTC window and its startup-pinned monotonic start/end, so clock jumps cannot extend or start it early. At startup the server exclusively claims query-captures/<window_id>.json beside the canonical store file, using a same-owner private directory and descriptor-relative, no-follow exclusive creation. Existing claims are never overwritten, including empty files left by a crash: restarting cannot renew the attempt. Unsupported platforms refuse enabled capture. Operators retain and adjudicate these private artifacts; the server does not delete or retry them.

A capacity-one worker writes at most 16 KiB outside store locks. The record contains exact raw SPARQL, bounded planning options, UTC, build identity and the existing request/process/query/input digests. Headers, response bodies and unknown JSON fields are excluded; omitted fields are explicitly flagged. Raw query literals can be sensitive, and a digest is not anonymization. The Seeds client category is an unauthenticated attribution label, not proof of caller identity. Serialization overflow consumes the attempt and records refusal without a truncated query. File failures leave an UNKNOWN partial or empty claim; no request is retried. Default-off source deployment does not authorize arming: use a separately approved window and flag-off rollback, then read back that the flag is off.

Triples and the Knowledge Graph

Everything in Quipu is a triple: a subject, a predicate, and an object.

<korimako>  <runs>  <traefik>
 subject  predicate  object

This triple says “korimako runs traefik.” Three triples can encode a complete service dependency:

@prefix hw: <http://example.org/homelab/> .

hw:korimako    a               hw:Host .
hw:korimako    hw:runs         hw:traefik .
hw:traefik  hw:dependsOn    hw:pihole .

That’s it. No tables to design, no schema migrations. You add facts incrementally and query them with SPARQL.

IRIs: Naming Things

Every entity and predicate is identified by an IRI (Internationalized Resource Identifier) — a globally unique name like a URL:

http://example.org/homelab/korimako

Prefixes keep things readable. Instead of writing the full IRI every time:

@prefix hw: <http://example.org/homelab/> .

hw:korimako  hw:runs  hw:traefik .

hw:korimako expands to http://example.org/homelab/korimako.

Objects: References vs Literals

The object of a triple can be either:

  • A reference to another entity (another IRI)
  • A literal value (a string, number, boolean, or date)
hw:korimako  hw:runs      hw:traefik .          # reference → another entity
hw:korimako  hw:hostname  "korimako.example" .          # literal → a string
hw:korimako  hw:cpuCores  "4"^^xsd:integer .     # literal → a typed number

How Quipu Stores Triples

Under the hood, Quipu stores triples as EAVT facts in an immutable log:

FieldMeaningExample
E (entity)The subjecthw:korimako
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:korimako a hw:Host ; hw:hostname \"korimako.example\" ."
  }'

From an episode (structured agent input):

curl -s localhost:3030/episode -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "name": "homelab-inventory",
    "nodes": [
      {"name": "korimako", "type": "Host", "properties": {"hostname": "korimako.example"}},
      {"name": "traefik", "type": "WebApp"}
    ],
    "edges": [
      {"source": "korimako", "target": "traefik", "relation": "runs"}
    ]
  }'

What’s Next

The Temporal Model

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 korimako has 4 CPU cores:

quipu knot - --db homelab.db --timestamp 2026-04-01 <<'EOF'
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
hw:korimako hw:cpuCores "4"^^xsd:integer .
EOF

On April 3 you upgrade to 8 cores and record the change:

quipu retract "http://example.org/homelab/korimako" \
  --predicate "http://example.org/homelab/cpuCores" \
  --db homelab.db --timestamp 2026-04-03

quipu knot - --db homelab.db --timestamp 2026-04-03 <<'EOF'
@prefix hw: <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
hw:korimako hw:cpuCores "8"^^xsd:integer .
EOF

Now you can time-travel:

# What's the current state?
quipu read "SELECT ?cores WHERE {
  <http://example.org/homelab/korimako> <http://example.org/homelab/cpuCores> ?cores
}" --db homelab.db
# → 8

# What was true on April 2?
quipu read "SELECT ?cores WHERE {
  <http://example.org/homelab/korimako> <http://example.org/homelab/cpuCores> ?cores
}" --db homelab.db --valid-at 2026-04-02
# → 4

The EAVT Fact Log

Under the hood, facts are stored as immutable rows:

E (entity)A (attribute)V (value)T (tx)valid_fromvalid_toop
hw:korimakohw:cpuCores412026-04-012026-04-03Assert
hw:korimakohw:cpuCores422026-04-032026-04-03Retract
hw:korimakohw: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

SPARQL reads are ROOT-scoped by default. The default graph is ROOT alone, not an all-graph union — silence must never expose another tenant’s overlay. A query widens its dataset only by saying so:

  • GRAPH <iri> { … } scopes the enclosed patterns to one named graph. An unknown IRI matches nothing.
  • ROOT is the default graph, not a named graph. GRAPH <urn:quipu:graph:root> is refused with an error naming the default-graph form. Read ROOT without a GRAPH clause, or explicitly use FROM <urn:quipu:graph:root>.
  • 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 }

/set and /retract operate only on ROOT and leave named-graph facts intact. They refuse a supplied graph field, including null, before changing facts. For a named graph, use the explicitly scoped /update endpoint instead.

POST /query and the quipu_query MCP tool also take a graph request param — a convenience that scopes the query’s default graph to one named graph without writing a FROM or GRAPH clause:

{"query": "SELECT ?s ?o WHERE { ?s <http://example.org/p> ?o }",
 "graph": "http://example.org/graphs/derived"}

Omitting it keeps the ROOT default; an unknown IRI gives an empty default graph; a FROM clause in the query text overrides the param. The param also resolves dataset names: passing a dataset IRI scopes the query to that dataset’s members, so FROM <dataset> and "graph": "<dataset>" mean the same thing. The same graph param on quipu_export / POST /export exports one named graph’s facts instead of ROOT.

Search has a broader default than SPARQL

On a new store, POST /search and quipu_search default to ROOT plus registered named graphs. Existing stores retain ROOT until explicitly broadened with [quipu.search] default_graph_scope = "all". When that setting is omitted, the store persists its default; read-only legacy stores use ROOT without migration. Coverage reports the effective default and its source. This policy is local to the receiving store: importing a share does not replace it with the sender’s choice. A whole-file backup retains the store’s policy. For example, a work tracker can keep its records in its own project graph: those records need not be copied into ROOT to be discoverable. This covers graphs registered in the local store, including attached layers registered there; it does not query remote federation endpoints automatically.

Configure retrieval policy under [quipu.search]:

default_graph_scope = "all"
exclude_graphs = ["urn:example:quarantine", "urn:example:private-work"]

Use default_graph_scope = "root" for the previous implicit scope. Requests can select graph: "urn:quipu:graph:root", one named graph IRI, graphs: [iri, iri], or all_graphs: true. Configured exclusions apply to all selections; a selection emptied by exclusions refuses. These controls are retrieval policy, not access control. Deployments needing tenant isolation must enforce authorization separately. Configure exclusions before exposing a store containing scratch or quarantine graphs to general search.

Every successful search returns search_scope: selected graph IRIs, configured exclusions, whether selection was explicit, named-vector enablement, and the fact that index completeness is not certified. Result graph provenance does not promote a fact or make its source authoritative. Similarity and context admission still apply; an empty search does not prove a graph contains no facts.

Semantic retrieval uses one vector per entity. An entity with ROOT text keeps that representation; a named-only entity combines its named-graph facts. Selecting a graph filters membership, not the vector’s text provenance. For assertion-level literal matching within one graph, use keyword search. This distinction is why listing selected graphs does not certify complete indexing.

Semantic search across named graphs still requires named_graphs = true. Prepare existing named-only vectors using bounded /embed_backfill_graph calls before enabling it; an unprepared default all-graph request refuses instead of silently reporting ROOT as complete coverage. Keyword search needs its enabled, backfilled lexical index, but no embedding provider or named-vector flag. ROOT-only content ranking, anchor traversal, and structured search refuse a broader scope. See the search API.

Querying and promotion are separate operations

To inspect one graph, use GRAPH <iri> or the /query graph parameter above. To join graph contents deliberately, use FROM <a> FROM <b> or a registered dataset. Search scope never changes these SPARQL defaults or writes facts.

Promotion admits reviewed content into ROOT; it is not required merely to find or query named-graph data. For bulk material, use the producer’s share, then import to stage and validate it, inspect the admission result, and use import promote <share-id> only when eligible and authorized. Do not replace promotion with a loop of episode or update writes. See bulk loads and migrations for exact commands, shape admission, and fork rollback. A search hit from a staging graph is still staged content until that governed promotion succeeds.

Property paths follow a fixed graph scope without crossing it: a GRAPH <iri> closure stays inside that graph, a FROM <a> FROM <b> path traverses their merge. A path never crosses a graph boundary — half a path in an overlay and half in ROOT is not a fact either graph asserts.

Overlays

An overlay is a scratch layer over a committed parent branch: hypotheses go in the overlay, the committed base is never mutated. Two write primitives, one uniform read:

  • Create (quipu_overlay_create / POST /overlay/create) registers an overlay-class graph bound once to its committed parent branch (ROOT by default). Idempotent; rebinding to a different parent is an error — the binding is unforgeable.
  • Write (quipu_overlay_write / POST /overlay/write) takes one of three ops: 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. Creating a dataset with a member that names no registered graph is refused, and the refusal names that member.
  • urn:quipu:graph:root is a valid member and reads the default graph, the same as FROM <urn:quipu:graph:root>. A dataset of ROOT plus a named graph is how a stored query reads the default graph AND that graph, without hardcoding either in the query text.
  • Members may carry a declared ordering ({"graph": …, "ord": N}); duplicate ranks are refused rather than tiebroken silently. An empty dataset is refused.
  • Datasets are mirrored into the meta-graph as quipu:Dataset / quipu: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

Create and use a named graph through MCP

Use a local scratch store for this recipe. Install the CLI and sibling server, then configure your MCP client with quipu mcp --db ./tutorial.db. Local MCP opens no HTTP listener. A configured protected server needs its existing write credential; an actor field declares provenance and never grants permission. See MCP connection setup.

Each JSON block below is an MCP tools/call parameter object. Send the blocks in order. Inspect isError before reading content; an error is not an empty result. These exact blocks are exercised by the fresh-store acceptance test. The timestamps are sample transaction times; use current UTC in your own work.

1. Create a committed graph

{
  "name": "quipu_graph_create",
  "arguments": {
    "graph": "urn:example:graph:job-search",
    "actor": "urn:example:agent:researcher",
    "timestamp": "2026-10-14T12:00:00Z"
  }
}

Expect created: true and creation containing the original timestamp and declared actor. Repeating create returns created: false and preserves that provenance. An already interned IRI still needs registration.

2. Declare its labels

{
  "name": "quipu_graph_label",
  "arguments": {
    "graph": "urn:example:graph:job-search",
    "timestamp": "2026-10-14T12:00:00Z",
    "actor": "urn:example:agent:researcher",
    "kind": "operational",
    "freshness": "fresh"
  }
}

3. Load the new class vocabulary

{
  "name": "quipu_shapes",
  "arguments": {
    "action": "load",
    "name": "job-search",
    "timestamp": "2026-10-14T12:00:00Z",
    "turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> . @prefix ex: <https://example.org/> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . ex:JobShape a sh:NodeShape ; sh:targetClass ex:Job ; sh:property [ sh:path rdfs:label ; sh:minCount 1 ] ."
  }
}

This loads the vocabulary into the local store; supplying a type name on a write does not create or govern that class. Shape changes on a shared server need that server’s normal schema authority.

4. Check the loaded vocabulary

{
  "name": "quipu_shapes",
  "arguments": {
    "action": "vocabulary"
  }
}

Expect https://example.org/Job in classes. Use this exact class IRI in your RDF.

5. Write one job into the graph

{
  "name": "quipu_knot",
  "arguments": {
    "graph": "urn:example:graph:job-search",
    "timestamp": "2026-10-14T12:00:00Z",
    "actor": "urn:example:agent:researcher",
    "turtle": "@prefix ex: <https://example.org/> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . ex:job1 a ex:Job ; rdfs:label \"Research engineer\" ."
  }
}

6. Query this graph explicitly

{
  "name": "quipu_query",
  "arguments": {
    "graph": "urn:example:graph:job-search",
    "query": "SELECT ?label WHERE { <https://example.org/job1> <http://www.w3.org/2000/01/rdf-schema#label> ?label }"
  }
}

Expect count: 1 and label Research engineer. Omitting graph queries ROOT; it must return no matching job in this fresh store.

7. List registered graphs and their provenance

{
  "name": "quipu_graph_list",
  "arguments": {}
}

Find the exact graph IRI, committed class, operational/fresh labels, and the same creation provenance from step 1. A ROOT row may also be present; compare the target row rather than assuming a total count.

Fix common errors

  • Unknown graph / interned but not registered: call quipu_graph_create with the exact graph IRI before quipu_knot. The error includes that call and the equivalent quipu graph create '<iri>' --db <path> command.
  • Unknown rdf:type / off-vocabulary class: load a SHACL shape with sh:targetClass matching that exact class IRI using quipu_shapes, then check action: vocabulary before repeating the rejected write. On a shared store, propose the shape through its governed schema lane instead of changing somebody else’s vocabulary without authority. A similarly spelled label or namespace is not a match.
  • SHACL validation failure: supply the required label; do not disable validation. The recipe’s Job shape requires rdfs:label.
  • Overlay graph: use quipu_overlay_write; committed graph registration does not reclassify an overlay.

Creation provenance records caller-declared metadata. It is not proof that the caller owned the actor IRI. Older or imported graphs may have an unknown actor; repeating create never invents an original creator.

Compose knowledge packs

quipu compose loads verified local share directories or archives into one store, retaining each pack as a named graph and naming their union as a dataset. ROOT remains unchanged, including when validation passes. Composition is an inspection operation; it does not grant foreign content trust.

quipu compose ./operations-pack ./repository-pack \
  --shapes-from ./operations-pack --db composed.db > composition.json

The command returns JSON and exits 0 for an admitted union, 2 for a retained, quarantined union, or 1 for a refusal. Exit 2 means the composition is available for inspection in quarantine, not that no data was written. Integrity and shape-selection errors refuse the whole operation. Internal shares require explicit --destination internal, as on the import path.

Identity and provenance

Exact IRIs denote one entity across packs. Matching labels never merge entities. An explicit owl:sameAs assertion supplies an auditable identity link; consumers can follow it while the original names remain in the source graphs. Blank node identifiers are scoped to the pack even when independent exports both use c14n0.

The result names the dataset and each source graph. Query the dataset explicitly:

SELECT ?item ?name FROM <urn:quipu:composition:HASH>
WHERE { ?item a <https://example.org/Item>; <https://example.org/name> ?name }

For a fact’s source membership, name the source graphs from the result:

SELECT ?pack
FROM NAMED <urn:quipu:composition:pack:FIRST_SHARE_HASH>
FROM NAMED <urn:quipu:composition:pack:SECOND_SHARE_HASH>
WHERE { GRAPH ?pack { <https://example.org/item> <https://example.org/name> "Item" } }

A fact supplied by both packs has two source memberships. The dataset’s RDF union suppresses duplicate triples. urn:quipu:composition:metadata records JSON manifests under urn:quipu:composition:manifest, including source references, share and store identities, transaction anchors, original manifests, attestation status, and validation results. Records describe observed compositions; they are not an automatically maintained validation certificate.

Shape authority and partial snapshots

Without --shapes-from, every input must carry an identical shape bundle. Different bundles are refused with their share IDs and hashes. Explicit selection chooses that pack’s complete bundle for this composition. It never silently unions conflicting constraints or installs foreign shapes as global policy.

Validation examines the union: another pack can supply a required field. Loaded local shapes control admission when present, including when an incoming bundle declares a conflicting policy. Without local shapes, explicit --shapes-from selection authorizes that bundle’s quipu:onViolation "emit" policy. Default identical bundles validate the union but do not implicitly authorize emit admission. Missing policies mean reject; unknown or conflicting values refuse.

Admission parses policy by RDF subject, so compact Turtle, alternate prefixes, comments and string literals cannot route a neighboring reject shape into emit. Both validator graphs preserve all constraints and shared RDF-list dependencies; only inactive independent targets are removed, including implicit class targets. Policy on a nested property does not downgrade its enclosing reject shape. The four core target kinds are supported; custom SHACL-AF targets refuse when emit partitioning is requested. Canonical and legacy annotation IRIs are checked together, so conflicting declarations cannot choose their own precedence.

conforms retains strict SHACL meaning: any diagnostic, including an advisory Violation or Warning, makes it false. Admission uses blocking separately. Emit-policy diagnostics appear in advisory_results; reject-policy Violations still quarantine the union. The report records admission_policy with its source, shape hash and emit authorization. The existing shapes_authority field identifies the selected input bundle.

The report preserves violation counts and counts by source shape. Its results preview contains at most 40 diagnostics; when truncated, complete_results contains every diagnostic and results_total records their count. Types outside the authoritative vocabulary are reported separately as off_vocabulary and also quarantine the union. Neither an admitted union nor an emit policy promotes data to ROOT or grants foreign content trust.

The snapshot vector remains visible. Different source stores’ transaction anchors are incomparable. Mixed dates are permitted and reported; composition does not claim that snapshots were taken together, every referenced entity has arrived, or missing facts represent upstream deletion. Pack selection is explicit. Loading a different snapshot names another composition without replacing an earlier dataset.

Retractions and replay

Reloading a share reuses its source graph rather than refilling it. Attested shares still obey the ordinary signature and nonce-replay checks. A local retraction in that graph survives reload of the same snapshot. Validation on replay examines the current local union, so removing a required field changes the outcome to quarantine. Source graph edits affect every dataset that includes that graph.

A distinct historical snapshot is a separate inspection graph, not a restoration of ROOT or an automatic replacement of the selected dataset. Historical source membership records what a snapshot contained, not whether its facts remain current. Retract in the graph whose view you intend to change.

A stable name per repository

Each composition gets a new dataset name, so an agent has nothing stable to ask for “this repository’s knowledge now”. Repository mode gives it one:

quipu compose --repo scbrown/demo packs/main=./main.pendant packs/ops=./ops.pendant \
  --db store.db

Each input is <pack-key>=<path>. The pack key (its path in the repository, for example) is what makes two commits of a pack the same pack, so it is supplied, never inferred from the bytes. An admitted composition moves two datasets:

  • urn:quipu:repo:<repo>:current names the latest admitted snapshot.
  • urn:quipu:repo:<repo>:previous names the snapshot before it.

A quarantined composition stays inspectable under its own urn:quipu:repo:<repo>:snapshot:HASH dataset, but it never moves CURRENT. Recomposing the snapshot CURRENT already names moves nothing.

SELECT ?item ?name FROM <urn:quipu:repo:scbrown/demo:current>
WHERE { ?item <https://example.org/name> ?name }

Pack graphs in this mode are scoped to the repository (urn:quipu:repo:<repo>:pack:<key>:SHARE_HASH). Two repositories composing identical bytes therefore get two graphs, and a retraction in one never shows through the other. Older snapshot graphs that neither pointer names are dropped: their facts are retracted and the graph is unregistered, and history stays bitemporal. A graph that another dataset still names is kept and reported under retained_shared.

Local retractions carry forward. Before each composition, statements retracted locally in this repository’s pack graphs are recorded as tombstones for that pack key. They are removed from every later snapshot of the pack before validation, so validation sees the union an agent will actually query. This holds across a version where the statement is absent, a version where it returns, retention pruning and rollback. Blank nodes are renamed per snapshot, so a retraction of a blank-node statement cannot be matched later. That case refuses the composition and names the statement; re-assert it, or give the node an IRI in the pack.

Roll back by moving the pointer:

quipu compose --repo scbrown/demo --rollback --db store.db

This swaps CURRENT and previous, and first applies any tombstone recorded since previous was current. The whole operation is one transaction: a failure at any step leaves both pointers, the graphs and the caches as they were.

Undo a local retraction

Revoke one carried-forward suppression explicitly, without editing SQLite:

quipu compose --repo scbrown/demo --pack-key packs/main \
  --revoke-tombstone '<https://example.org/alice> <https://example.org/name> "Alice" .' \
  --reason 'Undo an accidental local retraction' --db store.db

The statement must be exactly one N-Triples triple, with no blank nodes. A nonblank reason is required. This operation takes no packs and cannot be combined with rollback. It restores only matching, previously asserted composition facts that are retracted in this repository’s owned graphs for the exact pack key, then removes their carried-forward tombstone in the same transaction. The receipt reports revoked and graphs_restored. Repeating a successful reversal returns revoked: false; a missing statement never invents a fact.

Replaying or rolling back an already-stripped graph stays stripped. A tombstone may have removed the statement before that graph was first stored, so there is no historical assertion there to restore. Recovery is a fresh composition from a newly published source-pendant snapshot containing the fact (a distinct share ID), after revocation. Replaying the same stripped snapshot does not refill its graph. The operation never fabricates omitted facts from the requested statement alone.

CURRENT and PREVIOUS do not move, but restored facts become visible immediately in their graphs, including retained older graphs. Any other dataset referencing those same graphs sees the restoration too. Other repositories, other pack keys, unrelated statements and ROOT are unchanged. Recomposition and rollback do not recreate the revoked suppression; a later explicit local retraction can create it again. A failed operation rolls back both restoration and the ledger update.

Deleting a tombstone row by hand does not have these semantics: retracted facts in a retained graph would be harvested again on the next composition.

Pack pointers on first use

A manifest’s kind = "pack" pointers remain declarations at checkout and ordinary composition. A consumer follows only the graph it needs, using share_pointer_load::LazyPacks::use_graph or the native first-use command:

quipu compose --use-pointer urn:project:api \
  --declaration repository.pendant.tar.gz \
  --authority local-policy.pendant.tar.gz \
  --repo-remote https://github.com/example/project.git \
  --pointer-cache ./verified-archives --db ./inspection.db

The receiver supplies the repository remote and local authority. The command runs caboodle packs trust check against that repository and the pointed archive hash before reading the cache or fetching. A missing trust tool or a refused decision stops the operation. Foreign bundles never become global shape policy, and nothing is promoted into ROOT. Use the returned dataset in an explicit SPARQL FROM clause. This command does not move repository CURRENT.

The archive sha256 and the enclosed manifest’s share_id, graph, shapes and query hashes are verified independently. Cache keys use the archive digest; reads reverify the bytes and recheck trust. Cached archives work offline. A corrupt cache entry refuses rather than silently fetching a replacement. Fetch, integrity and validation failures name the requested pointer and leave no partial composition. Nested pointers are reported, not recursively loaded. The download limit is 16 MiB and the expanded archive limit is 64 MiB.

Library consumers supply their own trust and transport callbacks plus an ArchiveCache. Construction performs no network access. Invoke use_graph when a graph is first requested, rather than scanning and following every pointer. DirectoryCache provides atomic native archive writes.

Browser consumers

The explorer wasm bundle exports PointerExplorer. Supply the declaration archive and an authority archive chosen by the receiving application. The separate wasm/explorer/runtime/pointers.mjs helper provides bounded Fetch and optional CacheStorage persistence:

const engine = new PointerExplorer(declarationBytes, localAuthorityBytes);
const cache = await caches.open('project-pointer-archives');
const result = await usePointerGraph(engine, 'urn:project:api', authorize, { cache });
const rows = engine.query(`SELECT ?s ?p ?o FROM <${result.dataset}> WHERE { ?s ?p ?o }`);

authorize(pointer) is the receiving application’s policy decision, such as a Caboodle policy exported by its trusted host. It must return the literal boolean true; the archive cannot supply consent. The helper checks it before cache or network access. Rust checks trust and both integrity layers before composition, using the same implementation as native. Browser requests omit credentials and refuse redirects. Cached archives can be used by a fresh instance offline. This is a read-only named dataset; it does not use the ordinary explorer’s separate ROOT promotion ceremony.

Graph Labels & the Trust Lattice

Implementation status: 🟨 Substantially built. The axes (Freshness, Trust, PolicyClass, Durability, DataKind), the meet/join algebra 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: existing deployments are unchanged, and turning the gate on is a deliberate configuration act, not a side effect of upgrading. validate_placement (definition-time class↔placement conformance) and enforce_authority (below) are separate flags with the same opt-in posture.

On-demand evaluation: quipu_policy_check

The read-only half. Given a policy IRI (or an inline claim) and a target, it evaluates the claim over the committed graph and returns a Verdict:

{
  "predicate_id": "aegis:todo-needs-ticket",
  "target_ref": "ex:comment-42",
  "outcome": "unsatisfied",
  "evidence_hash": "fnv1a:9c2a41d07be3f118",
  "tier": "committed",
  "verifier": "quipu",
  "verifier_authorized": true,
  "signed": true,
  "signature": "…hex…"
}

outcome is satisfied | unsatisfied | unknown, bound to a reproducible evidence_hash over (predicate, target, valid_at, bound claim) — any verifier re-runs the same ASK over the same committed evidence and must get the same verdict. Checked, not trusted. valid_at/tx make the evaluation as-of.

Verdict signing

Verdicts are attestations, not claims. If the store holds a signing identity (ed25519 via ring, host-file key custody — explicitly v1), it signs the canonical message v1|predicate|target|outcome|evidenceHash|tier|verifier (src/signing.rs). No signing identity means no persisted verdict, never an unsigned one — a bare satisfied fact is forgeable by anyone who can write.

Write-gate verdicts are persisted as bitemporal aegis:Verdict facts (src/governance/verdict_facts.rs), staged during evaluation and flushed after the savepoint resolves — so a denied write still records its verdict, and unknown is recorded rather than skipped. Their evidence hash seals attribution: sha256 over predicate|target|outcome|writer|chain, binding the aegis:attributedWriter and aegis:principalChain into the signed seal. It is deliberately not a hash of graph state, which has no stable serialisation.

Linking a verdict to the judged transaction

The shipped governance shape permits an optional aegis:gatedTx: one xsd:integer greater than or equal to one. It identifies the committed transaction whose delta was judged, not the later transaction that persists the verdict. Transaction IDs are local to their originating store; cross-store audits must retain that store’s provenance.

Refused or rolled-back attempts omit gatedTx. Their attempted transaction row is rolled back, so its provisional ID is not a durable identity and must never be predicted or reused as the link. Use a durable write.refused event identity for a refusal correlation when the producer provides one. This shape does not introduce that event link. Historical verdicts may also omit gatedTx; absence means the transaction join is unavailable, not that the verdict was a refusal. Do not guess a backfill from timestamps or matching predicate/target pairs.

This is the schema contract, not automatic producer support. A producer adding gatedTx must bind it into the signed evidence and verdict identity: changing the judged transaction must change the seal and identity. Otherwise identical outcomes can collapse to one verdict with several transaction IDs, and the link itself is not authenticated. SHACL checks cardinality and datatype; it does not prove that the referenced transaction exists or that the signature binds it.

Replaying a refusal: the denial quarantine

A denied write is rolled back, so its verdict survives and the evidence behind it does not. From the governed graph alone a refusal can only be attested — the rules it cites were in force at the instant — never re-derived, because the post-state the gate judged is gone. The denial quarantine (src/governance/quarantine.rs) keeps that evidence outside the governed graph: two plain tables, not facts, that no SPARQL pattern, search, or fact read can reach. Refused content still never enters the graph.

For each verdict of a refused write, the gate records — while its savepoint still holds the post-state — a hash of the attempted delta (the writer’s datums, canonical, IRIs rather than term ids), a digest of the post-state it evaluated, a digest of the rule set in force, and the transaction the pre-state ended at. The entry is keyed by the verdict and sealed with the store’s signing key. That is the default, digest-only: nothing refused is retained. A graph opted into full retention also keeps the attempted delta itself, sealed.

[quipu.governance.quarantine]
enabled = true                                   # the default
retain_full = ["urn:quipu:graph:root"]          # or ["*"]; default []

quipu audit replay <verdict> rebuilds the store as of the refusal in a throwaway in-memory copy, applies the attempt — the sealed one, or one presented with --delta and checked against the digest first — and runs the real write path with the gate on. A refusal re-derives when the write is refused again with the same outcome, under the same rule-set digest, over the same post-state digest. A later rule change does not move the answer: the replay reads the rules as of the refusal, not today’s. A verdict whose write committed replays from that write’s transaction.

quipu audit quarantine purge erases sealed deltas by graph or by age and keeps each entry’s digests and seal, so the audit can still say “refused, content purged” and can still verify a delta someone presents. Full packs carry the digests (they back verdicts that travel) and never the sealed deltas.

Limits, stated. The post-state digest is over this store’s term ids, so a respaced copy replays its refusals as “post-state differs”. A refusal inside a multi-graph batch judged a post-state that held the batch’s earlier graphs, which no entry carries; its replay reports the divergence. The digest costs one scan of the live facts per denial (never per write) — about 2.4 s per million facts in a debug build.

The Phase-0 root of trust

Trust concentrates in a small, human-owned surface: aegis:VerifierRegistration facts carry each verifier’s name, hex aegis:publicKey, and the predicates it aegis:attests. A human registers a verifier; quipu never self-registers.

  • quipu_verifier_authorized — may this verifier attest this predicate?
  • quipu_verdict_verify — verify a signed verdict’s fields against the registry. 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.

The authority reader accepts both legacy and Quechua vocabulary spellings for Principal, principalId, and authorityOver, including mixed spellings. Identical aliases collapse; conflicting identity values grant no authority. Graph grants remain set-valued, and the reader retains its committed root-graph scope. This compatibility does not change principal identities or grant writers.

Audit & replay

The rest of src/governance/ closes the loop after the fact: quipu_audit_check (audit.rs) mechanically checks a recorded trace against the policy spec — coverage, class↔placement, outcome consistency, attribution — deterministically, never an LLM call. denial_replay.rs re-derives one recorded gate decision as of its transaction (see the quarantine above). replay.rs measures whether an advisory rule is ready for promotion to enforcement (liveness, both outcomes, recoverability). router.rs queues require-approval escalations as DecisionRequests with expiry (only an approval permits; a rejection outranks an approval); tree.rs, inventory.rs, and inheritance.rs reconstruct attribution, check dispatch-graph coverage, and detect constraint laundering under delegation.

See also

  • MCP tools reference — the seven governance and overlay tools.
  • REST API reference — the mirrored HTTP routes.
  • docs/design/policy-edit-hooks.md — the write-gate design and its backlog.
  • docs/design/signing-plane.md — where signing goes next (proposed).

Post-commit reactions and firing records

shapes/aegis-ontology.shapes.ttl defines aegis:Reaction and aegis:ReactionFiring for Chaski. These describe post-commit or scheduled reactions. Loading the schema does not start an evaluator, install a graph sink, or deliver an alert.

A reaction has one rdfs:label, triggerKind (event or schedule), condition (SPARQL SELECT ?focus), severity (info, warning, critical or page), and string owner. It requests one or more action values: alert, page, bead, or webhook. The existing string properties severity, owner, and schedule are reused.

Event reactions use a comma-separated eventTypes string; scheduled reactions use an ISO-8601 duration string in schedule, such as PT5M. Chaski’s current consumer supports time durations expressed with hours, minutes and seconds. enabled is an optional boolean; Chaski treats omission as enabled. The graph does not assert that default. A single rdfs:comment can explain the rule. SHACL checks the declared field types, cardinalities and enums. The consumer must also validate the trigger-specific configuration and query: the shape alone does not parse SPARQL or duration strings.

A firing records a transition, never each evaluation. It has exactly one firedBy link to a typed Reaction, one IRI focus, and one xsd:dateTime startedAt. Its stable IRI identifies the machine-emitted record; it does not require an rdfs:label. The rule and its durable firing record are BFO continuants, separate from the execution that produced the record. resolvedAt is absent while active and set once via /set when the condition resolves. A failed or timed-out query is unknown and must not resolve an existing firing. A later firing after resolution is a new occurrence. SHACL does not enforce these temporal rules or timestamp ordering.

Stable delivery identity

ReactionFiring optionally carries one string eventId. When present, derive the firing IRI deterministically from (reaction label, eventId) using an unambiguous encoding, for example a hash of a JSON array containing both strings. The event ID identifies an item and transition; including the stable reaction label separates reactions receiving the same event. If an adapter’s event ID does not uniquely identify its focus, it must include that focus in the identity input too. Do not substitute a send-time random ID.

For a Chaski outbox event, take startedAt from the frozen payload’s observed_at, never the retry time. Persist the graph-sink payload before its first send and reuse it unchanged after a crash. Stable identity alone does not provide idempotence when timestamps or descriptions change between attempts. Verify receiver state after an uncertain write; an unchanged response alone is not a presence check. Test send-then-crash replay at the sink. This schema does not itself implement an idempotent receiver or guarantee exactly-once external actions.

For an adapter that evaluates whether work has become unblocked, condition selects candidate WorkItems with aegis:blockedOn; the adapter decides their verdict. State that interpretation in the reaction’s rdfs:comment. Ordinary scheduled transition firings may omit eventId and retain a durable occurrence identity for each transition of the (reaction, focus) pair.

Knowledge Packs

Implementation status (2026-08-12): ✅ Built. src/pack.rs — Manifest, pack, pack_turtle, unpack, verify, content_hash — with 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, a pendant is the text share directory (named for the khipu’s pendant cords, each a self-contained record hanging from the primary cord; see Pendants). The artifact was called a qpack before; .qpack names still load for one release. The binary .pendant.db is internal plumbing, not the published artifact. A share is the canonical, line-oriented interchange surface that makes review and three-way history meaningful:

First prepare the store’s identifier-policy catalogue and load the shapes governing its data. These outward-share examples assume both prerequisites are present in the selected store:

quipu share --output graph-share
quipu share --output project-share --group-id project-a --shapes project-shapes --turtle

Every share contains normative, sorted export.nt, a non-empty shapes.ttl, and manifest.json. By default all loaded shape sets are included; repeat --shapes to select a subset. A store with no loaded shapes is refused unless the producer deliberately requests a shapes-free bundle with --no-shapes. The manifest records the stable store id, transaction anchor, graph and shapes hashes, scope, and optional parent-share hash. --turtle adds a derived export.ttl for people; it is not the graph identity. Stored queries registered against the scope travel as a sealed queries.ttl (select with --queries, omit with --no-queries) and install in the receiver’s registry under a pack namespace — see Stored queries. The anchored transaction timestamp—not the wall clock—is used for created_at, so exporting unchanged state with the same options is byte-identical. Use --parent-share sha256:... when continuing a lineage.

Before any bundle is returned or its output directory is published, Quipu evaluates every block-tier InternalIdentifierPattern present in the local ROOT and named graphs against the exact graph, shapes, and optional Turtle bytes. A hit refuses the entire share and leaves no partial directory. The gate never rewrites a match: IRIs are entity identity, so replacing a private-looking IRI would silently create a different graph. Warning-tier rules do not block. Rules must be complete within one graph; statements split across graphs are not combined into a rule. Duplicate rules are checked once. Catalogue location does not change the selected share payload scope.

An empty block-tier catalogue refuses outward sharing: the CLI exits 2 (cannot verify), distinct from exit 1 for a matched identifier and exit 0 for a checked, clean share. Load a catalogue before sharing outward. The explicit --destination internal path remains available for internal transfers.

Remote callers can request the identical artifact without access to the server’s filesystem using POST /share. The response is {"manifest": {...}, "files": {"manifest.json": "...", "export.nt": "...", "shapes.ttl": "..."}}; export.ttl is also present when turtle_view is true. Request fields mirror the CLI options: scope, shapes, no_shapes, parent_share, and turtle_view. An optional max_bytes may lower the server’s 8 MiB response cap. Every string in files is the exact UTF-8 file content, so consumers reconstruct the directory without reimplementing manifest canonicalization, hashes, or share IDs.

JSON-LD is deliberately not a share payload in v1. Quipu’s JSON-LD endpoint is an entity-oriented negotiated view, not a canonical RDF-dataset serializer; adding it would create bytes whose ordering and identity contract are weaker than the sorted N-Triples producer. export.nt remains normative and export.ttl remains the optional human-readable derived view.

What goes in a pack

  • Facts — the current facts of the source graph, written through the ordinary transaction path so term ids are correct by construction (a raw row copy would carry the producer’s private id assignment; a pack never does).
  • Shapes — named explicitly with --shapes, since shapes are global and carry no graph linkage. The selection is recorded in the manifest.
  • Stored queries — named with --queries, so a domain layer ships the competency questions that make it usable on arrival, not just its triples.
  • Labels — the graph’s freshness/trust/policy label travels with the pack, so a consumer can compose it without a side channel.
  • Vectors (optional, --with-vectors) — embeddings re-keyed by IRI. Restricted to the built-in SQLite vector backend; a delegated or LanceDB backend cannot be enumerated, so the flag is refused rather than silently producing a pack with no vectors.
  • The manifest — pack format, name, producer-declared semver, term space, content hash, creation time, source graph, producer identity, and row counts.

Creating a pack

Omit the graph argument to pack the ROOT default graph, or explicitly use urn:quipu:graph:root. The manifest records that IRI and defaults the name to root. ROOT packs contain only current ROOT facts, excluding named graphs and materialized inference graphs. --with-vectors carries embeddings for terms in the pack. ROOT uses its own graph-label row, without borrowing labels from an interned term with the same IRI. Verification and Turtle export use the same scope. Unpacking defaults to ROOT for these packs; --into <iri> selects a named graph.

quipu pack --out root.pendant.db --with-vectors
quipu pack urn:example:graph --out domain.pendant.db --name "domain" --version 1.0.0
quipu pack urn:example:graph --out domain.pendant.db --shapes s --queries q --with-vectors

The output is a single clean file — the build goes through VACUUM INTO, so no -wal/-shm siblings ride along beside the file you actually copy.

--format turtle emits an interop bundle instead: a directory of graph.ttl, shapes.ttl, queries.json, and manifest.json, for consumers that are not Quipu. It is export-only — nothing unpacks it — but it carries the same content hash as the .pendant.db form, because the hash is computed from canonical content, not from the emitted bytes.

Unpacking

quipu unpack domain.pendant.db --into urn:local:domain --db my.db

unpack materializes the pack’s facts into a local graph (defaulting to the pack’s own graph IRI) and installs its shapes and stored queries through the versioned write paths — never an overwrite of registries the consumer already has. The report states what arrived: facts, shapes, queries.

Verification

quipu pack --verify domain.pendant.db

Verification recomputes the pack’s content hash and compares it to the manifest. The hash is sha256 over the lexically sorted, deduplicated N-Triples of the graph plus the packed shapes, queries, and labels — sorted because the store’s own emission order depends on term-id assignment, and the hash must describe the content, not the producer. Two stores holding the same triples hash the same.

This makes the hash the pack’s citable version reference: pin it in an environment, promote the same hash-verified file dev → staging → prod, roll back by re-attaching the prior pack. Never re-pack between environments — that creates a different artifact and defeats the hash as the promotion identity. There are no signatures; verification is integrity, not provenance.

Use cases

  • Shipping a distilled derived layer — pack a curated or derived graph with its shapes and competency queries, and hand consumers a verifiable artifact instead of access to the producing store.
  • Small, portable subsets — cut a wasm-sized slice of a larger graph for embedded or edge deployment, where a single self-describing file matters.
  • Environment promotion — one artifact, one hash, attached identically in each environment.

Built vs designed

Built: quipu pack, quipu unpack, quipu pack --verify, the Turtle interop bundle, vector export on the SQLite backend, the stored-query registry, and --space <term-space> on export — the pack is built in space 0 and shipped through the same respace machinery as quipu db respace, so a consumer can attach it as-is without an id collision (.pendant.db packs only; a Turtle bundle carries IRIs, not term ids, so --space does not apply there). Designed but not yet built: the retrieval-policy block (default-dataset and recommended-floor facts a consumer could SPARQL), and delta/diff packs — v1 packs are whole-layer, read-only artifacts.

  • 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. Follow another related entity. Each click centers that entity in the map. The card shows its types, with Properties and Provenance disclosures. Read the source opens the repository file; Inspect facts & edit opens its local facts. SPARQL, export and the propose-as-PR flow remain available.

Related entities come from queries against the whole loaded graph. Arrows mark outgoing (→) and incoming (←) links, and every link names its predicate. The list includes entities outside the initial contributor overview, such as types and provenance sources. An engine-truncated answer is explicitly marked incomplete. The URL’s entity parameter identifies the selection: browser back and forward, reloading, or opening that URL in another tab restores it in the map.

The main cord joins five pendant cords: vision/book, decisions/rules, episodes, code, and reading trail. These are deterministic topic/type groups, not inferred Louvain communities. Entity IRIs sort the knots along each pendant; following a link can add entities beyond the initial overview. Drag or use arrow keys to pan; pinch, use the mouse wheel or the zoom buttons to zoom. All clusters fits the reading map; tap a cluster to enter it. At that overview scale, cluster controls replace individual node controls. Overview cords show up to eight actual knots in IRI order at reduced scale. Visible node targets and controls are at least 44 CSS pixels, independent of zoom. The node card and search results provide text navigation without hovering or precise graph taps.

Knot size and relation details

Knot radius is 5 + min(13, 3 × sqrt(degree)) CSS pixels. Degree counts distinct incoming and outgoing (subject, predicate, object) links in the currently loaded map, including newly explored neighbors. It is local view importance, not global PageRank. Selection changes highlighting, never importance size. The on-screen legend states the metric. Geometry is static; no force simulation or animation is needed, and reduced-motion preferences disable transitions.

Click a relation line, focus it and press Enter/Space, or use Relation details in the entity card. The panel names the predicate, subject, and object as full IRIs, with buttons to inspect each endpoint or the predicate itself. Subject provenance properties are labeled as subject-level evidence, not edge provenance.

Fact history, valid time & transaction time shows the receiving store’s assertions/retractions, valid intervals, transaction IDs/timestamps, sources and actors. Relation history filters by the exact predicate and IRI object. Entity history covers its outgoing facts in ROOT, matching this map’s dataset. History is capped at 2,000 facts and explicitly marked incomplete at the cap. It is not an all-graph or producer audit view. A static RDF share does not carry the original producer transaction log: receiver import time must never be presented as original author time. Episode/source properties retain whatever the pack actually states. An older browser bundle without factHistory displays an unavailable message; it never fabricates history from current SPARQL rows. The query and edit paths still wait for the normal native verification ceremony, including saved previews.

Ask the same question with SPARQL

The canned How does the vision guide code? query follows two explicit edges and checks the indexed module’s path. It does not infer that a similarly named file implements a decision.

PREFIX q: <https://quipu.dev/knowledge/>
SELECT ?decision ?module WHERE {
  q:vision q:guides ?decision .
  ?decision q:governs ?module .
  ?module a ?type .
}

Keep the released knowledge current

The release producer runs scripts/build-contributor-knowledge.mjs after Bobbin indexes the repository. Its curated registry is docs/knowledge/contributor-stories.json. Each story names a source passage and code witnesses; generation refuses a missing passage, missing file, or private identifier in the curated text. The book order comes from SUMMARY.md.

The producer includes this projection in its ordinary CONSTRUCT share scope, then adopts shapes, imports and promotes into a fresh receiver. The contributor proof requires all six knowledge classes, four source-backed episodes, and vision → decision → typed code paths. A code-only pack cannot pass that proof. The release workflow ships that same output as its repository pendant asset.

Use just contributor generate to inspect the deterministic projection. QUIPU_BIN=/path/to/quipu just contributor pack /tmp/repository-share runs the full producer and receiver proof. The output directory must not already exist. The page consumes the released artifact when the documentation build stages it; a source change alone does not change the pack already on the site.

The browser’s delta producer uses the same 128 MiB payload budget as its full export, so adding contributor knowledge does not disable proposing an edit. The default remote delta budget remains 8 MiB. During release publication, docs allow missing, not-yet-staged assets for 30 minutes. Once staged, every asset must be published byte-for-byte; beyond the grace a missing asset fails.

SHACL Validation

SHACL (Shapes Constraint Language) lets you define what valid data looks like and enforce it at write time. When an agent or user tries to add facts that violate a shape, Quipu rejects the write and returns structured feedback explaining exactly what’s wrong.

Why Validate?

Without validation, agents can write anything:

hw:korimako hw:cpuCores "lots" .   # Should be an integer
hw:korimako a hw:Host .            # Missing required hostname

With SHACL shapes loaded, Quipu catches these problems before they enter the fact log.

Class targets and subclasses

A sh:targetClass selects instances of that class and of its subclasses, following the data graph’s rdfs:subClassOf edges transitively. For example, if the validation data declares ex:Step rdfs:subClassOf ex:Action, an entity typed only ex:Step must satisfy a shape targeting ex:Action. Cycles and multiple inheritance do not duplicate a focus node within the same shape. This follows SHACL class-based targets.

Target selection leaves asserted data unchanged. It does not manufacture rdf:type ex:Action values for a constraint such as sh:hasValue on rdf:type. An explicit-type requirement still needs an explicit assertion. Subclass declarations present only in the shapes graph are not merged into the validation data by this mechanism. Write vocabulary admission remains a separate check against loaded shapes.

Defining a Shape

A shape declares constraints for a class of entities. Here’s a shape that says “every Host must have exactly one hostname (a string) and at least one cpuCores (an integer)”:

@prefix sh:  <http://www.w3.org/ns/shacl#> .
@prefix hw:  <http://example.org/homelab/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

hw:HostShape
    a sh:NodeShape ;
    sh:targetClass hw:Host ;
    sh:property [
        sh:path hw:hostname ;
        sh:datatype xsd:string ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path hw:cpuCores ;
        sh:datatype xsd:integer ;
        sh:minCount 1 ;
    ] .

Loading Shapes

CLI

quipu shapes load --name homelab --file shapes/homelab.shapes.ttl --db homelab.db

REST API

curl -s localhost:3030/shapes -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "action": "load",
    "name": "homelab",
    "turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> .\n@prefix hw: <http://example.org/homelab/> .\nhw:HostShape a sh:NodeShape ; sh:targetClass hw:Host ; sh:property [ sh:path hw:hostname ; sh:minCount 1 ] ."
  }'

Listing loaded shapes

quipu shapes list --db homelab.db

Declaring ontology classes and properties

The bundled governance shape set sanctions named ontology resources typed rdfs:Class and rdf:Property. Load that set before an adapter imports its ontology declarations through /knot. These declarations require IRI subjects.

Declaring <urn:example:Task> a rdfs:Class in the data graph does not authorize instances of urn:example:Task. The write vocabulary still comes from loaded shape sets; add and load a shape targeting the intended class before writing its instances. Unknown types continue to be refused.

Demoted derivation records

shapes/demoted-derivation.ttl defines the product evidence schema in http://quipu.local/graph#, matching the truth-maintenance runtime vocabulary. Ship and load this set together with the compatible demotion runtime.

A DemotedDerivation is an IRI identifying an rdf:Statement. It retains exactly one rdf:subject, rdf:predicate, rdf:object, premiseGraph, deriverSource, promotionTx, invalidationTx, and prov:invalidatedAtTime. Subject, predicate and premise graph are IRIs; the object may be any RDF term. The source is a string, transaction IDs are integers at least one, and the invalidation time is an xsd:dateTime.

derivationState must be exactly one string: unsupported or resolved. Resolution preserves the evidence record. Extra annotations are allowed. The shape rejects missing or ambiguous fields and incorrect RDF term kinds. It does not establish atomic demotion, deterministic identity, exclusion from premise evaluation, or authorization to promote; those are runtime guarantees. Loading the shape does not repair data or promote unsupported evidence.

Validation in Action

Recorded text-rule cases

The bundled aegis-ontology shapes allow aegis:mustMatch and aegis:mustNotMatch on TextRule and InternalIdentifierPattern. Each property accepts zero or more xsd:string values: example text that should trigger the rule, or should stay silent, respectively. Existing rules without cases remain valid. IRIs, language-tagged strings and numeric literals are not case text.

Schema validity does not establish enforcement readiness. A readiness checker must require at least one case of each polarity and execute every case through the rule engine against a current projection. Missing cases or indeterminate execution must not pass. These text-only examples do not prove path or repository exemptions; contextual cases require a separate model. Recording cases does not change a rule’s enforcement tier.

Rejected writes

Try to add a Host without a hostname:

quipu knot - --db homelab.db --shapes shapes/homelab.shapes.ttl <<'EOF'
@prefix hw: <http://example.org/homelab/> .
hw:badhost a hw:Host .
EOF

Quipu rejects it with structured feedback:

{
  "conforms": false,
  "violations": 1,
  "issues": [
    {
      "severity": "Violation",
      "focus_node": "http://example.org/homelab/badhost",
      "path": "http://example.org/homelab/hostname",
      "component": "MinCountConstraintComponent",
      "message": "Less than 1 values for hw:hostname",
      "source_shape": "http://example.org/homelab/HostShape"
    }
  ]
}

This feedback is designed for agents — structured JSON with enough detail to fix the problem automatically.

Value sets across writes

A valid delta can still violate a stored upper bound. For example, writing ex:item ex:value "A" and later ex:item ex:value "B" must not bypass sh:maxCount 1 just because each request carries one value.

Write admission checks predicate and inverse-predicate sh:maxCount, sh:uniqueLang true, sh:disjoint, sh:lessThan, and sh:lessThanOrEquals against the staged current facts inside the same SQLite savepoint as the write. The second distinct value is rejected and rolled back. Split writes cannot bypass language-tag uniqueness or comparisons between the stored values of two properties. Reasserting an identical RDF term remains idempotent; retracting the old value and asserting its replacement in one transaction is valid. Historical facts remain available.

This check covers CLI knot --shapes, the knot and SPARQL Update entrypoints, inline episode shapes, conditional writes with loaded shapes, and configured validate_on_write transactions. It preserves each entrypoint’s existing reject/advisory policy. Low-level transactions without explicit shapes or write validation configured do not acquire an implicit shape policy.

Value-set validation uses each destination graph’s own values. ROOT type and subclass context can select targets in a named graph; ROOT property values and unrelated third graphs do not contribute to its sets. The context is bounded to touched subjects and referenced objects, with at most 100,000 facts and 1,024 classes. Exceeding these limits, or encountering another complex value-set property path, refuses admission with cannot verify rather than accepting partial evidence.

Capability and incoming-context obligations use the batch validator’s compiled shape graph. Its deactivation flags suppress obligations for inactive node shapes and their solely dependent properties. A shared property still imposes its obligations when another active shape or its own target reaches it; logical and qualified shape references retain that conservative check. Unsupported active paths still refuse.

Other constraints retain payload validation and contextual type/required-field repair. This is not whole-store conformance checking or automatic revalidation of every dependent node after changing a schema, class hierarchy, or target relation. Standalone validate remains a check of the supplied data only.

Supported Constraints

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 ..."
  }'

Domain shapes in practice

Domain constraints belong to their domain producer. The staged Camayoc domain profiles separate infrastructure, personal media and software production. The worked example explains their compatibility and rollout. The transition retains the existing bundled Aegis shapes until destination publication, pinning, loading and validation have been verified. They cover:

  • LXCContainer, ProxmoxNode, BareMetalHost — compute 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

An offline check can use the producer-owned file:

quipu validate --shapes ../camayoc/shapes/aegis/homelab-infra.shapes.ttl --data example.ttl

Best Practices

  • Load shapes before data — shapes must be present to validate incoming writes
  • One shape set per domain — group related constraints (e.g., “homelab”, “code”)
  • Start permissive, tighten later — begin with minCount constraints, add datatype checks as your ontology stabilizes
  • Use validation feedback — the structured JSON is designed for automated remediation by agents

Episode provenance activities

The shipped shapes/provenance.ttl includes a minimal prov:Activity profile for episode provenance. It requires a string rdfs:label, which the episode producer emits. Body and source fields remain optional so historical episodes remain valid, and multiple retained comments are permitted. This profile does not authorize a new write path or change import/promotion checks. A receiver must load its reviewed shapes and verify the loaded vocabulary before importing activity facts; a source file alone does not establish runtime governance.

Write-time shape pre-parsing uses the batch validator’s fallback base IRI for relative Turtle identifiers, including relative manifest references in shape documents. An explicit Turtle base still takes precedence. This compatibility does not relax cross-write constraint checks or the atomic refusal of invalid post-state data.

Domain shapes in practice

Quipu enforces engine contracts: provenance, historical evaluation, admission, content-bound verdicts, certification, trust roots and single-use nonces. A domain producer defines what a host, a media record or a work item means. Those are separate responsibilities even when the same validator checks both.

The Camayoc domain example stages three domain files:

  • homelab-infra.shapes.ttl: hosts, networking, services and storage.
  • personal-media.shapes.ttl: people, accounts, libraries and entertainment.
  • software-factory.shapes.ttl: crew and software production.

Camayoc records the exact upstream source and each shape’s destination. Existing IRIs remain unchanged. A separate compatibility payload retains the original bytes while the source transition is reviewed. The destination’s union/parity tests detect a lost constraint; class competency queries have populated positive and missing-label negative fixtures. Neither test claims production acceptance.

Vocabulary before custom classes

Quechua’s trim decisions already distinguish reusable governance terms, public replacements and infrastructure-specific vocabulary. Keep those decisions: a domain-specific class does not become a new public Quechua term merely because it moved files. Public replacements are explicit profile changes, not namespace renames or automatic rewrites of existing data.

Work uses the Seeds profile

Seeds owns its work-item profile. Camayoc references its pinned source rather than maintaining another WorkItem gate. New records use schema:Action; historical WorkItem/Bead names remain deprecated compatibility vocabulary. The existing replacement/subclass chain does not itself rewrite stored types.

An iCalendar Vtodo remains a separate node linked through skos:exactMatch, as Seeds’ profile specifies. Do not replace that relation with owl:sameAs or claim that the records share an IRI.

A transition without a validation gap

  1. Review and publish the producer’s destination packs and competency queries.
  2. Propose exact released source pins through the deployment repository.
  3. Load destination bytes before removing original source ownership, preserving the existing loaded-set naming/count contract and classification guard.
  4. Revalidate existing data with no new violations and prove the intended alias queries, using the deployment’s bounded, authorized reader.
  5. Only then remove compatibility source copies. Retain prior pins and the prior engine artifact for the normal scoped rollback.

This chapter describes the source transition. It grants no load, unload, private data migration or deployment authority. The original Quipu copies remain until the destination gates have been measured.

Warning-first propagation

The source CI compares immutable RDF snapshots, including anonymous constraints, against a revision-scoped term/CQ catalogue. It retains a JSON report and warns about unresolved entries. Read and parse failures remain failures. Gaps become blocking only after the October backfill and a separate enforcement decision. Anonymous node shapes, including the four standard target forms, are compared as a multiset of canonical constraint components. Their report-local key _:anonymous-NodeShapes identifies that collection, not a production IRI. Evidence for it must match the complete collection digest. Blank-node spelling and declaration order do not change it; changed constraints and duplicate components do. Typed node shapes without targets are included as well. An event receiver must deduplicate by exact merge identity and retain uncertain writes for native readback; source CI alone does not create a task or change a pin.

Warning check in the existing CI target

The existing Pre-commit checks job runs the shape-propagation hook on every invocation. Its Python environment pins rdflib 7.6.0, runs the RDF and CI counterexamples, resolves the exact event base and checked-out head, and reads Quechua/Camayoc inputs at fixed commits. Shallow CI checkouts explicitly fetch source history. Execution and input-read errors fail; evidence gaps remain WARN. The complete delimited JSON report is retained in the job log, including source commits and input hashes. This replaces the earlier proposed separate workflow steps and report artifact; the workflow files themselves are unchanged.

For an offline local check, set QUIPU_PROPAGATION_QUECHUA_REPO and QUIPU_PROPAGATION_CAMAYOC_REPO to existing source repositories. That mode reads the pinned Git objects rather than working files, never fetches, and refuses a missing object without falling back to network. With neither set, public source reads have byte and timeout limits. No service credential or shape write is used.

Schema Evolution

Quipu enforces strict ontology rules via SHACL shapes. When an agent writes data that fails validation because the schema is too restrictive or missing a class/property, it can propose a schema change instead of asking a human to edit shapes manually.

The Proposal Workflow

Agent writes data → SHACL rejects it → Agent submits proposal → Approver accepts/rejects

Proposals follow a simple lifecycle: pending → accepted or pending → rejected. Every proposal requires an explicit approver — there is no auto-accept.

Proposal Kinds

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": "reviewer-1", "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.
  • Over MCP and REST there is no default approver: accept and reject refuse a request without decided_by. The quipu propose accept|reject CLI takes --decided-by <id> (or --decided-by=<id>); without the flag the CLI records cli:<QUIPU_AGENT or USER>, and with neither set it refuses rather than record an anonymous decision. A flag given with no usable value also refuses; it never falls back to the environment. A capability-based authorization system (quipu.schema.approve) is a future concern.

OWL Ontology Layer

Quipu supports OWL 2 RL reasoning through a built-in ontology engine. OWL ontologies define class hierarchies, property characteristics, and constraints that Quipu uses to materialize inferred facts, and — when owl.validate_on_write is enabled — enforces at write time.

Loading an Ontology

Ontologies are OWL axioms expressed in Turtle format. Load one via the CLI or MCP tool:

quipu ontology load aegis-ontology ontology.ttl --db quipu.db

Or via the MCP quipu_load_ontology tool:

{
  "action": "load",
  "name": "aegis-ontology",
  "turtle": "@prefix owl: <http://www.w3.org/2002/07/owl#> ...",
  "timestamp": "2026-04-13T00:00:00Z"
}

On load, Quipu:

  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.

Materialization

Materialized facts are written with source = "owl:materialize" into ROOT’s companion inferred graph (urn:quipu:graph:root#inferred, quipu-0b6) — quarantined by placement, composed back in with FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>. When an ontology changes, derived facts can be re-materialized.

Materialization runs to fixpoint across axiom families: a type introduced by rdfs:range feeds the subclass closure of the next pass, and passes repeat until one derives nothing new. Each pass derives only facts not already present, so re-running materialization at fixpoint is a no-op and the report counts stay honest.

Materialization can also stay live: with [quipu.owl] reactive_materialize = true (requires the owl and reactive-reasoner features — release full builds have both), the server re-runs materialization whenever a committed write touches vocabulary the loaded ontologies mention, so the closure extends as members arrive instead of going stale after load. This setting defaults to true; set it to false to disable the per-write materialization cost.

ex:fido a ex:Dog .
ex:Dog rdfs:subClassOf ex:Mammal .
ex:Mammal rdfs:subClassOf ex:Animal .

After materialization, ASK FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred> { ex:fido a ex:Animal } returns true — a plain ASK does not, because the entailment lives in the companion, not beside its premises.

Write-Time Validation

Two OWL constraints are enforced at write time by default when built with the owl feature. Set owl.validate_on_write = false only for an explicitly informal deployment. Before adopting new axioms, measure the existing graph: turning an incompatible declaration into live policy can reject future writes that touch historical drift.

Disjoint classes: If ex:Person owl:disjointWith ex:Robot, then an entity cannot be typed as both. Attempting to assert ex:alice a ex:Robot when ex:alice a ex:Person already exists returns a structured error and the write is rolled back.

Functional properties: If ex:ssn a owl:FunctionalProperty, an entity can have at most one current value. A later value supersedes the earlier one while preserving history; two competing values in one batch are rejected because the write provides no ordering.

Both reject the whole transaction: the constraint runs inside the write’s savepoint, so a violating batch commits nothing. Violations are reported together rather than one per round-trip.

Constraints are evaluated against the union of all loaded ontologies, and a load or remove through POST /ontology takes effect on the next write.

Feature Flag

OWL support is behind the owl feature flag:

cargo build --features owl
cargo test --features owl

The shacl feature continues to work independently.

The Reasoner

Raw facts tell you what is. The reasoner tells you what follows.

Quipu’s reasoner is a stratified Datalog engine that reads the EAVT fact log, applies rules, and writes derived facts into the store. Derived facts are quarantined by placement (quipu-0b6, 2026-08-27): they live in the premise graph’s companion inferred graph — <graph>#inferred, with ROOT’s at urn:quipu:graph:root#inferred — never beside the facts they were derived from. A plain query sees asserted facts only; a query that wants the closure composes it explicitly:

ASK FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred>
    { <traefik> <runsOn> <korimako> }

Within the companion, derived facts behave like any other fact — SPARQL, SHACL, time travel all work — their source tag traces to the rule that produced them, and the graph itself carries aegis:sourceKind "inferred" and a quipu:derivedAsOfTx freshness note (the premise-side transaction head the closure last reflected). The #inferred suffix is reserved: external writes to a companion are refused, so entailments cannot be forged by hand. Pre-regime stores move their legacy-placed derivations across with quipu db migrate-inferred.

Why Derive Facts?

Consider a homelab with containers running on hosts. You’ve recorded:

traefik  runsOn  webproxy
webproxy runsOn  korimako

A human reads this and concludes “traefik transitively runs on korimako.” But SPARQL doesn’t know that unless you either:

  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 korimako goes down, which derived dependencies break?”
  • “What’s the blast radius of upgrading this library?”

Provenance: Tracing Derived Facts

Every derived fact carries metadata that traces it back to its source:

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

Four-valued rule answers

rule-query reads two explicit evidence predicates for one grounded claim. It does not change SPARQL ASK, Datalog negation-as-failure, or materialization. The support bits are independent:

PositiveExplicit negativeTruth
truefalsesupported
falsetruerefuted
truetrueboth
falsefalseneither

Only complete scoped reads can establish neither. A failed read, missing purpose profile, cap, invalid judgment, or unsupported scope produces status: unknown with truth: null. Transport failures are also UNKNOWN to a consumer. A successful health check cannot establish evidence completeness. Configured ROOT label floors remain enforced; a quality refusal is UNKNOWN, not a filtered empty evidence set. Label floors are not access control.

Request and interfaces

CLI: quipu rule-query request.json --db existing.db. HTTP: POST /rule_query. MCP: quipu_rule_query. All use the same native query implementation and response types.

{
  "subject": "urn:claim",
  "positive_predicate": "urn:supported-by",
  "negative_predicate": "urn:refuted-by",
  "object": {"iri": "urn:evidence"},
  "judgment_policy": "urn:policy:referee",
  "witness_limit": 64
}

Version 1 supports IRI and plain-string objects ({"str":"text"}), current primary ROOT only. Predicates must differ. IRIs must be absolute. Unknown fields, named graphs, temporal selectors, and unsupported term variants refuse instead of selecting another scope. Attached/composed stores are unsupported. The CLI opens an existing database read-only and does not create or migrate it. Only ROOT assertions participate. The normal reasoner/OWL companion inferred graph is not folded into this scope, and the query does not run an engine or certify derived closure. Use an explicitly composed SPARQL dataset when that different scope is required.

CLI and direct HTTP JSON decoding reject duplicate fields. MCP arguments have already become a JSON object in the upstream protocol parser; this tool cannot recover duplicate keys discarded before its invocation. All adapters reject undeclared fields in the object they receive.

Response and completeness

The protocol is quipu.rule-answer/v1. A complete response contains evidence and judgment. Evidence includes typed truth, positive_supported, negative_supported, positive/negative witness arrays, stable store_id, transaction watermark, ROOT graph IRI, and scope current_primary_root_all_physical_assertions. claim_context binds the exact grounded claim, its digest, and the expected judgment policy to the response, so retained results do not depend on an out-of-band request to identify what was checked.

Each witness retains the asserting transaction, valid-from spelling, and nullable source. Sources are historical or caller-declared metadata, not authenticated authority. Physical source owners and legacy string representations participate; the witness cap (1–256 per side) is conservative across them. Exceeding it yields UNKNOWN, never a truncated answer. A missing source remains explicit. Metadata is also bounded before loading strings: 16 KiB per field and 64 KiB per support side. Exceeding either byte bound yields UNKNOWN.

Support and judgment reads share one SQLite read transaction. Reads do not intern missing terms, materialize facts, admit verdicts, or modify the ledger. Witnesses and admitted verdict identifiers have deterministic order. Removing one side of both leaves the other; unrelated claims are unaffected.

Every response says action_authority: not_granted. There is no boolean conversion. Before adopting this answer in a boolean consumer, define and review an explicit policy for conflict, neither, deferral, and UNKNOWN.

Separate judgment disposition

Judgment is none_recorded, deferred, or resolved. It is not a fifth truth value. none_recorded means absence from the complete supported current ledger scope, not absence of judgments elsewhere. An unresolved or unverifiable ledger is UNKNOWN. Deferral and resolution preserve the support bits, including both. The response names the judgment scope current_root_records_with_retained_marker_history. Retained historical markers locate withdrawn or altered judgments; retracting a marker cannot turn a deferral into none_recorded. Those records must still verify in their current state or the answer is UNKNOWN. This is not a certificate of history outside the retained primary store, and it does not provide historical snapshot selection.

The purpose profile in shapes/rule-answers.ttl targets only Decisions carrying http://quipu.local/rule#judgmentClaim. It reuses the governed Decision class; legacy Decisions outside that marker remain outside the profile. The reviewed profile must be loaded as rule-answers with exact source bytes before the query can establish judgment completeness. Source presence alone does not load it. Normal schema review and delivery are required; this query never promotes it.

The marker is SHA256 (lowercase hex) of UTF-8 JSON serialized in this field order: subject, positive_predicate, negative_predicate, object, graph. The graph is urn:quipu:graph:root; object uses the request’s externally tagged IRI/plain-string form. Native serialization defines the exact escaping. This is not a claim of JSON Canonicalization Scheme conformance. Policy and witness cap are excluded, so a record with a mismatching policy is detected rather than disappearing into absence.

The Decision’s rdf:value is strict quipu.rule-judgment/v1 JSON with protocol, the full claim above, named authority, nonempty reason, disposition (deferred/resolved), and nullable predecessor. The existing decisionPolicy must match the request. Resolution’s predecessor names the earlier admitted verdict and exact decision_digest (sha256:...). Earlier records remain. The existing Decision carrier also retains outcome: approve, by equal to the named authority, and evidenceHash: sha256:<claim-digest>. Its ordinary approve option endorses the sealed disposition record. These fields satisfy the existing Decision shape; the new purpose profile does not replace it.

Only an existing admitted sealed Decision counts: the normal seal verifier checks content, signature, historical registered HumanTier policy authorization, expiry at admission, and the native nonce-spend/verdict transaction binding. The named authority must match that verifier. The admitted outcome must be approve; deferred/resolved comes from the sealed JSON, never from an unsealed label. A copied signature or caller’s authorization assertion cannot supply this proof. No signer registration or authority policy is added.

The reader bounds records, entity bytes and references before invoking the seal verifier. The purpose profile supports scalar sealed Decision fields, avoiding unbounded blank-node CBD traversal. Unsupported fields, missing predecessors, tampering, mismatched policy/claim/authority, orphan resolution, and competing lifecycle heads yield UNKNOWN. Version 1 supports one deferral and its retained resolution, not arbitrary judgment histories or reopening.

Standards comparison

RIF-FLD Second Edition, sections 3.2 and 3.6 permits multiple truth values and distinguishes symmetric negation from NAF. The two-bit wire envelope here is an implementation contract, not an asserted RIF dialect or a RIF-prescribed JSON format.

LegalRuleML 1.0, sections 4.2.1 and 4.3.4 discusses conflicting defeasible conclusions and named authority metadata. This foundation reports both supports; superiority and defeasible adjudication are separate. Named metadata is distinct from local seal authorization. Neither standard prescribes this envelope or its judgment lifecycle.

Embeddings and Semantic Search

Quipu answers two different kinds of question. Lexical retrieval (SPARQL, CONTAINS, exact match) needs nothing but the fact log. Semantic retrieval (/context, quipu_hybrid_search, /search) needs vectors, and vectors need an embedding provider you configure yourself.

Automatic embedding is enabled by default when a provider is configured. No model is downloaded automatically. Without model and tokenizer paths, lexical graph operations still work and the loader reports that semantic indexing is unavailable. Set auto_embed = false to disable automatic vector writes. This does not disable model initialization, explicit semantic queries, or existing import identity resolution, which may also use the provider.

Writes and staged imports

Native CLI writes (knot, ingest, episodes) and server writes use the configured provider. A share import indexes eligible, uniquely owned entities in its staging graph. It does not adopt the graph into ROOT or turn on named-graph semantic search. Explicit search scope and search policy still apply.

Import responses include embedding.state, candidate/indexed counts, the local provider recipe when available, and an explanatory message. ready certifies indexing for eligible staged entities; partial preserves entities already owned by ROOT or another graph. pending, unavailable, failed, disabled, external, and quarantined must not be described as searchable completion. Vector-index inference uses bounded batches outside the server’s global writer lock. Existing import identity resolution remains a separate admission step; this indexing status does not certify that step’s lock latency.

A share may declare manifest.embedding with an ONNX basename, model_sha256, tokenizer_sha256 (lowercase SHA-256), and dimension. These fields participate in the share identity. The native provider hashes its actual files and validates the model output dimension. Import with an available mismatched provider refuses before staging. Legacy shares without this recipe remain importable; local indexing does not attest which model the publisher intended. Re-importing with a real provider recomputes eligible vectors instead of treating matching text as proof of model identity.

Existing graph data can still use the explicit startup --embed-backfill or bounded POST /embed_backfill_graph operations when needed.

Browser explorer boundary

The explorer uses its separate JavaScript ONNX adapter and self-hosted model assets. It loads the runtime on the first semantic query and rebuilds its in-memory vectors when the ROOT label/comment snapshot changes. It does not copy staged named graphs into that index. Native import readiness does not certify browser indexing or browser model-byte identity; the current browser adapter does not validate manifest.embedding against its JavaScript model. A loaded pendant alone must not be presented as browser semantic readiness.

Configuring a provider

Two things are required, and having one without the other is the common failure:

  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. A partial or invalid configuration is an error; omitting both produces a visible unavailable status. The same setup is used by native CLI loaders and the server.

Telling whether it worked

Three signals, in the order you will meet them.

At startup, a loaded provider announces itself:

embedding provider ready (dim=384, auto_embed=true)

--embed-backfill is fatal when it cannot run. The flag is an explicit request for a capability, so a server that cannot honour it exits non-zero rather than starting up without it. The error names the configuration it needs. Drop the flag if you deliberately want to serve without embeddings.

Every retrieval response carries its own status. /context, quipu_unified_search, and quipu_hybrid_search all report:

{
  "entities": [],
  "summary": {
    "total_entities": 0,
    "direct_hits": 0,
    "embeddings": { "configured": false, "embedded_entities": 0 }
  }
}

Read it as:

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:korimako      a hw:Host ;
    rdfs:label      "korimako" ;
    hw:hostname     "korimako.example" ;
    hw:cpuCores     "8"^^xsd:integer ;
    hw:memoryMB     "32768"^^xsd:integer ;
    hw:role         "hypervisor" .

hw:palau      a hw:Host ;
    rdfs:label      "palau" ;
    hw:hostname     "palau.example" ;
    hw:cpuCores     "4"^^xsd:integer ;
    hw:memoryMB     "16384"^^xsd:integer ;
    hw:role         "storage" .

hw:yap        a hw:Host ;
    rdfs:label      "yap" ;
    hw:hostname     "yap.example" ;
    hw:cpuCores     "4"^^xsd:integer ;
    hw:memoryMB     "8192"^^xsd:integer ;
    hw:role         "edge" .

# --- Services ---
hw:traefik    a hw:WebApp ;
    rdfs:label      "traefik" ;
    hw:runsOn       hw:korimako ;
    hw:port         "443"^^xsd:integer ;
    hw:dependsOn    hw:pihole .

hw:pihole     a hw:Service ;
    rdfs:label      "pihole" ;
    hw:runsOn       hw:korimako ;
    hw:port         "53"^^xsd:integer .

hw:grafana    a hw:WebApp ;
    rdfs:label      "grafana" ;
    hw:runsOn       hw:korimako ;
    hw:port         "3000"^^xsd:integer ;
    hw:dependsOn    hw:prometheus .

hw:prometheus a hw:Service ;
    rdfs:label      "prometheus" ;
    hw:runsOn       hw:palau ;
    hw:port         "9090"^^xsd:integer .

hw:minio      a hw:Service ;
    rdfs:label      "minio" ;
    hw:runsOn       hw:palau ;
    hw:port         "9000"^^xsd:integer .

hw:nginx      a hw:WebApp ;
    rdfs:label      "nginx" ;
    hw:runsOn       hw:yap ;
    hw:port         "80"^^xsd:integer ;
    hw:dependsOn    hw:minio .

# --- Type hierarchy ---
hw:WebApp     rdfs:subClassOf hw:Service .

Load it:

quipu knot homelab.ttl --db homelab.db

1. Your First Query: SELECT

A SPARQL query matches patterns against the graph. The simplest pattern is a single triple with a variable:

SELECT ?host
WHERE {
  ?host a <http://example.org/homelab/Host> .
}

This says “find every ?host that has type hw:Host.” The a keyword is shorthand for rdf:type.

Run it:

quipu read "SELECT ?host WHERE { ?host a <http://example.org/homelab/Host> }" \
  --db homelab.db
?host
http://example.org/homelab/korimako
http://example.org/homelab/palau
http://example.org/homelab/yap

2. Multiple Patterns: JOIN

Add more patterns to narrow results. Patterns in the same WHERE block are joined — every pattern must match:

SELECT ?host ?cores
WHERE {
  ?host a <http://example.org/homelab/Host> .
  ?host <http://example.org/homelab/cpuCores> ?cores .
}
?host?cores
hw:korimako8
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:korimako32768
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:korimako32768
hw:palau16384

OFFSET 1 LIMIT 1 would skip korimako and return only palau.

An explicit ORDER BY … LIMIT k OFFSET n retains the best k + n solutions when only column projection separates the limit from the sort and keys are constants or consistently typed variables. Mixed incomparable values, non-finite numbers, and computed keys retain the full-sort path. Response-provenance columns also keep the full relation until projection, so metadata present only outside the requested page cannot disappear from headers. It still evaluates the complete input: this optimization does not push a scan limit through filters, joins, DISTINCT, or aggregates. Equal sort keys preserve input order, and each sort expression is evaluated once per solution.

Native complete sorts above 4,096 input rows use private temporary-file runs; smaller sorts retain the in-memory path. The existing 65,536-key-entry and 8 MiB retained-key guards are unchanged. Spill runs use a 2 MiB key budget, at most 128 initial runs, a 1 MiB encoded-record limit, and a cumulative 256 MiB write quota including merge passes. Files contain evaluated keys and row positions, are private to the process, and are removed when closed. Disk errors, quota exhaustion, oversized keys, and deadlines return errors; they never return a silently shortened sorted result. WebAssembly keeps the explicit in-memory refusal because it has no native temporary-file backend.

These limits bound sorting keys and temporary storage. Earlier query operators may already have materialized the input rows, so they are not a bound on total query memory. A small LIMIT also cannot make an expensive input scan cheap.

8. Aggregates: COUNT, SUM, AVG

Group and aggregate with GROUP BY:

PREFIX hw: <http://example.org/homelab/>

SELECT ?host (COUNT(?svc) AS ?serviceCount)
WHERE {
  ?svc hw:runsOn ?host .
}
GROUP BY ?host
ORDER BY DESC(?serviceCount)
?host?serviceCount
hw:korimako3
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:korimako3
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.

Every result shape carries it — including ASK

The marker is not a SELECT feature. ASK is the shape most in need of it:

ASK { hw:postgres a hw:Service }     # -> {"result": true, "inference": {...}}

If hw:postgres is typed as hw:DatabaseService and that class is a subclass of hw:Service, the constant-type ASK includes it through subclass expansion. The inference marker identifies that expansion. CONSTRUCT and DESCRIBE results also carry the marker when their query patterns expand.

What the marker claims. It reports the type expansion applied to this query. A marked ASK can also match a directly asserted fact; the marker alone cannot prove how a particular row matched. To inspect asserted types, ask:

SELECT ?t WHERE { hw:postgres a ?t }    # what is it ACTUALLY typed as?

Standard result formats: the marker moves to a header

If you request a W3C shape with Accept (application/sparql-results+json, application/sparql-results+xml, text/turtle), the body is fixed by spec and has nowhere to put the marker. It travels as a response header instead, naming the affected type constants:

x-quipu-inference: applied: http://example.org/homelab/Service

The header is absent when no type expansion was applied. The body is untouched and stays conformant, so a standard parser is unaffected — but a client that ignores headers gets no signal, which is a reason to prefer the default JSON shape when the distinction matters. Full expandedTypes detail is available in the default JSON response.

Standard HTTP responses also carry X-Quipu-Truncated: true when the server row ceiling removed results, or false when the result is complete. This header applies to SPARQL JSON/XML, CSV/TSV and RDF graph output; browser clients can read it through CORS. Check it before treating a result as a complete answer. A result exactly at the ceiling is complete if no extra row was removed. The default JSON format continues to report its existing truncated field.

10. Property Paths

SPARQL 1.1 property paths let you traverse edges without binding intermediate variables.

Sequence (/)

“What hosts do web apps’ dependencies run on?”

PREFIX hw: <http://example.org/homelab/>

SELECT ?app ?depHost
WHERE {
  ?app a hw:WebApp .
  ?app hw:dependsOn/hw:runsOn ?depHost .
}

hw:dependsOn/hw:runsOn means: follow dependsOn, then follow runsOn.

?app?depHost
hw:traefikhw:korimako
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 korimako host?” using reverse traversal:

PREFIX hw: <http://example.org/homelab/>

SELECT ?svc
WHERE {
  hw:korimako ^hw:runsOn ?svc .
}

^hw:runsOn means “follow runsOn edges backwards.”

11. Temporal Queries

Every SPARQL query in Quipu can include a temporal context.

Valid-time travel

“What did the homelab look like on March 15?”

quipu read "PREFIX hw: <http://example.org/homelab/>
SELECT ?host ?cores WHERE {
  ?host a hw:Host .
  ?host hw:cpuCores ?cores .
}" --db homelab.db --valid-at 2026-03-15

Via REST:

curl -s localhost:3030/query -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "query": "PREFIX hw: <http://example.org/homelab/> SELECT ?host ?cores WHERE { ?host a hw:Host . ?host hw:cpuCores ?cores }",
    "valid_at": "2026-03-15"
  }'

Transaction-time travel

“What did the database know after the first 5 transactions?”

quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" --db homelab.db --tx 5

12. Other Query Forms

ASK: Yes/No Questions

PREFIX hw: <http://example.org/homelab/>

ASK { hw:korimako a hw:Host }

Returns true or false.

CONSTRUCT: Build New Triples

PREFIX hw: <http://example.org/homelab/>

CONSTRUCT {
  ?svc hw:colocatedWith ?other .
}
WHERE {
  ?svc hw:runsOn ?host .
  ?other hw:runsOn ?host .
  FILTER(?svc != ?other)
}

Returns triples showing which services share a host.

DESCRIBE: Entity Details

PREFIX hw: <http://example.org/homelab/>

DESCRIBE hw:korimako

Returns all triples where korimako is the subject.

Cheat Sheet

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 korimako goes down.”

You run a homelab — a handful of machines with dozens of services, wired together with reverse proxies, DNS, and hope. You need a way to track what runs where, what depends on what, and what the blast radius is when a host goes down.

This tutorial walks through modeling your infrastructure as a knowledge graph, querying dependencies with SPARQL, and ingesting changes from monitoring agents.

Step 1: Model Your Infrastructure

Create homelab.ttl with your hosts and services:

@prefix hw:   <http://example.org/homelab/> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

# Type hierarchy
hw:WebApp     rdfs:subClassOf hw:Service .
hw:Database   rdfs:subClassOf hw:Service .

# Hosts
hw:korimako a hw:Host ;
    rdfs:label   "korimako" ;
    hw:hostname  "korimako.example" ;
    hw:cpuCores  "8"^^xsd:integer ;
    hw:memoryMB  "32768"^^xsd:integer .

hw:palau a hw:Host ;
    rdfs:label   "palau" ;
    hw:hostname  "palau.example" ;
    hw:cpuCores  "4"^^xsd:integer ;
    hw:memoryMB  "16384"^^xsd:integer .

# Services
hw:traefik a hw:WebApp ;
    rdfs:label    "traefik" ;
    hw:runsOn     hw:korimako ;
    hw:port       "443"^^xsd:integer ;
    hw:dependsOn  hw:pihole .

hw:pihole a hw:Service ;
    rdfs:label  "pihole" ;
    hw:runsOn   hw:korimako ;
    hw:port     "53"^^xsd:integer .

hw:grafana a hw:WebApp ;
    rdfs:label    "grafana" ;
    hw:runsOn     hw:korimako ;
    hw:dependsOn  hw:prometheus ;
    hw:dependsOn  hw:postgres .

hw:prometheus a hw:Service ;
    rdfs:label  "prometheus" ;
    hw:runsOn   hw:palau .

hw:postgres a hw:Database ;
    rdfs:label  "postgres" ;
    hw:runsOn   hw:palau ;
    hw:port     "5432"^^xsd:integer .

hw:nginx a hw:WebApp ;
    rdfs:label    "nginx" ;
    hw:runsOn     hw:palau ;
    hw:dependsOn  hw:postgres .

Load it:

quipu knot homelab.ttl --db homelab.db

Step 2: Query — What Runs on Each Host?

PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?host ?svc
WHERE {
  ?svc hw:runsOn ?host .
  ?host rdfs:label ?hostLabel .
  ?svc rdfs:label ?svcLabel .
}
ORDER BY ?hostLabel
quipu read "PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?hostLabel ?svcLabel WHERE {
  ?svc hw:runsOn ?host .
  ?host rdfs:label ?hostLabel .
  ?svc rdfs:label ?svcLabel .
} ORDER BY ?hostLabel ?svcLabel" --db homelab.db
?hostLabel?svcLabel
korimakografana
korimakopihole
korimakotraefik
palaunginx
palaupostgres
palauprometheus

Step 3: Impact Analysis — What Breaks if Korimako Goes Down?

This is the killer query. Find all services on korimako, then find everything that depends on them (directly or transitively):

PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?affected ?label
WHERE {
  ?affected hw:dependsOn+/hw:runsOn hw:korimako .
  ?affected rdfs:label ?label .
}

The property path hw:dependsOn+/hw:runsOn means: follow one or more dependsOn edges, then one runsOn edge, and check if it lands on korimako.

?affected?label
hw:traefiktraefik

Traefik depends on pihole, which runs on korimako. But traefik itself also runs on korimako — so if korimako goes down, you lose traefik, pihole, and grafana (all locally hosted), plus anything that transitively depends on them.

A more complete impact query — services that run on korimako OR depend on services running on korimako:

PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT DISTINCT ?svc ?label
WHERE {
  {
    ?svc hw:runsOn hw:korimako .
  }
  UNION
  {
    ?svc hw:dependsOn+/hw:runsOn hw:korimako .
  }
  ?svc rdfs:label ?label .
}

Step 4: Enforce Structure with SHACL

Prevent malformed data from entering the graph. Create homelab.shapes.ttl:

@prefix sh:   <http://www.w3.org/ns/shacl#> .
@prefix hw:   <http://example.org/homelab/> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .

hw:HostShape a sh:NodeShape ;
    sh:targetClass hw:Host ;
    sh:property [
        sh:path hw:hostname ;
        sh:datatype xsd:string ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path hw:cpuCores ;
        sh:datatype xsd:integer ;
        sh:minCount 1 ;
    ] .

hw:ServiceShape a sh:NodeShape ;
    sh:targetClass hw:Service ;
    sh:property [
        sh:path hw:runsOn ;
        sh:class hw:Host ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
    ] .

Load shapes and validate:

quipu shapes load --name homelab --file homelab.shapes.ttl --db homelab.db

Now any new service without a runsOn edge is rejected.

Step 5: Ingest from Monitoring Agents

When an agent discovers new infrastructure, it can push episodes:

curl -s localhost:3030/episode -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "name": "prometheus-discovery-2026-04-04",
    "source": "prometheus-sd",
    "nodes": [
      {"name": "redis", "type": "Service", "description": "Cache layer"},
      {"name": "yap", "type": "Host", "properties": {"hostname": "yap.example"}}
    ],
    "edges": [
      {"source": "redis", "target": "yap", "relation": "runsOn"},
      {"source": "nginx", "target": "redis", "relation": "dependsOn"}
    ]
  }'

The episode creates entities and relationships in a single transaction, with provenance tracking back to the discovery agent.

Step 6: Time-Travel After Changes

After adding redis, query what the graph looked like before:

quipu read "PREFIX hw: <http://example.org/homelab/>
SELECT ?svc WHERE { ?svc a hw:Service }" --db homelab.db --tx 1

Transaction 1 only had the original six services. The current state includes redis.

Useful Queries for Operators

Services with no dependencies (leaf nodes)

PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?svc ?label
WHERE {
  ?svc a hw:Service .
  ?svc rdfs:label ?label .
  FILTER NOT EXISTS { ?svc hw:dependsOn ?dep }
}

Hosts sorted by service count

PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?hostLabel (COUNT(?svc) AS ?n)
WHERE {
  ?svc hw:runsOn ?host .
  ?host rdfs:label ?hostLabel .
}
GROUP BY ?hostLabel
ORDER BY DESC(?n)

Resource utilization summary

PREFIX hw: <http://example.org/homelab/>

SELECT (SUM(?cores) AS ?totalCores) (SUM(?mem) AS ?totalMB)
       (COUNT(?host) AS ?hostCount)
WHERE {
  ?host a hw:Host .
  ?host hw:cpuCores ?cores .
  ?host hw:memoryMB ?mem .
}

Step 7: Materialise Dependencies with the Reasoner

The property path queries above (dependsOn+, runsOn+) re-derive transitive chains every time you run them. For a graph you query often, the reasoner can materialise those chains once and keep them fresh.

Create infra-rules.ttl:

@prefix rule: <http://quipu.local/rule#> .
@prefix ex:   <http://example.org/rules/> .

ex:homelab a rule:RuleSet ;
    rule:defaultPrefix "http://example.org/homelab/" .

ex:depends_on_transitive a rule:Rule ;
    rule:id "depends_on_transitive" ;
    rule:head "dependsOn(?a, ?c)" ;
    rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .

ex:runs_on_transitive a rule:Rule ;
    rule:id "runs_on_transitive" ;
    rule:head "runsOn(?svc, ?host)" ;
    rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .

Run it:

quipu reason --rules infra-rules.ttl --db homelab.db

Now the “what breaks if korimako goes down?” query simplifies — no property paths needed:

PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT DISTINCT ?svc ?label
WHERE {
  ?svc hw:runsOn hw:korimako .
  ?svc rdfs:label ?label .
}

This returns both directly-hosted services AND services that transitively run on korimako via containers, because the reasoner already computed the transitive closure.

Stay Fresh with Reactive Evaluation

Enable reactive mode so derived facts update whenever you add or remove infrastructure:

quipu reason --reactive --rules infra-rules.ttl --db homelab.db

Now when a monitoring agent reports a new container on korimako, the transitive runsOn edges update automatically in the same transaction.

“What If?” Before You Change

Before decommissioning a host, ask the reasoner what would break:

#![allow(unused)]
fn main() {
// Hypothetical: retract all runsOn edges to korimako
let report = store.speculate(&koror_retractions, timestamp, |s| {
    evaluate(s, &ruleset, timestamp)
})?;
println!("Decommissioning korimako would retract {} derived facts", report.retracted);
}

The store remains unchanged — you see the impact without making the change. See The Rule Builder for a complete walkthrough.

What’s Next

The 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 korimako”. But other agents need to query derived facts — “traefik runs on korimako” (transitively). Instead of making every consuming agent write property path queries, use the reasoner to materialise derived facts that every agent can query directly.

Rules as Shared Infrastructure

Define rules once, and every agent benefits:

@prefix rule: <http://quipu.local/rule#> .
@prefix ex:   <http://aegis.gastown.local/rules/> .

ex:agent_rules a rule:RuleSet ;
    rule:defaultPrefix "http://aegis.gastown.local/ontology/" .

# If A depends on B and B depends on C, then A depends on C
ex:depends_on_transitive a rule:Rule ;
    rule:id "depends_on_transitive" ;
    rule:head "dependsOn(?a, ?c)" ;
    rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .

Reactive: Derive on Write

With reactive evaluation enabled, derived facts update every time an agent writes an episode:

quipu reason --reactive --rules agent-rules.ttl --db knowledge.db

Now when the deploy agent writes a new dependsOn edge, the transitive closure updates in the same transaction. The Q&A agent’s next query sees the full dependency chain without any property paths.

Pre-Flight Checks with Speculate

Before a deploy agent pushes a change, it can ask “what would this break?” without actually modifying the graph:

#![allow(unused)]
fn main() {
// Hypothetical: remove the old service version
let report = store.speculate(&removal_datums, timestamp, |s| {
    evaluate(s, &ruleset, timestamp)
})?;

if report.retracted > 0 {
    println!("WARNING: removing old version would retract {} derived facts", report.retracted);
    // Agent can decide to proceed or alert a human
}
// Store is unchanged — safe to inspect before committing
}

This is especially powerful in multi-agent systems: one agent proposes a change, the reasoner evaluates the impact, and a separate agent decides whether to approve it.

Provenance for Derived Facts

Derived facts carry source tags like reasoner:depends_on_transitive. Agents can distinguish raw observations from derived knowledge:

PREFIX ont: <http://aegis.gastown.local/ontology/>

SELECT ?a ?b
WHERE {
  ?a ont:dependsOn ?b .
  # This returns BOTH direct and transitively-derived dependencies
}

The provenance is in the fact metadata — agents that need to distinguish can filter on the source field.

What’s Next

The 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 korimako.”

You have a knowledge graph full of infrastructure facts. You’ve been writing SPARQL queries with dependsOn+ and runsOn+ property paths to chase transitive chains, and it works — but every query re-derives the same relationships from scratch. You want the graph to materialise those relationships once, keep them current, and let you query them directly.

This tutorial walks you through writing Datalog rules, running the reasoner, enabling reactive evaluation, and asking counterfactual “what if?” questions.

Prerequisites

You need a Quipu store with some infrastructure data. If you followed The Homelab Operator, you already have one. If not, create a quick test store:

quipu knot - --db lab.db <<'EOF'
@prefix hw: <http://example.org/homelab/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

hw:korimako a hw:Host ;
    rdfs:label "korimako" .

hw:webproxy a hw:Container ;
    rdfs:label "webproxy" ;
    hw:runsOn hw:korimako .

hw:traefik a hw:WebApp ;
    rdfs:label "traefik" ;
    hw:runsOn hw:webproxy ;
    hw:dependsOn hw:pihole .

hw:pihole a hw:Service ;
    rdfs:label "pihole" ;
    hw:runsOn hw:korimako .

hw:grafana a hw:WebApp ;
    rdfs:label "grafana" ;
    hw:runsOn hw:webproxy ;
    hw:dependsOn hw:prometheus .

hw:prometheus a hw:Service ;
    rdfs:label "prometheus" ;
    hw:runsOn hw:korimako ;
    hw:dependsOn hw:postgres .

hw:postgres a hw:Database ;
    rdfs:label "postgres" ;
    hw:runsOn hw:korimako .
EOF

Step 1: Your First Rule — Transitive runsOn

Traefik runs on webproxy, and webproxy runs on korimako. You want to materialise the fact that traefik runs on korimako without writing a property path query every time.

Create my-rules.ttl:

@prefix rule: <http://quipu.local/rule#> .
@prefix ex:   <http://example.org/rules/> .

ex:homelab a rule:RuleSet ;
    rule:defaultPrefix "http://example.org/homelab/" .

ex:runs_on_transitive a rule:Rule ;
    rule:id "runs_on_transitive" ;
    rule:head "runsOn(?svc, ?host)" ;
    rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .

Let’s unpack this:

  • rule: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 korimako (via webproxy or directly).

Query the derived facts:

quipu read "PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?svc ?host WHERE {
  ?svc hw:runsOn ?host .
  ?host a hw:Host .
  ?svc rdfs:label ?label .
}" --db lab.db

The transitive runsOn edges are now first-class facts — no property paths needed. Every SPARQL query, every API call, and every agent context lookup benefits.

Step 3: Add a Join Rule — Cross-Predicate Derivation

A single-predicate transitive closure is useful, but the real power is joining across predicates. Add a second rule that derives “this service is affected by this host going down”:

ex:affected_by_host a rule:Rule ;
    rule:id "affected_by_host" ;
    rule:head "affectedByHost(?svc, ?host)" ;
    rule:body "runsOn(?svc, ?container), runsOn(?container, ?host)" .

Wait — that’s the same rule as runs_on_transitive. Let’s do something more interesting. Suppose you also track package dependencies:

quipu knot - --db lab.db <<'EOF'
@prefix hw: <http://example.org/homelab/> .

hw:nginx_pkg a hw:Package ;
    hw:installedIn hw:webproxy .

hw:traefik hw:usesPackage hw:nginx_pkg .
EOF

Now add a rule that derives “service S is affected by package P”:

ex:affected_by_package a rule:Rule ;
    rule:id "affected_by_package" ;
    rule:head "affectedByPackage(?svc, ?pkg)" ;
    rule:body "usesPackage(?svc, ?pkg), installedIn(?pkg, ?container)" .

This joins across usesPackage and installedIn via the shared variable ?pkg. The head projects the service and package, dropping the container (which was only needed for the join condition).

Add both rules to my-rules.ttl and run again:

quipu reason --rules my-rules.ttl --db lab.db

Step 4: Transitive Dependencies

The most common pattern is transitive closure over dependsOn. Add:

ex:depends_on_transitive a rule:Rule ;
    rule:id "depends_on_transitive" ;
    rule:head "dependsOn(?a, ?c)" ;
    rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .

This rule is positively recursive — it reads and writes the same predicate (dependsOn). The reasoner handles this correctly: it keeps applying the rule until no new facts are derived (fixpoint), using semi-naive evaluation to avoid redundant work.

After running the reasoner, you can find the full transitive blast radius of any service with a simple flat query:

quipu read "PREFIX hw: <http://example.org/homelab/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?affected ?label WHERE {
  ?affected hw:dependsOn <http://example.org/homelab/postgres> .
  ?affected rdfs:label ?label .
}" --db lab.db

No property paths, no dependsOn+ — the transitive closure is already in the store.

Step 5: Understanding Stratification

Let’s look at what happens when you have multiple rules that depend on each other. Your complete ruleset now has:

  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 korimako
let koror_id = store.lookup("http://example.org/homelab/korimako")?.unwrap();
let runs_on_id = store.lookup("http://example.org/homelab/runsOn")?.unwrap();

let retractions: Vec<Datum> = store
    .current_facts()?
    .iter()
    .filter(|f| f.attribute == runs_on_id && f.value == Value::Ref(koror_id))
    .map(|f| Datum {
        entity: f.entity,
        attribute: f.attribute,
        value: f.value.clone(),
        valid_from: "2026-04-04T00:00:00Z".into(),
        valid_to: None,
        op: Op::Retract,
    })
    .collect();

// Ask "what if?"
let report = store.speculate(&retractions, "2026-04-04T00:00:00Z", |s| {
    evaluate(s, &ruleset, "2026-04-04T00:00:00Z")
})?;

println!("If korimako went down: {} facts retracted", report.retracted);
}

The store is unchanged after speculate() returns. The hypothetical facts were applied inside a SQLite savepoint and rolled back. You get the evaluation report without any side effects.

This is ideal for:

  • Pre-change impact assessment: “What breaks if I decommission this host?”
  • Capacity planning: “What if I move these containers to a new host?”
  • Incident simulation: “What’s the blast radius of this failure?”

Step 8: Debugging Rules

Common Errors and Fixes

“head variable ?z is not bound in the body”

Every variable in the head must appear in at least one positive body atom. If your head mentions ?z, make sure ?z appears in a body atom (not just under negation).

# Bad: ?host not in body
rule:head "runsOn(?svc, ?host)" ;
rule:body "service(?svc)" .

# Good: ?host bound by second body atom
rule:head "runsOn(?svc, ?host)" ;
rule:body "service(?svc), assignedTo(?svc, ?host)" .

Historical errors you will no longer see (lifted 2026-08-27, quipu-923): "two-atom body must share exactly one variable" and "body with more than 2 atoms". Bodies of any length now compile to a join pipeline — p(?a, ?b), q(?b, ?c), r(?c, ?d) works directly, atoms may share zero, one, or both variables, and not q(?x, ?y) applies stratified negation. Decomposing a long body into intermediate predicates (the old workaround) still works and remains useful when an intermediate relation is worth naming or reusing.

“rule set is not stratifiable: negation cycle through […]”

Your rules have a cycle through negation. Rule A negates something rule B produces, and rule B negates something rule A produces. The fix is to restructure so negation only flows in one direction (from higher strata to lower ones).

Inspecting Derived Facts

Derived facts live in the companion inferred graph (quipu-0b6), so select it — or compose it with the base — explicitly:

quipu read "SELECT ?e ?a ?v FROM <urn:quipu:graph:root#inferred> WHERE { ?e ?a ?v }" \
  --db lab.db

To re-run the reasoner and see what changed:

quipu reason --rules my-rules.ttl --db lab.db

If the report shows asserted 0, retracted 0, the derived facts are already up to date — nothing changed since the last run.

Your Complete Ruleset

Here’s the full my-rules.ttl from this tutorial:

@prefix rule: <http://quipu.local/rule#> .
@prefix ex:   <http://example.org/rules/> .

ex:homelab a rule:RuleSet ;
    rule:defaultPrefix "http://example.org/homelab/" .

# Transitive dependency closure
ex:depends_on_transitive a rule:Rule ;
    rule:id "depends_on_transitive" ;
    rule:head "dependsOn(?a, ?c)" ;
    rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .

# Transitive runsOn closure
ex:runs_on_transitive a rule:Rule ;
    rule:id "runs_on_transitive" ;
    rule:head "runsOn(?svc, ?host)" ;
    rule:body "runsOn(?svc, ?mid), runsOn(?mid, ?host)" .

# Package impact: which services are affected by a package?
ex:affected_by_package a rule:Rule ;
    rule:id "affected_by_package" ;
    rule:head "affectedByPackage(?svc, ?pkg)" ;
    rule:body "usesPackage(?svc, ?pkg), installedIn(?pkg, ?container)" .

What’s Next

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 build identity without loading configuration or opening a database:

quipu <version>
git_sha: <build-commit>
git_dirty: <true-or-false>

The first line retains the version-only format for existing parsers. The second line identifies the source commit, or unknown when built without Git metadata. The third reports the build-time working-tree dirty flag. quipu-server --version (and -V) exposes the same fields with a quipu-server first-line prefix. Compare known build commits to detect differences between releases; equal version strings alone do not establish that two binaries contain the same code.

Commands

quipu knot <file.ttl>

Load RDF facts from a Turtle file.

quipu knot data.ttl --db my.db
quipu knot data.ttl --shapes schema.ttl --db my.db  # With SHACL validation
quipu knot data.ttl --timestamp 2026-03-15T00:00:00Z --db my.db  # Source-true valid-time
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 validate --shapes schema.ttl --data test-data.ttl --format json

--format json prints one JSON object with conforms, violations, warnings and every result (severity, focus node, component, path, value, source shape, message), in the order the engine reported them and without deduplication. The text form prints only the counts on a conforming run and omits the shape, component and value. The exit status is the same in both forms: 0 when the data conforms, 1 otherwise or on an error.

quipu export

Export deterministic RDF from ROOT or one explicit scope.

quipu export --db my.db                        # N-Triples (default)
quipu export --format turtle --db my.db        # Turtle
quipu export --group-id project-a --db my.db   # provenance group
quipu export --construct 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }' --db my.db
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 demotions

List unsupported Datalog demotions in a premise graph’s companion:

quipu demotions --db my.db
quipu demotions --graph urn:example:premises --db my.db

The JSON rows identify the retained record, subject, predicate, object, premise graph, deriver source, promotion transaction and invalidation transaction. --graph defaults to ROOT. The command installs the shipped unsupported_demotions stored query if missing; a fresh store returns no rows. After the companion is initialized, HTTP clients can call POST /ask with {"name":"unsupported_demotions"}; for a named companion, add "params":{"graph":"urn:example:premises#inferred"}.

Support loss removes the original triple from first-class standing and retains one quipu:DemotedDerivation reification record. Datalog, OWL and RDFS materializers exclude plane bookkeeping from premises. Ordinary graph queries can still inspect the record; it does not assert the reified triple. Restored support marks it resolved and recreates the derivation only in the companion. Resolved records remain available through graph queries but leave this list. This is truth maintenance, not a promotion or automatic-promotion API. Load the product evidence schema alongside the application’s other shapes:

quipu shapes load demoted-derivations shapes/demoted-derivation.ttl --db my.db

Demotion does not load shapes automatically: installing the first shape set would activate the vocabulary gate for unrelated writes in an ungoverned store.

quipu impact <entity-IRI>

Bounded BFS over entity edges: what is downstream of this entity? With --remove, speculatively retracts the entity (SQLite savepoint, no mutation), re-runs the reasoner inside the fork, and walks the result — “what would break if I removed this?”.

quipu impact http://example.org/traefik --hops 3 --db my.db
quipu impact http://example.org/traefik --remove --db my.db
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 render

Render markdown and byte-addressed includes from a stored specification or an ad-hoc query into an owned destination. Supports single-file and per-entity hierarchies, --valid-at, a no-write destination dry-run and local/remote reads. See the render reference for the command, manifest and refusal contract. A remote server prepares bytes; only the client publishes files.

quipu report

Graph health report: hub entities (god-nodes), surprising connections, and suggested competency questions.

quipu report --hubs 10 --surprises 5 --db my.db
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 by default. The opt-in Git and Yupana passes below supply that evidence. The report counts lines it could not read rather than skipping them, so N line(s) unreadable is always part of the summary: conformance over a window that was only partly read is not conformance.

Git path-policy backstop

quipu audit trace.jsonl --repo . --from BASE --to HEAD --json --db my.db

All three Git flags are required together. BASE is exclusive; HEAD is inclusive. Both resolve to full immutable commit ids before enumeration. The base must be an ancestor. Shallow repositories and ancestry grafts are refused; replacement objects are ignored. The git object reports those ids, commits and changed paths checked, path-policy count, and unresolved coverage. An empty policy catalogue is unproven, not a clean scan.

The additional git-coverage pass reads ROOT’s current action policies with appliesTo path globs. Globs use the same glob::Pattern matching as Yupana tripwires. It checks each commit, not just the window’s net diff: a crossing followed by a revert is still visible. Renames check both old and new paths. The window includes side-branch commits. A merge only contributes paths whose committed state differs from every parent (including mode changes and deletions): inherited content is audited where it was introduced, while new merge content still needs its own evaluation. This also handles octopus merges; comparing against just one parent would either duplicate evidence or miss a new merge change. Policy scope is the supplied repository; use a store containing the policies intended for that repository. Policies introduced after the audited commits also apply: this is a current-policy retrospective audit, not historical policy reconstruction.

A changed matching path needs a conclusive evaluation with the exact policy id, repository-relative path, and full git_commit id in the trace record. Missing evidence produces a bypassed enforcement violation. A path-only deny crossing produces a violation even with a recorded evaluation: a claim that it was blocked cannot excuse the committed crossing. The report includes the commit author and declared Co-Authored-By, Claude-Session, and SHANTY_AGENT metadata for investigation.

Existing pre-edit spools do not emit git_commit; they remain valid for the trace-only checker but do not establish Git coverage. A trusted producer must bind an evaluation to its actual commit; do not backfill every old path record with the current tip. This reader does not authenticate such bindings.

Git mode exits 1 for a violation, 2 when Git coverage cannot be verified (including malformed trace lines, invalid refs/globs, no path policies, or unsupported selector replay without another violation), and 0 otherwise. Trace-only incompleteness retains its original semantics. Without --yupana, selector/predicate policies with matching appliesTo are unresolved and selectors without path scope remain outside the path-only pass.

Replay tree-sitter policies at each commit

quipu audit trace.jsonl --repo . --from BASE --to HEAD --db my.db \
  --yupana /trusted/path/to/yupana --json

The explicit executable must provide Yupana’s audit-rule JSON interface, schema version 1. Quipu loads each policy’s current selector and predicate from ROOT and supplies the exact regular-file blob from each commit to that parser. It never reads the working tree’s contents or follows committed symlinks. Unscoped structural policies are included in this mode; Yupana’s own path-to-language classifier decides language applicability. appliesTo still narrows a policy when present.

A replayed unsatisfied claim is a violation even if its trace claims success. Successful replay does not erase a missing enforcement record. The git object reports selectors_checked separately from paths and commits. Deleted paths, non-UTF-8 or oversized blobs, malformed/ambiguous selector definitions, unsupported grammars, invalid syntax, a missing executable, and protocol or process failures are unresolved. A pure path policy still covers deletions. Source blobs are limited to 8 MiB; each evaluator invocation has a 10-second limit and a 1-MiB output limit. Only tree-sitter selector definitions with regex predicates are supported; entity-grounding and other selector tiers remain unresolved.

This is an agent-invoked audit library/CLI, not an installed scheduler or forge gate. Select and pin a trusted Yupana build: invoking a caller-selected executable does not authenticate that executable or the response it produces.

This catches only changes reaching the selected Git window. Uncommitted edits, ignored files and writes outside the repository remain invisible. Authors, trailers and trace evidence can be forged: this is an audit backstop, not a server-side enforcement boundary. Human edits are audited too, because an absent agent trailer does not prove a human author. Signed exception verdicts and a required forge check are not implemented by this command.

quipu audit inventory

Check the dispatch graph rather than a trace — SARC I7, enforcement completeness.

quipu audit inventory --db my.db
quipu knot shapes/dispatch-inventory.ttl --db my.db   # load the shipped seed first

I7 is a property of the dispatch graph, not of any one constraint: a harness exposes N classes of tool call, and completeness is the question of whether every class that can change state passes through a point where a constraint could stop it. aegis:ToolClass declares each class, whether it is executable, and which governedAt points it traverses.

Findings, in the same two severities:

  • violation — an executable class that traverses no enforcement point and has no aegis:ungovernedReason. An unknown hole.
  • incompleteness — an executable class that traverses nothing but says why: an acknowledged bypass surface. Reported on every run, because a bypass surface an operator has stopped seeing is one they have stopped weighing. Also: a class that does not declare aegis:executable, since whether it needs a point is then undecidable.
  • violation, the other direction — a constraint in Σ placed at a point no declared executable class traverses. It reads as governance in the catalog and can never fire in the deployment.
  • incompleteness, the zero-trust boundary — a class declaring aegis: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 replay <verdict>

Re-derive one recorded gate decision against the store as of its transaction. When the argument is not a file, it is a verdict IRI (or its local verdict_… name).

quipu audit replay verdict_3f9a… --db my.db
quipu audit replay verdict_3f9a… --delta attempt.json --json --db my.db

A refused write replays from its quarantine entry: the store is rebuilt as of the refusal in memory, the attempt applied, and the gate re-run. It re-derives when the write is refused again with the same outcome, the same rule-set digest and the same post-state digest. Under digest-only retention, present the attempt with --delta (a quipu-refused-delta/v1 JSON document); a delta whose canonical hash matches no quarantined attempt is refused as not the delta the gate judged. A verdict whose write committed replays from that write’s transaction.

Exits 1 only when a replay contradicts the record — a different outcome, rule set or post-state, or a broken seal. “Attestation only” (content purged, or digest-only with nothing presented) exits 0: it is incomplete evidence, not contrary evidence.

quipu audit quarantine

quipu audit quarantine --db my.db                          # list entries
quipu audit quarantine list --verdict <iri> --json --db my.db
quipu audit quarantine purge --graph urn:quipu:graph:root --db my.db
quipu audit quarantine purge --older-than 90 --db my.db

purge erases the sealed attempts matching --graph, --before <ts> or --older-than <days> (or --all, which must be named) and keeps each entry’s digests and seal, and the verdict facts untouched.

quipu audit tree <trace.jsonl>

Reassemble the dispatch forest from the principal chains a trace carries.

quipu audit tree trace.jsonl
quipu audit tree trace.jsonl --json

Needs no store — the tree is a property of the trace alone — and exits 0 always. A shape is not a verdict; the findings that are verdicts (a laundered chain, a partial attribution tuple) belong to quipu audit <trace>.

SARC §9.5’s attribution dilution is what this addresses: an orchestrator dispatches, a worker acts, and a flat record cannot say which link was answerable. The trace this stack emits is a sequence, so the tree here is reconstructed rather than structural, and the output says so in three places:

  • Unattributed records are not placed. A record with no chain is counted and left out. Attaching it to whichever root happened to be first would invent an answer to the question the tree exists to answer.
  • Implied dispatch nodes are flagged. A chain [orchestrator, worker] proves an orchestrator exists; it does not prove the orchestrator’s own actions are in this window. “This agent did nothing” and “this agent’s actions were not recorded” are different facts and only one is good news.
  • Collapsed nodes get a note. Two separate dispatches of the same worker by the same caller produce the same chain and land on one node. Not an error — one agent legitimately does many things — but the reader must not be told the tree is unambiguous when it is not.

quipu audit inheritance <trace.jsonl>

Check whether a constraint declared aegis:inheritedByDelegates stopped applying under delegation — SARC §9.5’s constraint laundering.

quipu audit inheritance trace.jsonl --db my.db

Exits 1 on a violation, like quipu audit <trace>.

An orchestrator is bound by a constraint, dispatches a sub-agent, and the constraint is not re-applied at the deeper layer. Nobody decided to drop it; it simply was not carried, and the record looks exactly like a constraint that legitimately did not apply.

Two findings at deliberately different severities, because one has evidence and the other has a question:

  • Violation. Constraint C decided on target T under chain [orchestrator], and a deeper action on the same target under [orchestrator, worker] did not evaluate it. C proved it could decide for T, so its absence deeper is a drop.
  • Incompleteness. C was evaluated at a dispatch node and never anywhere in its subtree. That might be laundering and might be a selector that legitimately matched nothing deeper; deciding needs the files as they stood, which quipu does not have.

Reporting both as violations would make the strong one unfindable.

aegis:onUndecidable admits only "escalate" — the same shape as aegis:onTimeout admitting only "deny". That is the decidability rescue: evaluate at the deepest layer where the constraint still decides, or hand it to a human. A constraint that silently stops applying where it cannot be checked is one an agent escapes by dispatching into a context where the evidence is absent.

A Σ where no constraint declares inheritedByDelegates reports an incompleteness, never a clean pass: “nothing is inherited” and “nothing has said whether anything is inherited” are different states.

quipu propose

Schema-change proposal queue: submit a shapes/ontology change for review rather than applying it directly.

quipu propose list --status pending --db my.db
quipu propose submit shape ex:PersonShape new-shape.ttl --proposer agent-1 --rationale "tighten cardinality"
quipu propose accept 3 --note "LGTM" --db my.db
quipu propose reject 4 --note "breaks existing data" --db my.db
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 gate shadow

Judge a candidate policy set over recorded history before any of it is created, and report which committed writes it would refuse that the governing set admitted (and the reverse). Never writes.

sqlite3 live.db ".backup copy.db"      # a quiescent copy, never the live store
quipu gate shadow --rules candidate.ttl --db copy.db --since 7d
quipu gate shadow --rules candidate.ttl --db copy.db --from-tx 1000 --to-tx 2000 --json
FlagDescription
--rules <file.ttl>Candidate policies: action-boundary aegis:Policy nodes with aegis:targets and aegis:claim (required)
--db <copy.db>A quiescent copy of the store (required; see refusals below)
--since <dur>Window: transactions stamped within 90m, 24h, 7d, …
--from-tx <A> --to-tx <B>Explicit window; both together
--last-txs <N>The last N transactions (default: the whole log)
--max-txs <N>Stop after judging N; the report says where it stopped
--mode add|replaceadd (default) layers the candidate over the governing set, same IRI replacing; replace makes the candidate the whole set
--jsonMachine-readable report

Each transaction is judged by the write gate’s own evaluator against the post-state it saw, with the router read as of that transaction’s time. The baseline is the policy set in force at that transaction, so a policy change inside the window is reported as a boundary and never attributed to the candidate.

Refusals, not advice. The command refuses the configured live store and any path with a -wal, -shm or -journal sidecar (an open connection, or a copy taken mid-write): it reads with SQLite immutable=1, which ignores locks and the WAL and could see torn state on a live file.

Limits it reports rather than hides: refused writes are rolled back and cannot be replayed, so they are counted, not judged; recorded verdicts are joined by aegis:gatedTx when present and otherwise by the next-transaction convention, with the match rate printed (and UNMEASURED when nothing joined); and a baseline that would refuse a committed write is counted as the fidelity signal (enforcement off then, an approval, or a reconstruction gap).

quipu path

Golden-path analysis over recorded trajectories: the provenance cone, the backtest, and a grammar draft. All three are reads; draft prints Turtle for a human to review and load. See the golden paths design.

quipu path cone http://example.org/traj/42 --via http://example.org/derivedFrom --hops 6 --db my.db
quipu path backtest http://example.org/traj/42 --omit http://example.org/step/3 --json --db my.db
quipu path draft http://example.org/traj/42 --name fast-review --label "the short path" \
  --via http://example.org/derivedFrom \
  --omit http://example.org/step/3 --by http://example.org/decision/9 --db my.db

The trajectory IRI is the first positional argument to every subcommand.

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 .pendant.db artifact (facts, manifest, shapes, stored queries, optionally vectors), verify one, or import one into a local graph.

quipu pack urn:example:graph --out domain.pendant.db --name "domain" --version 1.0.0
quipu pack urn:example:graph --out domain.pendant.db --shapes s.ttl --queries q.json --with-vectors
quipu pack urn:example:graph --out domain.pendant.db --space 7
quipu pack --verify domain.pendant.db
quipu pack urn:example:repo --out repo.pendant.db --repo scbrown/example --repo-sha "$BASE_SHA" --model-id all-MiniLM-L6-v2 --model-version 1
quipu unpack repo.pendant.db --expect-repo scbrown/example --head-sha "$(git rev-parse HEAD)" --into urn:local:domain --db my.db
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 .pendant.db archive, verifies it by content hash, deletes the local rows and re-attaches the pack read-only; the graph stays queryable at the same IRI and refuses writes until thaw. list prints iri class kind lifecycle source per graph.

quipu import

Stage a git-native share directory without touching ROOT:

quipu import ./share --source https://example.org/alice --db my.db
quipu import promote sha256:0123... --actor reviewer --db my.db

The first command reads manifest.json, export.nt, and shapes.ttl, verifies their hashes, resolves exact local identities, surfaces fuzzy review candidates, and quarantines facts that fail local vocabulary or SHACL checks. The second is the separate, explicit ROOT-admission step and only accepts eligible staged shares. Both print the same JSON fields as the REST endpoints.

quipu fork

Persistent named forks (quipu-gp5): fork ROOT as of any transaction into an independent committed-class named graph (urn:quipu:fork:<name>), read it exactly like the main line, diff it, then drop it or promote it. Promotion re-enters through the SHACL + policy write gates — a refused promotion writes nothing and the fork stays open. Fork ergonomics are never a gate bypass.

quipu fork 42 --name experiment --db my.db     # fork ROOT as of tx 42
quipu fork list --db my.db
quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" --fork experiment --db my.db
quipu fork diff main experiment --db my.db     # each side: a fork name, or 'main'
quipu fork promote experiment --db my.db       # delta re-enters via the gates
quipu fork drop experiment --db my.db          # terminal; the name is not reusable
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

quipu hook session-capture and quipu hooks

quipu ships one agent hook: a Stop hook that asks the agent, once per session, whether the session produced durable knowledge worth writing as an episode. The agent may act or reply skip; the hook never blocks a second stop.

quipu hooks bundle                      # print the hook bundle (schema st.hook-bundle/1)
quipu hooks install --harness claude    # merge into ~/.claude/settings.json
quipu hooks install --harness codex     # merge into $CODEX_HOME/config.toml (default ~/.codex)
quipu hooks status                      # exit 1 unless every harness has the hook
quipu hooks uninstall                   # remove only quipu's hook

With no --harness, both harnesses are written. --project writes ./.claude/settings.json or ./.codex/config.toml instead. Install is idempotent, leaves every other hook in place, and keeps the previous file as <file>.bak-quipu. When a shantytown registry answers (st ops hooks list), install and uninstall register the bundle with st instead, which renders it into every role’s settings; pass --no-st to write the harness config directly.

The hook itself, quipu hook session-capture, reads the Stop-hook JSON on stdin and prints at most one response. It always exits 0, and every failure is silence rather than a malformed response. Its guards, in order:

  1. Scope. The crew comes from GT_CREW, else from a .../crew/<name>/... working directory. An unidentified crew is never interrupted. QUIPU_HOOK_CREWS (space-separated, default *) narrows the scope.
  2. Loop guard. stop_hook_active: true means the hook already fired this turn, so it stays silent.
  3. Once per session. A solicited-<session_id> marker in the state directory. Markers older than two days are reaped.
  4. Durable denominator. Each solicitation appends {ts, session_id, crew} to solicit-log.jsonl before it is sent. If that append fails the hook stays silent, so the log can never undercount.
VariableDefaultMeaning
QUIPU_SERVERhttp://127.0.0.1:3030Server URL named in the message
QUIPU_HOOK_GROUPdefaultEpisode group_id named in the message
QUIPU_HOOK_CREWS*Crews the hook may interrupt
QUIPU_HOOK_STATE_DIR~/.quipu-hookMarkers and solicit-log.jsonl
GT_CREW(unset)The crew name, when the cwd does not carry it

The message prefers authenticated Quipu MCP tools or the deployment’s gated graph-extract helper. For HTTP fallback, /search stays open, but /shapes vocabulary reads require a bearer just like /episode and /propose. The agent resolves a non-empty QUIPU_AUTH_TOKEN, then QUIPU_AUTH_TOKEN_FILE or the configured client token file. A missing credential stops the request before HTTP; it is not a reason to send an unauthenticated probe. Credentials stay server-side where possible and must never be printed. Requests carry X-Quipu-Client: session-capture. The act rate is episodes whose source carries the session id / lines in solicit-log.jsonl.

Create and label named graphs

quipu graph create urn:example:jobs --actor urn:example:agent --db tutorial.db
quipu graph label urn:example:jobs --label '{"kind":"operational","freshness":"fresh"}' --db tutorial.db
quipu graph list --json --db tutorial.db

Both verbs call the same handlers as MCP/REST. --timestamp <RFC3339> pins the creation/declaration time; otherwise current UTC is used. --actor is declared provenance, not authorization. Create is idempotent and preserves the original provenance. Label fields match quipu_graph_label, including trust, policy, durability and validity bounds. JSON listing exposes creation and all labels; the existing tab-separated listing remains the default.

graph overlay-create, graph overlay-write, and graph overlay-compose accept --input '<JSON object>' using the corresponding MCP tool input fields. For the complete new-class write recipe, see named graphs through MCP.

CLI: sharing, import and legacy packs

Reference for the commands behind Sharing & Federation. Every flag here is checked against quipu --help by tests/cli_doc_drift.rs, so this page cannot quietly fall behind the binary.

A note on vocabulary: the share is the portable artifact. quipu share writes its standard text files directly; releases may carry the same files in a deterministic .pendant.tar.gz archive. The older pack and unpack commands remain for local SQLite compatibility, but a .pendant.db is not the published interchange format.


quipu share — produce a share

Prerequisite: load the identifier-policy catalogue and the shapes governing your data. The default destination is outward. An empty block-tier catalogue exits 2 (cannot verify); a matching identifier exits 1; a checked, clean share exits 0. --no-shapes does not bypass this check.

quipu share --output <dir> [--graph IRI|--group-id ID|--construct QUERY]
            [--shapes NAME]... [--no-shapes] [--queries NAME]... [--no-queries]
            [--parent-share ID] [--since <parent-reference>] [--turtle]
            [--destination internal]

Writes a deterministic, git-native share into <dir>: RDFC-1.0 canonical export.nt (the facts), shapes.ttl (the constraints they were validated against), queries.ttl (the stored queries that answer questions about them, when there are any), and JSON plus PROV-O/DCAT/SPDX Turtle manifests.

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
--queries <NAME>carry exactly these stored queries in queries.ttl; repeatable. Default: every query registered against the scope — see Stored queries
--no-queriescarry no stored queries; no queries.ttl is written
--parent-share <ID>record lineage: the share this one descends from
--since <reference>emit a parent-bound SPARQL Update delta instead of a full share; the parent may be a directory, archive or URL, not a share_id
--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 pendant/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.

Stored queries (queries.ttl)

A share carries the stored queries quipu_ask answers by name, so a pendant ships its competency questions beside its data. Each query is described as RDF in queries.ttl; the manifest seals that file’s SHA-256 as queries_hash and it takes part in the share_id, so changing one query’s text changes the share’s identity. A share with no queries has no queries.ttl and a manifest byte-identical to one produced before the member existed.

@prefix dct: <http://purl.org/dc/terms/> .
@prefix quipu: <https://quipu.dev/ontology/> .

<urn:quipu:query:members-of> a quipu:StoredQuery ;
  quipu:queryName "members-of" ;
  quipu:queryForm "SELECT" ;
  dct:description "Who belongs to a team" ;
  quipu:parameter [ a quipu:QueryParameter ; quipu:parameterName "team" ;
    quipu:parameterIndex 0 ; quipu:parameterKind "iri" ; quipu:required true ] ;
  quipu:sparqlTemplate """SELECT ?p WHERE { ?p <http://example.org/memberOf> <{team}> }""" .

quipu:targetsClass lists every constant rdf:type object in the template’s patterns. The vocabulary is governed by shapes/stored-queries.ttl, which the producer and the importer both apply.

Which queries a share carries by default. A query is registered against the shared graph when quipu_ask would answer it from that graph: an unscoped query (no dataset) belongs to a ROOT share and to --group-id and --construct slices of ROOT; a dataset-scoped query belongs to a --graph <IRI> share whose IRI is a member of its dataset, and to nothing else. The dataset IRI itself is producer-local and is not carried.

A carried query is data, not code. Only SELECT, CONSTRUCT, ASK and DESCRIBE travel. The form is decided by parsing the template with Quipu’s SPARQL parser — at share time and again at import — and a SPARQL Update is refused at both ends. Import evaluates nothing: a query runs only when somebody asks for it, under the receiver’s normal read policy and timeouts.

Import namespaces and never overwrites. Carried queries install in the receiver’s registry as <namespace>/<name>. The namespace defaults to pack-<12 hex> derived from the producer’s store identity, so newer shares of the same pack land on the same names; --query-namespace chooses one. A local query with the same name is never touched. A different definition already at a pack-scoped name is reported under queries.collisions and left in place, and a pack-scoped query the share no longer carries is reported as stale. --replace-queries replaces the differing ones and closes the stale ones (closed, never deleted — the prior version stays queryable). A query that targets a class the receiver’s loaded shapes do not sanction adds the blocker query_off_vocabulary: the pack is quarantined and none of its queries is installed. A full share installs its queries when it stages, before promotion, so run quipu import promote before asking questions of the staged data in ROOT.

A delta share carries the resulting share’s complete queries.ttl, like its shapes, so queries added, replaced or removed since the parent arrive as what the member now says, sealed by the result manifest’s queries_hash.

quipu import — receive a share, into quarantine

quipu import <share-dir|archive|URL> [--source <uri>] [--actor <id>]
            [--destination internal] [--query-namespace NS]
            [--replace-queries] [--db <path>]
quipu import delta <parent-share> <delta-share> [--actor <id>]
            [--query-namespace NS]

Verifies the manifest and payload hashes, then stages a local directory in its selected store. Archives and URLs are fetched under fixed size limits and materialized in a fresh in-memory store by default, so no downloaded artifact or database is left behind. With an explicit --db <path>, archives and URLs stage in that database using its loaded shapes and registered identities, just like a directory. Carried shapes are not automatically adopted: a receiver without a matching local vocabulary still quarantines the typed data. Import never touches ROOT without promotion. A hash mismatch is refused outright:

share graph hash mismatch: manifest=… actual=…
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
--query-namespace <NS>install carried queries as NS/<name> ([A-Za-z0-9._-]+); default pack-<12 hex> from the producer’s store identity
--replace-queriesreplace differing NS/* queries and close ones the share no longer carries; otherwise both are only reported

import delta verifies the full parent and the delta’s lineage, hashes and restricted DELETE DATA / INSERT DATA operations, materializes the declared result, then sends that result through the same verified in-memory import path.

Loaded local shapes determine admission. Their explicit quipu:onViolation "emit" diagnostics are advisory; reject-policy Violations quarantine the share. Missing policies mean reject, and unknown or conflicting policy values refuse. Carried shapes cannot downgrade a local reject policy. The report keeps strict conforms separate from blocking, with complete diagnostics and advisory_results. An emit-only Violation can therefore produce conforms: false with blocking: false. Vocabulary, integrity, trust and explicit promotion requirements still apply.

quipu import promote — admit a staged share into ROOT

quipu import promote <share-id> [--actor <id>] [--db <path>]

The second, separate verb. Nothing reaches ROOT because a file arrived; it reaches ROOT because someone ran this. Keeping admission in its own command is the point rather than an inconvenience — see the primitive.

The project graph: quipu share --project and quipu load

quipu share --project [<id>] [--no-shapes] [--destination internal] [--db <path>]
quipu load <bundle-dir> [--destination internal] [--actor <id>] [--db <path>]

A repository commits its project’s graph under .quipu/, tool-neutral:

pathcommittedwhat
.quipu/projectyesone line, the project id; the graph is urn:quipu:project:<id>
.quipu/graph/yesthe share bundle for that graph (manifest.json, export.nt, …)
.quipu/.gitignoreyeswritten by quipu: an allow-list for the two above
.quipu/local.db*, .quipu/verifier.pk8neverthe local store, and the host’s PRIVATE signing key

share --project <id> names the project once (it is then committed) and re-shares the graph into .quipu/graph/. Re-running it replaces the bundle; it never re-points a repository at a different id. The outward scrub applies exactly as for any share, because the repository may be public; use --destination internal only for a private one.

load <dir>, on a directory holding a share manifest, is the one command a fresh clone needs:

git clone <repo> && cd <repo>
quipu load .quipu/graph --db .quipu/local.db

It runs the ordinary import, with every gate import has, and then promotes into the bundle’s own graph instead of ROOT, as a diff. A re-load after git pull changes only what changed; an unchanged bundle opens no transaction. A quarantined import loads nothing. load <file.ttl> still means knot.

The server equivalent is POST /import of the same bundle, which stages it.

quipu status — has this share diverged?

quipu status <share-dir> [--db <path>]

Reports divergence between the local store and the share’s parent, as JSON. Read it before merge to see what a reconnect would have to decide.

quipu merge — three-way reconnect

quipu merge <share-dir> [--actor <id>] [--db <path>]

Locates the common base through parent_share, merges shape-aware (SHACL cardinalities decide what is a conflict), and on conflict keeps the base value and records a decision rather than guessing.

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

Resolving conflicts: emit, propose, decide, apply

quipu merge <share-dir> --emit-decisions <file.json> [--slot-scoped] [--propose] [--db <path>]
quipu merge <share-dir> --decisions <file.json> --reviewer <who> [--dry-run] [--actor <id>] [--db <path>]

The same split as quipu align: a merge that cannot auto-merge is finished by a person, with the evidence in front of them.

  1. Emit. --emit-decisions writes one row per conflict to a JSON file and changes nothing. Each row has the slot (subject, predicate, max_count), the base / ours / theirs values, and each side’s provenance: ROOT’s current facts with their valid_from, transaction, actor and source; the incoming and base shares’ id, created_at, producer store and whether they are attested. For a person reading it, each row also has a kind (max_count_exceeded or delete_replace) and a rule in words (“sh:maxCount 1 on status: ours and theirs together hold 2 values”), and the file carries labels: one rdfs:label per IRI it mentions. Default emission stays v1, strictly bound to all ROOT changes. Explicit --slot-scoped (REST/MCP: slot_scoped: true) emits v2. These files bind each reviewed slot’s current values, local provenance and last-modifying ROOT transaction (including retractions) through local_slot_hash, plus the incoming share and base identities. Unrelated ROOT writes can survive an apply; the clean merge is recomputed against current ROOT. A slot changed and restored is stale, including empty -> value -> empty. Legacy v1 files and files with blank-node conflict subjects retain the conservative whole-ROOT binding. Older clients refuse v2 files; re-emit with the client that will apply the decisions.
  2. Propose (agent). --propose also fills each row’s proposal (choose plus evidence) from mechanical evidence: a side unchanged from base, the newer side, an attested share. It never sets decision.
  3. Decide (operator, or any tool that edits the file). A tool may also set decided_by and decided_at per row; apply echoes them back. They are a claim made by that tool. The attested record is the transaction’s reviewer and the decisions file’s hash. Set each row’s decision to {"choose": "ours"}, {"choose": "theirs"}, {"choose": "base"}, or {"values": ["\"an edited value\""]} (N-Triples terms).
  4. Apply. --decisions commits the clean part of the merge plus every decided slot in one transaction. Its source records both parents, the reviewer and the SHA-256 of the decisions file, and the output lists each applied row.

Apply refuses, and writes nothing, when:

RefusalWhy
a row has no decisionnothing is guessed
a reviewed slot’s values or provenance changed, or its hash is missingthe decisions were made against other evidence; emit again
the incoming share, its graph hash, or base identity changedthe decisions target another merge; emit again
ROOT changed for a legacy v1 filev1 retains its whole-ROOT binding; emit again
the conflicts differ from the file’s rowssame reason
a decision has more values than the slot’s sh:maxCountit would re-create the conflict
a value is not an RDF termit cannot be stored
the file has a field its merge-decisions schema does not defineapply would drop it while the file’s hash still covered it

--dry-run runs every check above and reports the counts that apply would assert and retract, without writing anything. A CI check uses it to validate a decided file before merge.

Plain quipu merge without these flags behaves as before.

quipu pack / quipu unpack — legacy SQLite compatibility

quipu pack <graph-iri> --out <file.pendant.db> [--name N] [--version V] [--space N]
           [--shapes S]... [--queries Q]... [--with-vectors] [--format turtle]
quipu pack --verify <file.pendant.db>
quipu unpack <file.pendant.db> [--into <graph-iri>] [--db <path>]
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 .pendant.db files.

quipu pack --full / quipu restore — whole-store packs

A --full pack is a different artifact from everything above: not a graph, but the whole store, carried losslessly for internal backup. share carries current facts; --full carries facts as whole rows — g, tx, valid_from, valid_to, op, retracted_tx — so history, including what was retracted, travels with it.

quipu pack --full [--format text] --destination internal --out <path> [--db <path>]
quipu restore <file.pendant | text-pack-dir> [--force] [--db <path>]
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> [--allow-write] [--db <path>]
quipu attest allow-write|deny-write <session> [--db <path>]
quipu attest revoke <session> [--db <path>]
quipu attest list [--db <path>]

A binding is share-only unless an operator grants it write (--allow-write at registration, or allow-write later). Only then may its key sign HTTP writes. list prints each binding’s allow_write, expires_at and revoked, so the scope a key holds is visible without reading the store. revoke keeps the row: a revoked binding refuses as revoked, which is a different finding from an unbound key. Rotation is a new binding plus a revoke of the old one.

To set up an agent end to end (its own user, sd key init, enrollment, verification and revocation), follow CABOODLE’s agent identity and write access guide.

Importing a share never registers its producer. This is the point, not an omission: a key that vouches for the bundle it arrived in vouches for nothing, and an attacker substituting the whole bundle would substitute the key with it. Registration is a separate act by the consumer, using a key obtained some other way — the same rule the governance plane states as quipu never self-registers.

So a first import from an unknown producer reports claimed, and reports it honestly. Reaching attested requires someone to decide that this key is that producer.

Automated callers should require attested. Accepting claimed is reasonable, but it should be a deliberate choice by a caller who says so, not the effect of a tier that merely does not read as failure.


Keeping this page honest

tests/cli_doc_drift.rs reconciles three surfaces: the dispatch arms in src/main.rs, the --help text, and this page. Checking any two is not enough — when that test was written, --help documented share, status, merge and unpack but not import, so a page-versus-help check would have passed while the verb that receives a share stayed undiscoverable.

quipu share diff — what changed between two packs

quipu share diff <old> <new> [--format text|markdown|json]

Each side is a pack directory (the standard artifact’s payload.nq, else a legacy share’s export.nt) or a single N-Triples/N-Quads file. Like the Git transport commands below, it reads files only and opens no store.

The diff compares facts, not lines, and groups them by subject entity:

~ Alice (people/alice)
  ~ age: "30"^^xsd:integer -> "31"^^xsd:integer
  - nickname: "Al"
+ Carol (people/carol)
  + rdfs:label: "Carol"
  + role: "designer"
2 entities: 1 changed, 2 added, 1 removed facts

That is the whole output for the fixture pair in tests/fixtures/share-diff/, whose raw line diff is 11 lines of full IRIs and blank-node labels: Alice’s address is a blank node that RDFC relabelled (_:b0 to _:c14n7) without any change to its content, and it contributes nothing.

  • An entity is shown by its rdfs:label with a compact name beside it; an unlabelled IRI is shown compactly (prefix:local for well-known vocabularies, otherwise its last two path segments). Predicates show their label or local name. When two distinct predicates under one entity would show the same name (ex:name and schema:name both labelled “name”, or two IRIs ending /name), each carries its compact IRI — name (ex/name), name (schema:name) — or its full IRI if even those collide. Compaction depends only on the IRI, never on the data, so both sides of a diff name things the same way.
  • ~ predicate: old -> new is reported only when the slot (subject, predicate, graph) holds exactly one value on both sides. A multi-valued slot shows its removed and added values separately.
  • Blank nodes are matched by structure, not label. RDFC-1.0 can relabel every blank node between two versions of a payload; a pure relabel is zero lines. A blank node referenced from another node is shown inline ([ city "Paris" ; zip "75001" ]) as part of the referencing fact, so an edit inside it is a change of that fact. Structurally identical blank nodes on one slot are one fact with a count: cardinality matters to shapes (sh:maxCount), so adding a second copy is shown as ~ p: [ r "v" ] x1 -> [ r "v" ] x2, and the textconv marks a fact asserted more than once with xN. Limits: identical values nested inside an inlined blank node still collapse; a blank node referenced only from inside a blank-node cycle has no named root and is not shown; a blank graph name is keyed by its label.
  • --format markdown suits a PR comment; --format json is the same structure (entities[].changed/added/removed, plus totals) for tools.

quipu share diff --report — the pull-request review

quipu share diff <old> <new> --report [--format markdown|json]
    [--old-shapes <ttl>] [--new-shapes <ttl>] [--decisions <json>]
    [--fail-on-introduced]

The report a reviewer reads when a pull request changes a pack. When <old> and <new> are pack directories, each side’s shapes.ttl and the new side’s decisions.json are picked up from the directory; the flags name them explicitly otherwise. The repository’s pendant review workflow (.github/workflows/pendant-review.yml) runs it for every pack a pull request touches and publishes the result as the check’s job summary.

Reading the PR report

Sections always appear in this order:

  1. Facts. The quipu share diff output above: per-entity changed, added and removed facts, by label.
  2. SHACL violations introduced. Old data is validated against the old pack’s shapes, new data against the new pack’s shapes, and the report lists the violations that occur more often in new than in old, keyed by focus node, path, constraint component and value. A violation already present before the change is counted as pre-existing, never as introduced; the count of violations the change resolves is shown beside it. When the shapes change, a row whose old data already fails the NEW shapes is marked shapes change: the data did not move, the rules did, and the pack still stops conforming. If the new pack ships no shapes, or the binary was built without the shacl feature, the section says NOT CHECKED. That is not a zero, and --fail-on-introduced refuses (exit 1) on a build without SHACL rather than pass. A surviving pack with missing new shapes also refuses this gate. Whole pack deletion is distinguished by the workflow only when every artifact file is absent at the head; incomplete surviving packs refuse. No new-head validation is claimed for a proven deletion. Blank-node labels are collapsed when matching, because RDFC may relabel every blank node between versions.
  3. Merge decisions. When the change carries a decisions.json sidecar from quipu git-merge (see below), each conflict is listed with its subject, predicate, the constraint that made it a conflict (sh:maxCount 1), the base/ours/theirs values and the recorded resolution, or UNRESOLVED. Alias proposals recorded by the driver are listed the same way. The CI script renders a sidecar only when the pull request adds or changes it.
  4. Alias caveat. Always present. A triple-level diff, merge or validation cannot see two different IRIs minted for one real entity; such a pair looks like two healthy entities. Below the caveat are advisory candidates: an entity ADDED by the change whose normalized label is at least 0.90 Jaro-Winkler-similar to a same-type entity in the old pack, or to another added entity. This is the merge driver’s proposer, reused. No candidates is not evidence of no aliases.
  5. Summary. The report’s last line, used as the check annotation title:
pendant review: 1 entity changed (1 changed, 0 added, 0 removed facts); SHACL 0 introduced; no merge decisions; 0 alias candidates

--format json carries the same structure (diff, shacl, decisions, alias_candidates, summary). With --fail-on-introduced the exit status is 3 when at least one violation is introduced; the report is printed in full first.

The check is red only when a pack introduces a SHACL violation or its report cannot be computed. Everything else is information for the reviewer. A pack, for the workflow, is a directory holding export.nt or payload.nq together with manifest.json or manifest.ttl; the two versions compared are the merge-base version and the pull request head. The workflow runs with a read-only token. It can also keep one sticky PR comment up to date, but only when the repository variable QPACK_REVIEW_COMMENT is true: that job needs pull-requests: write, and granting it is a security-posture decision, so it is off by default.

High-stakes (class-B) DecisionRecords are not settled by approving the pull request. They are resolved through the hardware-signed human verdict tracked as aegis-kzt0ql.9; the report only shows them.

quipu diff-textconv — readable git diff for pack files

quipu diff-textconv <file>

Prints one payload file as stable, labelled, entity-grouped text: one header per entity (sorted by IRI, so a relabel never reorders the file), one predicate: value line per fact, blank nodes inline. Git’s textconv runs it on both sides of a diff, so an ordinary git diff, git log -p or git show reads like this instead of two lines of full IRIs:

@@ -1,5 +1,5 @@
 AAA Tracking (ability/aaa-tracking)
-  abbrev: "AAA"
+  abbrev: "AAA CHANGED"
   effectText: "x2 vs. air attacks"

Setup, once per clone (the attribute is already in this repository’s .gitattributes; add it to your own):

printf '*.nt diff=quipu\n*.nq diff=quipu\n' >> .gitattributes
git config diff.quipu.textconv "quipu diff-textconv"

Without the git config line the attribute is inert and Git diffs raw lines. git diff --no-textconv shows the raw form on demand. A file that does not parse (a working copy with merge conflict markers, say) is printed unchanged, so git diff never fails on it. Textconv affects display only: merges, hashes and pendant-check still operate on the canonical bytes.

Git transport: driver, decisions, and CI

These commands operate on repository files and immutable Git snapshots. They do not open a Quipu store. Use a build with the shacl feature (the default).

They shell out to the git executable found on PATH (the wrapper, the driver’s snapshot reads, the shapes three-way merge, and pendant-check all do). If git cannot be found, the command exits 1 with `git` executable not found on PATH and writes nothing.

quipu git-merge <ref>
quipu merge-driver <base-file> <ours-file> <theirs-file> <path>
quipu pendant-resolve <base-ref> <ours-ref> <theirs-ref> <dir> <key> <choice>
quipu pendant-check <base-ref> <ours-ref> <theirs-ref> <result-ref>

Version attributes for each share directory (adjust pendant to your layout):

pendant/export.nt merge=quipu
pendant/shapes.ttl merge=quipu
pendant/manifest.json merge=quipu
pendant/manifest.ttl merge=quipu

Start at the repository root with a clean index and worktree. Save the branch tips for later checks:

ours=$(git rev-parse HEAD)
theirs=$(git rev-parse topic)
base=$(git merge-base "$ours" "$theirs")
quipu git-merge "$theirs"

git-merge supplies a command-local merge.quipu.driver definition and the three immutable commit IDs to its child Git process. Nothing is installed in global Git configuration. This context matters: Git does not promise to merge shapes.ttl before export.nt. The driver reads and merges shapes from the three commits first, rather than trusting whichever working file happens to exist. Directly invoking the low-level driver without context refuses. Overlapping shape edits refuse before the wrapper changes the worktree; resolve those edits on the branches first. Criss-cross bases and incompatible store/scope/destination/layout metadata also refuse.

The graph operator is the same one used by store-level quipu merge. Functional conflicts retain the base value and create entries in decisions.json. No Git conflict markers are written to N-Triples. New entities from opposite branches with a common type are compared locally: whitespace-normalized, lowercase labels, Jaro-Winkler similarity at least 0.90, top five candidates per new entity. These are proposals, never automatic identity assertions. No paid model or network call is used. This baseline can miss aliases and can propose false matches; the decision belongs to the reviewer.

Exit 2 means decisions or Git conflicts remain. Inspect the sidecar, then choose:

quipu pendant-resolve "$base" "$ours" "$theirs" pendant conflict:0 ours
quipu pendant-resolve "$base" "$ours" "$theirs" pendant alias:0 reject

Conflict choices are base, ours, or theirs; alias choices are accept (add an explicit owl:sameAs) or reject (keep identities distinct). Resolution reconstructs the operator result from Git and refuses stale or altered proposal records. The last resolution must pass SHACL. It rewrites canonical export.nt, regenerates both manifests, drops old attestations and the optional derived export.ttl, and records parent_share=ours plus both IDs in merge_parents. Input tx_anchor and created_at remain the ours snapshot’s metadata; a Git merge does not fabricate a store transaction. Each file is replaced atomically; the directory is not a multi-file transaction, so the hash gate must pass before use. Review and stage the complete directory, then commit:

git add pendant
git commit
quipu pendant-check "$base" "$ours" "$theirs" HEAD

The wrapper always stops before commit, including clean merges. It reconciles whole pendants after Git because Git may skip individual drivers when one file is unchanged. Blank-node graphs currently refuse divergent merges: canonical blank labels are not stable identities across snapshots. Skolemize those nodes first.

CI is required even when local driver use is documented. A clone with no driver definition, a forge merge button, or a rebase can fall back to text merge. The repository’s SHACL test job runs scripts/ci/pendant-merge-check.py with full history and the PR’s synthetic merge commit. It also replays two-parent merges introduced on the topic branch, then reconstructs base/ours/theirs, requires recorded resolutions, compares the committed graph, shapes and manifest with the operator result, and validates SHACL. A hand-rehashed unsafe result still fails. Push/nightly runs validate the committed packs too; a squash commit alone cannot reconstruct the original branch pair, so the PR check remains load-bearing. Keep the SHACL test check required in branch protection.

Known gap: an administrator bypasses the gate. The replay that can actually fail is the PR check. On a push event the final replay is trivially (base, ours, theirs) = (before, before, result): a fast-forward of the previous tip, so it re-validates the committed packs but cannot reconstruct a merge that happened elsewhere. This repository’s main protection uses strict status checks, so a squash merge’s tree equals the synthetic merge commit the PR check tested. It does not enforce protection for administrators, so an admin’s direct push, or an admin merge that skips pending or failing checks, lands a pendant that no merge replay has verified. Treat an admin bypass as unverified and run quipu pendant-check on the original base/ours/theirs yourself, or enable “include administrators” if that gap is unacceptable.

Measured on the repository’s real Datalinks pendant, replacing the same functional value on both branches produces a text conflict, not a clean double. Git writes three marker lines into the RDF. Separate new IRIs with identical labels merge cleanly without an identity decision; disjoint additions merge cleanly and correctly. tests/git_qpack_merge.rs exercises those cases, decision resolution, and the driver-absent CI refusals using actual Git repositories.

Carry alignment evidence

quipu share --alignments FILE --output DIR carries an optional, hash-sealed alignments.ttl. Input is bounded UTF-8 Turtle; an explicit empty carrier is valid. The manifest seals its exact bytes and fixed filename in the share ID. Missing, altered, misnamed or undeclared evidence is refused before import stages data. Legacy shares without this member retain their existing identity.

This is evidence transport, not identity adoption. Imported bytes are retained separately; neither import nor ROOT promotion applies the mappings or loads their shapes. Use the existing alignment propose/decide/apply workflow for curation. The browser reader uses the same verification, and Explorer export preserves the imported carrier. A release needs a compatible engine and actual asset readback; source configuration alone does not establish availability.

Render graph data into owned files

qp render produces markdown locally from a stored specification or an ad-hoc query. A remote server prepares a read-only plan; the client writes the files. Templates have no file loader, network access or nondeterministic functions.

mkdir -p output
qp render --query 'SELECT ?title WHERE { VALUES ?title { "Example" } }' \
  --template '# {{ rows[0].title }}' --dest output/report --db private.db
qp render --query 'SELECT ?title WHERE { VALUES ?title { "Example" } }' \
  --template '# {{ rows[0].title }}' --dest output/report --db private.db --dry-run

The second command reports a no-op. Both the file and manifest retain their bytes and modification times. Dry-run prints the complete file list and the content/mode diff without creating directories, lockfiles, manifests or events. The destination’s parent must already exist.

Store the specification as data

@prefix render: <urn:quipu:render:> .
<urn:example:report> a render:RenderSpec ;
    render:query "SELECT ?title WHERE { VALUES ?title { 'Example' } }" ;
    render:format "markdown" ;
    render:layout "single" ;
    render:path "report.md" ;
    render:template "# {{ rows[0].title }}" .

Validate this file against the supplied shapes/render.ttl when importing it, then render <urn:example:report>:

qp knot report.ttl --shapes shapes/render.ttl --db private.db
qp render urn:example:report --dest output/stored --db private.db

A specification carries its query, layout, path, optional template, includes and optional provenance footer. markdown is the first format; the renderer trait leaves room for other document formats. Binary postprocessing is outside core. Stored specifications and included byte literals travel in a pendant. Normal share/export scrub and destination rules still apply.

Hierarchies and byte-exact includes

Use per-entity layout and a path such as {scope}/{slug}/SKILL.md. Each row has its own row context; single-file templates receive all sorted rows. The small deterministic template language supports loops, conditionals and the upper and lower filters on bounded strings. Fields, indexes, scalar literals, comparisons and boolean conditions are supported. Allocation operators (~, +, *, **), reassignment, captures, recursive loops, function calls and dynamic container literals are refused before template compilation. Templates are capped at 256 KiB and 4096 tokens; final files share one incremental 16 MiB budget. Undefined variables, path collisions, traversal, case aliases and incomplete queries refuse before publication.

Includes are a JSON literal in render:includes, or an includes column on per-entity rows. Each entry has a relative path, sha256: content address, base64 bytes and a mode of 0644 or 0755. Raw markdown templates can copy row.content without parsing or rewriting YAML front matter. Includes preserve executable modes and are verified against their hashes.

Render skills into their own root outside the harness’s skills directory, then let the existing tooling projection mount individual skill names. A scoped query can select internal skills. The renderer does not overwrite unrelated harness entries or manage those mounts.

Ownership, time and publication

.quipu-render.json records the specification, source_tx, valid_at, the render timestamp and every managed file’s hash/mode. The complete plan is prepared within one SQLite read snapshot. Present reads pin that transaction; historical --valid-at reads use the same snapshot without changing the native mixed-clock guard. A write committed during rendering cannot change the plan.

qp render urn:example:report --valid-at 2026-01-15T00:00:00Z \
  --dest output/historical --db private.db
qp render urn:example:report --dest output/remote --server https://graph.example

Remote /render requests require an accepted bearer even on a read-only server. The client follows the documented credential order: nonempty QUIPU_AUTH_TOKEN, then QUIPU_AUTH_TOKEN_FILE, then ~/.config/quipu/token. Credentials are never passed as arguments or printed. Without a configured credential, remote rendering refuses; local rendering requires no bearer.

Only manifest-owned files can be updated or removed. Unknown files, edited managed bytes, changed modes, symlinks and a changed destination refuse. The client stages a complete directory and atomically publishes it on Linux/macOS. New roots are private (0700); updates preserve the existing root mode. The manifest is private (0600). Parent directories are opened component by component with no-follow capabilities. Unchanged files retain their inode and modification time across an update. A failed or indeterminate publication is not permission to retry blindly; inspect the retained destination and any reported staging directory first.

The manifest is the receipt for the current render, not a disk-to-graph sync or an append-only audit history. Historical graph access, installed CLI identity, shape projection and a real consumer run are separate from source tests.

Recorded CLI demonstration

docs/probes/pr-demos/render/reproduce.py invokes the actual candidate CLI against a private temporary local store. The recorded result is:

{
  "target": "candidate CLI / private local store",
  "before": "destination absent",
  "first_files": [
    "report.md"
  ],
  "after_utf8": "# Example",
  "repeat_noop": true,
  "repeat_same_bytes_mtime_inode": true,
  "unknown_file_refused_and_preserved": true,
  "running_installation": "not invoked"
}

The absent destination becomes one markdown file; the repeated render preserves bytes, inodes and modification times. An unknown file is refused and preserved. This recording makes no installed-service or deployment claim.

REST API

Request logs distinguish caller-declared host and agent from the receiver’s observed network hop. Direct HTTP requests include transport_peer and transport_peer_source: "socket"; unavailable transport context is null with source "unavailable". A proxy’s socket identifies that proxy, not the originating agent. Internal MCP or stdio dispatch may have no socket context. Forwarded headers do not establish this observation, and neither a socket address nor a declared agent authenticates a writer. Credentials and request bodies are not included in these fields. Existing authorization outcomes remain separate.

Write-route logs also include the bounded declaration class in request_provenance and missing required field names in request_provenance_missing. These describe requests, including refusals; they do not assert that a transaction committed and must not replace committed write-coverage metrics. Model and session values are not added to logs.

The quipu-server binary exposes all Quipu operations over HTTP (Axum).

Versioned file-memory RPCs

These JSON operations are disabled by default. To use them, a deployment must register the graph and configure quipu.memory_files.enabled, graph, deployment and project. They create neither graphs nor credentials. An owner is the exact deployment, project and crew IRI tuple; labels and caller actor fields do not authenticate it.

EndpointRequest and responseIdentity
POST /memory/readOwner tuple; current manifest or null, with explicit repository-owner coverageInherited deployment read boundary, including cross-crew reads
POST /memory/exportOwner tuple; complete manifest and identified source revisionsSame inherited read boundary
POST /memory/projectionOwner tuple; export plus authenticated_ownerExisting verified named principal must equal the owner
POST /memory/receiptExact original commit intent; original receipt or nullMatching named principal
POST /memory/commitOne file intent; verified receipt with file and snapshot revisionsMatching named principal and normal writable-server policy

Commit intents include owner, relative path, UTF-8 text, expected snapshot and file revisions, idempotency key, harness and opaque account label. Null text is an explicit tombstone. Receipt lookup precedes stale-parent rejection: replay the exact intent and key after an indeterminate result, rather than generating a new write. Stale new intents return HTTP 409; denied ownership returns 403. Disabled or invalid configuration refuses instead of accepting memory writes.

Requests are bounded to 1 MiB; a file is at most 256 KiB and an owner snapshot contains at most 128 entries and 4 MiB of live text. Existing graph, vocabulary, SHACL and write-policy checks remain in force. Projection reads data; it does not select or overwrite a native memory directory. See versioned file memory for the lossless envelope, local CLI workflow, public export boundary and filesystem/harness guarantees.

Queued write timeouts

When a request budget expires before a tool write starts, the server returns HTTP 503, Retry-After: 1, and a JSON error with code: "write_not_started", write_started: false, and waited_ms. This covers write admission and the first writer acquisition of the tool endpoints, including /episode and /set. The check runs before signed nonce settlement. The tool did not run, so the caller can retry after the indicated delay.

A real query timeout still returns 408 with query-specific advice. Do not infer that a write was unrun merely from a 408, a zero elapsed time, a transport failure, or a gateway error. A timeout after execution begins may have committed; verify the requested facts and preserve the same payload before retrying. These diagnostics do not change the configured request budget or bound direct protocol handlers that bypass tool-write admission.

Unrecognized request fields

Successful tool-backed JSON endpoints report unrecognized top-level request keys in a sorted ignored_fields array. Requests containing only recognized keys omit that field. The accepted keys come from the tool’s input schema, with HTTP adapter fields accounted for separately. /reason, /subscriptions, /graph/create, and /graph/label also report unrecognized keys. Errors retain their existing status and response body.

This is reporting, not rejection or a preview mode. For example, a successful POST /knot containing "dry_run": true still writes and includes "ignored_fields": ["dry_run"]. /knot does not support dry runs.

POST /export and /query responses negotiated as standard SPARQL results or RDF preserve their standard bodies and report the JSON array in the X-Quipu-Ignored-Fields response header instead. /search/nodes reports verbose as ignored; the separate /search_nodes endpoint supports it.

Reporting covers undeclared top-level fields, not nested keys, value types, or whether a recognized option applies to a particular action. It does not cover non-tool endpoints such as /share, /import, /import/promote, entity GETs, SPARQL protocol form fields, or direct Rust library calls. An absent warning on those surfaces is not evidence that every supplied field was used.

Starting the Server

quipu-server --db my.db --bind 0.0.0.0:3030
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

Legacy migration profile: reads are open; writes need a bearer token. When required session keys are disabled and an auth token is configured, write endpoints require:

Authorization: Bearer <token>

In that legacy profile, reads such as /query, /search, entity lookups, /health and /version need no credential. /identity always requires a signed session identity.

A bearer refusal returns HTTP 401 with a JSON reason of missing_or_invalid_bearer_token, credential_type: "bearer", the endpoint, and provisioning guidance. Clients should configure a matching QUIPU_AUTH_TOKEN or QUIPU_AUTH_TOKEN_FILE; retrying an absent or rejected credential does not repair it. Signed authentication uses the existing protected registry with a registered, unexpired identity and write scope; read routes also accept that signature. Signed mutations remain restricted to the four audited routes, and signed requests cannot carry URL query strings.

Set [quipu.server] session_keys = { required = true, onboarding_read_paths = [] } only after completing the client and distinct-OS-user migration. This policy requires signed keys even for reads; a shared or named bearer is insufficient. The optional onboarding list admits only unique exact /health and /version GET/HEAD paths. Other paths, wildcards and duplicates fail startup validation. Strict mode refuses OPTIONS outside CORS, requiring a same-origin client or an authenticated broker. This option is default OFF and performs no enrollment.

Provision a client credential

The server administrator issues credentials through the deployment’s approved secret distribution channel. For a shared bearer, the supplied value must match an active server bearer. For a named credential, the administrator registers its verifier and activates the validated registry before supplying the plaintext to the client. Installing an arbitrary token on a client cannot grant access.

Shantytown and CABOODLE use ~/.config/quipu/token as the default credential file. A nonempty QUIPU_AUTH_TOKEN overrides QUIPU_AUTH_TOKEN_FILE, which overrides this default. Files are read at request time. This is a client convention; the server neither reads your home directory nor distributes secrets.

install -d -m 700 "$HOME/.config/quipu" && install -m 400 /secure/issued-token "$HOME/.config/quipu/token"
st ops doctor quipu --no-latest
# Or, on a CABOODLE host:
caboodle doctor

Keep an existing explicit file override until provisioning and rotation move together. Do not create independent copies that can diverge at rotation. Never put a token value in command arguments, logs, issues, or version control.

The doctor check POSTs an empty object to /episode. Authentication and read-only policy run before episode parsing: an accepted request receives HTTP 400 with invalid episode JSON: missing field ..., and stores nothing. HTTP 401 means a missing or rejected bearer; HTTP 403 with reason: server_is_read_only means no credential can permit the write. Network failures and unexpected responses leave authorization unproven. This check proves access to the write handler, not a successful storage commit.

Additive named credentials

[quipu.server].crew_credentials_file optionally points to a local JSON registry of SHA-256 verifiers for administrator-issued, high-entropy bearer tokens. Keep auth_token in place: named credentials are additional identities and do not replace the current or previous shared bearer. Public reads and read-only mode retain their behavior. An unconfigured registry changes nothing.

The file has version: 1 and a credentials array. Each entry contains credential_id (a unique non-secret identifier), principal (an absolute crew IRI), audience (exactly quipu), and token_sha256 (64 lowercase hex digits, the SHA-256 of the presented token string). Issue at least 32 cryptographically random bytes per credential and encode them for transport; this fast verifier is not suitable for human passwords. The issuer must verify that the principal is an existing crew identity. Parsing a registry checks syntax, not graph membership. No bearer plaintext belongs in this file or in the graph.

Loading captures an immutable policy at startup. Invalid optional registries are reported without their contents; shared authentication remains available. Validate and atomically install a complete registry before provisioning clients. A restart with an invalid registry will not activate its named credentials; preserve the last validated file and verify candidate configuration before a rollout. This initial implementation provides no issuance, rotation, revocation, or hot reload.

Named writes produce authenticated_request_start and authenticated_request_complete JSON audit events with credential ID, principal, method, route, and a process-local correlation ID. Missing completion is indeterminate. The credential principal is independent of declared actor, task, and source values. Import/promotion and RDF graph-store transactions use the registry principal through their existing authenticated-actor paths. Locally created fact transactions also retain separate authenticated evidence, exposed as authenticated by /transactions. Generic handlers preserve caller-declared actor and source; neither can replace this credential evidence.

The evidence is inserted inside the fact transaction’s savepoint, including fork materialization and overlay tombstones. Rollback removes it. Owned request context crosses blocking dispatch and deferred snapshot promotion, and is restored before a worker thread is reused. Schema/registry operations without a fact transaction retain request-level audit only. Library/CLI writes without an HTTP identity and copied historical transactions have null local authentication evidence; a foreign actor is not proof of possession of a local credential. End-to-end MCP attribution requires the proxy to select the corresponding downstream Quipu credential. The shared bearer remains legacy-shared-bearer, including if a registry entry accidentally duplicates its verifier.

For a coherent, pinned source receipt, use POST /acquire for a single file, or POST /acquire/composed for qualified MAIN scope with fenced immutable providers. The latter uses a separate version-gated contract and refuses unsupported required attached or derived scope; see atomic acquisition for its versioned contract. POST /acquire/eligibility answers only whether each named graph would pass that admission and the configured label floor at the current head, from graph metadata alone; it is an observation, not a receipt (see eligibility observation).

POST /share is also read-only. It returns the canonical Git-share manifest and exact file contents in one JSON response, allowing a proxy on another host to forward Quipu’s own canonicalization, hashes, and share ID instead of reproducing them. The body accepts scope, shapes, no_shapes, parent_share, turtle_view, and an optional max_bytes that can lower (but not raise) the 8 MiB server cap.

The authoritative list is http_auth::WRITE_ENDPOINTS in src/http_auth.rs, not this page. It is enforced: write_endpoints_cover_every_route fails the build if any registered route is unclassified, so the code cannot drift from itself — but this page can drift from the code, so treat it as a summary and the constant as the answer.

Two entries surprise people, and both are deliberate:

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 /watermark

A bounded, read-only metadata endpoint for replication observers. Returns {"store_id":"<store lineage>","watermark_tx":123} using the indexed latest transaction ID. It does not enumerate facts, advance a cursor or create a transaction. A shared store ID establishes lineage, not proof that two copies have identical contents.

Opt-in read-only followers poll their configured primary every 15 seconds (two-second HTTP timeout). Their responses carry X-Quipu-Replica-Lag-Tx: primary minus follower transaction IDs from the last fresh observation, or unknown. Metrics quipu_replica_lag_transactions (-1 for unknown) and quipu_replica_lag_known expose the same bounded observation. Failure, stale metadata or mismatched lineage never means zero lag. Replica writes return 503 naming the primary; they are not forwarded.

GET /health

Health check.

curl localhost:3030/health

Response: {"status": "ok"}

POST /identity

Requires an existing session-attestation signature. Returns the principal, public key ID and session selected from the protected binding, with authenticated: true and auth_class: "attested_session". A bearer or unkeyed request returns 401. Agent/session declaration headers (X-Quipu-Agent, X-Quipu-Session) are declared provenance. They must match the verified binding only when session keys are required ([quipu.server.session_keys] required = true); with it off, a differing declaration is admitted and the returned principal is still the binding’s. Replay, expired/revoked bindings and wrong keys refuse. No private key is returned. Use sd identity --json with an enrolled own key; plain curl without a signed envelope cannot produce the authenticated result.

GET /stats

Store statistics.

curl localhost:3030/stats

Response: {"facts": 1234, "entities": 56, "predicates": 12}

GET /query and POST /query

Execute a SPARQL query. Quipu implements the SPARQL 1.1 Query Protocol GET and direct-query POST transports, while retaining its JSON POST extension:

# Standard GET (request target limited to 8 KiB)
curl -G localhost:3030/query \
  -H 'Accept: application/sparql-results+json' \
  --data-urlencode 'query=SELECT ?s WHERE { ?s ?p ?o } LIMIT 5'

# Standard direct-query POST (body limited to 1 MiB)
curl localhost:3030/query -X POST \
  -H 'Content-Type: application/sparql-query' \
  -H 'Accept: text/turtle' \
  --data 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 5'

# Quipu JSON extension
curl -s localhost:3030/query -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'

For SELECT and ASK, request application/sparql-results+json or application/sparql-results+xml, text/csv, or text/tab-separated-values. CSV and TSV follow the SPARQL 1.1 Results formats, including term spelling and escaping. For CONSTRUCT and DESCRIBE, request text/turtle or application/n-triples. All transports share the configured query deadline and return HTTP 408 when evaluation exceeds it.

POST /update

Execute SPARQL 1.1 Update using either application/sparql-update or an application/x-www-form-urlencoded body containing exactly one update parameter. The endpoint requires write authentication when a bearer is configured. Protocol using-graph-uri and using-named-graph-uri parameters scope a DELETE/INSERT WHERE; mixing them with an in-body USING clause is rejected. Every affected graph passes through Quipu’s normal authority and governance gates, and the request commits atomically across graphs.

curl localhost:3030/update -X POST \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/sparql-update' \
  --data 'INSERT DATA { <http://example/s> <http://example/p> "value" }'

A successful update returns 200 with what it committed:

{"tx": 4182, "asserted": 1, "retracted": 0,
 "graphs": [{"graph": null, "tx": 4182, "asserted": 1, "retracted": 0}]}

asserted and retracted count the triples the update changed, so a conditional DELETE/INSERT ... WHERE used as a compare-and-swap (claim if unassigned, release if still mine) can tell whether it won without a read-back: both zero means the WHERE matched nothing, or the update changed nothing, and tx is null. Each graph with changes commits its own transaction (graph is null for the default graph); tx is the last of them, usable with --at / tx reads. The SPARQL 1.1 Protocol leaves this body to the implementation.

Attribute a write with the optional actor and source query parameters (or form fields beside update=), each at most once, 1–256 characters, no control characters: POST /update?actor=agent:wu&source=seeds:claim. They are recorded on the transaction as declared, like /knot’s fields; the verified caller is recorded separately as its authenticated principal. Without actor the transaction’s actor is unknown (null); source defaults to sparql-update.

Quipu’s JSON extension compacts result IRIs to CURIEs by default using prefixes declared by the currently loaded shape sets; unknown namespaces remain full IRIs. Pass "verbose": true in a JSON request, or ?verbose=1 on GET, to return full IRIs. Standards result formats always retain their prescribed RDF term representation.

Optional fields: valid_at (ISO-8601), tx (integer), graph (a named-graph IRI or dataset name that scopes the query’s default graph without writing a FROM/GRAPH clause — an unknown IRI yields an empty default graph, never a silent ROOT fall-through), and fork (a fork name registered by quipu fork; unknown or dropped forks are refused loudly; mutually exclusive with graph).

include_kinds (array of dataKind tokens, e.g. ["archive"]) widens the default graph set with every registered graph declaring one of those kinds — the explicit opt-in for composing cold/frozen graphs into a hot read. Silence never widens: absent or empty means the scope is unchanged, a FROM clause in the query text still overrides the request-level scope, and fork + include_kinds is refused (one scope authority). The response’s composed labels.kind then honestly reports every kind that contributed.

With "federated": true the whole query text fans out through the federated provider — the local store plus every [[quipu.federation.remotes]] — and the response adds a per-member providers list (each carrying the remote’s operator-declared label) and a complete flag, with every row _provider-tagged and, for declared remotes, _trust/_freshness-stamped. The composed dataset labels fold the remotes in as members, and configured [quipu.labels] floors refuse a federated query exactly as a local one — an undeclared remote fails a configured freshness/trust floor (quipu-fd1). The temporal/graph fields are refused on a federated query (they only shape the local evaluator’s context). See Federation.

POST /knot

Assert facts from Turtle data.

curl -s localhost:3030/knot -X POST \
  -H "Content-Type: application/json" \
  -d '{"turtle": "@prefix ex: <http://example.org/> . ex:alice a ex:Person ."}'

Optional fields: shapes (SHACL Turtle), timestamp, valid_from, actor, source, replace_snapshot + snapshot (diffed replacement of a producer’s prior facts under a stable key), and graph (a named-graph IRI that must already be registered committed-class via POST /graph/create; unknown IRIs error, overlay-class targets are refused, omitted means ROOT).

timestamp is transaction time (when this store came to believe the facts, queried with tx/as_of_tx); valid_from is valid time (when they became true of the world, queried with valid_at). Omitting valid_from reuses timestamp for both, which is the historical behaviour. valid_from is RFC 3339 and is normalised to YYYY-MM-DDTHH:MM:SSZ — valid-time is compared as text, so an un-normalised offset sorts wrongly rather than merely looking untidy. A malformed value is refused before anything is written.

# a commit authored in March, ingested tonight
curl -s localhost:3030/knot -X POST \
  -H "Content-Type: application/json" \
  -d '{"turtle": "@prefix ex: <http://example.org/> . ex:c1 a ex:Commit .",
       "valid_from": "2026-03-04T09:15:00+01:00"}'

Response: {"tx_id": 1, "count": 2, "conforms": true, "valid_from": "2026-03-04T08:15:00Z"} — the echoed valid_from is the normalised key the facts were actually stored under.

Resumable snapshot promotion and cancellation

POST /knot/stage stores inert, content-addressed parts. Transport parts are limited to 512 KiB; the assembled upload is limited to 128 MiB and 512 parts. Those are byte limits, not a bound on promotion memory. Promotion still validates and replaces the complete producer snapshot atomically.

Start or poll with POST /knot/promote and {"upload_id":"sha256:..."}. A pending response includes "state":"running" and "lease_seconds":30. Poll regularly (for example once per second) using the same authenticated identity. Each live poll renews the lease. Bound the heartbeat request timeout and retry sequence below the lease (for example a five-second timeout with three attempts). A 30-second request timeout can itself exhaust this lease; a network partition can therefore cancel a still-running client. If the producer stops polling, its lease expires and the worker cooperatively cancels before committing the snapshot. A late poll cannot revive an expired lease. Only one promotion per store is admitted at a time; another upload receives an admission refusal.

For an explicit stop, send the same upload ID to POST /knot/cancel, using the identity that started promotion. Caller-declared actor is not authority. "cancellation_requested":true acknowledges the request, not worker exit. Poll /knot/promote until {"pending":false,"state":"cancelled","cancelled":true} confirms the worker has exited without committing its snapshot replacement. The previous snapshot and other producers’ facts remain active. Durable policy and refusal audit records are preserved; validation advisories from the aborted payload are discarded. Inert staged bytes and dictionary terms can remain until their normal cleanup; cancellation is not deletion of staging or an undo of an earlier successful promotion.

Cancellation competes with a single final commit claim. If commit wins, /knot/cancel reports "cancellation_requested":false and "state":"committing" or "complete"; the client must read the final promotion result. Once committed, deferred embedding may still be finishing and cannot be undone by cancellation. Checks run between upload parts, RDF rows, retraction comparisons and staged fact writes, and before commit. This is cooperative cancellation, not a promise that every validation or individual SQLite statement can be interrupted within 30 seconds. Measure stop latency and peak memory for the intended workload before admitting a larger publication; raising memory limits is not a substitute.

Terminal receipts are retained for an hour in a bounded process-local cache. Cancelled jobs stay terminal while their receipt is retained; polling does not restart them. To deliberately resume after confirmed terminal cancellation, the same identity can send {"upload_id":"sha256:...","retry_cancelled":true} to /knot/promote. This flag cannot restart a still-running worker, a failed job with an indeterminate commit, or a completed job. A fresh server process does not retain these receipts. After a restart or an indeterminate response, verify snapshot contents before starting another promotion. Staging remains content-addressed and independently expires.

POST /cord

List entities.

curl -s localhost:3030/cord -X POST \
  -H "Content-Type: application/json" \
  -d '{"type": "http://example.org/Person", "limit": 50}'

POST /unravel

Time-travel query.

curl -s localhost:3030/unravel -X POST \
  -H "Content-Type: application/json" \
  -d '{"tx": 5}'

POST /episode

Ingest an episode.

curl -s localhost:3030/episode -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "name": "deploy-v2",
    "nodes": [{"name": "myapp", "type": "WebApplication"}],
    "edges": [{"source": "myapp", "target": "kotare", "relation": "runs_on"}]
  }'

A bare node type such as WebApplication names a class under the store’s base_ns. To name a class in the public Quechua vocabulary instead, prefix it: "type": "quechua:WorkItem" asserts <https://scbrown.github.io/quechua/ns#WorkItem>. The prefix changes only the class. The node’s own IRI is still minted under base_ns. Like any other type, a Quechua class is refused until a loaded shape sanctions it. No other prefix is accepted.

Set "replace_snapshot": true for producers whose payload is the complete current state of an inventory. Facts previously asserted by the same episode name but absent from the new payload are retracted atomically with the new assertions. The default is false, preserving additive knowledge-ingestion semantics. Reusing a stable episode name is required for replacement.

outcome: what the ingest DID — branch on this, never on count

/episode is idempotent. The activity IRI is derived from the episode name and stamped with a content hash, so re-posting identical content is a no-op and retrying after a lost response is SAFE.

The response says which of three things happened:

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.

Work-item context without duplicate entities

An episode may carry one aegis:aboutWorkItem edge from its generated activity (episode_ plus the sanitized episode name) to an existing WorkItem IRI. This claims the episode’s context, not that the work is implemented or complete. Knowledge remains reachable by joining prov:wasGeneratedBy to that activity. Do not copy the WorkItem into the knowledge graph merely to create a reference.

The shipped AboutWorkItemShape constrains the reference to one IRI. The producer must first verify the target’s direct WorkItem type in its authoritative records graph, behind a passing control, and read the edge back after writing. SHACL does not prove a target’s type in a graph outside its validation dataset. The property has no global domain or range, so linking cannot manufacture the type assertion used by that verification. Historical episodes may omit it.

Select both graphs explicitly when retrieving bead-related knowledge. For example, with ex: bound to the deployment vocabulary, join the records graph’s ex:WorkItem to the knowledge graph’s activity and generated entities:

PREFIX ex: <https://example.org/vocab/>
PREFIX prov: <http://www.w3.org/ns/prov#>
SELECT DISTINCT ?knowledge WHERE {
  GRAPH <urn:example:records> {
    <https://example.org/vocab/task-123> a ?type .
    FILTER(?type = ex:WorkItem)
  }
  GRAPH <urn:example:knowledge> {
    ?episode ex:aboutWorkItem <https://example.org/vocab/task-123> .
    ?knowledge prov:wasGeneratedBy ?episode .
  }
}

The producer chooses the primary task explicitly; do not guess it from every identifier mentioned in free text. Direct /episode calls do not parse task markers automatically. This relationship does not repair historical omissions or establish that a tracker backlog has drained.

Edge relation: which vocabularies /episode can write

/episode represents the caller’s predicate faithfully or refuses it with an explanation of which path to use. It never silently rewrites a predicate.

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 — spaces are not accepted in a bare relation

The declared prefix set is KNOWN_PREFIXES in src/episode/mod.rs, kept in lockstep with the @prefix block episode_to_turtle emits.

Asserting an alias — entity dedup with owl:sameAs

owl:sameAs is this graph’s alias convention, and /episode’s resolution_hints exist to tell you at ingest time that you are about to split an entity. Acting on that hint is a normal /episode edge:

curl -s localhost:3030/episode -X POST -H "Content-Type: application/json" \
  -d '{"name": "alias-fix", "source": "<bead-id>",
       "nodes": [{"name": "backup-freshness.timer"}, {"name": "backup-freshness-exporter"}],
       "edges": [{"source": "backup-freshness.timer",
                  "target": "backup-freshness-exporter",
                  "relation": "owl:sameAs"}]}'

Two rules that are not obvious from the 200:

  • Reuse the existing node names byte-for-byte. Node identity here is the literal name string: quipu matches canonical_name: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).

This operation is ROOT/default-graph only. Named-graph facts are untouched. A supplied graph field, including null or ROOT’s IRI, is refused with HTTP 400 before mutation. Omit it for ROOT; use graph-scoped /update for a named graph.

  • The predicate is a full IRI, from any vocabulary. This is the endpoint /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.

When write validation is enabled, SET validates the replacement against loaded SHACL shapes, including the existing subject’s types. A tagged IRI does not bypass a string datatype constraint. Refusal preserves the previous value. Explicit operation shapes remain enforced when persistent validation is off. A concurrent change to the complete predicate slot produces a conditional-write conflict instead of silently overwriting a value the request did not observe.

POST /validate

Dry-run SHACL validation.

curl -s localhost:3030/validate -X POST \
  -H "Content-Type: application/json" \
  -d '{"shapes": "@prefix sh: ...", "data": "@prefix ex: ..."}'

Decision deck: POST /deck/present, POST /deck/attest, POST /deck/receipt

A curated decision request is settled by an enrolled human’s WebAuthn assertion. These routes are off by default: they answer 403 unless [quipu.server] deck_http = true. Even then they settle nothing until an operator has configured and enabled the store’s deck, which no route can do.

  • /deck/present (write): {"request", "revision", "credential", "choice"} returns {"challenge", "webauthn_challenge"}. Sign webauthn_challenge (unpadded base64url) exactly as returned. Never rebuild the challenge client-side: the server verifies against the challenge it stored.
  • /deck/attest (write): {"nonce", "client_data_json", "authenticator_data", "signature"}, each byte field base64url, returns {"decision", "nonce", "recorded_at"}. A challenge settles once: a replayed, expired or stale one is refused. After a lost response, look the receipt up; do not resubmit.
  • /deck/receipt (read): {"nonce"} returns {"found", "receipt", "tx"}.

Unknown fields are refused on all three.

POST /retract

Retract facts for an entity.

curl -s localhost:3030/retract -X POST \
  -H "Content-Type: application/json" \
  -d '{"entity": "http://example.org/old-service"}'

Optional: predicate (only retract matching), timestamp, actor, and value (retract only the one matching triple).

This operation retracts ROOT/default-graph facts only; named-graph facts survive. Supplying graph, including null, is refused with HTTP 400 before mutation. Use an explicitly scoped /update for named-graph retractions.

The value shape matters. An object that is an IRI reference — the target of an edge such as reports_to or rdf:type — must be given as a tagged object, not a bare string:

# retract exactly  <svc> reports_to <boss>
curl -s localhost:3030/retract -X POST -H "Content-Type: application/json" \
  -d '{"entity": "http://example.org/svc",
       "predicate": "http://example.org/reports_to",
       "value": {"iri": "http://example.org/boss"}}'

A bare string ("value": "http://example.org/boss") is matched as a string literal, which can never equal a stored IRI reference. Rather than silently report {"retracted": 0} — indistinguishable from “the triple was already gone” — the endpoint now returns a 400 error naming the {"iri": ...} form whenever a bare string cannot match: either the predicate’s stored objects are IRIs, or the string itself parses as an IRI (has a scheme://). A correctly shaped {"iri": ...} (or a genuine string literal) for a triple that does not exist is still a quiet, idempotent {"retracted": 0}.

POST /retract/source

Preview or apply a retraction by the exact transaction source string. Requires source and a nonempty repair ticket/reason. The default apply: false only previews; apply: true retracts the planned facts and stamps the transaction source as repair:<ticket>. Optional fields are graph (defaults to ROOT), timestamp, and actor. The graph must be committed; source matching is literal, not a prefix or pattern. As a write route, even preview requires the configured bearer.

The response reports planned, the affected entities count, a bounded sample, sample_truncated, and repair_source. A preview has applied: false and null tx_id/retracted; an applied result reports the actual retraction transaction.

Source cleanup removes only that source’s ownership claims. A statement remains visible while another recorded claim survives. This protection is forward-only for newly recorded claims. Older versions skipped identical assertions from later producers, leaving no recoverable claim. Reassert the relevant producer snapshots before cleaning up old shared data; this release does not backfill it. The retracted count reports source-owned statements processed, not necessarily statements removed from the visible graph.

POST /episode/retract

Episode-scoped logical retraction. Retracts the facts an episode’s ingest contributed — its activity node, generated entities, the bare relationship triples (edges), and any reified confidence statements — by closing their valid_to via the bitemporal retract path. Facts are never physically deleted, so time-travel queries (/cord, /unravel) still show them.

Identity of surviving nodes is preserved by default. Identity triples (rdfs:label, rdf:type) are ordinary facts, so a naive scope retraction would strip them from any node this episode named even when edges from other episodes keep that node alive — leaving a “ghost”: a node that answers predicate queries but is invisible to every label scan and type query. The on_orphan parameter decides that contract:

on_orphan (alias orphan_policy)Behaviour
preserve (default)Keep rdfs:label / rdf:type alive for nodes that retain surviving references. So the default does not retract “every currently-active fact” — it spares the identity of nodes that would otherwise be orphaned.
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

Usable context versus ranked candidates

Every successful response also carries the quipu-context/v1 contract:

{
  "insufficient_context": true,
  "context_results": [],
  "context": {
    "contract": "quipu-context/v1",
    "outcome": "NO CONTEXT",
    "reason": "subject_not_covered",
    "similarity_floor": 0.52,
    "minimum_term_coverage": 0.5,
    "subject_term": "unrecordedsubject",
    "required_identifiers": [],
    "top_term_coverage": 0.6667,
    "candidate_count": 3,
    "admitted_count": 0
  }
}

Consumer validation requires a finite declared floor in [0.52, 1] before accepting either CONTEXT or NO CONTEXT. A lower, missing or malformed floor is UNKNOWN in these consumers, even when the server reports no context.

results, count, ranking scores, ordering, scope and temporal provenance remain unchanged diagnostic search output. They are not evidence that an answer exists. Context consumers must render NO CONTEXT when insufficient_context is true and must not inject diagnostic candidate text. When it is false, consume only context_results. A missing or unknown contract, transport error or malformed response is UNKNOWN, never an empty successful search. This contract does not change storage, ranking or graph isolation. Native MCP renders only this admitted-context view: its results and count describe the admitted rows and omit diagnostic neighbors. Unsupported context responses are MCP errors marked UNKNOWN. REST retains the diagnostic candidate view.

Admission requires the top candidate’s raw similarity to reach the floor, the first non-stopword query term to occur as a complete word, every explicit identifier to occur exactly, and at least half the distinct non-stopword query terms to occur. Matching is case-insensitive. Identifier boundaries preserve hostnames, unit names, item IDs, IRIs/paths and hexadecimal identifiers of seven or more characters. An uncorroborated top candidate produces NO CONTEXT even if a lower candidate appears usable. Admitted rows preserve source and valid-time metadata. An admitted historical incident is not a verified cause or current state.

The default raw-cosine floor is 0.52, provisionally measured on a small set with only a 0.023 separation margin. context_similarity_floor may explicitly select a finite floor in [0,1]; it does not change ranking. Coverage and subject checks are conservative heuristics, not a calibrated answerability probability. The first-subject rule is order-sensitive; an entity-first query is clearest. A high score alone is insufficient: a nonsense subject with domain vocabulary can exceed the floor. Re-evaluate thresholds against positive and truly absent controls before changing policy.

Without query terms, or without a finite raw cosine (for example a keyword-only or lexical-only fusion result), context admission refuses with an explicit reason. BM25, normalized fusion, content and graph scores never substitute for cosine. Empty candidates produce no_candidates; all candidates below the floor produce no_result_above_floor. A below-floor top hit with another above-floor candidate produces top_result_below_floor. Other reasons include similarity_unavailable, query_terms_unavailable, query_too_long, subject_not_covered, identifier_not_covered, and insufficient_term_coverage. Queries over 4096 UTF-8 bytes cannot supply usable context under this contract.

Vector similarity search. Body: embedding (or query), optional limit, valid_at, ranking, and best-effort scoping by group_ids / entity_type. Optional mode: "keyword" selects the derived SQLite FTS5 index described below; omitted mode or mode: "semantic" preserves vector search.

Select one registered graph with graph: "<IRI>", several with graphs: ["<IRI>", "<IRI>"], or ROOT plus all named graphs with all_graphs: true (also graph: "all"). New stores default to ROOT plus all registered named graphs, minus [quipu.search] exclude_graphs. Existing stores retain ROOT until explicitly broadened with default_graph_scope = "all". An unset configuration uses the persisted store default; read-only legacy stores resolve to ROOT without a schema write. The response coverage reports the resolved default and its default_source. An explicit configuration overrides the stored choice. Set default_graph_scope = "root", or pass graph: "urn:quipu:graph:root" for one ROOT search. Selectors are mutually exclusive and unknown IRIs are refused. Explicit results carry graph and selected entity memberships in graphs. For keyword mode, graph identifies the matching assertion; for semantic mode it is the first selected membership. The graph metadata plane is excluded from all-graphs scope. Named-graph semantic text uses one vector per entity; entities with ROOT text keep it, otherwise text combines their named-graph facts. Scopes containing named graphs refuse ROOT content and anchor reranking; an explicit ROOT selection supports both.

Named-graph retrieval for semantic and hybrid search, and automatic named-only embedding text, are disabled by default ([quipu.search] named_graphs = false). Keyword mode (mode: "keyword") accepts an explicit scope without the flag, because the lexical index records each assertion’s graph; it still refuses until that index is complete. Prepare existing named-only vectors through bounded POST /embed_backfill_graph calls, then enable the flag. This avoids turning the next producer snapshot into an unpaced initial embedding drain. Graph backfill leaves entities with any ROOT history alone, including ROOT entities without vectors. Roll back exposure by setting default_graph_scope = "root" and disabling named_graphs; retain this binary’s ROOT exclusion while named vectors exist. Reverting to a pre-scope binary after backfill can expose those vectors in unscoped search.

A local named_search_entities origin cache is created when named vectors are prepared. It retains their exclusion from ROOT even if the named facts are physically cleaned up. The cache is regenerated with embeddings when a store is reconstructed; it is not exported as factual content.

Hybrid fusion

Hybrid is disabled by default. With [quipu.search] hybrid = true and a ready keyword index, request mode: "hybrid", alpha in [0,1], fusion: "weighted" | "rrf", and positive rrf_k (default 60). Alpha is the semantic contribution: alpha: 1 returns the existing semantic response exactly, without accessing the lexical index; alpha: 0 returns the keyword response exactly, without embedding. Omitted mode and parameters use the server search configuration; the default mode remains semantic.

Weighted fusion min-max normalizes each candidate list independently. A nonempty equal-score list normalizes to one; missing candidates contribute zero. RRF uses alpha/(rrf_k + semantic_rank) + (1-alpha)/(rrf_k + keyword_rank), with one-based ranks and zero for a missing branch. Equal fused scores are ordered by entity identifier. Candidate lists use the configured oversampling, capped at the server result limit and 1000 (the response reports candidate_limit); normalization is over this bounded pool, not the entire corpus. Intermediate hybrid requests whose effective result limit exceeds 1000 are refused; the pure semantic and keyword endpoints retain their configured result limits.

Both branches retain temporal, graph and provenance scopes. Intermediate hybrid weights use semantic type inference for both branches (infer_types: true); asserted-only hybrid type scopes are refused until both branches support them. Content reranking is not composed with intermediate fusion. Graph anchors compose with semantic, keyword and hybrid ranking when anchored = true. Intermediate weights require query text; an optional embedding supplies the semantic branch. Endpoints retain their pure modes’ input requirements and restrictions (keyword rejects embeddings; semantic permits an embedding without query text). Empty/punctuation-only lexical expressions are refused as in keyword mode.

explain: true adds raw BM25, cosine, fused score, branch ranks, actual matched FTS columns and applied filters. A missing branch component is null, not zero. Lexical snippets use the winning FTS assertion and tokenizer; semantic-only snippets use the vector text. Both are bounded, HTML-escaped text with <mark> highlights. The CLI accepts --mode hybrid --alpha 0.5 --fusion rrf --rrf-k 60 --explain; native MCP exposes the same fields. Fleet proxy rollout is a separate surface gate. Disable search.hybrid for rollback: callers omitting mode return to semantic even if the configured default remains hybrid. Explicit hybrid requests are refused while disabled. Fusion creates no additional persisted index.

Keyword index

Keyword search is off by default ([quipu.search] keyword = false). Explicit activation installs an empty FTS5 index and same-transaction fact triggers; it never backfills during startup or a read. Writes, source replacement, closure, rollback and physical deletion are reflected atomically. Index documents are assertion rows with graph IDs; omitted scope reads ROOT only. valid_at checks their original valid-time intervals, with an exclusive valid_to boundary. Attached packs are not part of this index.

An existing ROOT-only index is upgraded without dropping its documents. The upgrade replaces its derived view/triggers and invalidates completeness; bounded backfill must revisit fact ranges before keyword reads resume. A named-graph query against the older index is refused rather than reported as an empty graph.

Backfill is explicit and resumable:

quipu search-index backfill --batch-size 500 --db /path/to/store.db
quipu search-index status --db /path/to/store.db
quipu search '"complete episode phrase"' --mode keyword --db /path/to/store.db
quipu search 'Memory Beads' --mode keyword --graph urn:example:knowledge --db /path/to/store.db
quipu search 'Memory Beads' --mode keyword --all-graphs --db /path/to/store.db

One command commits one batch (1–10000 scanned fact rows), then releases the writer. A durable cursor/highwater bounds historical work, while insertion triggers index newer writes. The CLI refuses backfill during UTC minutes 10–20, inclusive. Repeat calls outside ingestion lanes until complete is true; an incomplete index returns an error instead of a partial answer.

mode: "keyword" requires query text and needs no embedding provider. It accepts literal terms and double-quoted phrases, with conjunctions scoped to one fact document; it does not interpret field/boolean syntax yet. All literal codecs are decoded, full comments/body text is retained, and type/entity local names include CamelCase tokens. Scores are positive BM25 relevance (score, higher first); bm25 exposes SQLite’s negative raw rank. Entity duplicates are collapsed and group/type scope is filtered before applying the limit. Embedding arrays, anchors and content reranking are refused in this mode.

Rollback: disable the flag, then quipu search-index drop --db <path> to remove the derived index and its triggers/progress only. Existing facts/vectors are preserved. A previously activated index continues following writes until it is dropped, even when keyword queries are disabled; no query silently serves stale documents after a flag toggle. Index size/RSS depend on the corpus and must be measured on an isolated restored store before production backfill.

Semantic ranking

Opt-in ranking: "content" reranks the oversampled candidates before the result limit. A Section, Chunk, or CodeSymbol with no explanatory comment or content beyond its label/name is demoted: positive cosine similarity is halved, and negative similarity is reduced by half its magnitude. Metadata such as paths, line numbers and revisions does not count as content. Exact label/name queries are exempt. Artifacts with explanatory text and other entity types keep their similarity score. This is a relevance heuristic, not a trust classification.

The default ranking: "semantic" preserves the original cosine order. Choose content when retrieving explanatory operational knowledge; keep semantic for general search or locating code and documentation. An identifier can be the correct answer without any body text, so content ranking must not be applied indiscriminately. Each result exposes similarity (raw cosine), score (ranking score), and ranking_reason (semantic or contentless_artifact). No vectors are deleted or re-embedded. Only the existing bounded candidate pool is reranked: this cannot recover an entity outside that pool or guarantee semantic equivalence detection. Historical searches classify candidates using facts valid at the requested valid_at.

curl -s localhost:3030/search -X POST \
  -H "Content-Type: application/json" \
  -d '{"embedding": [0.1, 0.2, ...], "limit": 10}'

group_ids is a best-effort provenance filter, not an isolation boundary: it narrows to entities whose facts trace (via prov:wasGeneratedBy → episode → groupId) to a listed group, and it drops ungrouped /knot facts (they have no episode to trace). entity_type restricts to an rdf:type IRI. See group-isolation.

⚠️ The type: A, 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.

Off unless the server sets [quipu.search] anchored = true. An anchor sent to a server with it off is refused rather than answered unanchored. A request without anchor is unchanged.

FieldDefaultMeaning
anchorOne entity, as an IRI, a CURIE or an exact rdfs:label. Ambiguity or absence is refused, with the candidates named.
max_hops3 (cap 4)Neighbourhood radius
anchor_modedecaydecay: score × decay^hops, unreachable × decay^(max_hops+1). sort: hops first, then score. filter: reachable only.
decay0.5Per-hop multiplier, in (0, 1]
viaTraverse only these predicates (replaces the default exclusions)
directionbothout, in or both
explainfalseAdds one shortest path per result

The neighbourhood is walked with bound single-pattern lookups over current ROOT facts (valid_at applies). owl:sameAs costs no hop. rdf:type, rdfs:subClassOf, PROV links, distinctFrom, mentions and inDocument are not traversed. Without that, everything is two hops from everything through a class, an activity or a document. A node with more than 150 edges is reached but not expanded along ordinary edges; zero-cost aliases remain traversable. Non-anchor reads fetch at most 151 object edges per direction before applying the hub rule. At the outer hop ring, only alias edges are queried. The anchor read is bounded by the node budget, and a possibly incomplete ring is reported as truncated. The walk stops at 5,000 nodes. The response’s anchor block reports reached, hubs_not_expanded, truncated and truncated_at_hop. A truncated ring is never presented as complete.

Each result gains hops (null when unreachable) and text_score (the blended text score before hop ranking). Global text candidates and candidates restricted to the bounded neighbourhood are unioned before fusion. Each source gets half the configured candidate pool; each text branch is capped at 1,000. This admits a near text match that falls below the global pool. Normalization and ranks use that bounded union. Anchored result limits above 1,000 are refused. Explicit named-graph anchors remain unsupported; traversal is ROOT-only. Malformed anchors and traversal options are refused.

The CLI exposes the same controls:

quipu search "backup failure" --mode hybrid --alpha 0.5 --fusion rrf \
  --anchor https://example.org/host --max-hops 3 --anchor-mode sort \
  --direction both --decay 0.5 --explain

POST /hybrid_search

Combined SPARQL filter + vector ranking.

curl -s localhost:3030/hybrid_search -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "sparql": "SELECT ?s WHERE { ?s a <http://example.org/Service> }",
    "embedding": [0.1, 0.2, ...],
    "limit": 5
  }'

POST /project

Graph projection and algorithms.

curl -s localhost:3030/project -X POST \
  -H "Content-Type: application/json" \
  -d '{"algorithm": "in_degree", "limit": 10}'

POST /render

Prepare a deterministic markdown file plan from a stored specification or an ad-hoc specification. This protected computation requires an accepted bearer; it remains a pooled read-only endpoint, including on read-only servers. Missing or invalid credentials refuse, and open-write mode does not open this route. The server neither writes files nor accepts a destination. The CLI declares cli-render in X-Quipu-Client and publishes the verified plan locally.

{
  "adhoc": {
    "query": "SELECT ?title WHERE { VALUES ?title { 'Example' } }",
    "template": "# {{ rows[0].title }}",
    "layout": "single",
    "path": "report.md"
  }
}

Pass spec as an absolute specification IRI instead of adhoc. Optional graph selects the same default dataset as /query; optional valid_at is an ISO timestamp. Unknown fields, including dest and dry_run, refuse rather than being ignored. Existing query floors, truncation and time-budget guards apply. Incomplete data cannot authorize a deletion.

The response contains schema, spec, source_tx, valid_at and files. Each file has base64 bytes, a sha256: content address and a mode. Output is bounded to 2,000 files and 16 MiB of decoded bytes. The client verifies the whole plan and the destination ownership manifest before atomic publication. The render reference explains includes, temporal snapshots and local refusal/no-op behavior. Preparing a plan is not a publication or a delivery receipt.

GET|POST /report

Live graph report: top hubs (god-nodes), surprising cross-community connections, and auto-suggested questions (see quipu_report in the MCP tools reference). Read-only. GET returns the report with defaults; POST accepts an options body (type, predicate, hubs, surprises, questions).

curl -s localhost:3030/report
curl -s localhost:3030/report -X POST \
  -H "Content-Type: application/json" \
  -d '{"hubs": 5, "surprises": 5, "questions": 6}'

GET /graphs

List registered named graphs with class, source, storage lifecycle, and label cache (freshness / durability / trust / policy / kind). Query params: kind (a dataKind token) and lifecycle (frozen). Also the consumer capability probe for the graph-kinds surface: a 404 means the store predates it — treat that as “cannot tell”, never as “no graphs”.

The optional graph=<absolute-IRI> selects one exact registered identity. It cannot be combined with kind or lifecycle. The response retains { "count": 0|1, "graphs": [...] }, including creation.timestamp and creation.actor (null for a legacy/imported source without a declaration). Point mode never enumerates the registry or computes materialization. Missing, interned-only and removed registry entries return count zero; malformed selectors, storage failures and ambiguous registered source identities are errors. No missing IRI is interned by this read.

For attached/frozen graphs, creation metadata comes from the selected source, not mutable local events. A frozen pack’s timestamp describes that pack’s registry identity, not an invented original creation time; existing pack formats may have no creator declaration. Each source needs creator index v1 events(type, subject, offset). An older immutable source is refused explicitly, never migrated or scanned by this read. The CLI equivalent is quipu graph list --graph <IRI> --json --db <existing-db>; its metadata reader opens read-only without schema migration, registration or lexical initialization. It selects an active frozen source by the requested IRI with a bounded indexed lookup; an unrelated broken frozen pack does not block that point read. Existing list mode and its enrichment remain unchanged.

Index migration v1 follows the store’s ordered additive migration seam on a writable open; it is idempotent and changes no facts or events. Its private fixture build measurements do not establish production build cost or admission. Operational rollback requires the prior binary and dropping only main.idx_events_type_subject_offset and main.idx_frozen_packs_graph_thawed; no database rewind or event deletion. The latter bounds the lookup of a frozen graph’s recorded active source alias. Retained local frozen IDs resolve through that alias; unrelated duplicate registered identities are errors. Do not drop the index while a point consumer depends on it. Live migration, resource admission and rollout remain separate operational decisions.

A graph whose latest RML materialization is on record additionally serves a materialization object (quipu-212): the mapping IRI, mapping-closure hash, external-truth subject, verified source hash, transaction, and timestamp of the last executor commit — the comparands a freshness verdict needs (camayoc’s rml_executor.py freshness/remap read them from here). Parsed from transaction provenance, so it cannot drift from what actually committed; omitted rather than faked on graphs with no RML history.

curl -s 'localhost:3030/graphs?kind=operational'

POST /graph/label

Declare a graph’s labels — any subset of the five axes. Required: graph, timestamp. Optional: freshness, durability, kind (a dataKind token, strictly parsed), trust ({"iri", "chain", "rank"}), policy (array of obligation tokens), valid_to (expiring declaration), actor. Each axis is parsed strictly: an unrecognised value is an error, never a dropped axis. Returns {"tx_id": N}. Write endpoint; honors bearer auth.

curl -s localhost:3030/graph/label -X POST -H "Content-Type: application/json" \
  -d '{"graph": "urn:app:runs/2026-08", "kind": "operational",
       "freshness": "fresh", "timestamp": "2026-08-24T00:00:00Z"}'

POST /graph/freeze and POST /graph/thaw

The deep-freeze surface — same inputs and outputs as the quipu_graph_freeze / quipu_graph_thaw MCP tools. Freeze relocates a graph’s full history into a read-only archive pack (kept addressable and composable at query time); thaw restores it. Both are write endpoints and honor bearer auth.

curl -s localhost:3030/graph/freeze -X POST -H "Content-Type: application/json" \
  -d '{"graph": "urn:app:shuttle/runs/2026-07", "timestamp": "2026-08-24T00:00:00Z"}'

POST /graph

Render-ready node-link projection — the single payload the web UI draws from.

curl -s localhost:3030/graph -X POST \
  -H "Content-Type: application/json" \
  -d '{"limit": 250}'

Body: optional limit (nodes, ranked by degree; default 250, max 2000), type (restrict to one rdf:type IRI), include_episodes (default false).

{
  "nodes": [{"iri": "…", "label": "kotare", "type": "…/ProxmoxNode", "deg": 8}],
  "edges": [[0, 10, "managed_by"]],
  "types": [{"iri": "…", "label": "SystemdService", "count": 15}],
  "truncated": {"shown": 250, "of": 1180},
  "stats": {"nodes": 250, "edges": 612}
}

edges address nodes by index into nodes, not by IRI — an IRI averages ~45 bytes and would otherwise repeat at both ends of every edge. prov:Activity episodes and rdf/rdfs/prov scaffolding predicates are excluded by default so the domain graph is not buried in provenance. truncated always states what was dropped rather than silently capping.

POST /context

Knowledge context pipeline.

curl -s localhost:3030/context -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "traefik", "max_entities": 10}'

The summary carries an embeddings block reporting whether semantic retrieval was possible at all, so an empty entities list is not ambiguous:

"embeddings": { "configured": true, "embedded_entities": 2579 }

configured: false means no embedding provider is attached; embedded_entities: 0 with configured: true means the store was never embedded (quipu knot does not embed — run a backfill). See Embeddings and Semantic Search.

POST /unified_search

Unified knowledge search (text + optional vector); results tagged source="knowledge" with normalized 0–1 scores. Body: query, optional embedding, limit, expand_links, max_facts_per_entity.

POST /ask

Run a curated, parameterized named query by name (see quipu_ask in the MCP tools reference). Body: name (omit or "list" to list the catalog), optional params map. Parameters are validated and escaped by type. Response: query, resolved sparql, columns, rows, count.

curl -s localhost:3030/ask -X POST \
  -d '{"name":"service_deps","params":{"entity":"http://example.org/traefik"}}'

POST /search_nodes

Search entities by natural-language query (text matching). Body: query, optional group_ids, max_results, entity_type_filter.

POST /search_facts

Search relationships/edges by natural-language query. Body: query, optional group_ids, max_results.

POST /search/nodes

Graphiti-compatible node search (mirrors Graphiti’s search_nodes shape).

The optional group_ids filter matches direct group labels or episode provenance in ROOT. It recognizes the configured vocabulary, the legacy vocabulary, and https://scbrown.github.io/quechua/ns#groupId; unrelated predicates with the same local name do not match. This is a provenance filter, not an access boundary. Results include distinct group_ids and retain scalar group_id when exactly one group is known. Property keys use the local name for both slash and hash IRIs.

POST /episodes/complete

Graphiti-compatible flat episode ingestion. Body: name, optional episode_body, group_id, source_description, timestamp.

POST /impact

Impact analysis: walk downstream from an entity, optionally counterfactual. Body: entity, optional remove, hops, predicates, timestamp.

POST /path/cone

Golden paths: the provenance cone of a trajectory — which steps did its falsifier-gated verified result depend on? Read-only. Body: trajectory (the Trajectory IRI, required), optional via (array of derivation predicate IRIs walked in addition to verifiedBy, which is always followed), hops (walk depth, default 8), base_ns (vocabulary namespace override; defaults to the store’s configured base_ns).

curl -s localhost:3030/path/cone -X POST \
  -H 'Content-Type: application/json' \
  -d '{"trajectory": "http://example.org/traj/42", "hops": 6}'

Returns the cone report: the trajectory, the hop bound, the verifications it was checked against, and one entry per step carrying iri, order, verdict (InCone / OutOfCone / CannotEvaluate) and the human-readable reason. Refuses a trajectory with no steps or no falsifier-gated verification.

POST /path/backtest

Golden paths: replay a pruned candidate (the exemplar trajectory minus omitted steps) over recorded history — which past trajectories sharing a work-item topic would have conformed under gp-grammar/1, and how did their work items close? Read-only. Body: exemplar (the exemplar Trajectory IRI, required), optional omit (array of step IRIs the candidate omits), base_ns.

curl -s localhost:3030/path/backtest -X POST \
  -H 'Content-Type: application/json' \
  -d '{"exemplar": "http://example.org/traj/42",
       "omit": ["http://example.org/step/3"]}'

Returns the backtest report: the exemplar, the grammar, the matched topics, one row per replayed trajectory, and the conformer/deviator completion counts with an explicit cannot_evaluate tally — 0 matches and “nothing measurable” are never reported as the same thing.

POST /propose

Submit a schema-evolution proposal. Body: kind, target, diff, proposer, optional rationale, trigger_ref, timestamp.

POST /proposals

List schema-evolution proposals. Body: optional status (pending/accepted/rejected).

POST /proposal/accept

Accept a pending proposal. Body: id, decided_by (required), optional note, timestamp.

POST /proposal/reject

Reject a pending proposal. Body: id, note, decided_by (required), optional timestamp.

POST /entity_history

Return the full fact history (across transactions) for an entity. The body field is iri, not entity — {"entity": ...} returns {"error": "missing 'iri' parameter"}.

curl -s localhost:3030/entity_history -X POST -H "Content-Type: application/json" \
  -d '{"iri": "http://example.org/svc"}'

Returns {"iri", "count", "history": [{"op", "predicate", "value", "tx", "valid_from", "valid_to"}, ...]}. The tx is the handle for /transactions below — together they answer “which write path asserted this fact, and who owned it”.

GET /transactions

List transactions in the store, oldest first.

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.

POST /embed_backfill_graph

Embed up to max_entities (default 256, clamped to 1-2000) entities of one registered named graph that have no current vector. Body: {"graph": "<IRI>", "max_entities": N}. Returns entities_embedded, remaining (still un-vectored in that graph) and stale_skipped (entities edited during the call, left for the next one). An unknown graph is refused.

Each call is deliberately bounded: pace repeated calls and watch server memory between them rather than draining a large graph in one pass. Entities that exist only in named graphs are embedded from their named-graph facts, and they are searchable with quipu_search graph=<IRI>; unscoped search stays ROOT.

GET /preview/{iri}

Return a preview rendering of an entity by IRI.

Service Metadata

GET /version

What build is actually running: {"version", "git_sha", "git_dirty", "features", "read_contracts"}. The git SHA is the field that matters for “is the fix deployed?” — a semantic version does not move when a fix lands. features maps every declared Cargo feature to whether this binary compiled it in. read_contracts lists recognized versioned source-acquisition contracts.

GET /metrics

Prometheus scrape endpoint (text/plain; version=0.0.4). Request counters come from the middleware. Graph-size gauges use a per-store snapshot: a single background task scans the live root graph at startup and again five minutes after each refresh completes. Scrapes neither acquire database connections nor trigger scans. WAL size still comes from a current filesystem metadata read.

quipu_http_requests_started_total counts HTTP arrivals before handler dispatch, including pending requests, cancelled requests and metrics scrapes. It has no labels and resets when the process restarts. Use it to measure arrival rate: quipu_http_requests_total and quipu_http_client_requests_total count terminal request observations, including dropped request futures with synthetic status 499. Endpoint histograms and client-duration counters include elapsed time until that drop. quipu_http_requests_cancelled_total{client,endpoint} separately counts dropped futures; its client budget is 31 named callers plus other, and endpoint labels use route templates. Low terminal rates alone do not establish low traffic.

quipu_http_auth_refusals_total{client,endpoint,method,expected_probe} counts completed HTTP 401 responses, including protected GET requests. Observed successful requests initialize the corresponding counter at zero. Methods are bounded to GET, POST, HEAD and OTHER; routes use templates and caller overflow folds into other. expected_probe is operational attribution, not an authenticated identity: it is true for the designated link-check GET caller and the designated negative-auth POST caller on /episode or /shapes. Exclude these controls when measuring unexpected authentication failures, and retain a separate check that the metric is being scraped. A newly seen counter can start above zero, so an increase-only alert may miss its first refusal.

Before the first successful refresh, graph-size gauges are omitted and quipu_graph_counts_ready is zero. A failed refresh retains the last successful snapshot and increments quipu_graph_counts_refresh_failures_total; it never substitutes zero counts. quipu_graph_counts_age_seconds and quipu_graph_counts_last_success_timestamp_seconds expose stale data, while quipu_graph_counts_refresh_duration_seconds includes the last attempt’s pool wait and scan time. These freshness signals must be considered when consuming the graph-size gauges: a successful HTTP scrape alone does not prove fresh counts.

Caller attribution uses the normalized X-Quipu-Client header (falling back to User-Agent, then unattributed) and is capped at 32 identities; overflow folds into other rather than creating unbounded Prometheus cardinality:

  • quipu_http_client_requests_total{client,endpoint} — request count;
  • quipu_http_client_request_seconds_total{client,endpoint} — wall time;
  • quipu_store_wait_seconds_total{client,endpoint} — time waiting to acquire a store connection;
  • quipu_store_held_seconds_total{client,endpoint} — store capacity consumed.

/query load is also counted by the caller’s DECLARED X-Quipu-Agent, so a read-heavy client such as seeds can be split by the crew member it acts for. The value is caller-declared and unauthenticated. It is normalised like the client label and capped at 32 agents; a missing or unusable value is undeclared and overflow folds into other. There is no task or endpoint dimension, so the series count stays at most clients x 32:

  • quipu_http_agent_query_requests_total{client,agent} — completed /query requests (including dropped futures, like the client counter);
  • quipu_http_agent_query_seconds_total{client,agent} — their wall time.

Writers declare structured provenance in five headers: X-Quipu-Agent, X-Quipu-Harness, X-Quipu-Model, X-Quipu-Session and X-Quipu-Host. Their values are not metric labels, except X-Quipu-Agent on /query above. On write routes the server classifies how completely they were declared, and counts each COMMITTED write transaction once, at commit. A refused or rolled-back request counts nothing, and commits the engine makes on a request’s behalf (verdicts, reasoner materialization, migration) are not counted as that client’s writes:

  • quipu_write_provenance_total{client,endpoint,provenance} — provenance is complete (agent, harness and host, plus session and model when the harness is claude or codex), partial, or absent (none of the five headers);
  • quipu_write_provenance_missing_total{client,field} — commits missing a required field (agent, harness, host, session, model).

A header that is present but blank counts as missing. Coverage per client is rate(...{provenance="complete"}[1h]) / rate(...[1h]); the counters reset when the process restarts, so use rates rather than raw values.

The server also writes one-line JSON request events to stderr for journald/Loki. request_start makes a request that never completes visible. request_complete adds status, duration_ms, and the actual auth_outcome; /query responses also add query_shape and result_size. Logs contain normalized attribution and bounded metadata, never the Authorization header or response body. Slow or failed query text retains its existing separate diagnostic line.

Requests may declare a finite command source with X-Quipu-Source and an underlying SDK operation with X-Quipu-Operation. Start and terminal logs retain these separately as declared_source and declared_operation; missing, unsupported, or duplicate values are null (UNKNOWN). Supported command sources use st- or sd- plus anchor, plate, inbox, stop_policy, stop_event, haul, tend, work, other, show, list, ready, where, search, count, info, stats, create, update, close, or comments. The component sources seeds and shantytown remain distinguishable from command attribution. Operations are query, health, graphs, show, list, ready, where, search, count, info, stats, tend, ready-count, create, update, close, or comments. These declarations contain no arguments, paths, message text, or credentials. They do not authenticate a caller or add metric labels. An attributed census additionally requires a reader that retains these fields and proves log coverage; an endpoint alone does not identify a command.

Dropping a polled request future also emits one request_complete with completion_outcome: "cancelled", synthetic status: 499 and elapsed time. No HTTP 499 response is delivered. Authorization remains pending and result metadata is absent because no response established those values. Normal responses use completion_outcome: "response". This records abandonment of the HTTP future, including cancellation during shutdown; it does not prove that a blocking worker stopped or that a write did not commit. Response-body streaming after the handler returns is outside this measurement. Completed /search, /hybrid-search, and /search-query pipelines exceeding 500 ms also emit search_phase_slow. This anonymous record reports pipeline_ms, combined admission_dispatch_ms, and phase_ms for embedding, reader acquisition, tool execution, SQLite scan/decode/scoring, sorting, and survivor metadata. Tool time contains the SQLite phases; these overlapping values must not be summed. Admission includes the asynchronous permit wait and blocking-pool dispatch, which this record cannot separate. The pipeline excludes HTTP body parsing and response serialization and therefore differs from request duration. Missing phases are null, including embedding for supplied-vector requests.

On Linux, thread_logical_read_bytes and thread_physical_read_bytes are counter deltas from the same blocking thread, including embedding and retrieval. They are null with thread_io_known=false if unavailable or inconsistent; physical reads of zero can mean cached reads. They are not process totals and do not count work performed on other threads. Records contain no query text, entity names, caller labels, embeddings, or headers. Errors retain the phases that ran and set ok=false. Cancellation before blocking work starts emits no phase record; panics are not completion records. Library and CLI vector calls outside these HTTP handlers do not emit records. This is diagnostic evidence for individual slow requests, not proof of a historical cause.

UI assets (not documented individually)

GET / and GET /ui serve the built-in web UI; GET /quipu-components.js, GET /graph-canvas.js, GET /datalinks.js and GET /vendor/three.module.min.js serve its static assets, vendored so the UI renders on an air-gapped deploy. They are part of the UI, not the API surface.

Export

GET|HEAD|PUT|POST|DELETE /rdf-graph-store

SPARQL 1.1 Graph Store HTTP Protocol using indirect graph identification. Select exactly one target with ?graph=<absolute-IRI> or ?default. GET returns the graph in the RDF syntax requested by Accept; HEAD returns the same status and headers without a body. PUT replaces the graph, POST merges the payload, and DELETE removes its contents (and a named graph’s registry entry). Writes use the same bearer-token and read-only enforcement as Quipu’s native write APIs.

curl localhost:3030/rdf-graph-store?graph=http%3A%2F%2Fexample.org%2Fg \
  -X PUT -H 'Content-Type: text/turtle' --data-binary '@graph.ttl'
curl localhost:3030/rdf-graph-store?graph=http%3A%2F%2Fexample.org%2Fg \
  -H 'Accept: application/n-triples'

Supported request and response syntaxes are Turtle (text/turtle) and N-Triples (application/n-triples). Unsupported request media types return 415; unsupported response types return 406; unknown named graphs return 404.

POST /export

Export deterministic RDF from ROOT, one named graph, one episode provenance group, or a SPARQL CONSTRUCT/DESCRIBE result. graph, group_id, and construct are mutually exclusive. The handler uses the read pool, so serializing a large export does not hold Quipu’s writer lock.

curl -s localhost:3030/export -X POST \
  -H "Content-Type: application/json" \
  -d '{"graph": "http://example.org/graphs/derived", "format": "turtle"}'
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”.

The REST materialisation action copies current ROOT and companion facts through a read-pool connection, then derives on a private temporary SQLite database. SQLite removes this database when its connection closes; its page cache is limited to 8 MiB and its term cache to 32,768 entries. Provide a writable, disk-backed SQLite temporary directory with enough space for the current facts and indexes; a RAM-backed temporary filesystem defeats the memory saving. Derivation still allocates premise and proposal vectors. It does not hold ordinary write admission or the live writer during that work. Publication takes write admission in batches of at most 64 assertions, allowing other writes between batches. A server without a read pool refuses this action rather than falling back to a full scan under the writer lock. Concurrent materialisation requests are rejected; a disconnected client cannot admit another derivation while its blocking work is still running.

Successful responses include complete: true, snapshot_tx, elapsed_ms, and applied_proposals. Completion means the captured snapshot was closed, not that facts arriving afterward have been processed. Concurrent additions are allowed; a retraction of a premise or a change to the loaded ontologies aborts publication. Earlier committed batches can remain as partial historical entailments after an error or interruption, but no new freshness marker is published. Retry on the next scheduled run. Consumers must inspect freshness; the companion graph does not automatically hide historical entailments.

Input snapshots are limited to three million current facts, and derivation is limited to 200,000 new assertions and 64 passes. Exhausting a budget is an error, not successful partial materialisation. These are work-size limits, not a hard wall-clock or memory guarantee: choose the cadence and service resource limits from a rehearsal of the actual ontology and workload. The retraction fence uses an index created at store startup; allow for its initial build on existing stores. Entailments remain in the companion inferred graph, never ROOT. The synchronous library/CLI materialiser is unchanged; this lock discipline belongs to the REST scheduling path.

POST /subscriptions

Event-push subscription registry: {"action": "create"|"list"|"delete", ...} — register an HTTP endpoint to receive graph-change events pushed by the server’s delivery worker (mirrors quipu_subscriptions).

POST /datasets

Named dataset registry: {"action": ..., "name", "members", ...} — declare a named set of graphs queryable as one unit via FROM <dataset> or the graph query param (mirrors quipu_datasets).

POST /queries

Stored named-query registry: {"action": "load"|"list"|"get"|"remove", "name", "template", "params", ...} — competency questions callable through /ask alongside the compiled-in catalog; definitions are validated at load and versioned (mirrors quipu_queries).

Governance

The REST half of the governance gate; each mirrors its MCP tool, where the semantics are documented in full.

POST /policy/check

Committed-tier evaluation of a governance Policy against a target: returns a Verdict — outcome ∈ satisfied | unsatisfied | unknown bound to a reproducible evidence_hash — signed when the store has a signing identity.

curl -s localhost:3030/policy/check -X POST \
  -H "Content-Type: application/json" \
  -d '{"policy": "http://example.org/policy/has-owner", "target": "http://example.org/svc"}'
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", "verdict"?, "signed_at"?, "tx"?} → {"signature_valid", "verifier_registered", "verifier_authorized", "trusted", "as_of"} — trusted is the conjunction to gate on. The registry is read as of the signature: pass verdict (the stored verdict IRI) to use the instant the store recorded it.

Overlays

Scratch layers over the committed graph (bind-once to a parent branch): hypotheses go in the overlay, the committed layer stays untouched. Mirror the quipu_overlay_* MCP tools.

POST /overlay/create

{"overlay": "<iri>", "parent_branch": "<iri>"?} → {"g", "parent_branch"}. Omitted parent_branch binds to ROOT.

POST /overlay/write

{"overlay", "op": "assert"|"retract"|"tombstone", "subject", "predicate", "object", "timestamp"?} → {"tx_id"}. tombstone masks the parent’s fact in the composed view.

POST /overlay/compose

{"overlay": "<iri>"} → {"triples": [{subject, predicate, object}], "count"}: the resolved view over [overlay > parent-branch-root], asserted-and-not-tombstoned, nearest wins.

Provenance Analytics

POST /cooccurrence

{"work_item": "<iri>", "valid_at"?, "tx"?} → the other work-items sharing at least one touched code entity, via Bead ←implements− GitCommit −modifies→ entity, ordered by overlap strength (mirrors quipu_cooccurrence).

Events

The durable graph-change event log (at-least-once delivery; consumers dedup by offset).

GET /events

Feed reads (/events, /changes, /transactions) use the WAL read pool when available, so a writer holding the store mutex does not block them. An empty read pool falls back to the writer. POST /events/commit remains a write. The quipu_store_wait_seconds_total and quipu_store_held_seconds_total metrics attribute each feed read to its normalized X-Quipu-Client and endpoint, including requests that return an error. An event page and its lag gauge are separate reads: a concurrent commit may increase the reported lag without changing the page’s next_offset cursor.

Pull a batch of events in offset order.

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.

GET /events/stream and GET /changes/stream

Read-only Server-Sent Events replay of the corresponding durable feed. Supply a nonnegative since or Last-Event-ID; if both appear they must agree. Event frames have event type quipu.events, a delivered event-offset ID, and the ordinary /events page as JSON data. Change frames use quipu.changes, a transaction-ID cursor and the ordinary /changes page as JSON data.

Event streams accept types and group; change streams accept graph, including a registered named graph. Unrelated filter kinds and invalid resume cursors return HTTP 400. Exhausted stream capacity returns HTTP 503.

Delivery does not acknowledge a consumer. Commit applied data and its cursor atomically, then reconnect from that cursor after a disconnect. A transaction ID is not an event offset and must never be sent to /events/commit. Fifteen-second heartbeat comments do not read the store; commit hints are checked against the durable feed after crossing the writer barrier. An hourly backstop covers writes from other connections. A quipu.error event has no ID and terminates a failed or oversized page; keep the prior applied cursor.

The process admits up to 64 streams, reads one transaction/event per page and limits output frames to 1 MiB. One large transaction may require more memory before the output check; this is not a total heap bound. See the committed-feed protocol.

POST /events/commit

{"consumer_id", "offset"} — durably record a consumer’s cursor. Any offset ≥ 0 is accepted, including a lower one: that is the explicit replay knob.

Refusal events (write.refused)

A refused write never enters the graph, so the event log is where the attempt is recorded (camayoc-0d3 — the incident-rate denominator: how many writes were attempted and refused, by which gate). Every write-gate refusal — SHACL on the episode and /knot paths, and the policy, authority, OWL and placement gates in transact — appends a write.refused event after the refused write’s savepoint has rolled back, so the event survives the rollback that the refusal caused. A failure to record never masks the refusal error itself.

Payload: {gate, graph, actor, source, reason, refused_datums} where gate is one of shacl | policy | authority | owl | placement, graph is the destination graph IRI, reason is the gate’s own terse text (shape/policy id, constraint name; truncated), and refused_datums counts what was refused.

Deliberately not recorded: the refused datum bodies. Refused payloads can be junk or sensitive — the event stores identifying metadata only.

Refusals inside speculate (counterfactual writes) are not recorded: the whole speculation rolls back by design, so a hypothetical write’s refusal is not a real one.

Query via GET /events?types=write.refused, or count by gate with the CLI: quipu events refusals.

Linked-Data Surface

Standards-flavoured read endpoints for semantic-web tooling.

GET /changes

Returns fact-level change records after the optional since transaction. capture selects new_values, old_and_new_values, or new_row; graph optionally scopes the feed to one graph IRI. The response includes next_tx and a watermark so consumers can distinguish an idle feed from a stalled one.

GET /entity/{iri}

Content-negotiated entity page: Accept: application/ld+json → JSON-LD, text/turtle → Turtle, anything else → the web UI’s HTML page for the entity. GET /entity/{iri}/json, GET /entity/{iri}/ttl and GET /entity/{iri}/html pin the format in the path instead of the header. For a full IRI containing path separators or a fragment, use the equivalent query form: GET /entity?iri=https%3A%2F%2Fexample.org%2Fresource%231. JSON-LD is compacted by default with a generated @context derived from the loaded shape prefixes. Add ?expanded=1 for full IRIs and no compact context.

POST /spotlight

DBpedia-Spotlight-style annotation: {"text", "confidence"?} → mentions of known entities found in the text, with offsets and IRIs. The labeled-entity list it scans against is generation-cached, so a burst pays the expensive fetch once. The fetch reads current ROOT labels and asserted types; named graphs and attached packs are excluded. Cold fills stream indexed rows, retain the existing query deadline and row limits, and avoid intermediate SPARQL binding tables.

GET /fragments

Triple Pattern Fragments: ?subject=&predicate=&object=&page=&pageSize= selectors, each optional — a paged triple-pattern read for TPF clients.

POST /reconcile

OpenRefine Reconciliation API: a body without queries returns the service manifest; {"queries": {...}} runs the batch and returns candidates per query, scored the way /resolve scores.

RDF /knot loads accept blank_node_scope: distinct IDs separate repeated identical input; the same ID shares blank nodes across graphs only for byte-identical input. By default, whole-document bytes and destination graph define scope.

POST /search_query

Search first, then evaluate SELECT from at most100 distinct seed IRIs. Keyword mode defaults and needs no embeddings; hybrid fuses successful lexical and semantic retrieval with reciprocal rank fusion. Seed ranks and explicit bounded completeness accompany the ordinary query result. See the request and scope contract.

POST /graph/create

Register a committed graph before graph-scoped knot writes or labeling. Input: {"graph":"urn:example:jobs","actor":"urn:example:agent","timestamp":"2026-10-14T12:00:00Z"}. Only graph is required; timestamp defaults to current UTC and supplied times must be RFC 3339. Returns graph, g, created and creation: {timestamp, actor}. A repeat is idempotent and preserves the original creator/time; an interned term alone is not registration. Actor is caller-declared provenance, never permission. Older graphs retain a null actor instead of fabricated attribution. An overlay cannot be reclassified as committed. This is a protected write, sharing the server’s normal bearer/read-only guards and MCP handler.

POST /graph/label

Declare versioned graph labels. Required: graph, timestamp; optional: actor, trust: {iri, chain, rank}, freshness, durability, policy token array, kind, valid_to. At least one axis is required. Unregistered graphs, unknown axis values, and empty labels are errors. Create first via POST /graph/create or MCP quipu_graph_create.

GET /graphs returns each row’s creation together with class/lifecycle/labels. The MCP-only recipe exercises the same handlers without a REST client.

Versioned file memory

File memory stores exact UTF-8 source text separately from extracted knowledge. It preserves BOMs, line endings and links. An owner is an exact deployment, project and crew IRI tuple. Names, account labels and caller actor fields do not establish authenticated ownership.

The feature is disabled by default. A deployment must register the graph and set [quipu.memory_files] with enabled = true, graph, deployment and project. No graph or credential is created by these endpoints. Existing graph authority, vocabulary, SHACL and write-policy admission still apply.

HTTP operations

All operations accept JSON and return bounded JSON envelopes:

  • POST /memory/read: owner tuple; current immutable manifest or null, with coverage = "this_repository_owner_records".
  • POST /memory/export: owner tuple; manifest and exact identified file revisions. Cross-crew reads retain the deployment’s inherited read boundary.
  • POST /memory/projection: owner tuple; same export plus authenticated_owner. Requires an existing verified named credential whose principal exactly matches the owner. A shared bearer cannot prove this. This endpoint reads data; it does not write native files.
  • POST /memory/receipt: the exact original intent; returns its verified receipt or null without retrying the write. Requires the matching named principal.
  • POST /memory/commit: one file intent with owner, relative path, UTF-8 text, expected snapshot and file revision, idempotency key, harness and opaque account label. A null text is an explicit tombstone. Requires the matching named credential and the normal writable-server policy. A stale revision returns HTTP 409 without changing the owner head.

Clients must retain the exact intent until its receipt is verified. Repeating that intent and idempotency key returns its original receipt even after the head advances. Reusing a key with different content refuses. A new stale intent conflicts even when its bytes equal the current file. Timeouts are indeterminate; clients must not silently create a new key or replace local files.

An owner snapshot contains at most 128 file entries and 4 MiB of live text; each file is at most 256 KiB. Tombstones remain in the manifest. Invalid UTF-8, traversal paths and ambiguous stored records refuse. The complete manifest is conditional on the owner head inside the ordinary transaction savepoint.

Generic RDF mutations cannot edit reserved file-memory subjects or predicates, even when caller-supplied actor/source fields resemble a memory writer. The shared transaction pipeline requires a private capability for the validated protocol’s exact rows. This also protects retractions and duplicate source claims. Current memory records must be changed through the memory protocol; privileged offline database restore remains filesystem authority.

Records use the existing base Artifact class with a memory profile, rather than minting new ontology classes. Owner, revision, snapshot and receipt records carry distinct kinds and explicit owner/project/deployment references. Their source JSON is versioned data, not an extracted factual assertion. The public repository release selector excludes memory subjects, predicates and references, including memory attached to an otherwise public subject. This export boundary does not introduce per-crew read secrecy.

Local CLI workflow

The local CLI uses the same core, but its authority is the filesystem principal that can open the database. It cannot isolate crews from a user who can modify that database. Every invocation requires an explicit database and graph:

qp memory read owner.json --db fixture.db --graph urn:example:memory
qp memory commit request.json --db fixture.db --graph urn:example:memory --local-authority
qp memory receipt request.json --db fixture.db --graph urn:example:memory
qp memory export owner.json --db fixture.db --graph urn:example:memory

The graph must already be registered. owner.json contains the three exact IRI fields. A first request uses null expected_snapshot and expected_file; later requests use the returned snapshot and file revision. The account field is an opaque selected-account identifier, never a credential. Help opens no store.

This export is a file-data envelope, not the older RDF export/share/import contract and not an instruction to overwrite a native memory directory. RDF group_id selection denotes provenance, not owner authorization.

Filesystem and harness guarantees

A consumer must verify authenticated own identity, manifest coverage, each revision ID, owner, path, byte count and SHA-256 before staging native data. A safe generic consumer creates an exclusive retained generation. A lock or a last hash check cannot make replacement safe against a noncooperating editor. Native root selection is a separate harness-specific operation; staging alone does not prove a running model consumed the files. Unsupported adapters must remain explicit and preserve the previous memory bridge.

Atomic source acquisition

POST /acquire returns complete canonical N-Triples for explicitly selected named graphs, plus a separate quipu-acquisition/v1 source receipt. A consumer must recognize that contract in /version.read_contracts before using it. Historical acquisition through /share is unsupported.

{
  "source_store_id": "the-known-source-lineage-id",
  "graphs": [
    "https://example.org/project/board"
  ],
  "tx": 42,
  "max_bytes": 262144
}

The expected store identity and exact required graph IRIs come from trusted consumer configuration. The endpoint requires 1–16 distinct registered committed graphs. It never expands a dataset, includes ROOT implicitly, follows an overlay, accepts arbitrary queries, or pages its response. An unknown graph is refused rather than represented as an empty successful acquisition. A graph IRI that also names a dataset alias is refused: /query would expand that alias, which could mislabel another graph’s data as the requested source graph.

tx must be a positive signed 64-bit JSON integer naming an existing committed transaction. Null, strings, fractions, negative values, unknown transactions, and future transactions are refused. Omit tx to capture present state. Unknown fields, including valid_at, fork, and per-graph clocks, are refused. Attached stores are unsupported by this contract, including for present acquisition: a main-file transaction cannot establish another file’s generation. Ordinary unpinned /query reads remain available on attached stores.

The producer opens one SQLite read transaction before reading its identity and head. Every graph is read at applied_tx, and the transaction remains open through canonicalization and receipt construction. A concurrent writer can commit, but that commit cannot alter part of the acquisition. The complete response is capped at 8 MiB; max_bytes can only lower that ceiling. Query row ceilings and deadlines also apply. An oversized, truncated, timed-out, or refused read produces an error and no complete receipt. Consumers must treat errors, missing fields, and incomplete transport bodies as UNKNOWN, never empty success.

The response has three members:

  • graphs: a map from each exact graph IRI to its canonical N-Triples string;
  • source_receipt: the source identity, transaction selection, build, and scope;
  • source_receipt_sha256: SHA-256 of compact UTF-8 JSON of the receipt, with recursively sorted object keys and no trailing newline. Strings retain UTF-8 characters rather than ASCII escapes; quotes and control characters use JSON escaping. This matches the producer’s deterministic share-manifest encoding.

The receipt contains schema, source_store_id, source_head_tx, requested_tx, applied_tx, history, history_min_tx, canonicalization, producer_version, producer_git_sha, producer_git_dirty, graphs, and complete. Its graph map records each graph’s triple count and SHA-256 of the exact N-Triples bytes. canonicalization is source-labels-sorted-ntriples/v1: triples are deduplicated and sorted by their serialized bytes, preserving the source’s blank-node labels across graphs. Independently renaming blank nodes within each graph could break cross-graph identity. Consumers must import these labels as one acquisition document scope, preserving identities across its graph members. This is not an RDFC-1.0 graph-isomorphism digest. requested_tx is null for a present-state acquisition; applied_tx then equals the captured source_head_tx. For a historical request, applied_tx equals the validated requested transaction. Both modes use the same pin for every graph.

history is file-local-bitemporal-eavt; the retained transaction-log range is history_min_tx through source_head_tx. Existence is checked separately, since an integer inside that range need not identify a committed transaction. The source head is a captured transaction generation, not a substitute for a requested pin or a content hash. The receipt hash binds the build, source, generation, exact graph scope, and graph content identities together.

Store identity records lineage: a file copy shares its source store ID. The receipt does not certify a unique physical host, an operator’s authority over an endpoint, or a trusted producer signature. Hashes detect altered graph bytes or an altered receipt against its expected digest; they cannot authenticate a hostile producer that replaces both. Consumers must retain their endpoint trust and expected source identity and inspect the producer build separately.

Native consumers can use quipu::acquisition::Payload::verify with their original request before importing any bytes. Verification checks the recognized contract, completeness, expected source/pin/scope, receipt digest, canonical graph bytes, content digests, and triple counts. Retain the source receipt and its digest unchanged beside any generated local artifact. That artifact’s local store ID, scope, and transaction anchor describe the artifact; rewriting them to resemble the source would discard the distinction the receipt preserves.

/query also validates its tx field without coercion. A transaction pin cannot be combined with valid_at or fork at this remote boundary. This restriction does not change the internal bitemporal evaluator’s independent time axes.

Composed service acquisition

POST /acquire/composed uses the separate quipu-acquisition/composed-v1 contract. /acquire retains its blanket attachment refusal. Consumers must gate on the new /version.read_contracts entry and retain the returned source receipt unchanged.

This first composed profile selects only registered, committed MAIN graphs. Other providers remain mounted and appear in a complete, bounded census, with outside-qualified-main-scope exclusions. Required unknown or attached graphs, dataset aliases, overlays, frozen selections, overlapping graph ownership, and selected identities affected by composed term aliases refuse. The direct-main reader explicitly differs from composed /query resolution: its advertised reader is qualified-main-committed/v1. The ownership digest binds the full selected graph label cache and configured floor. This first profile supports cache-only, timeless label-floor evaluation; expiring labels or trust-derived floor inputs refuse rather than borrowing unbound meta-graph history.

The request adds expected_generation to the v1 fields. It is optional for present capture, but required with any explicit tx: capture the admitted generation first, then submit that token with the MAIN pin. Equal transaction integers cannot identify a composition. The response contains graphs, source_receipt, and source_receipt_sha256. The receipt contains its own schema, complete, a nested main receipt with schema quipu-acquisition/main-direct-v1, and composition. The main receipt’s applied_tx applies only to MAIN. Composition binds the connection incarnation, selected ownership and policy digest, and every mounted provider’s alias, lineage, opaque physical identity, byte hash, manifest hash, local head, term space, graph inventory, and exclusion. It is not a global transaction clock.

The server serializes topology access on its canonical store handle and holds one MAIN read transaction. On Linux x86-64 and AArch64, read leases retain the provider files during census, hashing, selected reads, and final checks. The lease fence also refuses an existing SIGIO handler or blocked/pending notification on its synchronous thread. Existing writers, a lease break, mutable journal content, replacement, or a change in physical file identity refuse. Read-only mount configuration alone is insufficient. Other platforms refuse attached composed acquisition while still allowing the attachment-free direct-main profile.

Historical admission starts at the connection’s first successful observation of that scope and fenced topology. The composition’s history_min_tx records this floor separately from retained MAIN transaction history. Changing topology or policy resets the floor; restarting creates a new incarnation. Runtime mount, unmount, or synchronized remount invalidates the incarnation before the transition, even if the same file returns or a transition fails. An old MAIN pin cannot retroactively use a newly attached pack. A previously observed pin can be reused under its unchanged generation after ordinary MAIN data writes. An expected-generation mismatch refuses rather than silently resealing data.

Limits are 16 selected graphs, 16 mounted providers, 512 named graphs per provider, 256 MiB of aggregate provider identity reads, 100,000 selected rows per graph, an 8 MiB response, and a ten-second operation ceiling. A shorter request deadline also applies. Caller max_bytes only lowers the response ceiling; graph bytes consume one aggregate budget during reading rather than accumulating a separate full allowance per graph. Unsupported RDF values, including raw byte values, refuse. No error returns a complete receipt or falls back to an empty or present-state export.

Profiles must name their actual required scope. Committed comments may reside in the project graph itself; do not invent a separate comments graph. A committed-project profile that excludes derived ephemerals does not satisfy a full SDK snapshot requiring them. Unknown required sidegraphs refuse. Producer source tests and a version advertisement do not establish configured adapter acceptance, installed publication, or runtime activation.

Eligibility observation

POST /acquire/eligibility (contract quipu-eligibility/v1) reports, for 1 to 16 exact named graphs, whether each would pass direct-MAIN acquisition admission and the store’s configured label floor at one observed head:

{"source_store_id": "urn:uuid:...", "graphs": ["https://example.org/project/board"],
 "expected_head_tx": 4210}

The response carries observed_head_tx, a sanitized policy identity (a SHA-256 of the floor and the names of the configured axes, never their values), the producer identity, and per graph eligible, refused (scope admission or policy, with the reason) or unknown (the check failed; never eligibility). expected_head_tx, when given, refuses unless the head is exactly that one, and the head and policy are re-read after the checks; a change refuses.

It reads graph metadata, terms, the dataset registry, transaction ids and store identity in one read transaction, and nothing else: no facts, no writes. It holds the writer connection through a dedicated guard that never settles a signed attestation and never synchronizes or remounts providers, and it refuses a signed request outright. A configured trust- or time-derived floor refuses as unsupported before any label read, exactly as acquisition does.

An observation is not an acquisition receipt, an applied-pin policy proof, or authority to acquire. It carries no applied_tx, and a later acquisition can still refuse.

Physical scope measurement

quipu acquire scope (contract quipu-acquisition-scope/v1) measures, for 1 to 16 exact named graphs at one applied pin, the physical DISTINCT(e,a,v) rows a direct-MAIN acquisition reads, using the acquisition’s own row predicate, and the bytes of their values:

quipu acquire scope --db artifact.db --source-store-id urn:uuid:... \
  --graph https://example.org/project/board --tx 4210 \
  --max-vm-steps 50000000 --deadline-ms 60000

Each graph is first admitted exactly as acquisition admits it; a refused graph is reported and not read. A graph with more than 100,000 rows is reported as exceeds_cap with no count. The VM-step and wall budgets are required: SQLite counts steps in strides of 1,000 per statement, and exhausting either budget aborts the whole run with the phase that was running and no partial result.

The store is opened read-only with no migration, and a missing store is never created. The measurement returns counts only, never fact content. Running it against a production store or a copy of one is a read-cost decision for that store’s owner; the command does not authorize itself.

Explicit substring search

An optional SQLite FTS5 trigram index accelerates current, local subject-only projections of plain-string substring matches. Queries do not create or backfill the index. Unsupported expressions, historical reads and composed stores retain the ordinary evaluator.

Prepare an owned, quiescent database copy with the matching binary:

quipu search-index backfill --kind substring --batch-size 10000 --db COPY.db
quipu search-index status --kind substring --db COPY.db

Each invocation processes one bounded batch; an operator decides whether to run another. The CLI retains its existing protected ingest-minute refusal. status opens the store through the maintenance path; it is not a promise of a file-free observation. A complete, supported schema is required for ready: true.

The derived tables and transaction triggers track current inserts, retractions and rollbacks. They are omitted from logical exports; receivers prepare their own index. Exact RDF equality is checked after fingerprint collisions. Language and typed literals retain ordinary SPARQL semantics.

Clients requiring this path send X-Quipu-Require-Substring: v6 on /query. The server checks completeness before evaluation, pins readiness and facts in one SQLite read snapshot, and echoes X-Quipu-Substring-Index: v6 on success. Missing or incompatible indexes refuse; they do not silently trigger a scan. This contract excludes historical, federated and attached-provider reads. Its connection-local SQLite cache is capped at 8 MiB, preserving smaller caller budgets; cache and mmap policies are restored after success, error or unwind. This bounds that cache, not the whole query’s memory or result size.

To remove the optional index, first stop dependent full-field consumers and quiesce the database, then use the matching binary:

quipu search-index drop --kind substring --db COPY.db

Remove the derived triggers before rolling back to a binary that lacks their codecs. Facts remain intact, and requiring clients then refuse until preparation is explicitly restored. Production adoption is a separate operator decision.

See the synthetic before/after demo.

Python Client

quipu-client is a thin, typed Python client for the REST API, living in python/ in the main repository. Standard library only — urllib all the way down, zero runtime dependencies, Python >= 3.11. It is a wrapper, not a re-implementation: request shapes, auth, and error surfaces are kept honest against the REST reference, and anything not listed here is a call away with curl.

Installation

# from a quipu checkout
pip install ./python

Quick start

from quipu_client import QuipuClient, QuipuError

q = QuipuClient("http://localhost:3030", token="secret-for-writes")

q.health()      # {"status": "ok"}
q.stats()       # {"facts": ..., "entities": ..., "predicates": ...}
q.version()     # {"version", "git_sha", "git_dirty", "features"}

Reads are open; writes need a bearer token — the client mirrors that contract exactly. token is attached as Authorization: Bearer <token> on write calls only, and a client constructed without a token sends no Authorization header at all. The optional client_id / task_id constructor arguments become the X-Quipu-Client / X-Quipu-Task attribution headers.

Reads

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, from its own MCP server: quipu mcp --db <path> over stdio, or quipu-server at /mcp over HTTP. (Bobbin embeds Quipu but serves its own knowledge_* tools, not these.)

The registry (tool_definitions()) exposes 77 tools in a default build, or 79 when built with the owl feature (which adds quipu_load_ontology and quipu_explain). (The counts are pinned by tests in src/mcp/tests.rs, which also check this page and the README against the manifest.)

Connect directly

The server includes the native mcp feature. Build the CLI and its companion:

cargo build --release --features full --bin quipu --bin quipu-server
claude mcp add quipu -- /absolute/path/to/target/release/quipu mcp --db /absolute/path/to/store.db

quipu mcp starts the sibling quipu-server --mcp-stdio with the same configuration and store initialization as HTTP mode. It opens no network listener. On Unix it replaces the CLI process, so signals and EOF reach the server directly. Install both binaries together. Protocol output uses stdout; diagnostics use stderr.

For an already running server, register its /mcp URL as streamable HTTP. Calls are stateless: a server restart does not leave stale MCP sessions. The HTTP caller supplies the same service-specific bearer used for REST writes. For stdio with protected writes, pass --mcp-token-file /absolute/path/to/private-token; the file must be regular, at most 4096 bytes, and private (0600 or 0400 on Unix). Credentials are never accepted in tool arguments or forwarded from a claimed actor field. Unconfigured local stores retain the CLI’s existing local authority; configuring write authentication applies to stdio too. Read-only mode refuses writes even with a valid credential. No existing shared credential or open REST read changes.

Every tool dispatches through the existing REST application in process, sharing its authentication, dataset selection, read pools, validation, and committed transaction attribution. The schemas come from tool_definitions(); the native transport does not maintain a second schema catalogue. quipu_graph_list preserves its query filters. Tool failures are MCP error results with the REST error body. Natural-language quipu_search still requires the configured embedding provider; a precomputed embedding works without one. The transport does not download models.

Browser MCP requests require an Origin explicitly present in quipu.server.cors_allowed_origins; no Origin is normal for native agent clients. The REST origin policy remains unchanged. Tool requests are limited to 64 MiB.

Bobbin’s knowledge_* tools and existing Homelab quipu_* proxies remain supported compatibility surfaces. Native MCP lets an installation use Quipu without either proxy. The Homelab proxy continues to call REST; Bobbin keeps its existing library integration. Register the intended server explicitly to avoid ambiguous names; this addition does not remove an existing MCP entry or its credentials.

Run just mcp test for HTTP and stdio protocol acceptance against isolated stores, including concurrent named/shared credentials and read-only refusal. Set QUIPU_MCP_TEST_MODEL_DIR to a model directory containing onnx/model.onnx and tokenizer.json, plus ORT_DYLIB_PATH as needed, to test natural-language search with a real local embedding provider. Otherwise the test checks vector search and the explicit missing-provider error separately.

Tool Reference

quipu_query

Execute a SPARQL SELECT query.

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_rule_query

Read one grounded claim using independent positive and explicit-negative evidence. The result preserves supported, refuted, both and neither, with a separate admitted judgment disposition. Failed, capped or incomplete reads return UNKNOWN; answers never grant action authority. See rule answers for strict request fields, evidence scope and retained judgment requirements.

ParameterRequiredDescription
subjectYesAbsolute subject IRI
positive_predicateYesAbsolute predicate for explicit positive support
negative_predicateYesDistinct absolute predicate for explicit negative support
objectYesOne strict IRI or plain-string value object
judgment_policyYesExpected admitted judgment policy
witness_limitNoEvidence row bound per side, 1–256 (default 64)

quipu_export

Export deterministic RDF from ROOT, a named graph, an episode provenance group, or a SPARQL graph query. Scope parameters are mutually exclusive.

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_merge_decisions

READ. Lists the conflicts of merging an incoming share into ROOT: one row per slot with the base / ours / theirs values, the slot’s max_count, and each side’s provenance. The rows are bound to ROOT’s graph hash and the incoming share id. With propose: true, each row also gets a mechanical proposal (choose plus evidence). It never sets a decision and writes nothing.

incoming and base are shares inline, in the shape /import takes (manifest, export_ntriples, shapes_turtle). Both are verified by hash, and base must be the incoming share’s parent_share. A server path is never accepted. REST: POST /merge/decisions. CLI: quipu merge <share-dir> --emit-decisions <file> [--propose].

quipu_merge_apply

WRITE. Finishes the merge from the decisions file with each row’s decision set ({"choose": "ours"|"theirs"|"base"} or {"values": [<N-Triples terms>]}). It commits the clean part of the merge plus every decided slot in one transaction. The transaction’s source records both parents, the reviewer, and the SHA-256 of the decisions.

It refuses, and writes nothing, on an undecided row, stale decisions (ROOT or the share moved since they were emitted), more values than the slot’s sh:maxCount, or a value that is not an RDF term. It also refuses a field merge-decisions/v1 does not define. dry_run: true runs every check and returns the counts it would write, without writing. REST: POST /merge/apply. CLI: quipu merge <share-dir> --decisions <file> --reviewer <who> [--dry-run].

quipu_knot

Assert facts from Turtle data, with optional SHACL validation.

ParameterRequiredDescription
turtleYesRDF Turtle data
blank_node_scopeNoExplicit document identity for blank nodes; same ID shares only byte-identical input across graphs, distinct IDs separate repeat loads
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 ROOT/default-graph facts for an entity. Named-graph facts are untouched; supplying graph, including null, is refused before mutation.

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.

This operation affects only ROOT/default-graph facts. Named-graph facts are untouched, and supplying graph, including null, is refused before mutation.

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

Identical facts asserted by different sources retain independent ownership. Repair removes only the selected source’s claims in the selected graph; a fact remains visible until its last owner retracts it. Repeating an assertion under the same source is idempotent. The reported retracted count is the number of source-owned statements processed, not the number that disappeared from the logical graph. Historical assertions remain in the audit log.

The ownership fix is forward-only for newly recorded claims. Older versions skipped identical assertions from later producers, so those historical claims cannot be reconstructed. Reassert all relevant producer snapshots before retracting old shared facts; this release does not backfill ownership.

Retract-only repair of facts owned by a legacy transaction source (POST /retract/source).

Retraction in the store is source-scoped: it closes every currently-live fact whose transaction source equals a given string. quipu_knot composes its own tag as snapshot:<key>, so a producer can only ever clear what it wrote under that scheme — facts written under any other source string (a free-form producer string, a hand-run CLI promote) were unreachable by any retraction, permanently. This tool names the source explicitly and clears it.

It is deliberately not a raw source-tag input on quipu_knot. There, one tag stamps a transaction that carries both retractions and new assertions, so a raw tag would let any caller write facts attributed to any producer. Here there is no turtle parameter at all, so the transaction cannot carry an assertion and impersonation is impossible by construction rather than by discipline.

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 deletes nothing and for one that empties a graph. Read planned to distinguish those cases.
  • Re-keying order is retract FIRST, then re-promote. The store dedups an identical triple to one row carrying one source, and the existence check ignores the transaction source — so asserting canonically first is skipped as a duplicate, every row keeps its legacy source, and the retraction then removes everything.

quipu_episode

Ingest structured agent knowledge as an episode.

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)
modeNosemantic (default) or opt-in keyword (SQLite FTS5 BM25; requires query text, enabled and backfilled index; no embedding provider)
embeddingNoFloat array (query vector); takes precedence over query
limitNoMax results (default: 10)
rankingNosemantic (default) preserves cosine order; opt-in content demotes contentless repository artifacts
valid_atNoTemporal filter
graphNoRegistered graph IRI, or all; omitted scope searches ROOT
graphsNoNonempty list of registered graph IRIs; search their union
all_graphsNoSearch ROOT and all registered named graphs, excluding graph metadata
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 |

Results include raw similarity, adjusted score, and ranking_reason.

Graph selectors are mutually exclusive; unknown graphs are refused. Explicit scope results carry graph and graphs. Semantic scope selects entity membership before the result limit on the built-in SQLite backend. Other vector backends use bounded best-effort oversampling. Embeddings remain per entity: ROOT text is retained for entities with ROOT facts; other entities combine their named-graph facts. This is membership scoping, not per-graph embedding text. Explicit graph scope supports semantic and keyword ranking; ROOT content and anchor reranking are refused with it.

Explicit graph selection in semantic and hybrid search requires [quipu.search] named_graphs = true. The default is false. Prepare named-only vectors with bounded backfill before enabling it; the flag also gates automatic named-only embedding text. Keyword mode does not need it: the lexical index records each assertion’s graph, so keyword search accepts an explicit scope whenever keyword = true and the index is complete. Hybrid with alpha = 0 is pure keyword ranking and follows the keyword rule.

Keyword mode returns score (positive relevance, higher first), raw SQLite bm25 (lower first), and ranking_reason: "keyword". It indexes literal terms and quoted phrases from labels, alternate labels, descriptions including full episode bodies, other literal attributes, type names, and entity IRI local-name tokens. Phrase/term conjunctions currently match one fact document; structured cross-attribute expressions belong to the structured-query stage. The default semantic response and scores are unchanged. Keyword mode rejects embeddings, anchors and content ranking rather than silently ignoring them.

Activate [quipu.search] keyword = true, then run explicit bounded quipu search-index backfill --batch-size 500 --db <path> calls until status reports complete: true. Search refuses an incomplete index. Each call commits one batch and releases the writer; the CLI refuses UTC minutes 10 through 20 to protect scheduled ingestion. See keyword index. See search ranking for content criteria, exact-name exceptions, temporal behavior, and bounded candidate recall.

Anchored search (anchor, max_hops, anchor_mode, decay, via, direction, explain) roots the search on one entity and ranks by hop distance. It is off unless the server sets [quipu.search] anchored = true. See anchored search.

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.

For the built-in SQLite backend, an explicit SPARQL scope is scored before selecting the top results. Narrow scopes to at most 1,000 candidate rows, resolved entity IDs, and eligible embedding rows; larger scopes return an error. Historical ranking returns the text from the exact embedding version scored. Query and stored vectors must contain finite values with matching dimensions; the SQLite scoped path accepts up to 16,384 dimensions. An empty eligible scope returns no results. SPARQL candidates use the requested valid time and a bounded query budget. Unscoped ranking and delegated backend ranking retain their existing implementation; candidate-before-top-K is a SQLite guarantee. Scoring reads the local SQLite vector table, including rows keyed by composed alias IDs; it does not scan vector tables in attachments. The candidate, scoring, and text reads do not provide one snapshot across concurrent writes.

quipu_graph

Project the knowledge graph into a render-ready node-link payload in one response: nodes (IRI, label, type, degree), index-addressed edges, and a type census. Episode/provenance scaffolding is excluded by default; nodes are ranked by degree and capped, and the response states what was dropped.

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.

Stored claim and evidenceProbe properties are read under both vocabulary namespaces. Identical alias values collapse; conflicting values are refused. The stored query text, target identity, and signature inputs are preserved.

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.

Registration classes and verifier/key predicates are read under both vocabulary namespaces. Conflicting registered keys are refused, rather than choosing one; duplicate aliases carrying the same key are accepted.

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
signatureYesSignature over the verdict message: hex for ed25519; base64url assertion signature for webauthn-*; the armored SSH SIGNATURE for sshsig-sk-ed25519
verdictNoIRI of the stored verdict carrying this signature: verify as of when the store recorded it (use this for trust decisions)
signed_atNoExplicit valid-time instant (a what-if query; default now)
txNoExplicit transaction to verify as of (a what-if query; default latest)
schemeNoed25519 (default), webauthn-es256, webauthn-eddsa, or sshsig-sk-ed25519
authenticator_dataWebAuthnbase64url authenticatorData
client_data_jsonWebAuthnbase64url clientDataJSON

The registry is read as of the signature (signing-plane S1): a key that has since been rotated still verifies what it signed while registered, and cannot verify anything recorded after it was closed. The result’s as_of.basis is recorded, caller-supplied or now. A caller-supplied instant is a what-if: trusted is then always false, and the answer is in would_verify_as_of_supplied_instant. Otherwise naming a transaction from before a revocation would make a revoked key read as trusted.

Hardware schemes are off by default ([quipu.governance] hardware_verdict_schemes); while off, a verdict naming one is refused. A hardware verdict verifies only against a registration declaring the same aegis:signatureScheme, and is trusted only when that same registration attests the predicate. WebAuthn registrations carry the base64url COSE key in aegis:publicKey plus aegis:webauthnRpId and aegis:webauthnOrigin; SSHSIG registrations carry an OpenSSH sk-ssh-ed25519@openssh.com public key line. The WebAuthn challenge is derived by quipu from the verdict message, never taken from the caller, and SSHSIG must use namespace quipu-verdict. The response’s sign_count is the authenticator counter to record as the registration’s aegis:signCount. Once a nonzero counter is recorded, a signature whose counter does not exceed it (including 0) is refused as a possible clone; a credential that has only ever reported 0 is accepted. WebAuthn requires user verification (UV); SSHSIG requires user presence and reports UV without requiring it. See src/verdict_schemes/mod.rs for the full rule list.

The as-of rule binds every scheme: a hardware verdict is checked against the registrations, grants and recorded aegis:signCount in effect at the same instant, and a caller-supplied instant leaves it untrusted too.

quipu_verifier_authorized

Check the Phase-0 verifier registry: may this verifier attest this predicate? The discovery half of the governance gate. Registration classes and each verifier/attests predicate may independently use the legacy or Quechua vocabulary.

ParameterRequiredDescription
verifierYesVerifier IRI
predicateYesPredicate IRI to attest
signed_atNoValid-time instant to check at (default now)
txNoTransaction to check as of (default latest)

quipu_cooccurrence

Deterministic, auditable work-item co-occurrence: given a work-item (Bead) IRI, returns the other work-items that share at least one touched code entity via the provenance chain Bead ←implements− GitCommit −modifies→ entity. A graph query over typed provenance edges, ordered by overlap strength. Each implements/modifies edge may use the legacy or Quechua vocabulary independently; duplicate aliases do not inflate shared-entity counts.

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)

The provenance queries brief_ground, brief_related, entity_work, and cochanged_with read both legacy vocabulary predicates and their Quechua counterparts under https://scbrown.github.io/quechua/ns#. Each edge can use either spelling independently. Duplicate aliases do not inflate returned paths or shared-item counts. Entity IRIs and dataset selection stay unchanged; these queries do not require an equivalence reasoner.

The group scope in quipu_search, quipu_search_nodes, and quipu_search_facts accepts either vocabulary’s groupId predicate on the provenance episode. Duplicate aliases do not duplicate search results. Other groups, unrelated predicates with the same local name, and nodes without an episode remain outside a requested group scope.

Parameters are validated and escaped by type before substitution, so values are safe against SPARQL injection. The response includes the resolved sparql, the result columns, and rows.

Example — service dependencies of an entity:

{ "name": "service_deps", "params": { "entity": "http://example.org/traefik" } }

quipu_queries

Manage stored named queries — competency questions a consumer ships with its domain, callable through quipu_ask alongside the compiled-in catalog. Definitions are validated at load and versioned (re-loading a name closes the prior version rather than overwriting it).

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
graphNoExact registered absolute IRI; excludes kind/lifecycle; no materialization enrichment. Same count/graphs envelope, absent count zero, failures are errors. Attached source must support creator index v1.
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_byYesIdentity 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_byYesIdentity 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

Hybrid fusion parameters on quipu_search: mode: "hybrid", alpha (semantic weight, 0..1), fusion (weighted or rrf), positive rrf_k, and explain. Server activation and the ready lexical index are required for intermediate weights. Alpha endpoints retain exact pure responses. See the REST search reference for candidate bounds, scope compatibility, snippets and rollback.

quipu_graph_create

quipu_graph_create takes graph (required), optional RFC 3339 timestamp (default current UTC), and optional non-empty actor. It registers a committed named graph and returns graph, g, created, and creation with timestamp and actor. Repeats preserve original provenance. An actor is a declaration, not authenticated identity; missing historical attribution is null.

quipu_graph_label

quipu_graph_label takes required graph and timestamp, optional actor, trust: {iri, chain, rank}, freshness, durability, policy tokens, kind, and valid_to. At least one label axis must be supplied. Register the graph first. Label declarations are versioned. Invalid axes, empty declarations and unregistered graphs are refused.

quipu_graph_list includes each graph’s creation alongside class and labels. Follow the complete MCP-only named graph recipe for create, label, new-class shapes, write and scoped query.

Portable graph workflows

These tools call the existing guarded REST handlers. Bearer authentication, read-only mode, graph selectors and validation have the same authority. New workflow tools reject unknown or wrong-typed top-level fields; pass an exact scope to shares. None accepts a server-local file path.

quipu_share

Produce a bounded portable share payload, including canonical manifest and member bytes. Read-only; no server-local output path. Pass the exact scope. Internal-only export exemptions are not accepted over this transport.

Fields: scope (required), shapes, no_shapes, queries, alignments_turtle, parent_share, turtle_view, max_bytes.

quipu_import

Stage a portable share with hash, vocabulary and attestation gates. Copy member contents from quipu_share; this is not a server file path. Exact-name merges default off; review before promotion.

Fields: manifest (required), export_ntriples (required), shapes_turtle (required), queries_turtle, alignments_turtle, query_namespace, replace_queries, source (required), actor, accept_exact, attestation.

quipu_import_promote

Promote a staged share only when existing promotion gates permit it. Does not bypass quarantine or resolution review.

Fields: share_id (required), actor.

quipu_reason

Run stored or inline Datalog rules to fixpoint and persist derivations. Omitted graph means ROOT; pass graph to scope premises. Derivations persist in that graph’s reserved companion inferred graph, not alongside asserted facts.

Fields: rules, prefix, graph, timestamp.

quipu_update

Execute SPARQL 1.1 Update through the guarded update endpoint. Use explicit GRAPH clauses for named graphs; absent scope has standard SPARQL default-graph semantics. Actor/source are declared provenance, not authorization.

Fields: update (required), actor, source.

quipu_events

Read a bounded event batch without committing a consumer cursor. since overrides consumer. types is a comma-separated event-type filter; group is an episode group, not a graph selector.

Fields: since, limit, types, group, consumer.

quipu_events_commit

Durably set a consumer cursor. Lower offsets explicitly rewind; delivery alone never commits.

Fields: consumer_id (required), offset (required).

quipu_changes

Read whole transaction change pages. graph scopes an exact graph; use next_tx for the next page. No cursor write.

Fields: since, limit, capture, graph.

quipu_subscriptions

Manage persistent push subscriptions (create/list/delete). Creates may deliver externally; list is still conservatively write-classified like REST.

Fields: consumer_id, webhook_url, sparql_ask, action, types, mode, batch_size, batch_window_s.

quipu_entity

Dereference an exact IRI as JSON-LD. IRIs are encoded as query values, including slashes and fragments; expanded includes label expansion.

Fields: iri (required), expanded.

quipu_entity_history

Read the existing entity-history endpoint for an exact IRI. Its current MAIN-history semantics do not certify composed/named-graph audit completeness.

Fields: iri (required).

quipu_transactions

Read bounded transaction metadata after since; does not change a cursor.

Fields: since, limit.

quipu_acquire

Acquire complete bounded registered named graphs from an expected MAIN store lineage. Unknown graphs, unsupported attachments/pins and overflow refuse.

Fields: source_store_id (required), graphs (required), tx, max_bytes.

quipu_acquire_composed

Acquire qualified MAIN graphs with a fenced attachment census. expected_generation is required with an explicit main tx; provider clocks are never a global transaction.

Fields: source_store_id (required), graphs (required), expected_generation, tx, max_bytes.

quipu_embed_backfill

Backfill missing ROOT entity embeddings using the configured provider. May consume external embedding resources and persist vectors.

Fields: none.

quipu_embed_backfill_graph

Backfill a bounded number of named-graph entity embeddings; does not rewrite ROOT entity text.

Fields: graph (required), max_entities.

quipu_search_query

Fuse ranked search seeds with a SPARQL query. Existing mode-specific validation and query budgets apply.

Fields: mode, query, seed_variable, sparql, valid_at, entity_type, embedding, seed_limit, query_options, group_ids.

quipu_fragments

Read a bounded triple-pattern fragment. page and pageSize select a page; absent terms are wildcards.

Fields: subject, predicate, object, page, pageSize.

quipu_reconcile

Get the reconciliation service manifest or resolve keyed query objects without merging entities.

Fields: queries.

quipu_spotlight

Annotate text with known entities, read-only. A busy cold cache may return no annotations; that is not an absence proof.

Fields: text (required), confidence.

quipu_knot_stage

Stage one immutable numbered snapshot part. Supply exact sha256 hashes and upload identity; replay is idempotent, changed bytes refuse. No file paths.

Fields: upload_id (required), snapshot (required), content_hash (required), part_hash (required), payload (required), timestamp, actor, source, graph, total_parts (required), total_bytes (required), part_number (required).

quipu_knot_promote

Start or read back atomic promotion of a staged snapshot upload. pending=true is not completion; poll the same upload_id. Existing validation gates remain authoritative.

Fields: upload_id (required).

quipu_workflow_capabilities

Read the direct-workflow enforcement capability before guarded updates. No arguments. A successful read reports enabled, signature version and scope; it does not grant write authority.

To move a portable share, call quipu_share with {"scope":{"kind":"graph","value":"urn:example:graph"}}. Its result includes manifest and files. Pass that manifest, files["export.nt"] as export_ntriples, files["shapes.ttl"] as shapes_turtle, and a provenance source to quipu_import on the destination. Include queries_turtle and files["alignments.ttl"] as alignments_turtle when present; alignments are sealed evidence, never applied mappings. Review quarantine/resolution output before quipu_import_promote. An import result or pending upload result is not promotion acceptance.

For quipu_update, provide a SPARQL update string, with explicit GRAPH clauses for named scope. The adapter form-encodes the exact string and optional actor/source; the existing update handler applies structural, vocabulary, workflow and attribution gates. A declared actor is not authenticated identity.

quipu_events returns next_offset without committing. Commit only after processing with quipu_events_commit. quipu_changes returns next_tx and supports exact graph filtering. Both are bounded pull APIs; they do not promise a persistent SSE connection. Subscription creation can trigger external webhook delivery and retains the REST write classification even for list.

quipu_stats

Read serving-store statistics with no arguments. Unlike CLI stats --db, this operates on the server’s store, not a caller-selected local path.

quipu_health

Read server health with no arguments. A positive liveness control does not certify target coverage or graph completeness.

quipu_version

Read serving version, source commit and dirty-build identity without REST. No arguments; version identity does not replace behavioral acceptance.

Reasoner Reference

Complete reference for the Quipu reasoner: rule syntax, CLI, Rust API, error catalogue, and current limitations.

Placement (quipu-0b6, 2026-08-27). Derivations are written to the premise graph’s companion inferred graph (<graph>#inferred; ROOT’s 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.

Reactive graph scope

Each committed transaction carries its authoritative graph in the observer Delta. Reactive rules read that graph and its companion inferred graph; derivations and withdrawals stay in that companion. A multi-graph batch notifies each graph separately. A change in one project cannot update the same rule’s derivations in another project or in ROOT.

This applies only when the reactive reasoner is enabled and its rules are loaded. Explicit evaluate_in_graph remains available for a reconciliation or backfill; it is not a substitute for current reactive evaluation.

Rule Syntax

Rules are written in standard Turtle files using the rule: vocabulary. The reasoner reads any resource typed rule:Rule and ignores everything else, so rules can live alongside SHACL shapes in the same file.

Namespace

@prefix rule: <http://quipu.local/rule#> .
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

Historical edition evaluation (native library)

The native library exposes a separate historical path. The timestamp argument of evaluate stamps a current evaluation; it does not select historical premises or a historical rule edition. The file-based CLI above keeps its existing behavior. No historical CLI flags are provided by this library API.

Use the following sequence for an independently retained raw SQLite artifact:

  1. Verify a detached SignedHistoryAttestation with history_attestation::verify_history_attestation. The caller supplies a trusted HistoryCoverageGrant, including the external policy revision, reviewed evidence bytes and digest, issuer key, exact artifact digest, source lineage, catalogue and premise graphs, and both coverage intervals. A key or completeness assertion supplied by the manifest itself is not authority. Missing, expired, ambiguous or mismatched evidence refuses.
  2. Call raw_history::admit_raw_history with those verified terms, the exact retained bytes, a RawHistoryRequest { valid_at, as_of_tx }, configuration, the caller’s policy store, and the actual admission time. It verifies the digest, SQLite integrity, lineage, real transaction boundaries and graph registration, and refuses attachments and active frozen providers. The reader owns a private immutable copy; it does not snapshot a live service. ROOT is explicitly urn:quipu:graph:root, reserved graph zero; an unknown named graph never falls back to ROOT.
  3. Call governed::evaluate_governed_history(&source, ruleset_iri). Catalogue membership is read at the transaction cut, then the edition is selected independently by its valid-time applicability. Premises are read from the admitted premise graph at both pins. Current inferred companions and current shape sets do not participate. Evaluation occurs in isolation without writing derivations into the retained source.

GovernedHistoricalAnswer returns the selected edition identity, executable Turtle digest and applicability interval, the exact signed source terms, transaction cut, original admission time and sorted derived IRI tuples. It is an illustrative model answer, not a legal judgment or a captured derivation proof. Rule parsing alone does not establish that a vocabulary is loaded or that primary text supports the model.

The selector refuses absent, overlapping, malformed or digest-mismatched editions. Its bounded profile permits at most 64 editions, 4,096 catalogue triples and 1 MiB of catalogue text; execution permits at most 64 uniquely identified rules. Premise evaluation permits at most 10,000 tuples and 64 predicates. A smaller positive configured SPARQL output ceiling remains in force, with a reserved sentinel row: reaching the ceiling is a capacity refusal, not a truncated successful answer. Zero means no configured output ceiling, while these profile bounds still apply. Query deadlines and join budgets remain enforced for premise extraction. The same absolute deadline also covers derivation. Historical derivation streams one binding at a time, refuses before inserting a sentinel-sized derived relation, and shares one candidate-work budget across rules, strata and fixed-point rounds (at most 1,000,000 candidate steps, reduced by a smaller positive caller row cap or configured join cap). It admits at most 64 body atoms per rule. Capacity or deadline exhaustion refuses the whole answer; it never returns a successful partial closure. This bounded historical evaluator does not change the legacy file evaluator.

A matching artifact digest, store ID or known transaction cannot prove history completeness. That authority must come from the independently reviewed coverage grant, including pre-window facts needed by the catalogue and premises. This API supplies no producer certificate, issuer registration, live schema activation or automatic trust-policy loader. Preserve UNKNOWN when those prerequisites are unavailable. Retaining an old receipt does not extend its admission validity.

Bounded standards mapping

This profile makes a conceptual mapping to LegalRuleML 1.0, sections 4.3.1, 4.3.5 and 4.4. LegalRuleML separates legal entry, efficacy and applicability and supports source associations and temporal parameters for parts of rules. Here, one immutable executable edition has one applicability interval and separately linked primary sources. That interval is not its assertion time, execution time, event time, legal entry or efficacy. This is not LegalRuleML XML interchange or a conformance claim.

The catalogue reuses DCMI isVersionOf and source, PROV value, SPDX checksum terms and OWL-Time interval/instant relationships. Half-open applicability intervals, whole-second UTC and exact executable-byte SHA256 are application profile choices; OWL-Time does not mandate these choices.

Unsupported constructs include temporal associations below edition granularity, legal authority and jurisdiction reasoning, deontic operators, defeasible priorities, penalties and reparations, legal completeness, and civil calendar arithmetic. Synthetic transition facts are precomputed inputs, not calculated legal deadlines. A reviewed teaching-case oracle is required before presenting any jurisdiction-specific example as an accepted model result.

Reactive Evaluation

Requires the reactive-reasoner feature.

#![allow(unused)]
fn main() {
use quipu::reasoner::reactive::ReactiveReasoner;
use std::sync::Arc;

let observer = Arc::new(ReactiveReasoner::new(ruleset));
store.add_observer(observer.clone());

// Now any transact() call triggers automatic re-derivation.
store.transact(&new_facts, timestamp, Some("agent"), Some("discovery"))?;
// Derived facts are already updated.

// Check stats:
let stats = observer.stats();
println!("triggers: {}, asserted: {}", stats.triggers, stats.total_asserted);
}

ReactiveStats fields:

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.

Embed the browser playground

Use the same released engine and repository pack in an iframe:

<iframe id="quipu-playground" title="Quipu playground"
  src="https://scbrown.github.io/quipu/explore/?embed=1&amp;theme=intigraph"
  style="width:100%;height:860px;border:0"></iframe>

embed=1 opens Map first and hides navigation, the loading-ceremony hero, and the take-away cards. Ask, Map, the node inspector, and Try a bad write still use the real engine. Narrow screens place the map before starter questions. After one successful embedded load, the browser can show a saved map preview while the worker verifies a fresh copy. The cache is bounded and keyed to the exact pack bytes, engine revision and compiled features. It contains the map’s real query rows from a pristine imported pack. The preview is labelled; Ask and writes stay disabled until verification finishes. A verification refusal hides and removes it. Pack or engine changes, unavailable storage and invalid cache entries take the normal cold-load path.

The under-five-second warm target measures when that saved map appears. Full verification and interactive query readiness take longer; the page reports that transition explicitly. Loading still verifies and imports the complete pack on every visit.

theme=intigraph selects Intigraph’s palette, Inter and JetBrains Mono, with its light variant following the system color scheme. The normal page’s Theme picker also offers Intigraph. System, Light and Dark remain available. The fonts load from Google Fonts only when Intigraph is selected; system fonts remain the fallback if that service is unavailable.

Embedded pages send {type: "quipu:resize", height: <pixels>} to their parent when content height changes, including after tab selection. A host can resize the iframe after checking both the sender origin and frame identity:

const frame = document.querySelector("#quipu-playground");
window.addEventListener("message", (event) => {
  if (event.origin !== "https://scbrown.github.io"
      || event.source !== frame.contentWindow
      || event.data?.type !== "quipu:resize") return;
  const height = event.data.height;
  if (Number.isInteger(height) && height > 0 && height < 20000) {
    frame.style.height = `${height}px`;
  }
});

One run, end to end

This transcript is not illustrative output. It is examples/sharing-demo/expected.txt, included here verbatim. just sharing-demo creates two fresh stores and checks that a new run still matches it; the required CI Build job runs the same check.

The demo loads an explicit identifier-policy catalogue before its outward share. Its policy lives in a separate named graph, so it does not become widget data in the shared ROOT graph.

1. SHARE A             scope=root facts=2 shapes=1
2. IMPORT B            outcome=quarantined admitted=0 quarantined=2 blocker=off_vocabulary
3. ADOPT + REIMPORT    outcome=staged admitted=2 quarantined=0 promotion_eligible=true
4. PROMOTE B           outcome=promoted triples=2
5. COMPOSE B           root_names=shared
6. DIVERGE             A_adds=a-only B_adds=b-only
7. STATUS              diverged=true ours_added=1 theirs_added=1 conflicts=0
8. RECONNECT           outcome=merged asserted=1 retracted=0 provenance_parents=2
9. CONVERGED B         root_names=a-only,b-only,shared
10. BOUNDARY           provider federation unions labelled query results; it does not merge store histories

The receiver first quarantines the intact bundle because the type is not in its local vocabulary. It then adopts the bundled shape deliberately, re-imports, and promotes with a named actor. After A and B make independent additions, status proves both histories moved and merge records two provenance parents. The demo intentionally stops at the current boundary: provider federation unions labelled query results but does not merge store histories. The broader evaluation and claims belong to the arXiv submission source for the shape-aware merge paper, not to this ten-line walkthrough.

Every claim below names the command, symbol, or verbatim message that backs it, so you can check it rather than believe it. Citations are file plus symbol, not line numbers, because line numbers rot and a page that cites them stops being true without anyone editing it. Quoted messages are exact — grep for them.

Two things most graph databases do, Quipu deliberately does not. It does not merge on receipt, and it does not take a peer’s word for how trustworthy that peer is.

flowchart LR
  subgraph A["Store A — producer"]
    AR[("ROOT")]
  end
  subgraph B["Store B — consumer"]
    BQ[["quarantine graph<br/>urn:quipu:import:quarantine:…"]]
    BR[("ROOT")]
  end
  AR -->|"quipu share --output dir/"| S["share bundle<br/>export.nt · shapes.ttl · manifest.json"]
  S -->|"quipu import dir/<br/>hashes verified"| BQ
  BQ -->|"quipu import promote &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

New local shares stamp pack_dir: "pendant" in their manifest. Use --pack-dir knowledge to keep an explicit repository layout. Older manifests without this field still resolve to qpack; importing them does not rewrite their identity or silently move their deltas. HTTP shares do not assert a repository directory on a caller’s behalf.

Outward sharing checks the outgoing bytes against the store’s block-tier identifier-policy catalogue. Load your reviewed policy before the first share; loading only your data’s SHACL shapes does not supply these rules.

For a minimal example, save this shape as identifier-policy.shapes.ttl:

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix aegis: <http://aegis.gastown.local/ontology/> .

<urn:demo:IdentifierPolicyShape> a sh:NodeShape ;
    sh:targetClass aegis:InternalIdentifierPattern ;
    sh:nodeKind sh:IRI .

Save its rule as identifier-policy.ttl:

@prefix aegis: <http://aegis.gastown.local/ontology/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

# The demo's explicit identifier policy. Stored in its own named graph so the
# shared payload and its quarantine/admission transcript remain unchanged.
<urn:demo:private-identifiers> a aegis:InternalIdentifierPattern ;
    rdfs:label "Demo private identifiers" ;
    aegis:regex "private[.]example" ;
    aegis:enforcementTier "block" .

This demonstration rule only detects private.example. For real publication, replace it with a reviewed catalogue covering the identifiers your organization needs to exclude. The checker evaluates the supplied rules; a non-empty catalogue does not establish that your policy covers every private identifier.

Load the policy into the same database that will produce the share:

quipu shapes load identifier-policy identifier-policy.shapes.ttl --db my.db
quipu knot identifier-policy.ttl --graph urn:quipu:identifier-policy --db my.db
quipu share --output graph-share --db my.db

Also load the shapes governing your application data before ingesting it. The catalogue graph is separate from ROOT, the default share scope; its location does not change which application facts travel. Each rule’s type, label, regular expression and block tier must be together in ROOT or one named graph. Fragments split across graphs do not form a usable rule.

The CLI exits 2 if there is no block-tier catalogue, 1 if a rule matches, and 0 after checking a clean payload. A refusal creates no partial output directory. --no-shapes does not bypass the catalogue check. Use --destination internal only for an intended internal transfer; it skips the outward check and stamps the resulting artifact accordingly.

Release publication rollout

Native releases can ship before the trusted repository-share producer is enabled. While the repository variable TRUSTED_REPOSITORY_SHARE_ENABLED is unset or not true, the release summary explicitly records that no new repository pendant was published. The release may therefore have no graph artifact for the explorer.

Enable that variable only after the managed trusted producer is installed and its scheduled execution has been observed. Once enabled, the release job requires the pendant, checksum and source/binary provenance receipt; missing or invalid artifacts fail the job. The first enabled release must separately prove published artifact delivery. Hosted runners never receive the private policy catalogue.

The repository pack also carries a separate, public receiver catalogue from docs/knowledge/publication-policy.ttl, with its SHACL constraints. It contains generic access-token, private-key and user-directory patterns plus a fictional domain control. It does not publish the trusted producer’s private rules or claim to cover every organization’s private identifiers. Load stricter local rules when your destination requires them. The producer’s fresh private-policy check remains mandatory and is not replaced by this public catalogue.

Before emitting a release pack, the builder imports it into a fresh receiver and proves outward sharing and re-import without injecting policy afterward. Acceptance pins all four public rule IRIs and their exact labels, regexes and block tiers; missing one rule or changing any of those values fails the check. Missing receiver policy and a planted forbidden value must still refuse without output, and native SHACL must refuse an incomplete policy rule. Carrying shapes does not establish that a particular browser binary has compiled SHACL; the explorer reports that capability from the engine build.

What a share is

A share is a directory you can commit to git, attach to an email, or publish as a release asset. quipu share --output <dir> writes it deterministically (src/cli_pack.rs, cmd_share), and it holds exactly three files — the same three quipu import reads back (cmd_import):

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
queries.ttlonly when the store has stored queries registered against the scope: the competency questions a receiver can quipu_ask, sealed by the manifest

The manifest’s parent_share field (src/share.rs, ShareOptions) is what makes a share a link in a chain rather than a loose dump: it names the share this one descends from, which is what later lets quipu merge find a common base.

Shares carry their shapes on purpose. A receiving store is never asked to guess what the sender meant by a predicate — it gets the constraints alongside the facts.

Pendants: what the artifact is called

On a khipu, pendant cords hang from a primary cord, and each pendant carries a self-contained record. Quipu names its share artifact after them:

KhipuQuipu
Primary corda store
Pendant corda pendant: a shareable graph with its shapes and stored queries — the manifest, the payload (export.nt), shapes.ttl, and stored queries where the share carries them
Subsidiary corda delta share, hanging off the pendant it amends
Tying a pendant onto a primary cordcomposing an imported pendant into a store
Knots between cordsrelationships — edges join cords; they are not pendants

“Share” still names the protocol — quipu share, quipu import, share deltas. “Pendant” names the thing that travels. Packed for release, a pendant is a deterministic tar archive named *.pendant.tar.gz.

Renamed from “qpack”. Artifacts used to be called qpacks and carried a .qpack extension. For one release, anything named .qpack, .qpack.tar.gz or .qpack.db still loads unchanged, with a deprecation warning; release pages publish the repository pendant under both asset names. New artifacts are written only as .pendant.

How repository pendants connect

A repository pendant describes that repository and points back to its source. Load Quipu, Bobbin, Yupana and Camayoc pendants into the same receiving store to query their relationships together. Each import still passes verification and quarantine; composition follows explicit promotion, not merely downloading or opening an archive. The cords below illustrate that composition, not a claim that every repository already publishes a complete pendant.

Repository pendants compose; dependencies and alignment remain distinct Four repository cords join one receiving store after verification and explicit promotion. Inside each are ideas, interfaces and symbols pointing to repository files. A separate example shows Bobbin knowledge_import depending on Quipu /import. A dashed illustrative mapping between differently named ideas requires a reviewed alignment record and is not an accepted identity. One receiving store · explicit composition Verify each pendant → quarantine → explicitly promote QuipuBobbin YupanaCamayoc IdeasIdeas IdeasIdeas Interfaces · symbolsInterfaces · symbols Interfaces · symbolsInterfaces · symbols Repository filesRepository files Repository filesRepository files Pointers, not copied text Pointers, not copied text Pointers, not copied text Pointers, not copied text Repository-qualified entity IRIs · generic namespace shown Dependency · source-backed example Bobbinknowledge_import Quipu/import Mapping · illustrative candidate only Idea “A”Idea “B” depends on An edge between interfaces, not an identity merge Shareable alignment record: propose → decide → apply No accepted cross-stack identity is asserted here

An idea, interface or symbol can carry a repository-qualified entity IRI and a file pointer. The pointer identifies where to read the definition; it does not copy the file’s text into Quipu. For example:

These are navigable source references, not embedded copies or four equivalent interfaces. A released pendant’s source revision and provenance receipt identify the version to inspect; the links above follow each repository’s main branch. Bobbin’s knowledge_import handler calls Quipu’s /import endpoint, so a cross-repository depends-on edge can describe that interface dependency. It does not say that the two interfaces are the same entity.

The repository content contract requires the following alongside code and documentation pointers. This table specifies what a producer must represent; it does not certify those contents in every currently released artifact.

Required knowledgeWhat to connect
High-level ideasRepository concepts to the interfaces, symbols and source files that implement them
Public interfacesCLI commands, MCP tools, REST routes, configuration keys and file formats to defining files; cross-repository dependencies to their target interfaces
Stored queriesQueries the repository answers, with their definitions in queries.ttl
DecisionsDecision status and supersession, with links to the evidence and affected source
FreshnessreviewAfter / maxAge information on knowledge facts, so missing or expired review remains visible
Start-here knowledgeA glossary linked to Quechua terms, plus source files to read first
ShapesValidation of ideas, interfaces and mappings; a prose claim is not a shape
Cross-repository mappingsThe existing shareable alignment record, identifying the two IRIs and the reviewed relationship

Chunk nodes, where retained for navigation, carry pointer metadata and relationships. Chunking and source-text retrieval belong to Bobbin; this contract does not ask Quipu to duplicate chunk bodies or line spans. Missing producer coverage is unknown, not evidence that a relationship does not exist. Embedding model metadata and vector rebuilding are separate load capabilities; composing two graphs does not itself prove either one.

Composition is not an identity decision

  • The same exact IRI names the same RDF subject in the composed data. Repository-qualified identifiers prevent unrelated local names such as config from colliding. The diagram deliberately uses a generic repository namespace rather than declaring a new permanent namespace.
  • Different IRIs remain different by default, even when their labels match. The current import resolver reports exact canonical-name matches as proposals; it preserves the foreign IRI unless the caller explicitly enables accept_exact. That compatibility option rewrites IRIs and does not create a retractable alignment record.
  • A dependency is not identity. Bobbin calling Quipu is a relationship between interfaces, not permission to merge their names.
  • A proposed mapping is not accepted alignment. Use the existing propose → decide → apply workflow. Its SSSOM record carries subject_id, object_id, predicate_id and mapping_justification; the decision records the reviewer. Applying the decided version can derive accepted identity into a separate alignment graph, leaving both source graphs recoverable. A close match, undecided row or rejected identity does not authorize owl:sameAs.

The two differently named ideas in the diagram are an illustrative mapping candidate, not a published cross-stack alignment dataset. Share a reviewed alignment record with the pendants when one exists; do not manufacture mappings from a picture or a common label. See Aligning concepts across graphs for the expected-version guard and rejection history.

Receiving: verify, quarantine, promote

Import is two verbs, and the split is the point.

quipu import ./their-share --source https://example.org/share --actor alice
quipu import promote <share-id> --actor alice

quipu import verifies before storing anything. It recomputes the payload hash and refuses a mismatch (src/share_import.rs, verify_share):

share graph hash mismatch: manifest=… actual=…

What survives verification lands in a quarantine graph named urn:quipu:import:quarantine:<hash> (staging_graph) — present in the store, queryable, and not part of ROOT. The result reports an ImportCounts split of admitted versus quarantined triples.

quipu import promote is the separate, actor-attributed step that moves a staged share into ROOT (cmd_import, the promote arm). Nothing reaches your ROOT because a file arrived; it reaches ROOT because a named person ran the second command.

Identity across stores

Two stores will call the same thing by different names. Quipu handles that in two places, and today one of them is stronger than the other.

quipu knot writes real owl:sameAs edges, so the claim “these two IRIs are one entity” is itself a fact in the graph — visible, queryable, and retractable like any other fact. That is the model the project is built around.

Import proposes; it does not merge. On an exact canonical-name match (src/share_import.rs, resolve_and_rewrite: score == 1.0 && matched_on == "canonical_name:exact"), the incoming foreign IRI is kept. The match is reported as an exact_merges entry carrying its score and matched_on, so a caller can accept the exact hits in one action — but nothing is applied to the triples, and the foreign identity survives the import intact.

Sub-exact matches are reported the same way, as candidates for review. The difference between exact and sub-exact is now the confidence attached to a proposal, not whether it is applied behind you.

accept_exact restores the older behaviour explicitly: exact matches are applied as IRI rewrites at import time. It defaults to false. Use it only where you control the naming in both stores, and note that a rewrite is still not recorded as an owl:sameAs — it remains the one path on which identity is not queryable and not retractable.

Accepting a proposal is knot’s job, and that is the point of the split: an accepted alignment becomes an owl:sameAs fact — visible, queryable, retractable — rather than a silent edit to someone else’s identifiers.

What travels: facts, graphs, whole repositories

A share is the portable graph artifact. Its scope may be a slice of facts, a whole graph, or a repository graph; a release .pendant.tar.gz is a deterministic archive of the same text bundle, not a SQLite database. The bundle contains a canonical RDF payload, SHACL shapes, and JSON plus PROV-O/DCAT/SPDX RDF manifests (src/share.rs: share_payload, manifest_turtle).

Import accepts a directory, archive, or HTTP(S) release artifact. URL and archive inputs are bounded, verified, and loaded into a fresh in-memory store without a user-visible download (src/share_transport.rs: read_reference, import_in_memory). Verification is unchanged because bytes arrived over the network: graph, shapes, and envelope hashes must agree before the store is opened.

For a modified copy, --since writes a parent-bound delta using the deliberately restricted, interoperable part of SPARQL 1.1 Update:

quipu share --out child --since <parent-share>
quipu import delta <parent-share> child

The delta contains only ground DELETE DATA and INSERT DATA. Its manifest names and hashes the immediate parent and the materialized result; the importer rejects a wrong parent, a changed update, any other Update operation, or a result digest mismatch (src/share_delta.rs: write_delta, materialize). These are local artifact writes and reads: they do not send a delta to a remote store or grant ROOT admission.

Pointers: graphs a share names but does not carry

A share can also name graphs it does not contain. A repository that only points at knowledge ships a share with no facts:

quipu share --output pointers --pointers pointers.toml --no-shapes
[[pointer]]
graph = "https://example.org/homelab"
kind = "endpoint"                         # a live graph, reached by federation
endpoint = "https://quipu.example.org/sparql"

[[pointer]]
graph = "https://example.org/reference"
kind = "pack"                             # a pinned pack, fetched on first use
url = "https://example.org/reference.pendant.tar.gz"
sha256 = "<64 lowercase hex digits of the archive>"

Pointers are part of the share’s identity, like the internal destination marker. Removing or editing one changes the hash, so verify_share refuses the share, and the verifier applies the producer’s own rules, so a hand-edited manifest cannot carry what quipu share would refuse:

  • an IRI graph;
  • an http(s) URL with no credentials;
  • a 64-hex sha256;
  • one pointer per graph.

A share without pointers is byte-identical to one produced before the field existed. An older quipu refuses a share that has pointers, because it recomputes a different identity; it never silently drops them.

quipu compose reports every input’s pointers, each with the share that declared it, and never follows them. Loading a pointer is the consumer’s trust decision (caboodle packs trust). Following kind = "pack" pointers on first use, and joining kind = "endpoint" pointers to the federation list, are separate steps.

The older SQLite pack commands remain an internal/archive compatibility surface:

quipu pack <graph-iri> --out <file>     # export a graph as an attachable pack
quipu pack --verify <file>              # check one before trusting it
quipu unpack <file> [--into <graph-iri>]

(src/cli_pack.rs: cmd_pack, cmd_unpack.) --verify exists so that “is this pack intact?” is answerable before you load it rather than after. SQLite packs are no longer the published repository interchange artifact.

For archives, quipu graph freeze|thaw|list is the deep freeze surface (src/cli_graph.rs), producing read-only full-history graphs. See Graph Kinds & Deep Freeze and Knowledge Packs.

Querying across stores

A FederatedProvider composes members behind one query (src/provider/mod.rs), and two properties are what make its answers honest.

Trust is declared locally. A member’s DeclaredLabel carries only trust and freshness — in the source’s words, “the axes an operator can honestly declare about a peer” — and it is declared by the local operator, never read from the member itself (src/provider/label.rs). A remote that declares nothing does not quietly pass a configured floor: an undeclared value fails a configured trust or freshness floor, which the source describes as “fail-safe at enforcement, honest at reporting”. And because durability, policy and kind cannot be honestly declared about a remote, a remote member degrades a dataset’s coverage on those axes to partial — “the conservative reading, not an omission”.

A merge is never silent. FederatedQuery returns one ProviderOutcome per member (src/provider/mod.rs), so a partial answer is reported as a partial answer instead of arriving as a short one. And when a share has diverged, quipu merge performs a genuine three-way reconnect against the common base located through parent_share (src/share_merge.rs, locate_base — which refuses outright with “incoming share has no parent_share; three-way merge has no base”). Conflicts are detected against the SHACL cardinalities (max_counts); on conflict Quipu keeps the base value, emits a DecisionRecord, and the CLI exits 2 (src/cli_pack.rs, cmd_merge). It would rather stop and tell you than pick a winner.

Federation and SPARQL SERVICE

Quipu supports SPARQL SERVICE, restricted to endpoints the operator has configured. Queries are parsed by spargebra (src/sparql/mod.rs) and the SERVICE pattern is evaluated in src/sparql/pattern.rs under the remote feature — which server implies and the shipped full build includes (Cargo.toml).

It is narrower than SPARQL 1.1’s open federation, by design:

  • Variable endpoints are refused — “variable SERVICE endpoints are refused; use a configured endpoint IRI”. A query cannot compute its target at runtime.
  • Unconfigured hosts are unreachable — with no configured remotes an endpoint fails with “SERVICE endpoint ‘…’ is unavailable: no configured remotes”, or returns the seed row under SILENT.
  • Every returned row is labelled — _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.

A pendant can declare a kind = "endpoint" pointer without carrying that remote graph. Register the local declaring pendant and its repository remote in the operator’s federation configuration:

[[quipu.federation.pendants]]
path = "/path/to/repo/knowledge/declaration.pendant"
repo_remote = "https://github.com/example/project.git"

On each SERVICE <exact-endpoint-url> use, Quipu verifies the pendant’s share identity and invokes caboodle packs trust check-endpoint <repo-remote> <url>. That command requires Tambo/Caboodle with endpoint decision support. Missing, refused, invalid or timed-out policy checks fail before network access; the error names the endpoint and pointing repository. Consent is rechecked after policy changes, including revocation. A foreign archive allow alone does not admit its endpoint. Use Caboodle’s packs trust allow-endpoint or owner policy to grant consent; Quipu keeps no second trust policy or persistent admission cache.

An admitted pointer contributes a credential-free, unlabelled endpoint only to the current SPARQL SERVICE plan. Its _provider is the declared graph IRI. It does not import triples or shapes, promote to ROOT, forward local credentials, follow redirects, infer trust labels, or enable REST whole-query fanout. The URL must match exactly, including its trailing slash: consent does not extend to an appended /query URL. Existing explicit federation remotes retain their operator-defined credentials and labels.

The server and MCP use their federation configuration. To opt into strict project/user configuration from the native CLI, use:

quipu query 'SELECT ?s WHERE { SERVICE <https://example.org/sparql> { ?s ?p ?o } }' --federation

Without the remote build feature, SERVICE remains unsupported. Local filesystem declarations and the native Caboodle policy process are not a browser API; browser pack resolution is a separate host interface.

The whole stack speaks it

A primitive is only first-class if the tools around it use it, rather than reaching past it. The same share format and the same verify → quarantine → promote discipline appear at every layer:

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 pendant archive, checksum and provenance receipt locally. --publish additionally uploads those three explicit files to the existing release. It refuses to replace existing assets. The receipt binds the archive to the release tag, source revision and native binary hash; it includes no policy content or authority address.

The hosted release job uploads native binaries first. A separately managed trusted-host trigger must then run the producer. The hosted job waits for its three assets and verifies the receipt against its own source and binary. It fails if the producer cannot deliver within the deadline. Provision and prove that trigger before enabling this workflow; a local --prepare pass alone does not establish release automation.

These receipt checks establish byte and build correspondence under the release uploader’s authority. They are not a cryptographic producer attestation and do not upgrade an import from transport to attested; those tiers are described below. Neither the catalogue nor the producer’s private logs are release assets.

What an import proves about its producer

Hash verification proves a share is intact. It says nothing about who made it. Those are different claims, and since aegis-tadzdf every import reports which one it reached.

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.

Quarantine review events

Verified share imports append receiver-local lifecycle events to the existing GET /events cursor feed: import.quarantined, import.staged, import.adopted, import.promoted, import.rejected, import.expired, and import.reopened. State and its event commit together; identical retries do not announce a transition again. Import integrity failures produce no import event. import.adopted means an earlier quarantine became eligible on re-import after local validation; it does not install any bundled shape. Promotion stays explicit.

Events contain the share ID, source, caller and separately claimed actor, unknown types, blocking reasons, shapes hash, triple counts, byte size and waiting age. Old imports without review metadata gain that metadata when re-imported; this is observation time, not a reconstructed historical arrival. Composition inspection datasets are separate from the share-import review lifecycle.

The pending registry survives pruning of the event delivery log. Review pages are bounded, ordered by share ID and return has_more and next_share_id. Pass the cursor with --after to inspect the next page. These local commands operate on the receiver selected by --db:

quipu import review pending --limit 100 --db receiver.db
quipu import review notify --age-seconds 86400 --route reviewer --db receiver.db
quipu import review rejected <share-id> --actor alice --reason 'not trusted' --db receiver.db
quipu import review expired <share-id> --actor alice --reason 'review policy expired' --db receiver.db
quipu import review reopen <share-id> --actor alice --reason 'reconsider authority' --db receiver.db

notify emits import.review_due after the explicit threshold, once per share for the current threshold/route policy. Changing that policy deliberately permits another notice. Consumers poll the existing feed and commit their offsets using POST /events/commit; they decide whether and how to send a nudge. No timer, webhook, external message, or automatic shape adoption is installed. A scheduled consumer must page through pending reviews, specify its policy and verify its own observed run and delivery. This library/CLI provides the event producer.

Rejection and expiry require an actor and reason and retain quarantine contents. They prevent re-import and promotion until an explicit reopening; eligible or already promoted packs cannot be closed through the quarantine-review command. Receiver-local review metadata stays in the receiver SQLite database across restart and event retention. It is excluded from portable packs and fact shares: a new receiver records its own decisions. Event offsets follow the existing retention/replay contract.

Aligning concepts across graphs

Sharing moves a graph. It does not move an opinion about what the graph’s concepts are.

Import a colleague’s graph and you may now hold two nodes for one thing — their bobbin-release and your Bobbin_release-artifact. Nothing in the import can decide they are the same, because nothing in the import knows. quipu align is the step that closes that gap: it proposes candidate pairs with evidence, the operator decides, and accepted pairs are recorded as owl:sameAs in a dedicated alignment graph.

Three properties hold throughout, and they are the reason the verb is split in three:

  • Nothing is applied on a score. A candidate is a proposal until a human accepts it.
  • The record lives outside both source graphs, so an imported graph stays byte-recoverable against its own share hash.
  • Rejections are remembered, so the same pair is not proposed at you again.

propose

quipu align propose <graph-a> <graph-b> [--out <set.tsv>] [--db <path>]

Enumerates both graphs and emits candidate pairs as a SSSOM mapping set — a TSV with a YAML metadata header, the standard interchange format for ontology mappings, so the artifact is diffable, shareable and readable by tools that have never heard of quipu.

Over REST the same call returns the set inline:

$ curl -s http://quipu.example/align/propose -H 'content-type: application/json' \
    -d '{"graph_a":"…/plane/crew/records","graph_b":"urn:shuttle:graph:identity"}'
{
  "candidates": 0,
  "set_aside": 0,
  "summary": "0 candidate(s); 0 entity(ies) set aside as ambiguous",
  "expected_version": "sha256:ce548369e02ee10e…",
  "set_tsv": "#curie_map:\n…"
}

The summary reports both numbers on purpose. set_aside counts entities excluded because they carry more than one label — alignment never guesses which label is the one to match on. A caller that prints only the candidate count hides a graph it could not read.

Reading a zero

0 candidates has more than one cause, and they need different actions:

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/korimako",
      "attribute": "http://example.org/cpuCores", "value": 8
    }
  ],
  "next_tx": 43,
  "watermark_tx": 43,
  "watermark_timestamp": "2026-04-03",
  "capture": "new_values"
}

Pass next_tx back as the cursor (--from / since). On an empty page it stays put, so polling is a fixpoint, not a rewind. Ref values resolve to {"ref": "<iri>"} so a consumer can tell an edge from a string that happens to look like one; bytes report a length, not a body — the feed is a notification surface, and blob bodies belong on the entity read path.

First consumers

The intended first consumer is incremental index/embedding maintenance (bobbin): re-embed exactly the entities whose facts changed rather than rescanning, using per-entity ordering to apply changes safely. The event-push delivery worker’s semantic events remain the right surface for workflow triggers; the change feed is for mirrors.

Committed feed streams

GET /events/stream replays the existing event log. GET /changes/stream replays complete transaction pages from the fact change feed, including named graph writes. Both are read-only SSE endpoints. Changes use transaction IDs; events use event offsets. Never submit a transaction ID to /events/commit.

Pass a nonnegative since, or Last-Event-ID. If both are supplied they must agree. Each quipu.events or quipu.changes frame contains the corresponding ordinary feed response as JSON; its SSE ID is the delivered page cursor. Delivery does not acknowledge a consumer. Persist the applied data and cursor atomically, and reconnect from that durable cursor after any failure.

SQLite commit hooks supply wake hints only. The stream crosses the writer barrier and reads the committed feed before delivery; a rolled-back savepoint or failed commit does not authorize an event. A watch channel coalesces hints; read-after-subscribe prevents a missed commit during subscription. An hourly backstop also checks for external writers. Fifteen-second heartbeat comments perform no database reads.

There are at most 64 active streams and one transaction/event per read page. Frames larger than 1 MiB terminate with an error event without acknowledging a cursor. This is an output budget, not a bound on the memory needed to read one large transaction. Consumers must refuse incomplete frames and retain the last applied cursor on errors, with bounded reconnect backoff. Event filters use types and group; transaction filters use graph. Combining unrelated filter kinds is refused.

Streaming is a source capability. Enabling a consumer, replaying production history, changing subscriptions or claiming scheduled acceptance requires its separate operational decision and measurements.

RDF Data Model

Implementation status (2026-07-23, kelly): ✅ Implemented (a stale Language-Tags section was corrected in this commit). The capability is shipped: ingest_rdf / export_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) ...

An ungrouped, non-distinct COUNT(*) or COUNT of a bound variable over one triple pattern can use a scalar SQL count. This avoids retaining a binding for every matching triple. Composed stores canonicalize subject, predicate and reference-object aliases before counting, so duplicate assertions across attached dictionaries still count once. The query’s graph and temporal scope remain in force.

During the scalar statement, file mappings are disabled and an oversized main page-cache budget is reduced to 8 MiB. Each provider’s mapping policy and the main cache policy are restored afterward, including on errors. Smaller cache budgets are preserved. Query deadlines still apply; unsupported aggregate forms and joins retain their existing complexity limits.

FILTER Expressions

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
}

Validation scope and pending post-state

The gates have different scopes. Do not describe all write validation as a whole-graph pending-post-state check:

  • quipu knot --shapes and validate_shapes validate the submitted data.
  • Contextual write-time SHACL starts with that payload verdict. If it fails, a bounded second pass adds referenced types and required properties already stored in the destination graph and ROOT. It can remove payload violations; it cannot introduce new violations found only in that context. A conforming payload takes the fast path. This preserves incremental-write compatibility and is not whole-graph conformance certification.
  • Write-savepoint admission also checks the stored values of touched focuses for maximum count, datatype, node kind, class, pattern, length, numeric bounds, enumeration, language, and nested-node constraints. This catches a value written before the type or edge that targets it. Referenced-node context stays in the destination graph (ROOT contributes types only). Scalar constraints do not walk references; class checks add only referenced types, and nested-node checks follow declared direct-predicate node paths, not arbitrary links. Optional context has fact/node work budgets, not graph-size admission limits. Exhaustion discards partial value context and falls back to the pre-existing payload/maxCount path; it does not reject a valid write or certify its stored value constraints. Existing maxCount context limits still apply. These budgets are not measurements of the largest production graph. It does not turn context nodes into additional write-owned focuses. Changes to a child recheck its bounded incoming dependents on declared direct-predicate sh:node paths. Other incoming dependencies are not a whole-graph revalidation guarantee. This does not add a new minimum-count requirement to an incomplete incremental write. Existing deactivation and unsupported cardinality path behavior remain unchanged.
  • With governance.enforce_on_write enabled, an applicable action-boundary policy evaluates its claim for touched targets against the transaction’s pending post-state. A parent can require evidence on its linked child in the same transaction or already committed in the store. Evidence promised in a later transaction does not satisfy the current write: refusal rolls back the parent and link. This guarantee is for applicable touched targets, not automatic revalidation of every incoming dependency of a changed node.

tests/pending_poststate.rs pins the distinction with positive and negative controls. A linked child’s missing required name fails SHACL when the child is typed in the submitted payload. If its incomplete type exists only in store context, the contextual repair may accept a parent reference: the new child violation is outside the original payload ceiling. Conversely, a policy requiring linked evidence accepts a complete same-transaction write and refuses the missing-evidence write before that evidence exists.

just test --locked --test pending_poststate

Use the policy guarantee for transactional cross-entity requirements. Use a separate full-store or explicitly scoped validation for stored SHACL conformance; incremental acceptance alone does not establish it. Neither this documentation nor the tests enable a gate or change its runtime policy.

Agent-Friendly Feedback

When validation fails, the ValidationFeedback struct provides structured details that agents can act on:

#![allow(unused)]
fn main() {
if !feedback.conforms {
    for issue in &feedback.results {
        println!("Severity: {}", issue.severity);
        println!("Focus node: {}", issue.focus_node);
        println!("Component: {}", issue.component);
        if let Some(path) = &issue.path {
            println!("Path: {}", path);
        }
        if let Some(msg) = &issue.message {
            println!("Message: {}", msg);
        }
    }
}
}

This is the core of Quipu’s “strict but helpful” philosophy – validation doesn’t just reject, it tells the agent exactly what’s wrong and where.

Supported Constraints

Through rudof, Quipu supports the full SHACL Core specification:

  • Cardinality: sh:minCount, sh:maxCount
  • Value type: sh:datatype, sh:class, sh:nodeKind
  • Value range: sh:minInclusive, sh:maxInclusive, sh:minExclusive, sh:maxExclusive
  • String: sh:minLength, sh:maxLength, sh:pattern
  • Property pair: sh:equals, sh:disjoint, sh:lessThan
  • Logical: sh:and, sh:or, sh:not, sh:xone
  • Shape-based: sh:node, sh:property, sh:qualifiedValueShape
  • Other: sh:closed, sh:ignoredProperties, sh:hasValue, sh:in

Reusable Validators

Create a Validator once and validate multiple data payloads:

#![allow(unused)]
fn main() {
let validator = Validator::from_turtle(shapes)?;

// Validate multiple payloads
let result1 = validator.validate(data1.as_bytes())?;
let result2 = validator.validate(data2.as_bytes())?;

// Or use the convenience reject method
validator.validate_or_reject(data.as_bytes())?;
}

Code Entity Shapes

Quipu ships SHACL shapes for Bobbin’s code entity model in shapes/code-entities.ttl. These enforce the knowledge-aware bundles ontology at write time:

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": "korimako-rebuild-2026-03-29",
  "episode_body": "Korimako was rebuilt on kotare after disk failure",
  "source": "aegis/crew/ellie",
  "group_id": "aegis-ontology",
  "nodes": [
    {
      "name": "korimako",
      "type": "ProxmoxNode",
      "description": "Proxmox host, rebuilt after failure",
      "properties": { "hostname": "korimako.example", "status": "recovered" }
    },
    {
      "name": "kotare",
      "type": "ProxmoxNode",
      "description": "Primary Proxmox host"
    }
  ],
  "edges": [
    {
      "source": "korimako",
      "target": "kotare",
      "relation": "rebuilt_on"
    }
  ]
}

Fields

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/korimako-rebuild-2026-03-29>
}

The episode_provenance() function returns all entities and their facts for a given episode name.

Description Revisions

A current entity carries one rdfs:comment. When an ordinary (additive) episode supplies a different description for an existing node, the superseded text is not silently discarded and not left to pile up on the entity: in the same transaction, each superseded comment is retracted from the entity and re-asserted on the episode entity whose prov:wasGeneratedBy assertion shared its original transaction (src/episode/descriptions.rs). Last write wins on the entity; the history stays lossless because every prior description remains readable on the episode that produced it.

Two edges of that rule:

  • Unattributable history refuses the whole write. If a current comment has no same-transaction prov: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; built-in SQLite scores the bounded SPARQL candidate set before top-K
  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

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

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/kotare", "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 korimako goes down” — services running on korimako plus anything that depends on them:

PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT DISTINCT ?affected ?label ?reason
WHERE {
  {
    ?affected ont:runsOn <http://aegis.gastown.local/ontology/korimako> .
    BIND("runs on korimako" AS ?reason)
  }
  UNION
  {
    ?affected ont:dependsOn+/ont:runsOn <http://aegis.gastown.local/ontology/korimako> .
    BIND("depends on service on korimako" AS ?reason)
  }
  ?affected rdfs:label ?label .
}
?label?reason
traefikruns on korimako
piholeruns on korimako
grafanaruns on korimako

The BIND clause annotates each row with the reason it appears.

Impact Count by Host

Which hosts are single points of failure?

PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT ?hostLabel (COUNT(DISTINCT ?affected) AS ?blastRadius)
WHERE {
  ?host a ont:Host .
  ?host rdfs:label ?hostLabel .
  {
    ?affected ont:runsOn ?host .
  }
  UNION
  {
    ?affected ont:dependsOn+/ont:runsOn ?host .
  }
}
GROUP BY ?hostLabel
ORDER BY DESC(?blastRadius)

Graph Projection for Visual Analysis

For more complex analysis, project the dependency graph and run algorithms:

curl -s localhost:3030/project -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "predicate_filter": "http://aegis.gastown.local/ontology/dependsOn"
  }'

The projection returns nodes and edges suitable for:

  • In-degree centrality: Most-depended-upon services
  • Connected components: Independent failure domains
  • Shortest path: How are two services connected?

In-Degree: Most Critical Services

{
  "tool": "quipu_project",
  "input": {
    "predicate_filter": "http://aegis.gastown.local/ontology/dependsOn"
  }
}

Services with the highest in-degree are your most critical dependencies.

Materialised Impact via the Reasoner

The SPARQL property path approach above re-derives transitive chains at query time. For graphs that change infrequently but are queried often, you can materialise the transitive closure using the reasoner — derived facts sit in the store alongside raw facts and are queryable without property paths.

Set Up Rules

Create impact-rules.ttl:

@prefix rule:  <http://quipu.local/rule#> .
@prefix ex:    <http://aegis.gastown.local/rules/> .

ex:impact a rule:RuleSet ;
    rule:defaultPrefix "http://aegis.gastown.local/ontology/" .

ex:depends_on_transitive a rule:Rule ;
    rule:id "depends_on_transitive" ;
    rule:head "dependsOn(?a, ?c)" ;
    rule:body "dependsOn(?a, ?b), dependsOn(?b, ?c)" .

ex:runs_on_transitive a rule:Rule ;
    rule:id "runs_on_transitive" ;
    rule:head "runsOn(?svc, ?host)" ;
    rule:body "runsOn(?svc, ?container), runsOn(?container, ?host)" .

Run the Reasoner

quipu reason --rules impact-rules.ttl --db homelab.db

Now transitive edges are first-class facts. The blast radius query simplifies to a flat lookup:

PREFIX ont: <http://aegis.gastown.local/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT DISTINCT ?affected ?label
WHERE {
  ?affected ont:dependsOn <http://aegis.gastown.local/ontology/postgres> .
  ?affected rdfs:label ?label .
}

No + operator, no property paths — the reasoner has already closed the chain. This is faster for repeated queries and simpler for agents to consume (they don’t need to understand property path syntax).

Keep It Fresh

Enable reactive evaluation so derived facts update automatically when base facts change:

quipu reason --reactive --rules impact-rules.ttl --db homelab.db

Now every transact() that touches dependsOn or runsOn triggers re-derivation of the affected transitive edges.

Property Paths vs Reasoner: When to Use Which

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:korimako a hw:Host ; hw:hostname \"korimako.example\" .",
    "timestamp": "2026-04-01",
    "actor": "bulk-import"
  }'

Episodes (Agent Observations)

Episodes are the structured write path for agents. Each episode is a transaction with nodes, edges, and provenance:

curl -s localhost:3030/episode -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "name": "discovery-run-42",
    "source": "prometheus-sd",
    "group_id": "monitoring",
    "episode_body": "Periodic service discovery sweep",
    "nodes": [
      {"name": "redis", "type": "Service", "description": "Cache layer"},
      {"name": "memcached", "type": "Service"}
    ],
    "edges": [
      {"source": "redis", "target": "korimako", "relation": "runsOn"},
      {"source": "memcached", "target": "palau", "relation": "runsOn"}
    ]
  }'

Episode Fields

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. 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:korimako a hw:Host .",
    "shapes": "@prefix sh: ... optional validation ..."
  }
}

Ingest episode

{
  "tool": "quipu_episode",
  "input": {
    "name": "agent-observation",
    "source": "my-agent",
    "nodes": [{"name": "x", "type": "Thing"}],
    "edges": []
  }
}

Retraction (Removing Facts)

Retract all facts about an entity:

quipu retract "http://example.org/homelab/old-host" --db knowledge.db

Retract a specific predicate:

quipu retract "http://example.org/homelab/korimako" \
  --predicate "http://example.org/homelab/cpuCores" --db knowledge.db

Via REST:

curl -s localhost:3030/retract -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "entity": "http://example.org/homelab/old-host",
    "timestamp": "2026-04-04",
    "actor": "cleanup-agent"
  }'

Retractions don’t delete data — they close the valid-time window. The original facts remain in the log for audit and time-travel.

Bulk loads and migrations: share → import → promote

Use this path whenever you have a lot of data at once:

  • a first load;
  • a backfill;
  • moving a dataset from one store to another;
  • migrating a board or a catalogue.

Do not use a loop of thousands of quipu knot calls, /knot requests or SPARQL Update writes for this.

situationpath
live, incremental facts arriving one at a timequipu knot, /knot, /episode
a bulk or one-time load, or a migrationshare → import → promote (this page)

Why

  • Validated before it touches your graph. quipu import stages the whole dataset in its own staging graph and checks it against your shapes there. Non-conforming data is quarantined, and import never writes ROOT.
  • One transaction. quipu import promote admits the staged data into ROOT in a single transaction. Thousands of small writes give you thousands of transactions, each paying the write path’s per-write cost, and a half-finished loop leaves a half-loaded graph.
  • One rollback. A promotion is one transaction, so undoing it is one step (see Rollback).
  • Integrity. The share carries canonical payload hashes, and import refuses a share whose bytes do not match its manifest.

The three steps

1. Build a share from the data

Load the data into a scratch store, with the shapes you will validate against, and write a share from it:

quipu shapes load items shapes.ttl --db scratch.db
quipu knot data.ttl --shapes shapes.ttl --db scratch.db
quipu share --output share1 --db scratch.db --destination internal

share1/ now holds export.nt (the canonical facts), shapes.ttl and the manifests. --destination internal is for a migration inside your own infrastructure. Without it, quipu share runs the outward identifier scrub, which needs an identifier-policy catalogue.

2. Import: stage and validate

The target validates the data against its own shapes, not the ones the share carries, so load the same shape set there first:

quipu shapes load items shapes.ttl --db target.db
quipu import share1 --db target.db --actor you --destination internal

The JSON result is the review:

{
  "outcome": "staged",
  "share_id": "sha256:1a3581b0…",
  "staging_graph": "urn:quipu:import:staging:1a3581b0…",
  "triples": { "accepted": 4000, "quarantined": 0 },
  "validation": { "conforms": true, "blocking": false },
  "promotion": { "eligible": true, "blockers": [] }
}
  • ROOT is unchanged at this point.
  • A share stamped internal must be imported with --destination internal too, or import refuses it.
  • If the data breaks your shapes, the result says so, and nothing can be promoted:
{ "outcome": "quarantined",
  "triples": { "accepted": 0, "quarantined": 5 },
  "promotion": { "eligible": false, "blockers": ["shacl_nonconforming"] } }
  • If the target has no shapes for a class the share uses, every typed triple is quarantined as off-vocabulary. The data is neither dropped nor admitted. Load the shapes and import again:
{ "outcome": "quarantined",
  "triples": { "accepted": 0, "quarantined": 4000 },
  "validation": { "off_vocabulary": ["http://example.org/Item"] },
  "promotion": { "eligible": false, "blockers": ["off_vocabulary"] } }

3. Promote: admit it into ROOT

quipu import promote sha256:1a3581b0… --db target.db --actor you
{ "outcome": "promoted", "tx_id": 3, "triples": 4000, "suppressed_retractions": 0 }

Read back by count, not by the success message:

quipu read 'SELECT (COUNT(?s) AS ?n) WHERE { ?s a <http://example.org/Item> }' --db target.db

Promoting the same share again re-asserts the same facts and adds no duplicates.

Rollback

Before promoting, note the store’s current transaction. To undo a promotion, fork ROOT as of the transaction just before it, check the diff, and promote the fork:

quipu fork 2 --name pre-import --db target.db         # 2 = the tx before the promotion
quipu fork diff main pre-import --db target.db         # expect -<the promoted triples>
quipu fork promote pre-import --db target.db           # one transaction retracts them

Read the diff first. It is the safety check: the fork also undoes any write made after that transaction. History is kept, so quipu read … --tx 2 still shows the pre-promotion state.

On a running server

The same steps exist over HTTP: POST /import stages a share and POST /import/promote admits it. See the REST API.

Measured

These are the numbers from the example above, measured with a release build of quipu 0.11.0: 2,000 entities and 4,000 triples moved from a scratch store into a target that already held its own data and had the same shapes loaded.

stepresult
import, target without the shapesquarantined: 4,000 triples, blocker off_vocabulary
import, target with the shapesstaged, 4,000 accepted, 0 quarantined; ROOT unchanged (1 entity)
import promoteone transaction (tx_id 3), 4,000 triples, 0.12 s
read-back2,001 entities (1 existing + 2,000 promoted)
re-promotesame 4,002 current facts, no duplicates
fork rollbackretracted 4000 in one transaction; back to 1 entity

See also: CLI: sharing, import and legacy packs and Sharing & Federation.

Public benchmarks

Read this before quoting any number from this section. Every benchmark class below is scored separately and is never combined into a single figure. A blended score would hide exactly the classes that are unimplemented, unrun, or measuring somebody else’s system. Unrun classes are published as NOT RUN rather than omitted — a page that lists only what went well flatters by silence.

This page is an index, not a ledger. Numbers produced by Quipu’s own runners are published here and re-derivable from this repository. Numbers produced elsewhere in the stack stay in the repository that produced them and are linked with the commit that published them — they are not copied into a table here, because a copied number rots silently while its source moves on.

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
Separate-process memorymemory residency by phase, Quipu SQLite versus Oxigraph RocksDBthis repository, published evidencepublished, limited to the stated memory observations
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.

The separate-process memory comparison below is published, but does not satisfy this query-latency row. The diagnostic checkpoint above is likewise not a substitute: it has no second engine in it at all.

Note also that the in-process oxi_compare harness in this repository cannot fill this row. It deliberately shares the parser and data model between arms, so what it measures is storage and evaluation, never engine versus engine — and with one process there is no separate resident set to compare.

Separate-process memory comparison

The September 16 run-4 evidence and phase table are published with machine-readable results. Each engine ran in its own process on the pinned WatDiv artifact. Read the whole phase table, including the anonymous/file-backed split and the query-completion gap: raw RSS favours different engines at different phases, so no overall engine ranking follows from these observations.

This is one dataset, one machine, one run per engine, with concurrency one and four. Timing figures in the receipts are not admissible as a comparison because the host was shared; store-device effects were not independently controlled. These are historical memory observations, not a measurement of the current release. The WatDiv/LUBM query-latency comparison remains NOT RUN.

The rules this section is held to

Inherited from the benchmark programme, and stated here so a future page cannot quietly drop one:

  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 does not pass every approved W3C SPARQL 1.1 Query, Update, Protocol and Results test at rdf-tests 369a90d: query syntax 86/86, query evaluation 167/168, update 93/93, protocol 34/34, result format 10/10, SPARQL 1.0 query 199/242. Exceptions, each named below: federated query (SERVICE) passes 6/7, with 1 refused by policy (variable endpoints); entailment regimes are scored separately (35/70 passed, 0 failing, 35 declared non-goals); SHACL-SPARQL, OWL, RIF and D entailment are declared non-goals. What these counts are. Working Group–approved tests only. The query-evaluation manifests list 225 tests, and the 168 approved ones are scored; the 57 Proposed or unclassified are not run. The update-syntax suites are not run yet. The SPARQL 1.0 query tests are scored separately. The SPARQL 1.1 manifests hold what 1.1 added; the 1.0 tests (rdf-tests sparql/sparql10) also bear on SPARQL 1.1 Query conformance. Quipu passes 199/242 of the approved ones, with 33 failing, 0 errors and 10 not comparable; see SPARQL 1.0 query tests. This score is fitted to this suite. Quipu’s failures here were found by running this suite and fixed against it, case by case, so a perfect score is partly a record of that work rather than an independent sample. Other stores measured with the same harness were not tuned to it. Every class below is scored separately and is never combined into a single compliance percentage, because a blended figure would hide exactly the classes that are not implemented at all.

These results come from the W3C RDF Tests suite at a pinned revision, run against throwaway stores by a checked-in runner. You can re-derive every number on this page yourself — the commands are below.

For what Quipu does with a graph once it is correct — handing it to another store, and composing another store’s without trusting it — see Sharing & Federation. That page states its own claim boundary for SERVICE, including the configured-endpoint policy deviation scored below.

What was measured

FieldValue
W3C RDF Tests revision369a90d1a60c021b746df2e411da0ff36258a758
Quipu revision (evaluation)8fb57af0472af4bb8038247ccb54f9a4b2afe689
Quipu revision (syntax)8fb57af0472af4bb8038247ccb54f9a4b2afe689
Quipu versionquipu 0.15.0
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 evaluation167100168
federated query (SERVICE)60017
result format1000010
protocol3400034
update9300093
entailment35003570
all classes4311036468

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.15.0167/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
Turtle313/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.

Known deviation: literal lexical identity

The current loader canonicalizes integer and double literals: for example, "01"^^xsd:integer is stored and exported as "1"^^xsd:integer. It also converts an ill-typed boolean such as "z"^^xsd:boolean to false, and rejects some ill-typed numeric literals during loading. These are known deviations, not the intended storage contract; affected cases remain failures.

The chosen contract is to preserve lexical form and datatype as RDF term identity, including ill-typed literals, and derive numeric values separately. Distinct terms such as "01" and "1" with the same integer datatype must coexist even though their numeric values compare equal. This follows RDF 1.1 literal term equality. An expression’s effective boolean value must not replace the stored term.

The preservation fix is not implemented yet. Existing data cannot recover discarded spellings; a future compatibility plan must preserve legacy term identity, exact retraction and history without inventing missing lexical forms.

Ledgers: rdf11-syntax.json and rdf12-syntax.json.

SPARQL 1.0 query tests

The approved W3C SPARQL 1.0 query-evaluation tests (sparql/sparql10) at the same rdf-tests revision (369a90d1), run by the same runner: 199/242 pass.

Most SPARQL 1.0 answers are RDF result-set graphs (rs:ResultSet). They are read with rdflib, pinned, with literal normalisation off, so "01"^^xsd:integer stays 01. Quipu never reads its own expected answers. Where the answer numbers its solutions (rs:index), order is compared, not just the multiset. The unsupported cases have RDF/XML answers, which the runner does not read.

FamilyPassedFailedErrorUnsupportedCases
expr-builtin12120024
open-world5120017
sort3001013
distinct650011
expr-equals1020012
reduced11002
regex31004
algebra1400014
ask40004
basic2700027
bnode-coreference10001
boolean-effective-value70007
bound10001
cast70007
construct50005
dataset1200012
expr-ops70007
graph1100011
i18n50005
optional70007
optional-filter40004
solution-seq1300013
triple-match40004
type-promotion3000030

Some failures are the lexical-form design choice described above (a number is stored by value, so "01" reads back as 1); others are engine defects being fixed. Every case, with its diagnostic, is in sparql10-evaluation.json.

Named SPARQL 1.0 deviations

These are the current ledger’s non-passing cases, including loader errors and runner limitations. Listing a case does not claim its engine defect is fixed.

W3C testStatusDiagnostic
:open-eq-03failedactual result differs from expected multiset
:open-eq-04failedactual result differs from expected multiset
:open-eq-06failedactual result differs from expected multiset
:open-eq-07failedactual result differs from expected multiset
:open-eq-08failedactual result differs from expected multiset
:open-eq-10failedactual result differs from expected multiset
:open-eq-11failedactual result differs from expected multiset
:open-eq-12failedactual result differs from expected multiset
:date-2failedactual result differs from expected multiset
:date-3failedactual result differs from expected multiset
:open-cmp-01failedactual result differs from expected multiset
:open-cmp-02failedactual result differs from expected multiset
:dawg-str-2failedactual result differs from expected multiset
:dawg-isBlank-1failedactual result differs from expected multiset
:dawg-datatype-2failedactual result differs from expected multiset
:dawg-lang-1failedactual result differs from expected multiset
:dawg-lang-2failedactual result differs from expected multiset
:dawg-isURI-1failedactual result differs from expected multiset
:dawg-isIRI-1failedactual result differs from expected multiset
:dawg-langMatches-4failedactual result differs from expected multiset
:lang-case-insensitive-eqfailedactual result differs from expected multiset
:sameTerm-simplefailedactual result differs from expected multiset
:sameTerm-eqfailedactual result differs from expected multiset
:sameTerm-not-eqfailedactual result differs from expected multiset
:eq-2-1failedactual result differs from expected multiset
:eq-2-2failedactual result differs from expected multiset
:dawg-regex-003failedactual result differs from expected multiset
:no-distinct-1failedactual result differs from expected multiset
:distinct-1failedactual result differs from expected multiset
:no-distinct-2failedactual result differs from expected multiset
:no-distinct-9failedactual result differs from expected multiset
:distinct-9failedactual result differs from expected multiset
:dawg-sort-1unsupported
:dawg-sort-2unsupported
:dawg-sort-3unsupported
:dawg-sort-4unsupported
:dawg-sort-5unsupported
:dawg-sort-6unsupported
:dawg-sort-7unsupported
:dawg-sort-8unsupported
:dawg-sort-9unsupported
:dawg-sort-10unsupported
:reduced-2failedactual result differs from expected multiset

SPARQL 1.2 query tests

The W3C SPARQL 1.2 query tests (sparql/sparql12) at the same rdf-tests revision (369a90d1). No SPARQL 1.2 case is Working Group–approved yet; every one is counted anyway. 3 of 269 pass, and 263 are not run.

Most of SPARQL 1.2 needs grammar or terms Quipu does not have: triple terms, the VERSION declaration, base direction, new codepoint escapes. Those cases are listed and never run, because a parser that rejects all SPARQL 1.2 input would “pass” every negative case. The few cases that need nothing new are run by the same runner as everything above.

PartPassedFailedNot runCasesWhy not run
codepoint-escapes001414SPARQL 1.2 codepoint-escape grammar is not implemented; its negative cases would pass by rejection
eval-triple-terms004141needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk)
expression0145needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk)
grouping0202run
lang-basedir001111RDF 1.2 base direction (rdf:dirLangString) is not implemented
rdf113003run
syntax0066not wired: the syntax runner scores the 1.1 syntax manifest only
syntax-triple-terms-negative006565needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk)
syntax-triple-terms-positive00113113needs RDF 1.2 triple terms: Quipu is built without rdf-12 (aegis-6l8hkk)
version0099the SPARQL 1.2 VERSION declaration is not implemented

Ledger: sparql12.json.

Query evaluation, by feature family

The family is the pinned suite’s own directory for each manifest, so this grouping is re-derivable by anyone holding the same suite revision. It is the map of where the work is: the largest failing families are where conformance moves the most per fix.

FamilyPassedFailedErrorUnsupportedCases
functions5700057
property-path2400024
aggregates2110022
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.

aggregates — 1 of 22 not passing
TestNameStatusReason
:agg-min-02MIN with GROUP BYfailedactual result differs from expected multiset

Federated query (SERVICE)

Quipu passes 6/7 approved W3C Basic Federated Query cases. SERVICE is a query-planned subquery path using the same declarations and labels as RemoteProvider; it is separate from GraphProvider whole-query fanout and is not open federation. The variable-endpoint case is a deliberate policy deviation because query data cannot widen the configured remote allowlist.

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 on whole graphs; the write path is measured below.

Pinned W3C Data Shapes revision: 9c863967bceaef1a87c24e4dd761eda763823120. The pinned manifest exposes 120 approved cases (98 Core + 22 SHACL-SPARQL). nodeValidator-001.ttl exists in the checkout but is not manifest-reachable and is not scored.

SHACL classPassedFailedErrorUnsupportedCases
core-complex-misc70007
core-node3200032
core-property3800038
core-path1300013
core-targets70007
core-validation-reports10001
shacl-sparql0002222

Write-path conformance

The same 98 Core cases are also written through the real /knot handler, whole and split across writes (subject halves, one write per subject, one write per triple, each in both orders). 90/98 agree with the W3C verdict under every split; 7 diverge under at least one; 1 could not be judged under at least one split (the write gate answered cannot verify instead of a SHACL verdict). A divergence is a case whose final graph violates (or conforms) while the write gate accepted (or refused) every write. These are known defects, listed in the ledger with the splits that expose them; the CI gate fails on any new divergence and on any entry that no longer diverges, so this number can only fall.

Constraint componentCases using itAgreeDivergeUnjudged
sh:and4310
sh:class191360
sh:closed4310
sh:datatype282710
sh:disjoint3210
sh:equals3210
sh:hasValue5410
sh:in5320
sh:languageIn3210
sh:lessThan3210
sh:lessThanOrEquals2110
sh:maxCount131111
sh:maxExclusive3210
sh:maxInclusive3210
sh:maxLength3210
sh:minCount201910
sh:minExclusive4310
sh:minInclusive4310
sh:minLength3210
sh:node5410
sh:nodeKind6411
sh:not4310
sh:or5410
sh:pattern6510
sh:property555131
sh:qualifiedValueShape4310
sh:uniqueLang3210
sh:xone3210

Ledger: shacl-write-path.json.

Entailment-regime commitments

2 of 6 regimes are goals (RDF, RDFS): 35/35 of their cases pass. The remaining 4 are deliberate non-goals. Ledger re-derived 2026-10-21T15:07:06Z by CI run, from quipu 8fb57af0472a. Local RDFS and OWL extensions beyond a goal regime are not standards-regime claims.

Do not read the goal-regime fraction as “nearly done”. The two numbers have different characters. Most RDF-regime cases are bind* tests answerable under simple entailment, so they pass without any additional inference — a high RDF score is not evidence of an entailment engine. The RDFS score DOES reflect one: an RDFS closure (rdfs2/3/5/7/9/11) is materialised into the graph’s companion inferred graph and composed into the default graph when the regime is in force, which is what a query 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

If the wait budget expires while correctness is queued, running, absent or unreadable, the waiter reports DEFERRED, emits ready=false, and skips release-plz. This is neither a code failure nor permission to publish. A genuine completed non-success check still fails the waiter. release-ci-ready.yml observes both CI and Release completion, then rearms only an initially deferred Release whose exact SHA is still main and whose genuine correctness check is terminal. It retries once, using the existing gate again. Superseded commits, pull requests, foreign repositories and already retried runs are never rearmed. If that one retry remains unready, inspect the recorded reason before an operator reruns it; no deadline or completion event substitutes for a successful check.

Conventional Commits

Commit messages drive changelog categories:

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

Related work

Two recent preprints address evidence and maintenance in agent-accessible knowledge graphs. The descriptions below are limited to their abstracts; their evaluations are not benchmarks of Quipu.

Official arXiv BibTeX: Keep Your Receipts and Plant Memory.

Docs map

Every document in this repository that is not a page of this book, with one line on what it is. Design notes record how a feature was decided and may describe an earlier state; the book pages are the current description.

Design notes

Papers

Release and operations

Direct workflow processing

Direct workflows use the existing Workflow, WorkflowDefinition, WorkflowStep, WorkflowRun and TransitionEvent entities. A definition is one entity typed as both Workflow and WorkflowDefinition; a declared step is typed as both Step and WorkflowStep. Labels retain the existing shapes. No local journal is the authority for this processing path.

enforce_workflows in [quipu.governance] defaults to false. Enabling it is a separate deployment decision: legacy definitions and active runs must be migrated before enrollment. A client must check GET /workflow/capabilities and refuse writes unless processing is enabled and signature version 2 is supported. The read exposes processing capability, not keys or configuration. Source availability alone does not establish an enabled guard.

In the same graph, a definition declares initialState, terminalState, and hasStep. Each step declares one fromState and toState. The combination of step name and starting state must be unambiguous. A terminal state cannot have an outgoing step. Definitions and steps cannot change their committed process; use a new identity for a revised plan. Existing shapes keep optional fields for legacy records, while enrolled processing requires them.

A run starts at the definition’s initial state with currentRevision zero. An advance must atomically write a new signed event and replace both currentState and currentRevision. Its fromRevision must equal the current revision, and the resulting run revision must be exactly one higher. The event must name a step declared by that run’s definition and match its states. Committed events are immutable. Steps, definitions and runs from another graph cannot substitute for those in the authoritative graph. Identity registrations retain their existing authority and key-rotation rules.

For enrolled processing, signatures bind the expected revision:

shuttle-transition-v2|{run}|{step}|{from}|{to}|{at}|{agent}|{from_revision}

transitionVersion is integer 2. Revision values use canonical nonnegative decimal encoding. The revision prevents an old advance being reused when a loop returns to the same state. Legacy v1 signatures remain supported outside enrolled processing; the v1 message format does not change. Both client and server must adopt v2 before enrollment.

Use SPARQL Update on /update: its WHERE clause compares the current state and revision; its DELETE/INSERT replaces them together with the signed event. The same native transaction guard covers other write surfaces. A refused operation leaves the run and event facts unchanged. A retry whose WHERE clause no longer matches is a no-op. After an indeterminate response, read the exact event identity and run revision, validate the immutable payload, and do not mint a new event merely because the response was lost.

The native controls include signatures, illegal steps, missing atomic state changes, exact replay, graph isolation, plan/event immutability, initial state, and a stale advance after a state cycle. Server controls exercise both normal and full-copy SPARQL Update paths, concurrent compare-and-set, lost-response replay, and the capability response. These are source tests, not installed workflow, priority-consumer or scheduled notification acceptance.

Before activation, adopt reviewed source and the shared property catalogue, verify the installed guard, migrate active workflows and clients, and retain a named rollback. fromState and toState are shared by declared steps and observed events, so their catalogue must not materialize a Step as a TransitionEvent. Do not enable processing while legacy writers still mutate a run or batch-export v1 transitions as its primary state.