HTTP API¶
The endpoints below exist in docbank daemon run and back the CLI's data
commands — the CLI is an HTTP client of exactly this surface, with no other
path into the vault.
Design test: an agent must be able to do everything the CLI can, through the API alone. Agents are not a secondary interface bolted onto a human tool; browsing, retrieving, filing, and reorganizing the tree must work for a client that only speaks HTTP — and the CLI itself takes no shortcut, so this is enforced by construction rather than by discipline.
Shape¶
Served by docbank daemon run: Huma v2 with the
humago (stdlib net/http) adapter, a typed OpenAPI contract
(docbank openapi, or GET /openapi.json / /openapi.yaml / /docs
on a running daemon), and X-Api-Key / Authorization: Bearer auth.
Endpoints are filesystem-shaped, under /api/v1:
| Endpoint | Purpose | Status |
|---|---|---|
GET /nodes/{id} |
stat by id (live or trashed) | Implemented |
GET /path?path=/a/b |
stat by virtual path | Implemented |
GET /nodes/{id}/children |
list a directory, paginated (limit/offset) |
Implemented |
GET /nodes/{id}/content |
stream document bytes with catalog identity and a computed digest trailer | Implemented |
PUT /nodes/{id}/content |
replace raw content under revision, size, and digest preconditions — see addendum | Implemented |
POST /nodes/{id}/revert |
create a new head from a prior version of the same file | Implemented |
GET /nodes/{id}/versions |
list immutable content versions newest-first, paginated (limit/offset) |
Implemented |
GET /nodes/{id}/provenance |
inspect immutable ingest-origin facts newest-first, paginated (limit/offset) |
Implemented |
GET /versions/{version_id} · GET /versions/{version_id}/content |
inspect or stream one immutable version by stable UUID | Implemented |
GET /content-references?sha256=&limit=&offset= |
find every stable node/version pair retaining a content hash | Implemented |
GET\|POST /tags · GET /tags/by-name · GET\|PATCH\|DELETE /tags/{tag_id} |
list, resolve, create, rename, or delete stable tag definitions | Implemented |
GET /nodes/{id}/tags · GET /tags/{tag_id}/nodes · PUT\|DELETE /nodes/{id}/tags/{tag_id} · PUT\|DELETE /path/tags/{tag_id} |
inspect and change tag assignments | Implemented |
POST /audit/preview · POST /audit/enable · GET /audit/status |
review permanent first-scope retention, enable the exact reviewed plan, and inspect authority or membership | Implemented |
GET /audit/history?path=&node_id=&limit=&cursor= |
read one audited node's canonical newest-first event timeline with a stable continuation cursor | Implemented |
GET /audit/scopes/{scope_id}/history?limit=&cursor= |
read canonical newest-first events across every member of one permanent scope | Implemented |
POST /audit/verify |
independently replay audit authority, optionally prove recorded evidence is an exact prefix, and re-hash every protected blob | Implemented |
POST /nodes/{id}/verify |
re-hash one file, bound to an inspected node revision | Implemented |
GET /search?q=&tag_id=&mime_type=&under_node_id=&modified_since=&modified_before=&limit= |
bounded name and extracted-content search (FTS5), optionally restricted by stable tag identity, current base media type, descendants of a live directory, and current node modification time, with match source and explicit truncated status |
Implemented |
POST /nodes · POST /path/mkdir |
create a directory beneath a stable parent ID or at one exact virtual coordinate | Implemented |
POST /ingest · POST /ingest/stream · POST /ingest/preflight |
import with JSON or streamed progress / inventory server-side paths — see addendum | Implemented |
POST /uploads?parent_id=&name= |
stream one digest-checked remote file — see addendum | Implemented |
PATCH /nodes/{id} |
move and/or rename, including resolving an absolute dest_path transactionally |
Implemented |
POST /path/move · POST /path/trash |
move / trash by virtual path, resolved and mutated in one store transaction | Implemented |
POST /batch/move |
validate and apply up to 1,000 moves as one final-state transaction | Implemented |
POST /nodes/{id}/trash · POST /nodes/{id}/restore |
soft delete / recover | Implemented |
GET /trash · POST /trash/empty {run, older_than} |
list (optionally paginated) / report or hard-delete trash roots | Implemented |
POST /gc {run} · POST /verify |
reclaim unreachable blobs / validate metadata and re-hash all blobs | Implemented |
GET /storage · POST /storage/pack · POST /storage/repack |
inspect usage / pack loose blobs / compact sparse packs | Implemented |
GET /jobs |
inspect daemon-owned background tasks and terminal failures | Implemented |
GET /watches |
inspect effective watched-inbox configuration and runner state | Implemented |
POST /backup/init · POST /backup/snapshots · POST /backup/snapshots/stream · GET /backup/snapshots |
initialize a repository / create with JSON or streamed progress / list snapshots | Implemented |
Root-level, outside /api/v1 and auth-exempt: GET /health, GET
/api/ping (daemon discovery), GET /docs and the OpenAPI documents,
and / plus /assets/ (the static web application, when [web] enabled). A hidden POST
/api/daemon/shutdown (not in the OpenAPI document) backs docbank
daemon stop; it isn't auth-exempt, so it requires both the API key and
its own shutdown token. The hidden POST /api/daemon/web-session exchanges
that master authority for a random daemon-lifetime browser token, an
independent upload-proof secret, and the fresh loopback origin dedicated to
that daemon lifetime, while
DELETE /api/daemon/web-session revokes the calling browser session. Those
tokens authenticate only the explicit routes used by the built-in document,
tag-definition/assignment, recoverable-trash, storage, job, configured-backup, and
verified-download workflows; they are intentionally not another general API
credential. Browser file bytes use the
hidden /api/daemon/web-upload WebSocket instead. The page verifies a
challenge proof over the upload secret before sending bytes, binds the socket
to one session, and never reconnects it. An ordinary browser token is
explicitly forbidden from POST /api/v1/uploads.
GET /nodes/{id}/children binds the live directory projection—including its
current canonical path—and the requested child page to one read transaction.
Refresh clients therefore do not combine an earlier directory name with a
later child listing.
IDs are canonical everywhere: every response carries them, and mutating endpoints address nodes by ID so a rename can't strand a concurrent client's reference.
For stable-identity moves, PATCH /nodes/{id} accepts either the lower-level
new_parent_id/new_name fields or one absolute dest_path; these forms are
mutually exclusive. The latter resolves POSIX-style destination semantics and
checks If-Match in the same transaction. POST /path/move remains the
coordinate-oriented form when the source path itself is the intended target.
Directory creation has the same identity-versus-coordinate choice.
POST /nodes accepts a previously resolved parent_id, so a concurrent parent
move does not change which directory receives the child. POST /path/mkdir
accepts {"path":"/projects/2026"} and resolves the existing parent inside
the creation transaction, so the exact coordinate cannot be redirected between
separate client requests. Both return the new node and its canonical path from
that transaction; neither creates missing ancestors.
POST /batch/move accepts {moves:[...]}. Each item selects its source with
either source_path, or node_id plus the revision previously inspected by
the caller, and supplies destination_path. Every selector resolves against
one pre-transaction topology. Each destination is an exact final coordinate,
and its parent resolves against the planned final topology; batch requests do
not apply the single-move “move into an existing directory” shorthand. Docbank
constructs and checks the complete final topology in Go before applying it, so
file and directory swaps and nested
reorganizations do not depend on unsafe intermediate names. A failure rejects
the whole plan. The response preserves request order and returns each node's
prior path plus its complete final node projection and path.
Backup repository endpoints¶
POST /backup/init accepts {"repo": "/absolute/server/path"} and returns
the repository identity and canonical path. repo may be omitted when
[backup] repo is configured. POST /backup/snapshots accepts the same
optional repository plus tag, jobs, and force_unlock; it returns a
stable logical summary rather than exposing Kit's physical manifest layout.
The /backup/snapshots/stream variant accepts the same body and returns
application/x-ndjson: zero or more progress events followed by exactly one
terminal result or error event. Progress data carries stage, item counts,
byte counts, and a final-stage marker, so clients can render bars without
parsing human text. Because response headers commit when streaming begins, an
HTTP 200 means only that the stream started; clients must read through EOF and
require the terminal event. The CLI uses this variant for human output and the
single-JSON endpoint for --json.
GET /backup/snapshots?repo=... returns
{repository: {id, path}, items: [...]} so clients can identify the repository
behind the immutable manifests and pagination can be added later without
changing a top-level array contract. A browser session may use this GET only
without repo, which confines the web application to the daemon's configured
repository; backup mutations and arbitrary server-path selection still require
the master API authority.
Explicit repository paths are server filesystem paths and must be absolute.
The CLI resolves a relative --repo against its own working directory before
sending it. API clients over an SSH tunnel must reason about the daemon host's
filesystem, not the caller's. Every endpoint requires the daemon API key.
Audit expected-evidence verification¶
POST /audit/verify accepts an empty body for a fresh proof. To prove ancestry,
send the evidence object from a previously successful report:
{"expected":{"vault_id":"...","lineage_id":"...","operation_sequence_high_water":12,"allocation_entry_count":12,"allocation_head":"...","scopes":[{"id":"...","entry_count":9,"chain_head":"..."}]}}
The current vault is independently replayed before comparison. A successful
prefix proof returns evidence_check: {"extends":true}. Evidence disagreement
remains an HTTP 200 verification report so clients can inspect current terminal
evidence and protected-byte problems together; evidence_check.problems uses
the stable codes audit_not_enabled, vault_mismatch, lineage_mismatch,
allocation_shorter, allocation_diverged, scope_missing, scope_shorter,
and scope_diverged. Malformed expected evidence is a 422 validation request
error.
Background-job status¶
GET /jobs returns {items: [...]} in stable job-name order. Each item carries
name, status (running, completed, failed, or cancelled), and a UTC
started_at; terminal jobs add finished_at, and failures add a bounded
error. Records describe this daemon run only and disappear when it restarts.
The endpoint is observation, not control: stopping a task requires stopping or
reconfiguring the daemon feature that owns it.
GET /watches returns {items: [...]} in stable watch-name order. Each item
joins the effective machine-local source, virtual destination, settle and scan
durations, and literal exclusions with its current watch:<name> job record.
The job is omitted only when no runner has been registered, which is not an
ordinary live-daemon state. Configuration remains file-owned: this endpoint
does not create, edit, or restart watches.
Path resolution: a query parameter, not a URL segment¶
GET /path takes the virtual path as ?path=/inbox/doc.pdf, not a
catch-all URL segment (/path/{path...}). Stdlib-mux decoding of a
wildcard segment makes a route ambiguous for names containing
/-adjacent percent-encoding; a query parameter has one well-defined
encoding instead. The path must be absolute (leading /); ?path=/
resolves the root. The server applies the store's existing NFC name
normalization and validation and returns 422 for an invalid path.
Concurrency: resource revisions and If-Match¶
Every node carries a revision that bumps on each mutation (directories
bump when their contents change). The granularity is deliberate: a
global tree ETag would invalidate every agent's in-flight work whenever
anything anywhere changed, while per-node revisions scope conflicts to
actual contention. SQLite already serializes the writes — preconditions
exist to catch lost updates across an agent's read-modify-write
turns, not to lock.
Every tag definition likewise carries a revision. It advances when its name
or assignment set changes, so a client cannot rename over a concurrent rename
or delete assignments it did not inspect. Single-tag responses carry an ETag
matching this revision.
If-Match is required where a mutation targets one existing node that
the caller read in an earlier request; path mutations, bulk operations,
and maintenance are explicit exceptions:
| Endpoint | Precondition |
|---|---|
PATCH /nodes/{id} |
required — target node's revision; an optional dest_path is resolved in the same transaction |
PUT /nodes/{id}/content |
required — prevents a replacement from overwriting a head the caller did not inspect |
POST /nodes/{id}/revert |
required — binds the selected source to the current head the caller inspected |
POST /nodes/{id}/trash |
required — target node's revision |
POST /nodes/{id}/restore |
required — target node's revision |
POST /nodes/{id}/verify |
required — binds the evidence to the exact node state the caller inspected |
PATCH /tags/{tag_id}, DELETE /tags/{tag_id} |
required — tag definition/assignment-set revision |
PUT\|DELETE /nodes/{id}/tags/{tag_id} |
required — target node revision; the tag revision also advances on a real assignment change |
POST /path/move, POST /path/trash |
none — the path is resolved and mutated inside one store transaction, so there is no separate read for a revision to guard |
POST /batch/move |
each path source resolves in the transaction; each stable-ID source carries its own required revision |
POST /nodes (create dir) |
none — creation has no prior revision; a name collision is 409 |
POST /path/mkdir |
none — the exact parent coordinate resolves inside the creation transaction; a name collision is 409 |
POST /ingest · POST /ingest/stream |
none — long-running bulk operations with per-path partial success; the destination directory may legitimately change while they run |
POST /uploads |
none — creates or idempotently resolves one file under the stable parent_id; name/content collision policy is transactional |
POST /trash/empty, POST /gc, POST /verify |
none — vault-wide maintenance, serialized by the maintenance gate |
POST /backup/snapshots, POST /backup/snapshots/stream |
none — mutations pause only while pinning one logical snapshot; a preservation lease queues maintenance for the full capture, and the repository has its own exclusive lock |
A stale revision gets 412 Precondition Failed, telling the caller to
re-read and retry. A required If-Match that's missing gets 428
Precondition Required. Both carry the problem-JSON error envelope
below explaining the rule.
Content identity and verification evidence¶
Every file-node representation includes a stable current_version_id plus
blob_hash, docbank's canonical lowercase SHA-256 content identity, and raw
size. Directories omit content identity. Node and version IDs are stable
across moves and renames. Content replacement retains the node ID, creates an
immutable version, and changes its current pointer, hash, media type, and
revision.
GET /nodes/{id}/content exposes the catalog identity before streaming in
X-Docbank-Content-Version, X-Docbank-Blob-Hash, and
X-Docbank-Blob-Size. It then hashes the bytes while they pass through the
response and emits the result as the
RFC 9530 Content-Digest
trailer. The response deliberately omits standard
Content-Length: HTTP/1.1 cannot carry a trailer on a fixed-length message,
and pre-reading a large loose or packed blob solely to populate a header would
double physical I/O. Clients that need independent transfer proof hash the
body themselves and compare both their digest and the trailer with the node's
blob_hash; the version header must equal current_version_id.
GET /nodes/{id}/versions returns a bounded, newest-first page with items,
total, limit, and offset. GET /versions/{version_id} resolves immutable
metadata globally, and its /content child streams that version with the same
identity headers and digest contract. A path rename cannot strand a retained
version reference.
GET /nodes/{id}/provenance returns the requested file node, its live path when
one exists, and a bounded newest-ingest-first page from one read snapshot. Each
fact carries its canonical SHA-256 identity, active/superseded state, ingest
identity and time, source kind and description, original path and mtime, and an
optional superseded-fact identity. A trashed node remains inspectable by ID but
has no live path. The route is observation only: it neither accesses the
source nor changes retention authority.
GET /content-references is the inverse identity lookup. It accepts one
canonical lowercase SHA-256 and returns only logical content_versions
references backed by blob-catalog authority; it never infers a match from a
loose file or pack entry alone. Each item contains the complete immutable
version, its current node projection, whether that version is the node's
current head, and a path only while the node is live. Results are bounded and
deterministic: live current references, live history, then trashed references.
POST /nodes/{id}/verify is the bounded server-side proof. It requires
If-Match from a prior node response, reopens the blob through the same mixed
loose/packed store used for downloads, and returns the recorded and computed
version ID, hashes, and sizes. Missing, corrupt, and unreadable content are successful
reports with verified: false and a problem; transport, validation, and
stale-node failures remain non-2xx responses. The route checks the revision
again after reading, so a concurrent rename, trash, or content replacement
yields 412 instead of ambiguous evidence.
The single-node route is exempt from the ordinary request timeout. It is bounded in scope, not necessarily short in duration: hashing one very large blob may legitimately take longer than a minute.
These are integrity receipts from the authenticated daemon, not non-repudiable attestations against a malicious server. Signed receipts or a transparency log are outside docbank's current trust model.
Addendum: POST /ingest, POST /ingest/stream, and POST /ingest/preflight¶
POST /ingest/preflight takes {paths: [...], exclude: [...]} and performs a
metadata-only source inventory. It opens no regular-file content and writes no
vault metadata or blobs. Its report includes file/directory/logical-byte totals,
pack-eligible, loose-only, and rejected size classes, exclusion/skip/error
counts, bounded findings, and extension summaries. The route uses the same
absolute-path validation, explicit root-directory-symlink behavior, exclusion
rules, loopback fence, and timeout exemption as the real ingest. Findings are
observations rather than a snapshot lock: sources can still change before
ingest, and metadata-only scanning cannot prove later content readability.
POST /ingest takes server-side local paths — {paths: [...],
dest: "/inbox", exclude: [...]} — and returns an IngestReport (added, skipped, excluded,
per-path failed entries), backing docbank add. Paths must be
absolute: the long-lived daemon's working directory is meaningless,
so a relative path is rejected with 422. The CLI resolves docbank
add's arguments to absolute paths before calling, so the command-line
UX still accepts relative and cwd-relative sources. Collisions resolve by the
same suffixing rules as other imports.
POST /ingest/stream accepts the same body and returns
application/x-ndjson. A metadata-only scan stage establishes advisory file
and byte totals, followed by ingest progress for bytes read and file outcomes.
Exactly one result carrying IngestReport or error terminates the stream;
HTTP 200 alone is not success. A write failure or client disconnect cancels the
request context used by traversal, blob writing, and metadata transactions.
Already completed files remain valid and converge on retry, while an
incomplete blob never receives node authority.
Because they grant "read any daemon-readable local path," POST /ingest and
POST /ingest/stream are checked per-request against RemoteAddr and
restricted to loopback callers regardless of bind address or API key — a
non-loopback client gets 403 (loopback_only). There is no remote file-upload
capability on this route: remote bytes use POST /uploads, while remote access
to the loopback-bound daemon still terminates through the configured SSH/VPN
tunnel.
Each exclusion is either a bare entry name, matched at any depth, or a relative
path containing /, matched within every supplied source. Matching a directory
prunes its subtree. The preflight and ingest implementations share this matcher
so reviewed selection and actual selection cannot drift.
Addendum: POST /uploads¶
POST /uploads?parent_id=<id>&name=<filename> accepts exactly one
multipart/form-data file field named file. The query uses a stable
destination directory ID rather than a mutable path. The multipart filename
must equal the normalized name query value, preventing the envelope and
requested tree entry from describing different files.
Two request headers declare the expected identity of the file part, not the multipart envelope:
X-Docbank-Blob-Hash: canonical lowercase hexadecimal SHA-256;X-Docbank-Blob-Size: raw byte length, bounded by docbank's explicit 4 GiB format-v1 backup ceiling.
The server streams the file once through Kit's durable writer and independently
computes both values. Only after they match, the closing multipart boundary has
been validated, and no extra parts remain does one metadata transaction grant
blob authority and create the node. 201 with status: "added" identifies a
new node and its initial content_create version. Repeating the same name, hash,
and parent converges to that stable node
with 200 and status: "skipped". The receipt always includes the server's
computed_hash, computed_size, and the node's ID and revision; clients compare
the values themselves.
Uploads are intentionally file-granular. A caller sending many files issues independent requests (concurrently when useful), so one failure never makes the success of another file ambiguous and each item can be retried on its own. Different content under the same requested name follows normal ingest suffixing rather than overwriting an existing document.
Objects through 64 MiB are eligible for packing. Larger accepted objects remain loose, but use the same content-hash authority, verified streaming, backup, and restore contracts. The 4 GiB admission limit therefore does not imply that a single object will be moved into a pack.
Digest or size disagreement returns 422 digest_mismatch or
422 size_mismatch and grants no new blobs row or node authority. Because
physical bytes are published before metadata by design, a rejected stream may
leave an authority-free loose object; the normal GC untracked-file scan removes
it. Malformed envelopes and extra parts are also rejected before authority.
Request bodies are capped at the declared size plus bounded multipart overhead,
and the route is exempt from the ordinary timeout so a legitimate large upload
is governed by client cancellation rather than a one-minute deadline.
Addendum: PUT /nodes/{id}/content¶
Content replacement accepts raw bytes rather than multipart. A caller first
reads the file node and sends its revision in If-Match, then declares the raw
body's canonical SHA-256 and byte count in X-Docbank-Blob-Hash and
X-Docbank-Blob-Size. Content-Type is normalized and stored on the new
version; an omitted value becomes application/octet-stream.
The daemon streams the body into durable authority-free storage and computes
the identity independently. Only exact agreement permits one metadata
transaction to create a content_replace version, advance the node's current
pointer, and bump its revision. The old head remains an immutable history and
GC root until the operator explicitly prunes that version. A
successful response includes the resulting ETag plus a receipt containing the
node, new version, computed_hash, and computed_size; clients compare every
field with the request before accepting success.
Clients should send Expect: 100-continue for large writes. The daemon checks
the target kind and revision before its first body read, then repeats those
checks in the committing metadata transaction. The early check avoids wasting
bandwidth; only the transactional check grants authority.
Stale revisions return 412 stale_revision; missing preconditions return
428 precondition_required; identity disagreements return
422 digest_mismatch or 422 size_mismatch. A failed operation grants no new
catalog authority, although a completely written loose object can remain
authority-free until GC. Because the body may be binary, this route is an
explicit exception to the raw JSON text validator; its byte count and digest
are the lossless boundary instead. Cancellation propagates through physical
writing and prevents the metadata transaction.
Addendum: POST /nodes/{id}/revert¶
Reversion accepts JSON {"source_version_id":"<uuid>"} and requires the
target node's revision in If-Match. The source must be an immutable version of
that same node and cannot be its current version. A successful transaction
creates a distinct content_revert row, copies the source's blob hash, size,
and media type into it, records source_version_id, advances the current
pointer, and bumps the node revision.
This operation is metadata-only: it neither streams nor copies the source blob,
whether loose or packed. Every existing version remains a reachability root
until explicitly selected by version pruning.
The receipt contains node, the new version, and source_version, while the
ETag carries the resulting revision. Clients cross-check all four authorities;
HTTP 200 alone is not sufficient evidence.
A stale target returns 412 stale_revision. A source from another node returns
422 version_node_mismatch, selecting the current head returns
422 version_already_current, and an unknown source returns 404 not_found.
Addendum: POST /nodes/{id}/versions/prune¶
Version pruning releases selected non-current history without changing current
content. Every request requires the inspected node revision in If-Match and
chooses exactly one selector: version_ids (at most 1,000 canonical UUIDs),
keep_newest, older_than, or all_prior. The default is a dry run;
"run":true performs the reported class of operation under the same node
revision precondition.
Ordinary selectors retain revert-source dependencies and report them
separately. all_prior may first install a same-byte, source-free checkpoint
when the current head is a revert, allowing the complete older graph to be
removed safely. A successful run advances the node revision once when it
deletes history and does not advance it for an empty selection. Deleted version
IDs stop resolving.
An older_than selector computes and returns its cutoff for each request. The
node ETag protects content-graph changes, but wall-clock aging does not advance
the revision; a later run can therefore include versions that crossed the age
boundary after a preview. Callers needing an exact replay execute the preview's
candidate IDs through version_ids.
The receipt separates logical history bytes from physical consequences. Shared
blobs remain reachable, authority-free loose blobs await GC, and dead packed
payload awaits GC followed by repack. Loose and packed counts may overlap when
one blob has both representations across stores; the receipt reports that
intersection as mixed_blobs_pending_maintenance, and its physical byte totals
cover all affected authoritative locations. Pruning itself does not claim
physical space reclamation.
Addendum: tags¶
Tag definitions use stable server-generated UUIDv4 identities, mutable,
unique NFC-normalized names, and a revision covering both the definition and
its assignment set. POST /tags, GET /tags, GET /tags/by-name,
and GET|PATCH|DELETE /tags/{tag_id} expose definition lifecycle. GET
/nodes/{id}/tags and GET /tags/{tag_id}/nodes provide bounded forward and
reverse listings; reverse results include a path only for live nodes.
live_only=true returns a bounded live projection and omitted_trashed count
from one metadata snapshot, which is suitable for an interactive live browser
without assembling paths and trash states across pages.
PUT|DELETE /nodes/{id}/tags/{tag_id} assign and unassign under the required
node If-Match revision. Their receipt contains the resulting node and tag,
changed, and the resulting node ETag. Repeating the requested state returns
changed: false without advancing either revision. A real assignment change
advances the node and tag once. Single-tag definition responses carry the tag
ETag; PATCH|DELETE /tags/{tag_id} require that ETag in If-Match. Renaming
advances the tag and every assigned node; deleting checks the current tag
revision before removing the complete assignment set and advancing each
assigned node. Deletion never removes nodes or document bytes.
Path-oriented clients use PUT|DELETE /path/tags/{tag_id} with {"path":"/..."}.
The store resolves that live path and changes the assignment in one SQLite
transaction. This is stronger than a separate path lookup followed by the
ID-addressed endpoint: moving an ancestor changes a descendant's path without
changing that descendant's revision.
Ingest provenance is currently filesystem-shaped: each import records the
source's original path and mtime in the store's provenance table. The path is
a record, never node identity. Non-file origin fields and lookup by content
hash are not part of the current API.
Maintenance gate¶
gc --run, trash empty, and verify need the vault quiescent while
they run — the same reachability-then-delete race described in
Ownership & Concurrency. Rather than the daemon's exclusive
vault lock (held for the daemon's whole lifetime, not per-request), an
in-process sync.RWMutex-shaped gate serializes them against regular
mutations: ordinary mutating handlers (PATCH, trash, restore, create,
ingest) take the read side and may run concurrently with each other;
maintenance handlers take the write side. Once maintenance is running or
queued for that write side, a new HTTP mutation fails immediately with
503 maintenance_busy instead of becoming an indistinguishable long wait.
Daemon-owned background jobs retain blocking gate semantics so a transient
maintenance run does not permanently fail a watcher or extraction worker.
Maintenance routes are exempt from the per-request timeout since
gc/verify can legitimately run long on a large vault.
Backup creation uses the mutation-exclusive side only for Kit's freeze window. Once Docbank's deferred read transaction is pinned, ordinary mutations continue into SQLite's WAL while verified metadata and blob streams are captured. The backup retains a separate shared preservation lease until capture ends; maintenance takes that lease exclusively, so GC cannot delete a loose blob or catalog mapping still referenced by the pinned snapshot. The create route is timeout-exempt; cancellation still propagates through Kit and prevents publication of a snapshot manifest.
Auth¶
X-Api-Key or Authorization: Bearer <key>, constant-time compared
against the daemon's effective key. The daemon always has one: with
[server] api_key unset it generates a fresh key at startup and
publishes it, inside the owner-private $DOCBANK_HOME, through the same runtime
record the CLI already uses for discovery — readable only by the
vault's owner, never sent over the network unencrypted, never logged.
Binds are loopback-only: the API is plain HTTP, so a non-loopback bind
would expose the key and vault contents in cleartext, and docbank
daemon run refuses to start on one — remote access goes through an SSH
tunnel or VPN (see Configuration). /health,
/api/ping, /docs, the OpenAPI documents, and the static web application at
/ and /assets/ are auth-exempt; everything else, including the shutdown route,
requires the key.
Error mapping¶
Errors are RFC 7807 problem-JSON with one extension member, code, a
machine-readable string clients branch on instead of parsing detail:
{
"title": "Conflict",
"status": 409,
"detail": "node \"report.pdf\" already exists",
"code": "exists"
}
code |
HTTP | Source |
|---|---|---|
not_found |
404 | store.ErrNotFound |
exists |
409 | store.ErrExists (name collision) |
cycle |
409 | store.ErrCycle (move under own descendant) |
audit_mutation_unsupported |
409 | the audited vault does not yet record this logical mutation class |
audit_already_enabled |
409 | an initial-scope preview cannot execute because audit authority was enabled concurrently |
audit_scope_overlap |
409 | the proposed scope shares a live or retained-trash member with permanent protection |
audit_scope_limit |
409 | the vault already has the maximum 1,000 permanent scopes representable by terminal evidence |
audit_preview_stale |
409 | the one-use enrollment preview expired, was consumed, came from another daemon, or no longer matches the vault |
audit_acknowledgment_required |
422 | enrollment execution omitted the explicit permanent-retention acknowledgment |
audit_not_enrolled |
422 | the selected node exists but is outside every permanent audit scope |
invalid_audit_cursor |
422 | the history cursor is malformed or belongs to another stable node or scope |
invalid_batch_move |
422 | a batch has no moves, too many moves, ambiguous selectors, or an invalid final-state plan |
stale_revision |
412 | store.ErrStaleRevision — If-Match didn't match the current revision |
not_dir / not_file / invalid_name / invalid_tag / not_trashed / is_root |
422 | store.ErrNotDir / ErrNotFile / ErrInvalidName / ErrInvalidTag / ErrNotTrashed / ErrIsRoot |
validation |
400, 415, or 422 | malformed request (bad If-Match, paths, media type, multipart envelope, or generated validation) |
precondition_required |
428 | required If-Match header missing |
loopback_only |
403 | server-path ingest or preflight called by a non-loopback peer |
digest_mismatch / size_mismatch |
422 | uploaded file bytes disagree with the required declaration; no node/blob authority committed |
too_large |
413 | upload exceeded its declared size plus bounded multipart overhead |
maintenance_busy |
503 | exclusive vault maintenance is running or queued; retry the mutation after it finishes |
pack_retirement_deferred |
503 | repack authority committed but an old source pack remains physically locked; release the lock, then run storage pack reconciliation |
unauthorized |
401 | missing or invalid API key; bad shutdown token |
web_session_read_only |
403 | a daemon-issued browser session attempted an endpoint outside its explicit attenuated allowlist |
web_unavailable |
503 | this daemon is not serving compiled web assets |
internal |
500 | unmapped error (still surfaced with a message — this is a single-user local daemon, not a hardened multi-tenant service) |
Non-goals¶
- No server-side rendering. The kit-ui application is static public code; it
receives a daemon-lifetime attenuated session from
docbank web. Reads, verified-download preparation, and digest-checked file upload remain ordinary authenticated API requests. The master API key never enters the browser. - No multi-user model: one vault and one master authority. Browser sessions are attenuated local capabilities, not accounts. Sharing is out of scope for v1.
- No MCP server.
- No remote-daemon mode or
[remote]configuration.