REST API
Request logs distinguish caller-declared host and agent from the receiver’s
observed network hop. Direct HTTP requests include transport_peer and
transport_peer_source: "socket"; unavailable transport context is null with
source "unavailable". A proxy’s socket identifies that proxy, not the originating
agent. Internal MCP or stdio dispatch may have no socket context. Forwarded
headers do not establish this observation, and neither a socket address nor a
declared agent authenticates a writer. Credentials and request bodies are not
included in these fields. Existing authorization outcomes remain separate.
Write-route logs also include the bounded declaration class in
request_provenance and missing required field names in
request_provenance_missing. These describe requests, including refusals;
they do not assert that a transaction committed and must not replace committed
write-coverage metrics. Model and session values are not added to logs.
The quipu-server binary exposes all Quipu operations over HTTP (Axum).
Versioned file-memory RPCs
These JSON operations are disabled by default. To use them, a deployment must
register the graph and configure quipu.memory_files.enabled, graph,
deployment and project. They create neither graphs nor credentials. An
owner is the exact deployment, project and crew IRI tuple; labels and caller
actor fields do not authenticate it.
| Endpoint | Request and response | Identity |
|---|---|---|
POST /memory/read | Owner tuple; current manifest or null, with explicit repository-owner coverage | Inherited deployment read boundary, including cross-crew reads |
POST /memory/export | Owner tuple; complete manifest and identified source revisions | Same inherited read boundary |
POST /memory/projection | Owner tuple; export plus authenticated_owner | Existing verified named principal must equal the owner |
POST /memory/receipt | Exact original commit intent; original receipt or null | Matching named principal |
POST /memory/commit | One file intent; verified receipt with file and snapshot revisions | Matching named principal and normal writable-server policy |
Commit intents include owner, relative path, UTF-8 text, expected snapshot and file revisions, idempotency key, harness and opaque account label. Null text is an explicit tombstone. Receipt lookup precedes stale-parent rejection: replay the exact intent and key after an indeterminate result, rather than generating a new write. Stale new intents return HTTP 409; denied ownership returns 403. Disabled or invalid configuration refuses instead of accepting memory writes.
Requests are bounded to 1 MiB; a file is at most 256 KiB and an owner snapshot contains at most 128 entries and 4 MiB of live text. Existing graph, vocabulary, SHACL and write-policy checks remain in force. Projection reads data; it does not select or overwrite a native memory directory. See versioned file memory for the lossless envelope, local CLI workflow, public export boundary and filesystem/harness guarantees.
Queued write timeouts
When a request budget expires before a tool write starts, the server returns
HTTP 503, Retry-After: 1, and a JSON error with code: "write_not_started",
write_started: false, and waited_ms. This covers write admission and the
first writer acquisition of the tool endpoints, including /episode and
/set. The check runs before signed nonce settlement. The tool did not run,
so the caller can retry after the indicated delay.
A real query timeout still returns 408 with query-specific advice. Do not infer that a write was unrun merely from a 408, a zero elapsed time, a transport failure, or a gateway error. A timeout after execution begins may have committed; verify the requested facts and preserve the same payload before retrying. These diagnostics do not change the configured request budget or bound direct protocol handlers that bypass tool-write admission.
Unrecognized request fields
Successful tool-backed JSON endpoints report unrecognized top-level request keys
in a sorted ignored_fields array. Requests containing only recognized keys omit
that field. The accepted keys come from the tool’s input schema, with HTTP adapter
fields accounted for separately. /reason, /subscriptions, /graph/create,
and /graph/label also report unrecognized keys. Errors retain their existing status and response body.
This is reporting, not rejection or a preview mode. For example, a successful
POST /knot containing "dry_run": true still writes and includes
"ignored_fields": ["dry_run"]. /knot does not support dry runs.
POST /export and /query responses negotiated as standard SPARQL results or RDF
preserve their standard bodies and report the JSON array in the
X-Quipu-Ignored-Fields response header instead. /search/nodes reports verbose
as ignored; the separate /search_nodes endpoint supports it.
Reporting covers undeclared top-level fields, not nested keys, value types, or
whether a recognized option applies to a particular action. It does not cover
non-tool endpoints such as /share, /import, /import/promote, entity GETs,
SPARQL protocol form fields, or direct Rust library calls. An absent warning on
those surfaces is not evidence that every supplied field was used.
Starting the Server
quipu-server --db my.db --bind 0.0.0.0:3030
| Flag | Description |
|---|---|
--db <path> | Store database path (default: .bobbin/quipu/quipu.db) |
--bind <addr> | Bind address (default: 127.0.0.1:3030) |
GET /.well-known/void
Returns a live VoID and SPARQL 1.1 Service Description projection. The default
representation is Turtle; send Accept: application/ld+json for JSON-LD. The
document advertises the query endpoint, exact dataset and named-graph counts,
used vocabulary namespaces, executable result formats, and compiled entailment
features. Quipu’s share manifest.json remains the integrity contract.
Read Concurrency
Reads are served from a pool of read-only connections; writes keep the single FIFO-fair writer connection. WAL already permits N concurrent readers alongside one writer — before the pool, every read took the writer’s mutex, so that capability was present and unused.
[quipu.server]
read_pool_size = 4 # 0 disables the pool; every read then serialises
MEASURED on a 160k-fact store, same binary, pool the only variable (server-CPU
divided by wall time, so it counts cores actually used rather than inferring
them):
| N=8 concurrent | N=16 | |
|---|---|---|
read_pool_size = 0 | 1.09s, 1.00 cores | 2.18s, 0.99 cores |
read_pool_size = 8 | 0.43s, 6.40 cores | 0.80s, 6.80 cores |
quipu_store_wait_seconds_total — time spent acquiring, exported on /metrics —
falls to 0.000s with the pool on. That is the number to watch: a rising
wait means readers are queueing again.
Wall-clock speedup is smaller than the core count because each query costs more CPU when eight run at once (2.6x for a full scan, 1.4x for an index lookup — shared-cache contention, not a lock). The pool removes the serialisation; it cannot make a memory-bandwidth-bound scan free.
Two cases where the pool disables itself, both announced on stderr at startup:
- an in-memory store — each
:memory:connection is its own empty database, so a pool there would not be slow, it would be wrong; - a configured vector delegate or local vector backend — those are not
shareable with a read-only connection, and several pooled handlers
(
/search_nodes,/search_facts,/unified_search,/ask) are vector-backed. Rather than answer the same question from two different indexes depending on which connection took it, the pool stands down.
read_pool_size = 0 is the rollback, and it is runtime config — no redeploy.
Authentication
Legacy migration profile: reads are open; writes need a bearer token. When required session keys are disabled and an auth token is configured, write endpoints require:
Authorization: Bearer <token>
In that legacy profile, reads such as /query, /search, entity lookups,
/health and /version need no credential. /identity always requires a signed
session identity.
A bearer refusal returns HTTP 401 with a JSON reason of
missing_or_invalid_bearer_token, credential_type: "bearer", the endpoint,
and provisioning guidance. Clients should configure a matching
QUIPU_AUTH_TOKEN or QUIPU_AUTH_TOKEN_FILE; retrying an absent or rejected
credential does not repair it. Signed authentication uses the existing protected
registry with a registered, unexpired identity and write scope; read routes also
accept that signature. Signed mutations remain restricted to the four audited
routes, and signed requests cannot carry URL query strings.
Set [quipu.server] session_keys = { required = true, onboarding_read_paths = [] }
only after completing the client and distinct-OS-user migration. This policy
requires signed keys even for reads; a shared or named bearer is insufficient.
The optional onboarding list admits only unique exact /health and /version
GET/HEAD paths. Other paths, wildcards and duplicates fail startup validation.
Strict mode refuses OPTIONS outside CORS, requiring a same-origin client or an
authenticated broker. This option is default OFF and performs no enrollment.
Provision a client credential
The server administrator issues credentials through the deployment’s approved secret distribution channel. For a shared bearer, the supplied value must match an active server bearer. For a named credential, the administrator registers its verifier and activates the validated registry before supplying the plaintext to the client. Installing an arbitrary token on a client cannot grant access.
Shantytown and CABOODLE use ~/.config/quipu/token as the default credential
file. A nonempty QUIPU_AUTH_TOKEN overrides QUIPU_AUTH_TOKEN_FILE, which
overrides this default. Files are read at request time. This is a client
convention; the server neither reads your home directory nor distributes secrets.
install -d -m 700 "$HOME/.config/quipu" && install -m 400 /secure/issued-token "$HOME/.config/quipu/token"
st ops doctor quipu --no-latest
# Or, on a CABOODLE host:
caboodle doctor
Keep an existing explicit file override until provisioning and rotation move together. Do not create independent copies that can diverge at rotation. Never put a token value in command arguments, logs, issues, or version control.
The doctor check POSTs an empty object to /episode. Authentication and
read-only policy run before episode parsing: an accepted request receives
HTTP 400 with invalid episode JSON: missing field ..., and stores nothing.
HTTP 401 means a missing or rejected bearer; HTTP 403 with
reason: server_is_read_only means no credential can permit the write.
Network failures and unexpected responses leave authorization unproven. This
check proves access to the write handler, not a successful storage commit.
Additive named credentials
[quipu.server].crew_credentials_file optionally points to a local JSON registry
of SHA-256 verifiers for administrator-issued, high-entropy bearer tokens. Keep
auth_token in place: named credentials are additional identities and do not
replace the current or previous shared bearer. Public reads and read-only mode
retain their behavior. An unconfigured registry changes nothing.
The file has version: 1 and a credentials array. Each entry contains
credential_id (a unique non-secret identifier), principal (an absolute crew
IRI), audience (exactly quipu), and token_sha256 (64 lowercase hex digits,
the SHA-256 of the presented token string). Issue at least 32 cryptographically
random bytes per credential and encode them for transport; this fast verifier
is not suitable for human passwords. The issuer must verify that the principal
is an existing crew identity. Parsing a registry checks syntax, not graph membership.
No bearer plaintext belongs in this file or in the graph.
Loading captures an immutable policy at startup. Invalid optional registries are reported without their contents; shared authentication remains available. Validate and atomically install a complete registry before provisioning clients. A restart with an invalid registry will not activate its named credentials; preserve the last validated file and verify candidate configuration before a rollout. This initial implementation provides no issuance, rotation, revocation, or hot reload.
Named writes produce authenticated_request_start and
authenticated_request_complete JSON audit events with credential ID, principal,
method, route, and a process-local correlation ID. Missing completion is
indeterminate. The credential principal is independent of declared actor, task,
and source values. Import/promotion and RDF graph-store transactions use the
registry principal through their existing authenticated-actor paths. Locally
created fact transactions also retain separate authenticated evidence, exposed
as authenticated by /transactions. Generic handlers preserve caller-declared
actor and source; neither can replace this credential evidence.
The evidence is inserted inside the fact transaction’s savepoint, including fork
materialization and overlay tombstones. Rollback removes it. Owned request
context crosses blocking dispatch and deferred snapshot promotion, and is
restored before a worker thread is reused. Schema/registry operations without a
fact transaction retain request-level audit only. Library/CLI writes without an
HTTP identity and copied historical transactions have null local authentication
evidence; a foreign actor is not proof of possession of a local credential. End-to-end MCP attribution
requires the proxy to select the corresponding downstream Quipu credential.
The shared bearer remains legacy-shared-bearer, including if a registry entry
accidentally duplicates its verifier.
For a coherent, pinned source receipt, use POST /acquire for a single file,
or POST /acquire/composed for qualified MAIN scope with fenced immutable
providers. The latter uses a separate version-gated contract and refuses
unsupported required attached or derived scope;
see atomic acquisition for its versioned contract.
POST /acquire/eligibility answers only whether each named graph would pass
that admission and the configured label floor at the current head, from graph
metadata alone; it is an observation, not a receipt (see
eligibility observation).
POST /share is also read-only. It returns the canonical Git-share manifest and
exact file contents in one JSON response, allowing a proxy on another host to
forward Quipu’s own canonicalization, hashes, and share ID instead of reproducing
them. The body accepts scope, shapes, no_shapes, parent_share,
turtle_view, and an optional max_bytes that can lower (but not raise) the 8
MiB server cap.
The authoritative list is http_auth::WRITE_ENDPOINTS in src/http_auth.rs, not
this page. It is enforced: write_endpoints_cover_every_route fails the build if
any registered route is unclassified, so the code cannot drift from itself — but
this page can drift from the code, so treat it as a summary and the constant as the
answer.
Two entries surprise people, and both are deliberate:
| Endpoint | Why it is a WRITE |
|---|---|
/project | Looks read-only — stats, pagerank, ppr, components only read — but louvain with persist: true writes quipu:memberOfCommunity and supersedes any prior derivation. The route is gated as a whole. |
/shapes | Gated even to list. Loading a shape set persists it, and a listed-but-unloaded set validates nothing while still reporting success. |
A refusal is never silent. Both refusals return a JSON body naming
the cause, so curl -s cannot render an auth failure as an empty result:
{"endpoint":"/project","reason":"missing_or_invalid_bearer_token","error":"unauthorized: ..."}
{"endpoint":"/knot","reason":"server_is_read_only","error":"read-only mode: ..."}
reason is the stable field to branch on; error is prose and may be reworded.
Request Attribution
Callers can attach two optional headers to every endpoint:
| Header | Meaning | Missing/invalid value |
|---|---|---|
X-Quipu-Client | Stable caller kind, such as query-first or graph-extract | Falls back to User-Agent, then unattributed |
X-Quipu-Task | Work-item join key, normally a bead id such as aegis-3aybc | unattributed; there is deliberately no inferred fallback |
Both values appear in request start/completion logs. The client, task, and
route-template dimensions are also exported by
quipu_http_client_requests_total and
quipu_http_client_request_seconds_total. Client and task identities have
independent cardinality budgets; excess caller-controlled values fold into a
visible other bucket instead of growing the registry without bound. Use
increase(...[window]) for counter comparisons so process restarts do not
invalidate the result.
Endpoints
All POST endpoints accept Content-Type: application/json.
GET /watermark
A bounded, read-only metadata endpoint for replication observers. Returns
{"store_id":"<store lineage>","watermark_tx":123} using the indexed latest
transaction ID. It does not enumerate facts, advance a cursor or create a
transaction. A shared store ID establishes lineage, not proof that two copies
have identical contents.
Opt-in read-only followers
poll their configured primary every 15 seconds (two-second HTTP timeout). Their
responses carry X-Quipu-Replica-Lag-Tx: primary minus follower transaction IDs
from the last fresh observation, or unknown. Metrics
quipu_replica_lag_transactions (-1 for unknown) and quipu_replica_lag_known
expose the same bounded observation. Failure, stale metadata or mismatched
lineage never means zero lag. Replica writes return 503 naming the primary;
they are not forwarded.
GET /health
Health check.
curl localhost:3030/health
Response: {"status": "ok"}
POST /identity
Requires an existing session-attestation signature. Returns the principal,
public key ID and session selected from the protected binding, with
authenticated: true and auth_class: "attested_session". A bearer or unkeyed
request returns 401. Agent/session declaration headers (X-Quipu-Agent,
X-Quipu-Session) are declared provenance. They must match the verified binding
only when session keys are required ([quipu.server.session_keys] required = true); with it off, a differing declaration is admitted and the returned
principal is still the binding’s. Replay, expired/revoked bindings and wrong keys
refuse.
No private key is returned. Use sd identity --json with an enrolled own key;
plain curl without a signed envelope cannot produce the authenticated result.
GET /stats
Store statistics.
curl localhost:3030/stats
Response: {"facts": 1234, "entities": 56, "predicates": 12}
GET /query and POST /query
Execute a SPARQL query. Quipu implements the SPARQL 1.1 Query Protocol GET and direct-query POST transports, while retaining its JSON POST extension:
# Standard GET (request target limited to 8 KiB)
curl -G localhost:3030/query \
-H 'Accept: application/sparql-results+json' \
--data-urlencode 'query=SELECT ?s WHERE { ?s ?p ?o } LIMIT 5'
# Standard direct-query POST (body limited to 1 MiB)
curl localhost:3030/query -X POST \
-H 'Content-Type: application/sparql-query' \
-H 'Accept: text/turtle' \
--data 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 5'
# Quipu JSON extension
curl -s localhost:3030/query -X POST \
-H "Content-Type: application/json" \
-d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"}'
For SELECT and ASK, request application/sparql-results+json or
application/sparql-results+xml, text/csv, or
text/tab-separated-values. CSV and TSV follow the SPARQL 1.1 Results
formats, including term spelling and escaping. For CONSTRUCT and DESCRIBE, request
text/turtle or application/n-triples. All transports share the configured
query deadline and return HTTP 408 when evaluation exceeds it.
POST /update
Execute SPARQL 1.1 Update using either application/sparql-update or an
application/x-www-form-urlencoded body containing exactly one update
parameter. The endpoint requires write authentication when a bearer is
configured. Protocol using-graph-uri and using-named-graph-uri parameters
scope a DELETE/INSERT WHERE; mixing them with an in-body USING clause is
rejected. Every affected graph passes through Quipu’s normal authority and
governance gates, and the request commits atomically across graphs.
curl localhost:3030/update -X POST \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/sparql-update' \
--data 'INSERT DATA { <http://example/s> <http://example/p> "value" }'
A successful update returns 200 with what it committed:
{"tx": 4182, "asserted": 1, "retracted": 0,
"graphs": [{"graph": null, "tx": 4182, "asserted": 1, "retracted": 0}]}
asserted and retracted count the triples the update changed, so a
conditional DELETE/INSERT ... WHERE used as a compare-and-swap (claim if
unassigned, release if still mine) can tell whether it won without a read-back:
both zero means the WHERE matched nothing, or the update changed nothing, and
tx is null. Each graph with changes commits its own transaction (graph is
null for the default graph); tx is the last of them, usable with --at /
tx reads. The SPARQL 1.1 Protocol leaves this body to the implementation.
Attribute a write with the optional actor and source query parameters (or
form fields beside update=), each at most once, 1–256 characters, no control
characters: POST /update?actor=agent:wu&source=seeds:claim. They are recorded
on the transaction as declared, like /knot’s fields; the verified caller is
recorded separately as its authenticated principal. Without actor the
transaction’s actor is unknown (null); source defaults to sparql-update.
Quipu’s JSON extension compacts result IRIs to CURIEs by default using prefixes
declared by the currently loaded shape sets; unknown namespaces remain full
IRIs. Pass "verbose": true in a JSON request, or ?verbose=1 on GET, to
return full IRIs. Standards result formats always retain their prescribed RDF
term representation.
Optional fields: valid_at (ISO-8601), tx (integer), graph (a
named-graph IRI or dataset name that scopes the query’s default graph
without writing a FROM/GRAPH clause — an unknown IRI yields an empty
default graph, never a silent ROOT fall-through), and fork (a fork name
registered by quipu fork; unknown or dropped forks are refused loudly;
mutually exclusive with graph).
include_kinds (array of dataKind tokens, e.g. ["archive"]) widens the
default graph set with every registered graph declaring one of those kinds —
the explicit opt-in for composing cold/frozen graphs into a hot read. Silence
never widens: absent or empty means the scope is unchanged, a FROM clause in
the query text still overrides the request-level scope, and fork +
include_kinds is refused (one scope authority). The response’s composed
labels.kind then honestly reports every kind that contributed.
With "federated": true the whole query text fans out through the federated
provider — the local store plus every [[quipu.federation.remotes]] — and the
response adds a per-member providers list (each carrying the remote’s
operator-declared label) and a complete flag, with every row
_provider-tagged and, for declared remotes, _trust/_freshness-stamped.
The composed dataset labels fold the remotes in as members, and configured
[quipu.labels] floors refuse a federated query exactly as a local one — an
undeclared remote fails a configured freshness/trust floor (quipu-fd1). The
temporal/graph fields are refused on a federated query (they only shape the
local evaluator’s context). See
Federation.
POST /knot
Assert facts from Turtle data.
curl -s localhost:3030/knot -X POST \
-H "Content-Type: application/json" \
-d '{"turtle": "@prefix ex: <http://example.org/> . ex:alice a ex:Person ."}'
Optional fields: shapes (SHACL Turtle), timestamp, valid_from, actor,
source, replace_snapshot + snapshot (diffed replacement of a producer’s
prior facts under a stable key), and graph (a named-graph IRI that must
already be registered committed-class via POST /graph/create; unknown IRIs
error, overlay-class targets are refused, omitted means ROOT).
timestamp is transaction time (when this store came to believe the facts,
queried with tx/as_of_tx); valid_from is valid time (when they became
true of the world, queried with valid_at). Omitting valid_from reuses
timestamp for both, which is the historical behaviour. valid_from is RFC
3339 and is normalised to YYYY-MM-DDTHH:MM:SSZ — valid-time is compared as
text, so an un-normalised offset sorts wrongly rather than merely looking
untidy. A malformed value is refused before anything is written.
# a commit authored in March, ingested tonight
curl -s localhost:3030/knot -X POST \
-H "Content-Type: application/json" \
-d '{"turtle": "@prefix ex: <http://example.org/> . ex:c1 a ex:Commit .",
"valid_from": "2026-03-04T09:15:00+01:00"}'
Response: {"tx_id": 1, "count": 2, "conforms": true, "valid_from": "2026-03-04T08:15:00Z"} — the echoed valid_from is the
normalised key the facts were actually stored under.
Resumable snapshot promotion and cancellation
POST /knot/stage stores inert, content-addressed parts. Transport parts are
limited to 512 KiB; the assembled upload is limited to 128 MiB and 512 parts.
Those are byte limits, not a bound on promotion memory. Promotion still
validates and replaces the complete producer snapshot atomically.
Start or poll with POST /knot/promote and {"upload_id":"sha256:..."}.
A pending response includes "state":"running" and "lease_seconds":30.
Poll regularly (for example once per second) using the same authenticated
identity. Each live poll renews the lease. Bound the heartbeat request timeout
and retry sequence below the lease (for example a five-second timeout with
three attempts). A 30-second request timeout can itself exhaust this lease;
a network partition can therefore cancel a still-running client. If the producer stops polling, its
lease expires and the worker cooperatively cancels before committing the
snapshot. A late poll cannot revive an expired lease. Only one promotion per
store is admitted at a time; another upload receives an admission refusal.
For an explicit stop, send the same upload ID to POST /knot/cancel, using the
identity that started promotion. Caller-declared actor is not authority.
"cancellation_requested":true acknowledges the request, not worker exit.
Poll /knot/promote until {"pending":false,"state":"cancelled","cancelled":true}
confirms the worker has exited without committing its snapshot replacement.
The previous snapshot and other producers’ facts remain active. Durable policy
and refusal audit records are preserved; validation advisories from the aborted
payload are discarded. Inert staged
bytes and dictionary terms can remain until their normal cleanup; cancellation
is not deletion of staging or an undo of an earlier successful promotion.
Cancellation competes with a single final commit claim. If commit wins,
/knot/cancel reports "cancellation_requested":false and "state":"committing"
or "complete"; the client must read the final promotion result. Once committed,
deferred embedding may still be finishing and cannot be undone by cancellation.
Checks run between upload parts, RDF rows, retraction comparisons and staged
fact writes, and before commit. This is cooperative cancellation, not a promise
that every validation or individual SQLite statement can be interrupted within
30 seconds. Measure stop latency and peak memory for the intended workload
before admitting a larger publication; raising memory limits is not a substitute.
Terminal receipts are retained for an hour in a bounded process-local cache.
Cancelled jobs stay terminal while their receipt is retained; polling does not
restart them. To deliberately resume after confirmed terminal cancellation, the
same identity can send {"upload_id":"sha256:...","retry_cancelled":true} to
/knot/promote. This flag cannot restart a still-running worker, a failed job
with an indeterminate commit, or a completed job. A fresh server process does
not retain these receipts. After a
restart or an indeterminate response, verify snapshot contents before starting
another promotion. Staging remains content-addressed and independently expires.
POST /cord
List entities.
curl -s localhost:3030/cord -X POST \
-H "Content-Type: application/json" \
-d '{"type": "http://example.org/Person", "limit": 50}'
POST /unravel
Time-travel query.
curl -s localhost:3030/unravel -X POST \
-H "Content-Type: application/json" \
-d '{"tx": 5}'
POST /episode
Ingest an episode.
curl -s localhost:3030/episode -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "deploy-v2",
"nodes": [{"name": "myapp", "type": "WebApplication"}],
"edges": [{"source": "myapp", "target": "kotare", "relation": "runs_on"}]
}'
A bare node type such as WebApplication names a class under the store’s
base_ns. To name a class in the public Quechua vocabulary instead, prefix it:
"type": "quechua:WorkItem" asserts
<https://scbrown.github.io/quechua/ns#WorkItem>. The prefix changes only the
class. The node’s own IRI is still minted under base_ns. Like any other type, a
Quechua class is refused until a loaded shape sanctions it. No other prefix is
accepted.
Set "replace_snapshot": true for producers whose payload is the complete
current state of an inventory. Facts previously asserted by the same episode
name but absent from the new payload are retracted atomically with the new
assertions. The default is false, preserving additive knowledge-ingestion
semantics. Reusing a stable episode name is required for replacement.
outcome: what the ingest DID — branch on this, never on count
/episode is idempotent. The activity IRI is derived from the episode name and
stamped with a content hash, so re-posting identical content is a no-op and
retrying after a lost response is SAFE.
The response says which of three things happened:
outcome | Meaning | count | tx_id |
|---|---|---|---|
created | The episode did not exist; its facts were written. | > 0 | > 0 |
updated | It existed with DIFFERENT content; stale activity facts were retracted and the new content written. | > 0 | > 0 |
unchanged | It already existed with identical content. Nothing was written and nothing needed to be. This is success. | 0 | 0 |
Why this field exists. Before it, the idempotent no-op returned count: 0, tx_id: 0 — byte-for-byte what a write that achieved nothing returns — while the
documented success check for callers of this API was “HTTP 200 with count > 0”.
So a successful retry reported as a failure. The natural recovery from “my
episode did not land” is to re-post it under a different name or with re-worded
nodes, and that mints duplicate entities. The safe mechanism was steering
callers into the unsafe action.
So the success check is:
# right: the facts are in the store for all three outcomes
curl -s .../episode -X POST ... | jq -e '.outcome' >/dev/null
# WRONG: reports a successful idempotent retry as a failure
curl -s .../episode -X POST ... | jq -e '.count > 0'
count > 0 remains a useful “did this call write anything” question. It was
never a “did the write land” question, and only looked like one because the
first post and the only post were usually the same post.
Two things it does not promise, both still on the caller:
outcomedescribes THIS episode name. Re-posting the same knowledge under a different name is a new episode and will becreated— idempotency is keyed on the name plus content hash, not on meaning.- A
200still is not proof of retrievability. A node filed under a type nobody queries iscreatedand unreachable. Ask it back the way a reader would.
Work-item context without duplicate entities
An episode may carry one aegis:aboutWorkItem edge from its generated activity
(episode_ plus the sanitized episode name) to an existing WorkItem IRI. This
claims the episode’s context, not that the work is implemented or complete.
Knowledge remains reachable by joining prov:wasGeneratedBy to that activity.
Do not copy the WorkItem into the knowledge graph merely to create a reference.
The shipped AboutWorkItemShape constrains the reference to one IRI. The
producer must first verify the target’s direct WorkItem type in its authoritative
records graph, behind a passing control, and read the edge back after writing.
SHACL does not prove a target’s type in a graph outside its validation dataset.
The property has no global domain or range, so linking cannot manufacture the
type assertion used by that verification. Historical episodes may omit it.
Select both graphs explicitly when retrieving bead-related knowledge. For
example, with ex: bound to the deployment vocabulary, join the records graph’s
ex:WorkItem to the knowledge graph’s activity and generated entities:
PREFIX ex: <https://example.org/vocab/>
PREFIX prov: <http://www.w3.org/ns/prov#>
SELECT DISTINCT ?knowledge WHERE {
GRAPH <urn:example:records> {
<https://example.org/vocab/task-123> a ?type .
FILTER(?type = ex:WorkItem)
}
GRAPH <urn:example:knowledge> {
?episode ex:aboutWorkItem <https://example.org/vocab/task-123> .
?knowledge prov:wasGeneratedBy ?episode .
}
}
The producer chooses the primary task explicitly; do not guess it from every
identifier mentioned in free text. Direct /episode calls do not parse task
markers automatically. This relationship does not repair historical omissions
or establish that a tracker backlog has drained.
Edge relation: which vocabularies /episode can write
/episode represents the caller’s predicate faithfully or refuses it with
an explanation of which path to use. It never silently rewrites a predicate.
relation | Emitted |
|---|---|
runs_on | aegis:runs_on — the domain vocabulary, unchanged |
owl:sameAs, rdfs:seeAlso, rdf:*, skos:*, prov:*, quipu:*, xsd:*, sh:* | verbatim, in that namespace |
<http://example.org/p> | verbatim (full IRI in angle brackets) |
foo:bar (undeclared prefix) | 400, naming /set and the angle-bracket form |
runs on (would not round-trip sanitization) | 400 — spaces are not accepted in a bare relation |
The declared prefix set is KNOWN_PREFIXES in src/episode/mod.rs, kept in lockstep
with the @prefix block episode_to_turtle emits.
Asserting an alias — entity dedup with owl:sameAs
owl:sameAs is this graph’s alias convention, and /episode’s resolution_hints
exist to tell you at ingest time that you are about to split an entity. Acting on that
hint is a normal /episode edge:
curl -s localhost:3030/episode -X POST -H "Content-Type: application/json" \
-d '{"name": "alias-fix", "source": "<bead-id>",
"nodes": [{"name": "backup-freshness.timer"}, {"name": "backup-freshness-exporter"}],
"edges": [{"source": "backup-freshness.timer",
"target": "backup-freshness-exporter",
"relation": "owl:sameAs"}]}'
Two rules that are not obvious from the 200:
-
Reuse the existing node names byte-for-byte. Node identity here is the literal name string: quipu matches
canonical_name:exactand merges, or it does not match and mints a second node. Re-wording a name on a follow-up post is how aliases get created rather than resolved./searchor/resolvefirst, and copy the name out. -
count > 0proves the write landed, not that a reader can find it. Follow every alias write with the query a reader would actually run:curl -s localhost:3030/query -X POST -H "Content-Type: application/json" \ -d '{"query":"SELECT ?o WHERE { <http://aegis.gastown.local/ontology/backup-freshness.timer> <http://www.w3.org/2002/07/owl#sameAs> ?o }"}'A
0here on a200write is the silent-rewrite shape: the fact is present and misnamed. Pair it with a control (query a predicate you know is populated) before believing an empty result.
Historical note, since the answer is not guessable from the data: the alias pairs
predating this fix were written through /knot (Turtle), which is the only write
path that accepts a caller-supplied source. Their transaction source strings are
free text — e.g. "schema-gate ruling 2026-07-20" — where /episode always stamps
episode:<name>, /set stamps set, and /retract stamps retract. Some are stamped actor: null, source: null: /knot called with neither,
which lands a structural identity fact with no audit trail. Pass actor and source.
POST /set
Atomic single-call supersede: set (entity, predicate) to exactly value, retracting
every current object on that predicate and asserting the new one in ONE transaction.
Re-parenting (reports_to A → B) is one call with no window where the predicate is
empty and no way to end up multi-valued by forgetting the retract half.
curl -s localhost:3030/set -X POST -H "Content-Type: application/json" \
-d '{"entity": "http://example.org/svc",
"predicate": "http://example.org/reports_to",
"value": {"iri": "http://example.org/new-boss"},
"actor": "<who>"}'
Optional: timestamp, actor. Returns
{"tx_id", "retracted": N, "asserted": 0|1, "entity", "predicate"}; setting the
already-sole-current value is an idempotent no-op (tx_id: 0, retracted: 0, asserted: 0).
This operation is ROOT/default-graph only. Named-graph facts are untouched.
A supplied graph field, including null or ROOT’s IRI, is refused with HTTP
400 before mutation. Omit it for ROOT; use graph-scoped /update for a named graph.
- The predicate is a full IRI, from any vocabulary. This is the endpoint
/episodenames in its refusal when an edge relation uses an undeclared prefix. - SINGLE-VALUE semantics: all current objects are replaced. For
add-without-remove, assert via
/knot. - The entity must already exist —
/seton a typo’d IRI must not mint an unlabelled orphan node. The predicate may be new. - The
valueshape discipline is the same as/retract: a bare string is a literal; an edge must be{"iri": "..."}. A bare IRI-shaped string aimed at a Ref-holding predicate is a loud 400, not a mis-shaped write.{"str": "..."}states that a literal is intended and disarms that heuristic.
When write validation is enabled, SET validates the replacement against loaded SHACL shapes, including the existing subject’s types. A tagged IRI does not bypass a string datatype constraint. Refusal preserves the previous value. Explicit operation shapes remain enforced when persistent validation is off. A concurrent change to the complete predicate slot produces a conditional-write conflict instead of silently overwriting a value the request did not observe.
POST /validate
Dry-run SHACL validation.
curl -s localhost:3030/validate -X POST \
-H "Content-Type: application/json" \
-d '{"shapes": "@prefix sh: ...", "data": "@prefix ex: ..."}'
Decision deck: POST /deck/present, POST /deck/attest, POST /deck/receipt
A curated decision request is settled by an enrolled human’s WebAuthn
assertion. These routes are off by default: they answer 403 unless
[quipu.server] deck_http = true. Even then they settle nothing until an
operator has configured and enabled the store’s deck, which no route can do.
/deck/present(write):{"request", "revision", "credential", "choice"}returns{"challenge", "webauthn_challenge"}. Signwebauthn_challenge(unpadded base64url) exactly as returned. Never rebuild the challenge client-side: the server verifies against the challenge it stored./deck/attest(write):{"nonce", "client_data_json", "authenticator_data", "signature"}, each byte field base64url, returns{"decision", "nonce", "recorded_at"}. A challenge settles once: a replayed, expired or stale one is refused. After a lost response, look the receipt up; do not resubmit./deck/receipt(read):{"nonce"}returns{"found", "receipt", "tx"}.
Unknown fields are refused on all three.
POST /retract
Retract facts for an entity.
curl -s localhost:3030/retract -X POST \
-H "Content-Type: application/json" \
-d '{"entity": "http://example.org/old-service"}'
Optional: predicate (only retract matching), timestamp, actor, and value
(retract only the one matching triple).
This operation retracts ROOT/default-graph facts only; named-graph facts
survive. Supplying graph, including null, is refused with HTTP 400 before
mutation. Use an explicitly scoped /update for named-graph retractions.
The value shape matters. An object that is an IRI reference — the target of
an edge such as reports_to or rdf:type — must be given as a tagged object, not
a bare string:
# retract exactly <svc> reports_to <boss>
curl -s localhost:3030/retract -X POST -H "Content-Type: application/json" \
-d '{"entity": "http://example.org/svc",
"predicate": "http://example.org/reports_to",
"value": {"iri": "http://example.org/boss"}}'
A bare string ("value": "http://example.org/boss") is matched as a string
literal, which can never equal a stored IRI reference. Rather than silently
report {"retracted": 0} — indistinguishable from “the triple was already gone” —
the endpoint now returns a 400 error naming the {"iri": ...} form whenever a
bare string cannot match: either the predicate’s stored objects are IRIs, or the
string itself parses as an IRI (has a scheme://). A correctly shaped {"iri": ...} (or a genuine string literal) for a triple that does not exist is still a
quiet, idempotent {"retracted": 0}.
POST /retract/source
Preview or apply a retraction by the exact transaction source string. Requires
source and a nonempty repair ticket/reason. The default apply: false only
previews; apply: true retracts the planned facts and stamps the transaction source
as repair:<ticket>. Optional fields are graph (defaults to ROOT), timestamp,
and actor. The graph must be committed; source matching is literal, not a prefix
or pattern. As a write route, even preview requires the configured bearer.
The response reports planned, the affected entities count, a bounded sample,
sample_truncated, and repair_source. A preview has applied: false and null
tx_id/retracted; an applied result reports the actual retraction transaction.
Source cleanup removes only that source’s ownership claims. A statement remains
visible while another recorded claim survives. This protection is forward-only
for newly recorded claims. Older versions skipped identical assertions from
later producers, leaving no recoverable claim. Reassert the relevant producer
snapshots before cleaning up old shared data; this release does not backfill it.
The retracted count reports source-owned statements processed, not necessarily
statements removed from the visible graph.
POST /episode/retract
Episode-scoped logical retraction. Retracts the facts an episode’s ingest
contributed — its activity node, generated entities, the bare relationship
triples (edges), and any reified confidence statements — by closing their
valid_to via the bitemporal retract path. Facts are never physically deleted,
so time-travel queries (/cord, /unravel) still show them.
Identity of surviving nodes is preserved by default. Identity triples
(rdfs:label, rdf:type) are ordinary facts, so a naive scope retraction would
strip them from any node this episode named even when edges from other episodes
keep that node alive — leaving a “ghost”: a node that answers predicate queries
but is invisible to every label scan and type query. The on_orphan parameter
decides that contract:
on_orphan (alias orphan_policy) | Behaviour |
|---|---|
preserve (default) | Keep rdfs:label / rdf:type alive for nodes that retain surviving references. So the default does not retract “every currently-active fact” — it spares the identity of nodes that would otherwise be orphaned. |
refuse | If the retraction would orphan any node’s identity, reject the whole operation (400) and change nothing. The safe mode when you do not want to strand entities. |
allow | Legacy behaviour: retract every currently-active fact the episode wrote, orphaned identity included. |
Regardless of policy the response reports identity_orphans (a count) and names
the affected nodes, so a caller can tell a cleanup from a mutilation.
The retraction unit is the episode’s ingest transaction(s), identified by their
source = "episode:{name}" tag. Because identical assertions are deduplicated to
a single owning transaction, retracting an episode only removes the facts that
episode actually wrote — entities and facts contributed by other episodes (even
about the same shared IRIs) survive untouched. This is the safe way to undo a
specific episode’s contributions without SQL surgery on shared entities.
curl -s localhost:3030/episode/retract -X POST \
-H "Content-Type: application/json" \
-d '{"episode": "goldblum-deploy-verify-032"}'
Aliases for episode: episode_id, name. Optional: timestamp, actor,
on_orphan (preserve | refuse | allow, default preserve — see the table
above). Idempotent — retracting an already-retracted or unknown episode
returns {"retracted": 0} and changes nothing.
Response fields: tx_id, retracted (count), episode, statements (the
retracted facts), and the identity accounting — on_orphan (the policy applied),
identity_preserved (count) with identity_preserved_statements, and
identity_orphans (count) with identity_orphan_entities (entity,
lost_label, lost_type).
Auth (hq-azs / hq-otm). Retraction is a write — and a more sensitive one than assertion, since it removes facts from current views. The endpoint is in
http_auth::WRITE_ENDPOINTS, so it already honours read-only mode and the bearer token like every other write. When per-principal scopes (hq-azs) and crew identity (hq-otm) land, retraction should be gated to an authorized principal, not merely the same token that permits assertion.
POST /resolve
Ask what entity resolution would say about a name, without writing anything.
Returns the same candidate list the ingest path computes, so “is this a duplicate of something we already have?” can be answered before minting the entity.
curl -s localhost:3030/resolve -X POST \
-H "Content-Type: application/json" \
-d '{"name": "example-service", "properties": {"type": "DatabaseService"}}'
# {"candidates":[{"iri":"http://example.org/ontology/example-service",
# "score":0.9,"matched_on":"canonical_name:jaro_winkler:0.90"}],
# "count":1,"has_matches":true}
name is required. properties (object), top_k and threshold are optional;
top_k and threshold default to [quipu.resolution] config, so this route and
the ingest path agree by construction rather than by convention.
Both matchers run: Jaro-Winkler over rdfs:label (matched_on: canonical_name:jaro_winkler:<score>) and vector similarity when an embedding
provider is configured (matched_on: embedding:<score>). The embedding half is
the reason a client-side name check is not a substitute.
Notes:
- It does not write — and that is guaranteed by a test, not by the type. The
handler takes a
&Store, but do not read that as a read-only capability:Storewrites through&selfmethods via interior mutability, so a&Storehandler can commit. Several routes registered the same way do write, which is why/overlay/createsits inWRITE_ENDPOINTSdespite its signature. What actually holds this route read-only is the explicit assertion intool_resolve_entity_is_a_genuine_read_commits_nothing. - It is not a write endpoint (absent from
http_auth::WRITE_ENDPOINTS), so it needs no bearer token and answers normally on aread_only = trueserver — wherePOST /episodereturns 403. - It does not require
[quipu.resolution].enabled. Resolution being off disables the ingest-time hints; this route still answers. - Not to be confused with
POST /reconcile— the W3C Reconciliation API, which has its own substring scoring on a 0-100 scale and does not consult embeddings. (It is routed but undocumented here, as are/spotlightand/fragments.)
POST /shapes
Manage persistent SHACL shapes.
# Load
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{"action": "load", "name": "person", "turtle": "@prefix sh: ..."}'
# List
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{"action": "list"}'
# Remove
curl -s localhost:3030/shapes -X POST \
-H "Content-Type: application/json" \
-d '{"action": "remove", "name": "person"}'
Rule Turtle (a rule:Rule subjects) may be stored alongside SHACL shapes. A
successful load or remove also hot-reloads the reactive reasoner’s ruleset
— rules take effect on the next write, no restart (before 2026-08-27 the
ruleset was a startup snapshot).
POST /reason
Run a Datalog ruleset to fixpoint and persist its derivations
(source = reasoner:<rule-id>). A write endpoint — derivations assert and
retract through the fact log — so it is bearer-gated like /episode.
Derivations land in the target graph’s companion inferred graph
(<graph>#inferred; ROOT’s is urn:quipu:graph:root#inferred, quipu-0b6).
Read them composed: FROM <urn:quipu:graph:root> FROM <urn:quipu:graph:root#inferred> in a /query body. The suffix is reserved —
external writes to a companion graph are refused.
Body fields, all optional: rules (inline rule Turtle; absent, the stored
combined shapes are used), prefix (default IRI prefix for unqualified
predicate names), graph (a named-graph IRI — premises and derivations both
scope to it; absent means ROOT), timestamp (valid-from for derived facts).
# Evaluate the rules already loaded via /shapes, against ROOT
curl -s localhost:3030/reason -X POST \
-H "Content-Type: application/json" -d '{}'
# Evaluate an inline ruleset against a named graph
curl -s localhost:3030/reason -X POST \
-H "Content-Type: application/json" \
-d '{"rules": "@prefix rule: ...", "graph": "http://example.org/graphs/staging"}'
# {"rules":2,"strata_run":1,"asserted":14,"retracted":0,
# "per_rule":[{"rule":"R1","asserted":14},{"rule":"R2","asserted":0}]}
POST /explain
Walk a fact’s derivation chain from the provenance in the fact log. Read-only
and open (no bearer token). Body: s, p, o (IRIs; a non-IRI o is
treated as a string literal), optional depth (default 5).
A base fact answers with its transaction and source. A reasoner:<rule-id>
fact answers with the rule and the premise facts it currently re-matches; an
owl:materialize fact answers with every axiom family that currently
re-derives it — premises recursed, so the tree bottoms out in base facts.
Support is re-matched, not stored: a premise retracted since derivation
shows as absent support, which is itself diagnostic.
curl -s localhost:3030/explain -X POST \
-H "Content-Type: application/json" \
-d '{"s": "http://example.org/a", "p": "http://example.org/dependsOn",
"o": "http://example.org/c"}'
# {"fact":{...},"found":true,"tx":42,"source":"owl:materialize",
# "derivation":{"kind":"owl","families":[{"family":"transitive",...}]}}
POST /search
Usable context versus ranked candidates
Every successful response also carries the quipu-context/v1 contract:
{
"insufficient_context": true,
"context_results": [],
"context": {
"contract": "quipu-context/v1",
"outcome": "NO CONTEXT",
"reason": "subject_not_covered",
"similarity_floor": 0.52,
"minimum_term_coverage": 0.5,
"subject_term": "unrecordedsubject",
"required_identifiers": [],
"top_term_coverage": 0.6667,
"candidate_count": 3,
"admitted_count": 0
}
}
Consumer validation requires a finite declared floor in [0.52, 1] before
accepting either CONTEXT or NO CONTEXT. A lower, missing or malformed floor
is UNKNOWN in these consumers, even when the server reports no context.
results, count, ranking scores, ordering, scope and temporal provenance remain
unchanged diagnostic search output. They are not evidence that an answer
exists. Context consumers must render NO CONTEXT when
insufficient_context is true and must not inject diagnostic candidate text.
When it is false, consume only context_results. A missing or unknown contract,
transport error or malformed response is UNKNOWN, never an empty successful
search. This contract does not change storage, ranking or graph isolation. Native MCP
renders only this admitted-context view: its results and count describe the
admitted rows and omit diagnostic neighbors. Unsupported context responses are
MCP errors marked UNKNOWN. REST retains the diagnostic candidate view.
Admission requires the top candidate’s raw similarity to reach the floor,
the first non-stopword query term to occur as a complete word, every explicit
identifier to occur exactly, and at least half the distinct non-stopword query
terms to occur. Matching is case-insensitive. Identifier boundaries preserve
hostnames, unit names, item IDs, IRIs/paths and hexadecimal identifiers of seven
or more characters. An uncorroborated top candidate produces NO CONTEXT even
if a lower candidate appears usable. Admitted rows preserve source and valid-time
metadata. An admitted historical incident is not a verified cause or current state.
The default raw-cosine floor is 0.52, provisionally measured on a small set
with only a 0.023 separation margin. context_similarity_floor may explicitly
select a finite floor in [0,1]; it does not change ranking. Coverage and subject
checks are conservative heuristics, not a calibrated answerability probability.
The first-subject rule is order-sensitive; an entity-first query is clearest.
A high score alone is insufficient: a nonsense subject with domain vocabulary
can exceed the floor. Re-evaluate thresholds against positive and truly absent
controls before changing policy.
Without query terms, or without a finite raw cosine (for example a keyword-only
or lexical-only fusion result), context admission refuses with an explicit
reason. BM25, normalized fusion, content and graph scores never substitute for
cosine. Empty candidates produce no_candidates; all candidates below the floor
produce no_result_above_floor. A below-floor top hit with another above-floor
candidate produces top_result_below_floor. Other reasons include
similarity_unavailable, query_terms_unavailable, query_too_long,
subject_not_covered, identifier_not_covered, and insufficient_term_coverage.
Queries over 4096 UTF-8 bytes cannot supply usable context under this contract.
Vector similarity search. Body: embedding (or query), optional limit,
valid_at, ranking, and best-effort scoping by group_ids / entity_type.
Optional mode: "keyword" selects the derived SQLite FTS5 index described below;
omitted mode or mode: "semantic" preserves vector search.
Select one registered graph with graph: "<IRI>", several with
graphs: ["<IRI>", "<IRI>"], or ROOT plus all named graphs with
all_graphs: true (also graph: "all"). New stores default to ROOT plus all
registered named graphs, minus [quipu.search] exclude_graphs. Existing stores
retain ROOT until explicitly broadened with default_graph_scope = "all".
An unset configuration uses the persisted store default; read-only legacy
stores resolve to ROOT without a schema write. The response coverage reports
the resolved default and its default_source. An explicit configuration
overrides the stored choice. Set default_graph_scope = "root", or pass
graph: "urn:quipu:graph:root" for one ROOT search. Selectors
are mutually exclusive and unknown IRIs are refused. Explicit results carry
graph and selected entity memberships in graphs. For keyword mode,
graph identifies the matching assertion; for semantic mode it is the first
selected membership. The graph
metadata plane is excluded from all-graphs scope. Named-graph semantic text
uses one vector per entity; entities with ROOT text keep it, otherwise text
combines their named-graph facts. Scopes containing named graphs refuse ROOT
content and anchor reranking; an explicit ROOT selection supports both.
Named-graph retrieval for semantic and hybrid search, and automatic
named-only embedding text, are disabled by default ([quipu.search] named_graphs = false). Keyword mode (mode: "keyword") accepts an explicit
scope without the flag, because the lexical index records each assertion’s
graph; it still refuses until that index is complete. Prepare existing named-only
vectors through bounded POST /embed_backfill_graph calls, then enable the
flag. This avoids turning the next producer snapshot into an unpaced initial
embedding drain. Graph backfill leaves entities with any ROOT history alone,
including ROOT entities without vectors. Roll back exposure by setting default_graph_scope = "root" and disabling
named_graphs; retain this binary’s ROOT exclusion while named vectors exist.
Reverting to a pre-scope binary after backfill can expose those vectors in
unscoped search.
A local named_search_entities origin cache is created when named vectors are
prepared. It retains their exclusion from ROOT even if the named facts are
physically cleaned up. The cache is regenerated with embeddings when a store
is reconstructed; it is not exported as factual content.
Hybrid fusion
Hybrid is disabled by default. With [quipu.search] hybrid = true and a ready
keyword index, request mode: "hybrid", alpha in [0,1],
fusion: "weighted" | "rrf", and positive rrf_k (default 60).
Alpha is the semantic contribution: alpha: 1 returns the existing semantic
response exactly, without accessing the lexical index; alpha: 0 returns the
keyword response exactly, without embedding. Omitted mode and parameters use
the server search configuration; the default mode remains semantic.
Weighted fusion min-max normalizes each candidate list independently. A
nonempty equal-score list normalizes to one; missing candidates contribute zero.
RRF uses alpha/(rrf_k + semantic_rank) + (1-alpha)/(rrf_k + keyword_rank),
with one-based ranks and zero for a missing branch. Equal fused scores are
ordered by entity identifier. Candidate lists use the configured oversampling,
capped at the server result limit and 1000 (the response reports candidate_limit);
normalization is over this bounded pool, not the entire corpus. Intermediate
hybrid requests whose effective result limit exceeds 1000 are refused; the pure
semantic and keyword endpoints retain their configured result limits.
Both branches retain temporal, graph and provenance scopes. Intermediate hybrid
weights use semantic type inference for both branches (infer_types: true);
asserted-only hybrid type scopes are refused until both branches support them.
Content reranking is not composed with intermediate fusion. Graph anchors
compose with semantic, keyword and hybrid ranking when anchored = true.
Intermediate weights require query text; an optional embedding supplies the
semantic branch. Endpoints retain their pure modes’ input requirements and
restrictions (keyword rejects embeddings; semantic permits an embedding without
query text).
Empty/punctuation-only lexical expressions are refused as in keyword mode.
explain: true adds raw BM25, cosine, fused score, branch ranks, actual matched
FTS columns and applied filters. A missing branch component is null, not zero.
Lexical snippets use the winning FTS assertion and tokenizer; semantic-only
snippets use the vector text. Both are bounded, HTML-escaped text with <mark>
highlights. The CLI accepts --mode hybrid --alpha 0.5 --fusion rrf --rrf-k 60 --explain; native MCP exposes the same fields. Fleet proxy rollout is a separate
surface gate. Disable search.hybrid for rollback: callers omitting mode return
to semantic even if the configured default remains hybrid. Explicit hybrid
requests are refused while disabled. Fusion creates no additional persisted index.
Keyword index
Keyword search is off by default ([quipu.search] keyword = false). Explicit
activation installs an empty FTS5 index and same-transaction fact triggers; it
never backfills during startup or a read. Writes, source replacement, closure,
rollback and physical deletion are reflected atomically. Index documents are
assertion rows with graph IDs; omitted scope reads ROOT only. valid_at
checks their original valid-time intervals, with an exclusive valid_to
boundary. Attached packs are not part of this index.
An existing ROOT-only index is upgraded without dropping its documents. The upgrade replaces its derived view/triggers and invalidates completeness; bounded backfill must revisit fact ranges before keyword reads resume. A named-graph query against the older index is refused rather than reported as an empty graph.
Backfill is explicit and resumable:
quipu search-index backfill --batch-size 500 --db /path/to/store.db
quipu search-index status --db /path/to/store.db
quipu search '"complete episode phrase"' --mode keyword --db /path/to/store.db
quipu search 'Memory Beads' --mode keyword --graph urn:example:knowledge --db /path/to/store.db
quipu search 'Memory Beads' --mode keyword --all-graphs --db /path/to/store.db
One command commits one batch (1–10000 scanned fact rows), then releases the
writer. A durable cursor/highwater bounds historical work, while insertion
triggers index newer writes. The CLI refuses backfill during UTC minutes
10–20, inclusive. Repeat calls outside ingestion lanes until complete is
true; an incomplete index returns an error instead of a partial answer.
mode: "keyword" requires query text and needs no embedding provider. It
accepts literal terms and double-quoted phrases, with conjunctions scoped to
one fact document; it does not interpret field/boolean syntax yet. All literal
codecs are decoded, full comments/body text is retained, and type/entity local
names include CamelCase tokens. Scores are positive BM25 relevance (score,
higher first); bm25 exposes SQLite’s negative raw rank. Entity duplicates
are collapsed and group/type scope is filtered before applying the limit.
Embedding arrays, anchors and content reranking are refused in this mode.
Rollback: disable the flag, then quipu search-index drop --db <path> to remove
the derived index and its triggers/progress only. Existing facts/vectors are
preserved. A previously activated index continues following writes until it
is dropped, even when keyword queries are disabled; no query silently serves
stale documents after a flag toggle. Index size/RSS depend on the corpus and
must be measured on an isolated restored store before production backfill.
Semantic ranking
Opt-in ranking: "content" reranks the oversampled candidates before the
result limit. A Section, Chunk, or CodeSymbol with no explanatory comment
or content beyond its label/name is demoted: positive cosine similarity is
halved, and negative similarity is reduced by half its magnitude. Metadata such
as paths, line numbers and revisions does not count as content. Exact label/name
queries are exempt. Artifacts with explanatory text and other entity types keep
their similarity score. This is a relevance heuristic, not a trust classification.
The default ranking: "semantic" preserves the original cosine order.
Choose content when retrieving explanatory operational knowledge; keep
semantic for general search or locating code and documentation. An identifier
can be the correct answer without any body text, so content ranking must not
be applied indiscriminately. Each result exposes
similarity (raw cosine), score (ranking score), and ranking_reason
(semantic or contentless_artifact). No vectors are deleted or re-embedded.
Only the existing bounded candidate pool is reranked: this cannot recover an
entity outside that pool or guarantee semantic equivalence detection. Historical
searches classify candidates using facts valid at the requested valid_at.
curl -s localhost:3030/search -X POST \
-H "Content-Type: application/json" \
-d '{"embedding": [0.1, 0.2, ...], "limit": 10}'
group_ids is a best-effort provenance filter, not an isolation boundary:
it narrows to entities whose facts trace (via prov:wasGeneratedBy → episode → groupId) to a listed group, and it drops ungrouped /knot facts (they have
no episode to trace). entity_type restricts to an rdf:type IRI. See
group-isolation.
⚠️ The
type: A, Bin a result’stextis NOT valid as/episodeinput. A multi-typed entity renders as... type: Feature, Tool, Concept, and that string looks exactly like atypevalue you could paste back. It is not one —/episodetakestypeas a SINGLE class and refuses a comma-separated value (400). To give an entity several types, send one node entry per type, repeating the same name; the canonical-name resolver folds them into one entity:{"nodes":[{"name":"governor","type":"Feature"},{"name":"governor","type":"Concept"}]}This bites careful readers specifically: searching first to reuse existing conventions is what hands you the string, so following the “search before you mint” rule is what leads into it.
Why the rendering is not simply changed: that
textis the EMBEDDING SOURCE (src/embedding.rs,format!("type: {}", types.join(", "))), not a display string. Altering the separator changes the text every stored vector was computed from, so it would need a full re-embed backfill to stay coherent — a much larger change than it looks. Documented here rather than “fixed” cheaply and inconsistently.
Anchored search
Off unless the server sets [quipu.search] anchored = true. An anchor sent to
a server with it off is refused rather than answered unanchored. A request
without anchor is unchanged.
| Field | Default | Meaning |
|---|---|---|
anchor | One entity, as an IRI, a CURIE or an exact rdfs:label. Ambiguity or absence is refused, with the candidates named. | |
max_hops | 3 (cap 4) | Neighbourhood radius |
anchor_mode | decay | decay: score × decay^hops, unreachable × decay^(max_hops+1). sort: hops first, then score. filter: reachable only. |
decay | 0.5 | Per-hop multiplier, in (0, 1] |
via | Traverse only these predicates (replaces the default exclusions) | |
direction | both | out, in or both |
explain | false | Adds one shortest path per result |
The neighbourhood is walked with bound single-pattern lookups over current ROOT
facts (valid_at applies). owl:sameAs costs no hop. rdf:type,
rdfs:subClassOf, PROV links, distinctFrom, mentions and inDocument are
not traversed. Without that, everything is two hops from everything through a
class, an activity or a document. A node with more than 150 edges is reached
but not expanded along ordinary edges; zero-cost aliases remain traversable.
Non-anchor reads fetch at most 151 object edges per direction before applying
the hub rule. At the outer hop ring, only alias edges are queried. The anchor
read is bounded by the node budget, and a possibly incomplete ring is reported
as truncated. The walk stops at 5,000
nodes. The response’s anchor block reports reached, hubs_not_expanded,
truncated and truncated_at_hop. A truncated ring is never presented as
complete.
Each result gains hops (null when unreachable) and text_score (the
blended text score before hop ranking). Global text candidates and candidates
restricted to the bounded neighbourhood are unioned before fusion. Each source
gets half the configured candidate pool; each text branch is capped at 1,000.
This admits a near text match that falls below the global pool. Normalization
and ranks use that bounded union. Anchored result limits above 1,000 are refused.
Explicit named-graph anchors remain unsupported; traversal is ROOT-only.
Malformed anchors and traversal options are refused.
The CLI exposes the same controls:
quipu search "backup failure" --mode hybrid --alpha 0.5 --fusion rrf \
--anchor https://example.org/host --max-hops 3 --anchor-mode sort \
--direction both --decay 0.5 --explain
POST /hybrid_search
Combined SPARQL filter + vector ranking.
curl -s localhost:3030/hybrid_search -X POST \
-H "Content-Type: application/json" \
-d '{
"sparql": "SELECT ?s WHERE { ?s a <http://example.org/Service> }",
"embedding": [0.1, 0.2, ...],
"limit": 5
}'
POST /project
Graph projection and algorithms.
curl -s localhost:3030/project -X POST \
-H "Content-Type: application/json" \
-d '{"algorithm": "in_degree", "limit": 10}'
POST /render
Prepare a deterministic markdown file plan from a stored specification or an
ad-hoc specification. This protected computation requires an accepted bearer;
it remains a pooled read-only endpoint, including on read-only servers. Missing
or invalid credentials refuse, and open-write mode does not open this route.
The server neither writes files nor accepts a destination. The CLI declares cli-render in
X-Quipu-Client and publishes the verified plan locally.
{
"adhoc": {
"query": "SELECT ?title WHERE { VALUES ?title { 'Example' } }",
"template": "# {{ rows[0].title }}",
"layout": "single",
"path": "report.md"
}
}
Pass spec as an absolute specification IRI instead of adhoc. Optional
graph selects the same default dataset as /query; optional valid_at is an
ISO timestamp. Unknown fields, including dest and dry_run, refuse rather
than being ignored. Existing query floors, truncation and time-budget guards
apply. Incomplete data cannot authorize a deletion.
The response contains schema, spec, source_tx, valid_at and files.
Each file has base64 bytes, a sha256: content address and a mode. Output is
bounded to 2,000 files and 16 MiB of decoded bytes. The client verifies the whole
plan and the destination ownership manifest before atomic publication. The
render reference explains includes, temporal snapshots and local
refusal/no-op behavior. Preparing a plan is not a publication or a delivery
receipt.
GET|POST /report
Live graph report: top hubs (god-nodes), surprising cross-community connections,
and auto-suggested questions (see quipu_report in the
MCP tools reference). Read-only. GET returns the report with
defaults; POST accepts an options body (type, predicate, hubs,
surprises, questions).
curl -s localhost:3030/report
curl -s localhost:3030/report -X POST \
-H "Content-Type: application/json" \
-d '{"hubs": 5, "surprises": 5, "questions": 6}'
GET /graphs
List registered named graphs with class, source, storage lifecycle, and label
cache (freshness / durability / trust / policy / kind). Query params: kind
(a dataKind token) and lifecycle (frozen). Also the consumer
capability probe for the graph-kinds surface: a 404 means the store
predates it — treat that as “cannot tell”, never as “no graphs”.
The optional graph=<absolute-IRI> selects one exact registered identity. It
cannot be combined with kind or lifecycle. The response retains
{ "count": 0|1, "graphs": [...] }, including creation.timestamp and
creation.actor (null for a legacy/imported source without a declaration).
Point mode never enumerates the registry or computes materialization.
Missing, interned-only and removed registry entries return count zero; malformed
selectors, storage failures and ambiguous registered source identities are errors.
No missing IRI is interned by this read.
For attached/frozen graphs, creation metadata comes from the selected source,
not mutable local events. A frozen pack’s timestamp describes that pack’s
registry identity, not an invented original creation time; existing pack formats
may have no creator declaration. Each source needs creator index v1
events(type, subject, offset). An older immutable source is refused explicitly,
never migrated or scanned by this read. The CLI equivalent is
quipu graph list --graph <IRI> --json --db <existing-db>; its metadata reader
opens read-only without schema migration, registration or lexical initialization.
It selects an active frozen source by the requested IRI with a bounded indexed
lookup; an unrelated broken frozen pack does not block that point read.
Existing list mode and its enrichment remain unchanged.
Index migration v1 follows the store’s ordered additive migration seam on a
writable open; it is idempotent and changes no facts or events. Its private
fixture build measurements do not establish production build cost or admission.
Operational rollback requires the prior binary and dropping only
main.idx_events_type_subject_offset and main.idx_frozen_packs_graph_thawed;
no database rewind or event deletion. The latter bounds the lookup of a frozen
graph’s recorded active source alias. Retained local frozen IDs resolve through
that alias; unrelated duplicate registered identities are errors.
Do not drop the index while a point consumer depends on it. Live migration,
resource admission and rollout remain separate operational decisions.
A graph whose latest RML materialization is on record additionally serves a
materialization object (quipu-212): the mapping IRI, mapping-closure hash,
external-truth subject, verified source hash, transaction, and timestamp
of the last executor commit — the comparands a freshness verdict needs
(camayoc’s rml_executor.py freshness/remap read them from here). Parsed
from transaction provenance, so it cannot drift from what actually
committed; omitted rather than faked on graphs with no RML history.
curl -s 'localhost:3030/graphs?kind=operational'
POST /graph/label
Declare a graph’s labels — any subset of the five axes. Required: graph,
timestamp. Optional: freshness, durability, kind (a dataKind token,
strictly parsed), trust ({"iri", "chain", "rank"}), policy (array of
obligation tokens), valid_to (expiring declaration), actor. Each axis is
parsed strictly: an unrecognised value is an error, never a dropped axis.
Returns {"tx_id": N}. Write endpoint; honors bearer auth.
curl -s localhost:3030/graph/label -X POST -H "Content-Type: application/json" \
-d '{"graph": "urn:app:runs/2026-08", "kind": "operational",
"freshness": "fresh", "timestamp": "2026-08-24T00:00:00Z"}'
POST /graph/freeze and POST /graph/thaw
The deep-freeze surface — same inputs and outputs as the quipu_graph_freeze
/ quipu_graph_thaw MCP tools. Freeze relocates a graph’s full history into
a read-only archive pack (kept addressable and composable at query time);
thaw restores it. Both are write endpoints and honor bearer auth.
curl -s localhost:3030/graph/freeze -X POST -H "Content-Type: application/json" \
-d '{"graph": "urn:app:shuttle/runs/2026-07", "timestamp": "2026-08-24T00:00:00Z"}'
POST /graph
Render-ready node-link projection — the single payload the web UI draws from.
curl -s localhost:3030/graph -X POST \
-H "Content-Type: application/json" \
-d '{"limit": 250}'
Body: optional limit (nodes, ranked by degree; default 250, max 2000),
type (restrict to one rdf:type IRI), include_episodes (default false).
{
"nodes": [{"iri": "…", "label": "kotare", "type": "…/ProxmoxNode", "deg": 8}],
"edges": [[0, 10, "managed_by"]],
"types": [{"iri": "…", "label": "SystemdService", "count": 15}],
"truncated": {"shown": 250, "of": 1180},
"stats": {"nodes": 250, "edges": 612}
}
edges address nodes by index into nodes, not by IRI — an IRI averages
~45 bytes and would otherwise repeat at both ends of every edge. prov:Activity
episodes and rdf/rdfs/prov scaffolding predicates are excluded by default
so the domain graph is not buried in provenance. truncated always states what
was dropped rather than silently capping.
POST /context
Knowledge context pipeline.
curl -s localhost:3030/context -X POST \
-H "Content-Type: application/json" \
-d '{"query": "traefik", "max_entities": 10}'
The summary carries an embeddings block reporting whether semantic
retrieval was possible at all, so an empty entities list is not ambiguous:
"embeddings": { "configured": true, "embedded_entities": 2579 }
configured: false means no embedding provider is attached;
embedded_entities: 0 with configured: true means the store was never
embedded (quipu knot does not embed — run a backfill). See
Embeddings and Semantic Search.
POST /unified_search
Unified knowledge search (text + optional vector); results tagged
source="knowledge" with normalized 0–1 scores. Body: query, optional
embedding, limit, expand_links, max_facts_per_entity.
POST /ask
Run a curated, parameterized named query by name (see quipu_ask in the
MCP tools reference). Body: name (omit or "list" to list
the catalog), optional params map. Parameters are validated and escaped by
type. Response: query, resolved sparql, columns, rows, count.
curl -s localhost:3030/ask -X POST \
-d '{"name":"service_deps","params":{"entity":"http://example.org/traefik"}}'
POST /search_nodes
Search entities by natural-language query (text matching). Body: query,
optional group_ids, max_results, entity_type_filter.
POST /search_facts
Search relationships/edges by natural-language query. Body: query, optional
group_ids, max_results.
POST /search/nodes
Graphiti-compatible node search (mirrors Graphiti’s search_nodes shape).
The optional group_ids filter matches direct group labels or episode provenance
in ROOT. It recognizes the configured vocabulary, the legacy vocabulary, and
https://scbrown.github.io/quechua/ns#groupId; unrelated predicates with the same
local name do not match. This is a provenance filter, not an access boundary.
Results include distinct group_ids and retain scalar group_id when exactly
one group is known. Property keys use the local name for both slash and hash IRIs.
POST /episodes/complete
Graphiti-compatible flat episode ingestion. Body: name, optional
episode_body, group_id, source_description, timestamp.
POST /impact
Impact analysis: walk downstream from an entity, optionally counterfactual.
Body: entity, optional remove, hops, predicates, timestamp.
POST /path/cone
Golden paths: the provenance cone of a trajectory — which steps did its
falsifier-gated verified result depend on? Read-only. Body: trajectory (the
Trajectory IRI, required), optional via (array of derivation predicate IRIs
walked in addition to verifiedBy, which is always followed), hops (walk
depth, default 8), base_ns (vocabulary namespace override; defaults to the
store’s configured base_ns).
curl -s localhost:3030/path/cone -X POST \
-H 'Content-Type: application/json' \
-d '{"trajectory": "http://example.org/traj/42", "hops": 6}'
Returns the cone report: the trajectory, the hop bound, the verifications it
was checked against, and one entry per step carrying iri, order, verdict
(InCone / OutOfCone / CannotEvaluate) and the human-readable reason.
Refuses a trajectory with no steps or no falsifier-gated verification.
POST /path/backtest
Golden paths: replay a pruned candidate (the exemplar trajectory minus omitted
steps) over recorded history — which past trajectories sharing a work-item
topic would have conformed under gp-grammar/1, and how did their work items
close? Read-only. Body: exemplar (the exemplar Trajectory IRI, required),
optional omit (array of step IRIs the candidate omits), base_ns.
curl -s localhost:3030/path/backtest -X POST \
-H 'Content-Type: application/json' \
-d '{"exemplar": "http://example.org/traj/42",
"omit": ["http://example.org/step/3"]}'
Returns the backtest report: the exemplar, the grammar, the matched topics, one
row per replayed trajectory, and the conformer/deviator completion counts with
an explicit cannot_evaluate tally — 0 matches and “nothing measurable” are
never reported as the same thing.
POST /propose
Submit a schema-evolution proposal. Body: kind, target, diff, proposer,
optional rationale, trigger_ref, timestamp.
POST /proposals
List schema-evolution proposals. Body: optional status
(pending/accepted/rejected).
POST /proposal/accept
Accept a pending proposal. Body: id, decided_by (required), optional
note, timestamp.
POST /proposal/reject
Reject a pending proposal. Body: id, note, decided_by (required),
optional timestamp.
POST /entity_history
Return the full fact history (across transactions) for an entity. The body field is
iri, not entity — {"entity": ...} returns
{"error": "missing 'iri' parameter"}.
curl -s localhost:3030/entity_history -X POST -H "Content-Type: application/json" \
-d '{"iri": "http://example.org/svc"}'
Returns {"iri", "count", "history": [{"op", "predicate", "value", "tx", "valid_from", "valid_to"}, ...]}. The tx is the handle for /transactions below —
together they answer “which write path asserted this fact, and who owned it”.
GET /transactions
List transactions in the store, oldest first.
| Param | Effect |
|---|---|
| (none) | the whole log |
since=<tx> | only transactions newer than <tx> — the poller’s cursor, so a watermarked poll is O(new) rather than O(log) |
limit=<n> | clamped to 1..=10_000; applies from the start of the log, not the end |
There is no offset. Passing limit alone therefore returns the oldest N — on a
38k-transaction store, ?limit=40000 hands back transactions 1–10000 and nothing
recent. To look up a specific transaction, use ?since=<tx-1>&limit=1.
Each entry is {id, timestamp, actor, source, authenticated}. authenticated
is null when local credential evidence is absent; otherwise it contains
principal, credential_id (null for a shared bearer), and auth_class
(named_bearer or legacy_shared_bearer). It is separate from declared fields. source identifies the write path:
episode:<name> (/episode), set (/set), retract (/retract,
/episode/retract), or caller-supplied free text (/knot). actor and source are
both optional on /knot. Supply them for source provenance; authenticated
credential evidence does not reconstruct a missing source or historical actor.
POST /embed_backfill
Backfill embeddings for entities that lack them. Returns
{"status": "error", ...} when no embedding provider is configured; the
--embed-backfill startup flag instead exits non-zero rather than serving
without the capability it was asked for.
POST /embed_backfill_graph
Embed up to max_entities (default 256, clamped to 1-2000) entities of one
registered named graph that have no current vector. Body:
{"graph": "<IRI>", "max_entities": N}. Returns entities_embedded,
remaining (still un-vectored in that graph) and stale_skipped (entities
edited during the call, left for the next one). An unknown graph is refused.
Each call is deliberately bounded: pace repeated calls and watch server memory
between them rather than draining a large graph in one pass. Entities that
exist only in named graphs are embedded from their named-graph facts, and they
are searchable with quipu_search graph=<IRI>; unscoped search stays ROOT.
GET /preview/{iri}
Return a preview rendering of an entity by IRI.
Service Metadata
GET /version
What build is actually running: {"version", "git_sha", "git_dirty", "features", "read_contracts"}. The git SHA is the field that matters for “is the fix
deployed?” — a semantic version does not move when a fix lands. features
maps every declared Cargo feature to whether this binary compiled it in.
read_contracts lists recognized versioned source-acquisition contracts.
GET /metrics
Prometheus scrape endpoint (text/plain; version=0.0.4). Request counters
come from the middleware. Graph-size gauges use a per-store snapshot: a single
background task scans the live root graph at startup and again five minutes
after each refresh completes. Scrapes neither acquire database connections nor
trigger scans. WAL size still comes from a current filesystem metadata read.
quipu_http_requests_started_total counts HTTP arrivals before handler dispatch,
including pending requests, cancelled requests and metrics scrapes. It has no
labels and resets when the process restarts. Use it to measure arrival rate:
quipu_http_requests_total and quipu_http_client_requests_total count terminal
request observations, including dropped request futures with synthetic status
499. Endpoint histograms and client-duration counters include elapsed time until
that drop. quipu_http_requests_cancelled_total{client,endpoint} separately
counts dropped futures; its client budget is 31 named callers plus other, and
endpoint labels use route templates. Low terminal rates alone do not establish
low traffic.
quipu_http_auth_refusals_total{client,endpoint,method,expected_probe} counts
completed HTTP 401 responses, including protected GET requests. Observed
successful requests initialize the corresponding counter at zero. Methods
are bounded to GET, POST, HEAD and OTHER; routes use templates and caller
overflow folds into other. expected_probe is operational attribution,
not an authenticated identity: it is true for the designated link-check GET
caller and the designated negative-auth POST caller on /episode or /shapes.
Exclude these controls when measuring unexpected authentication failures,
and retain a separate check that the metric is being scraped. A newly seen
counter can start above zero, so an increase-only alert may miss its first
refusal.
Before the first successful refresh, graph-size gauges are omitted and
quipu_graph_counts_ready is zero. A failed refresh retains the last successful
snapshot and increments quipu_graph_counts_refresh_failures_total; it never
substitutes zero counts. quipu_graph_counts_age_seconds and
quipu_graph_counts_last_success_timestamp_seconds expose stale data, while
quipu_graph_counts_refresh_duration_seconds includes the last attempt’s pool
wait and scan time. These freshness signals must be considered when consuming
the graph-size gauges: a successful HTTP scrape alone does not prove fresh counts.
Caller attribution uses the normalized X-Quipu-Client header (falling back
to User-Agent, then unattributed) and is capped at 32 identities; overflow
folds into other rather than creating unbounded Prometheus cardinality:
quipu_http_client_requests_total{client,endpoint}— request count;quipu_http_client_request_seconds_total{client,endpoint}— wall time;quipu_store_wait_seconds_total{client,endpoint}— time waiting to acquire a store connection;quipu_store_held_seconds_total{client,endpoint}— store capacity consumed.
/query load is also counted by the caller’s DECLARED X-Quipu-Agent, so a
read-heavy client such as seeds can be split by the crew member it acts for.
The value is caller-declared and unauthenticated. It is normalised like the
client label and capped at 32 agents; a missing or unusable value is
undeclared and overflow folds into other. There is no task or endpoint
dimension, so the series count stays at most clients x 32:
quipu_http_agent_query_requests_total{client,agent}— completed/queryrequests (including dropped futures, like the client counter);quipu_http_agent_query_seconds_total{client,agent}— their wall time.
Writers declare structured provenance in five headers: X-Quipu-Agent,
X-Quipu-Harness, X-Quipu-Model, X-Quipu-Session and X-Quipu-Host. Their
values are not metric labels, except X-Quipu-Agent on /query above. On write routes the server classifies how
completely they were declared, and counts each COMMITTED write transaction once,
at commit. A refused or rolled-back request counts nothing, and commits the
engine makes on a request’s behalf (verdicts, reasoner materialization,
migration) are not counted as that client’s writes:
quipu_write_provenance_total{client,endpoint,provenance}—provenanceiscomplete(agent, harness and host, plus session and model when the harness isclaudeorcodex),partial, orabsent(none of the five headers);quipu_write_provenance_missing_total{client,field}— commits missing a required field (agent,harness,host,session,model).
A header that is present but blank counts as missing. Coverage per client is
rate(...{provenance="complete"}[1h]) / rate(...[1h]); the counters reset when
the process restarts, so use rates rather than raw values.
The server also writes one-line JSON request events to stderr for journald/Loki.
request_start makes a request that never completes visible. request_complete
adds status, duration_ms, and the actual auth_outcome; /query responses
also add query_shape and result_size. Logs contain normalized attribution
and bounded metadata, never the Authorization header or response body. Slow or
failed query text retains its existing separate diagnostic line.
Requests may declare a finite command source with X-Quipu-Source and an
underlying SDK operation with X-Quipu-Operation. Start and terminal logs retain
these separately as declared_source and declared_operation; missing,
unsupported, or duplicate values are null (UNKNOWN). Supported command sources
use st- or sd- plus anchor, plate, inbox, stop_policy, stop_event,
haul, tend, work, other, show, list, ready, where, search,
count, info, stats, create, update, close, or comments.
The component sources seeds and shantytown remain distinguishable from
command attribution. Operations are query, health, graphs, show, list, ready, where,
search, count, info, stats, tend, ready-count, create, update,
close, or comments. These declarations contain no arguments, paths, message
text, or credentials. They do not authenticate a caller or add metric labels.
An attributed census additionally requires a reader that retains these fields
and proves log coverage; an endpoint alone does not identify a command.
Dropping a polled request future also emits one request_complete with
completion_outcome: "cancelled", synthetic status: 499 and elapsed time.
No HTTP 499 response is delivered. Authorization remains pending and result
metadata is absent because no response established those values. Normal responses
use completion_outcome: "response". This records abandonment of the HTTP
future, including cancellation during shutdown; it does not prove that a blocking
worker stopped or that a write did not commit. Response-body streaming after the
handler returns is outside this measurement.
Completed /search, /hybrid-search, and /search-query pipelines exceeding 500 ms also emit
search_phase_slow. This anonymous record reports pipeline_ms, combined
admission_dispatch_ms, and phase_ms for embedding, reader acquisition,
tool execution, SQLite scan/decode/scoring, sorting, and survivor metadata.
Tool time contains the SQLite phases; these overlapping values must not be
summed. Admission includes the asynchronous permit wait and blocking-pool
dispatch, which this record cannot separate. The pipeline excludes HTTP body
parsing and response serialization and therefore differs from request duration.
Missing phases are null, including embedding for supplied-vector requests.
On Linux, thread_logical_read_bytes and thread_physical_read_bytes are
counter deltas from the same blocking thread, including embedding and retrieval.
They are null with thread_io_known=false if unavailable or inconsistent;
physical reads of zero can mean cached reads. They are not process totals and
do not count work performed on other threads. Records contain no query text,
entity names, caller labels, embeddings, or headers. Errors retain the phases
that ran and set ok=false. Cancellation before blocking work starts emits
no phase record; panics are not completion records. Library and CLI vector
calls outside these HTTP handlers do not emit records. This is diagnostic
evidence for individual slow requests, not proof of a historical cause.
UI assets (not documented individually)
GET / and GET /ui serve the built-in web UI; GET /quipu-components.js,
GET /graph-canvas.js, GET /datalinks.js and
GET /vendor/three.module.min.js serve its static assets, vendored so the UI
renders on an air-gapped deploy. They are part of the UI, not the API surface.
Export
GET|HEAD|PUT|POST|DELETE /rdf-graph-store
SPARQL 1.1 Graph Store HTTP Protocol using indirect graph identification.
Select exactly one target with ?graph=<absolute-IRI> or ?default. GET
returns the graph in the RDF syntax requested by Accept; HEAD returns the
same status and headers without a body. PUT replaces the graph, POST merges
the payload, and DELETE removes its contents (and a named graph’s registry
entry). Writes use the same bearer-token and read-only enforcement as Quipu’s
native write APIs.
curl localhost:3030/rdf-graph-store?graph=http%3A%2F%2Fexample.org%2Fg \
-X PUT -H 'Content-Type: text/turtle' --data-binary '@graph.ttl'
curl localhost:3030/rdf-graph-store?graph=http%3A%2F%2Fexample.org%2Fg \
-H 'Accept: application/n-triples'
Supported request and response syntaxes are Turtle (text/turtle) and
N-Triples (application/n-triples). Unsupported request media types return
415; unsupported response types return 406; unknown named graphs return 404.
POST /export
Export deterministic RDF from ROOT, one named graph, one episode provenance
group, or a SPARQL CONSTRUCT/DESCRIBE result. graph, group_id, and
construct are mutually exclusive. The handler uses the read pool, so
serializing a large export does not hold Quipu’s writer lock.
curl -s localhost:3030/export -X POST \
-H "Content-Type: application/json" \
-d '{"graph": "http://example.org/graphs/derived", "format": "turtle"}'
| Field | Required | Description |
|---|---|---|
graph | No | Named-graph IRI (omit for ROOT; unknown IRI → 400) |
group_id | No | ROOT entities attributed through prov:wasGeneratedBy to episodes in this group, plus those episode resources |
construct | No | SPARQL CONSTRUCT or DESCRIBE query to export |
format | No | turtle (default) or ntriples |
Returns the RDF document itself with the matching content-type, not JSON. N-Triples output is lexically sorted and duplicate-free. Blank-node dataset canonicalization belongs to the share-bundle layer; raw export preserves blank node labels.
Share Import and Composition
POST /import
Verify a v1 share manifest and its exact export.nt and shapes.ttl payloads,
then stage the resolved triples in a per-share named graph. Exact canonical-name
matches are rewritten to local IRIs; fuzzy matches are returned as review
candidates and never merged automatically. Local loaded shapes remain the
authority: bundled shapes are evidence only. Off-vocabulary or non-conforming
data is retained in a quarantine graph and is not eligible for promotion.
The request fields are manifest, export_ntriples, shapes_turtle, source,
and optional actor. The response reports staged, quarantined, or
unchanged, the stable import and graph IRIs, accepted/quarantined counts,
resolution candidates, the SHACL report, and promotion blockers. This is an
authenticated write endpoint.
POST /import/promote
Explicitly copy an eligible staging graph into ROOT. The body is
{"share_id":"sha256:...","actor":"optional"}. Quarantined shares have no
eligible staging graph and are refused. Importing never promotes implicitly.
Promotion preserves exact-fact ROOT retractions: replaying a snapshot cannot
restore a locally removed fact. suppressed_retractions reports withheld facts;
triples counts eligible snapshot facts, including already-present duplicates.
An explicit local reassertion can restore a fact. Source-graph membership remains
available as provenance even when a ROOT fact was removed. Foreign transaction
anchors are never compared with local transaction IDs.
Registries
These mirror their MCP tools (see the MCP reference) —
action-style managers where an unknown action errors rather than falling
through to list.
POST /ontology
Manage OWL ontologies: {"action": "load"|"materialize"|"list"|"remove", "name", "turtle", "timestamp"} (mirrors quipu_load_ontology). Registered
even without the owl feature — a build without it answers with an explicit
error naming the missing feature rather than a 404, so “not compiled in”
and “no such route” stay distinguishable.
materialize re-derives entailments from the ontologies already loaded,
without loading anything. It is the endpoint a scheduler calls, and it exists
because on a deployment with [quipu.owl] reactive_materialize = false there
is otherwise nothing that ever runs the reasoner: ReactiveOwl is not
registered, so the write path never materialises, and OWL entailment is dark
for every family rather than merely delayed (aegis-v3gf6u).
curl -s localhost:3030/ontology -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $QUIPU_AUTH_TOKEN" \
-d '{"action": "materialize"}'
# {"action":"materialize","ontologies":7,
# "materialized":{"same_as_inferences":42, ..., "total":118}}
It materialises the combined ontology, matching what the write gate
reasons over: an axiom in one document over a class declared in another is
invisible to either alone. A store with nothing loaded answers "ontologies": 0 with a null materialized and a note, rather than reporting a successful
run of zero — a scheduler has to be able to tell “ran, derived nothing” from
“there was nothing to derive from”.
The REST materialisation action copies current ROOT and companion facts through a read-pool connection, then derives on a private temporary SQLite database. SQLite removes this database when its connection closes; its page cache is limited to 8 MiB and its term cache to 32,768 entries. Provide a writable, disk-backed SQLite temporary directory with enough space for the current facts and indexes; a RAM-backed temporary filesystem defeats the memory saving. Derivation still allocates premise and proposal vectors. It does not hold ordinary write admission or the live writer during that work. Publication takes write admission in batches of at most 64 assertions, allowing other writes between batches. A server without a read pool refuses this action rather than falling back to a full scan under the writer lock. Concurrent materialisation requests are rejected; a disconnected client cannot admit another derivation while its blocking work is still running.
Successful responses include complete: true, snapshot_tx, elapsed_ms, and
applied_proposals. Completion means the captured snapshot was closed, not
that facts arriving afterward have been processed. Concurrent additions are
allowed; a retraction of a premise or a change to the loaded ontologies aborts
publication. Earlier committed batches can remain as partial historical
entailments after an error or interruption, but no new freshness marker is
published. Retry on the next scheduled run. Consumers must inspect freshness;
the companion graph does not automatically hide historical entailments.
Input snapshots are limited to three million current facts, and derivation is limited to 200,000 new assertions and 64 passes. Exhausting a budget is an error, not successful partial materialisation. These are work-size limits, not a hard wall-clock or memory guarantee: choose the cadence and service resource limits from a rehearsal of the actual ontology and workload. The retraction fence uses an index created at store startup; allow for its initial build on existing stores. Entailments remain in the companion inferred graph, never ROOT. The synchronous library/CLI materialiser is unchanged; this lock discipline belongs to the REST scheduling path.
POST /subscriptions
Event-push subscription registry: {"action": "create"|"list"|"delete", ...}
— register an HTTP endpoint to receive graph-change events pushed by the
server’s delivery worker (mirrors quipu_subscriptions).
POST /datasets
Named dataset registry: {"action": ..., "name", "members", ...} — declare a
named set of graphs queryable as one unit via FROM <dataset> or the graph
query param (mirrors quipu_datasets).
POST /queries
Stored named-query registry: {"action": "load"|"list"|"get"|"remove", "name", "template", "params", ...} — competency questions callable through
/ask alongside the compiled-in catalog; definitions are validated at load
and versioned (mirrors quipu_queries).
Governance
The REST half of the governance gate; each mirrors its MCP tool, where the semantics are documented in full.
POST /policy/check
Committed-tier evaluation of a governance Policy against a target: returns a
Verdict — outcome ∈ satisfied | unsatisfied | unknown bound to a
reproducible evidence_hash — signed when the store has a signing identity.
curl -s localhost:3030/policy/check -X POST \
-H "Content-Type: application/json" \
-d '{"policy": "http://example.org/policy/has-owner", "target": "http://example.org/svc"}'
| Field | Required | Description |
|---|---|---|
policy | One of policy/claim | Policy IRI whose aegis:claim to evaluate |
claim | One of policy/claim | Inline SPARQL ASK |
target | Yes | Target IRI bound to $target |
predicate_id | No | Recorded predicate id for inline claims |
evidence_probe | No | ASK for “does the evidence exist?” → unknown |
valid_at | No | ISO-8601 point-in-time |
POST /verifier/authorized
{"verifier", "predicate"} → {"authorized": bool}: may this verifier
attest this predicate, per the Phase-0 verifier registry?
POST /verdict/verify
Verify a signed Verdict against the Phase-0 root of trust:
{"predicate_id", "target_ref", "outcome", "evidence_hash", "tier"?, "verifier", "signature", "verdict"?, "signed_at"?, "tx"?} →
{"signature_valid", "verifier_registered", "verifier_authorized", "trusted", "as_of"} — trusted is the conjunction to gate on. The registry is read as of
the signature: pass verdict (the stored verdict IRI) to use the instant the
store recorded it.
Overlays
Scratch layers over the committed graph (bind-once to a parent branch):
hypotheses go in the overlay, the committed layer stays untouched. Mirror
the quipu_overlay_* MCP tools.
POST /overlay/create
{"overlay": "<iri>", "parent_branch": "<iri>"?} → {"g", "parent_branch"}.
Omitted parent_branch binds to ROOT.
POST /overlay/write
{"overlay", "op": "assert"|"retract"|"tombstone", "subject", "predicate", "object", "timestamp"?} → {"tx_id"}. tombstone masks the parent’s fact
in the composed view.
POST /overlay/compose
{"overlay": "<iri>"} → {"triples": [{subject, predicate, object}], "count"}: the resolved view over [overlay > parent-branch-root],
asserted-and-not-tombstoned, nearest wins.
Provenance Analytics
POST /cooccurrence
{"work_item": "<iri>", "valid_at"?, "tx"?} → the other work-items sharing
at least one touched code entity, via Bead ←implements− GitCommit −modifies→ entity, ordered by overlap strength (mirrors
quipu_cooccurrence).
Events
The durable graph-change event log (at-least-once delivery; consumers dedup by offset).
GET /events
Feed reads (/events, /changes, /transactions) use the WAL read pool when
available, so a writer holding the store mutex does not block them. An empty
read pool falls back to the writer. POST /events/commit remains a write.
The quipu_store_wait_seconds_total and quipu_store_held_seconds_total metrics
attribute each feed read to its normalized X-Quipu-Client and endpoint,
including requests that return an error. An event page and its lag gauge are
separate reads: a concurrent commit may increase the reported lag without
changing the page’s next_offset cursor.
Pull a batch of events in offset order.
| Param | Effect |
|---|---|
since=<offset> | start after this offset |
consumer=<id> | omit since to resume from this consumer’s committed offset |
limit=<n> | batch size, clamped to 1..=10_000 (default 100) |
types=<a,b> | filter by event type |
group=<g> | filter by provenance group |
Returns {events, next_offset, lag, committed_offset?}; pass next_offset
back as since (or commit it) to page forward — polling is a fixpoint, not a
rewind.
GET /events/stream and GET /changes/stream
Read-only Server-Sent Events replay of the corresponding durable feed. Supply a
nonnegative since or Last-Event-ID; if both appear they must agree. Event
frames have event type quipu.events, a delivered event-offset ID, and the
ordinary /events page as JSON data. Change frames use quipu.changes, a
transaction-ID cursor and the ordinary /changes page as JSON data.
Event streams accept types and group; change streams accept graph,
including a registered named graph. Unrelated filter kinds and invalid resume
cursors return HTTP 400. Exhausted stream capacity returns HTTP 503.
Delivery does not acknowledge a consumer. Commit applied data and its cursor
atomically, then reconnect from that cursor after a disconnect. A transaction
ID is not an event offset and must never be sent to /events/commit.
Fifteen-second heartbeat comments do not read the store; commit hints are
checked against the durable feed after crossing the writer barrier. An hourly
backstop covers writes from other connections. A quipu.error event has no ID
and terminates a failed or oversized page; keep the prior applied cursor.
The process admits up to 64 streams, reads one transaction/event per page and limits output frames to 1 MiB. One large transaction may require more memory before the output check; this is not a total heap bound. See the committed-feed protocol.
POST /events/commit
{"consumer_id", "offset"} — durably record a consumer’s cursor. Any offset
≥ 0 is accepted, including a lower one: that is the explicit replay knob.
Refusal events (write.refused)
A refused write never enters the graph, so the event log is where the attempt
is recorded (camayoc-0d3 — the incident-rate denominator: how many writes were
attempted and refused, by which gate). Every write-gate refusal — SHACL on the
episode and /knot paths, and the policy, authority, OWL and placement gates
in transact — appends a write.refused event after the refused write’s
savepoint has rolled back, so the event survives the rollback that the refusal
caused. A failure to record never masks the refusal error itself.
Payload: {gate, graph, actor, source, reason, refused_datums} where gate
is one of shacl | policy | authority | owl | placement, graph is the
destination graph IRI, reason is the gate’s own terse text (shape/policy id,
constraint name; truncated), and refused_datums counts what was refused.
Deliberately not recorded: the refused datum bodies. Refused payloads can be junk or sensitive — the event stores identifying metadata only.
Refusals inside speculate (counterfactual writes) are not recorded: the
whole speculation rolls back by design, so a hypothetical write’s refusal is
not a real one.
Query via GET /events?types=write.refused, or count by gate with the CLI:
quipu events refusals.
Linked-Data Surface
Standards-flavoured read endpoints for semantic-web tooling.
GET /changes
Returns fact-level change records after the optional since transaction.
capture selects new_values, old_and_new_values, or new_row; graph
optionally scopes the feed to one graph IRI. The response includes next_tx
and a watermark so consumers can distinguish an idle feed from a stalled one.
GET /entity/{iri}
Content-negotiated entity page: Accept: application/ld+json → JSON-LD,
text/turtle → Turtle, anything else → the web UI’s HTML page for the
entity. GET /entity/{iri}/json, GET /entity/{iri}/ttl and
GET /entity/{iri}/html pin the format in the path instead of the header.
For a full IRI containing path separators or a fragment, use the equivalent
query form: GET /entity?iri=https%3A%2F%2Fexample.org%2Fresource%231.
JSON-LD is compacted by default with a generated @context derived from the
loaded shape prefixes. Add ?expanded=1 for full IRIs and no compact
context.
POST /spotlight
DBpedia-Spotlight-style annotation: {"text", "confidence"?} → mentions of
known entities found in the text, with offsets and IRIs. The labeled-entity
list it scans against is generation-cached, so a burst pays the expensive
fetch once. The fetch reads current ROOT labels and asserted types; named
graphs and attached packs are excluded. Cold fills stream indexed rows,
retain the existing query deadline and row limits, and avoid intermediate
SPARQL binding tables.
GET /fragments
Triple Pattern Fragments: ?subject=&predicate=&object=&page=&pageSize=
selectors, each optional — a paged triple-pattern read for TPF clients.
POST /reconcile
OpenRefine Reconciliation API: a body without queries returns the service
manifest; {"queries": {...}} runs the batch and returns candidates per
query, scored the way /resolve scores.
RDF /knot loads accept blank_node_scope: distinct IDs separate repeated identical input; the same ID shares blank nodes across graphs only for byte-identical input. By default, whole-document bytes and destination graph define scope.
POST /search_query
Search first, then evaluate SELECT from at most100 distinct seed IRIs. Keyword mode defaults and needs no embeddings; hybrid fuses successful lexical and semantic retrieval with reciprocal rank fusion. Seed ranks and explicit bounded completeness accompany the ordinary query result. See the request and scope contract.
POST /graph/create
Register a committed graph before graph-scoped knot writes or labeling.
Input: {"graph":"urn:example:jobs","actor":"urn:example:agent","timestamp":"2026-10-14T12:00:00Z"}.
Only graph is required; timestamp defaults to current UTC and supplied times
must be RFC 3339. Returns graph, g, created and creation: {timestamp, actor}.
A repeat is idempotent and preserves the original creator/time; an interned
term alone is not registration. Actor is caller-declared provenance, never
permission. Older graphs retain a null actor instead of fabricated attribution.
An overlay cannot be reclassified as committed. This is a protected write,
sharing the server’s normal bearer/read-only guards and MCP handler.
POST /graph/label
Declare versioned graph labels. Required: graph, timestamp; optional:
actor, trust: {iri, chain, rank}, freshness, durability, policy token
array, kind, valid_to. At least one axis is required. Unregistered graphs,
unknown axis values, and empty labels are errors. Create first via
POST /graph/create or MCP quipu_graph_create.
GET /graphs returns each row’s creation together with class/lifecycle/labels.
The MCP-only recipe exercises the same handlers
without a REST client.