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

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.