Skip to content

Integrity and trust

This page records where docbank's integrity guarantees live and — just as deliberately — where they don't. Several of the decisions below have been proposed, evaluated, and declined during review; they are written down here so the rationale is auditable and doesn't have to be re-litigated from scratch each time.

Trust boundary

docbank is a single-user tool. The vault lives under a 0700 directory owned by the user, its location comes from the user's own environment (DOCBANK_HOME), and every docbank process runs with the user's privileges. Consequently:

  • In scope: crashes and power loss at any instant; filesystem bit rot; accidental damage to the vault or source trees (a stray symlink where a document or blob file belongs, a deleted file, a manual edit inside ~/.docbank); concurrent docbank processes; a stale or tampered object being silently vouched for. The vault's own directory structure is trusted — see the read-guarantee note below.
  • Out of scope: an adversary with the user's own privileges. Anyone who can race a running docbank process while rewriting the user's files already executes as the user and does not need docbank as a vector. Defenses whose only payoff is against that actor are declined by policy.

Which layer owns which guarantee

Guarantee Owner Mechanism
A referenced blob is durable blob.Write tmp → fsync → rename → dir fsync, on every path including dedup
A deleted blob stays deleted blob.Remove shard dir fsync before gc deletes the metadata row, including on the already-missing retry path
A stored object is what its name claims structurally blob.Write dedup check Kit performs a no-follow identity check; a wrong-sized object, symlink, or special file fails closed and is left unchanged for explicit recovery
Reads serve only stored blobs blob.Open / blob.Exists no-follow open + regular-file check on the blob itself; the vault's own directory structure above it is trusted per the boundary above (a symlinked shard dir is user-privileged relocation or tampering, not an attack docbank can meaningfully resist)
Metadata and audit authority are internally consistent docbank verify relational validation plus independent replay and reconciliation of canonical audit history
Permanent audit evidence and retained bytes agree docbank audit verify independent replay, an optional exact-prefix proof against externally recorded lineage/scope evidence, and a re-hash of every unique protected blob
Content matches its hash byte-for-byte docbank verify; POST /nodes/{id}/verify full-vault re-hash on demand, or a revision-bound fresh read of one file through the mixed store
No orphan blob file survives docbank gc reachability query for rows plus a directory scan for files that never gained (or lost) their row
Imports read the file they classified ingest O_NOFOLLOW + fstat at open, not the earlier Lstat/WalkDir classification
Remote writes match the writer's bytes POST /uploads required SHA-256/size declarations are compared with Kit's streamed result before any blob row or node authority commits
Mutations act on the node the caller named store id-addressed mutations (Move, Trash, Restore) require an If-Match revision precondition; path-addressed mutations (MovePath, TrashPath, backing POST /path/move and /path/trash) resolve and mutate inside one store transaction — either way, no path-race window between resolving a name and acting on it
Node identity is permanent schema AUTOINCREMENT ids plus trash_parent ... ON DELETE SET NULL — a dangling origin becomes NULL, never a reused id
Vault contents are private to the user home.Ensure the 0700 boundary is enforced on every open, not assumed: layout directories are tightened to 0700, the database file to 0600 (WAL/SHM inherit its mode), and blob files are written 0600

Accepted trade-offs

These were proposed (most more than once) and deliberately not taken.

No re-hash on the dedup fast path

blob.Write's dedup check verifies structure (regular file, expected size), not content. Re-hashing the existing blob on every duplicate import would double the I/O of the most common bulk-import case to detect exactly one thing the structural check misses: same-length corrupt bytes, i.e. bit rot or tampering.

That detection intentionally lives in docbank verify, and for a reason beyond cost: a corrupt blob is equally wrong for every node already pointing at it. Catching it only when a duplicate import happens to pass by is not an integrity guarantee — it's a coincidence. The systematic answer is a scan that covers every blob regardless of import traffic, which is precisely verify's contract.

Fail closed on an invalid canonical object

An existing wrong-sized file, symlink, or special file at a blob's canonical path is evidence of damage or manual modification, not a valid deduplication target. blob.Write returns a content-mismatch error and leaves that object unchanged; it does not replace a path whose identity may have raced or been tampered with.

Recovery is explicit: stop the daemon, move the suspect object outside the vault for diagnosis, restart the daemon, and re-add the content from a trusted source (or restore it from a verified backup). The durable write happens before the existing node is recognized as an idempotent ingest, so re-adding repairs a missing loose copy without creating a duplicate document. Run docbank verify afterward to confirm the vault.

No fd-relative directory traversal on import

docbank add walks source trees by path (filepath.WalkDir). A directory swapped for a symlink between classification and descent can redirect the walk — but only by an actor mutating the user's own source tree mid-import, which is outside the trust boundary above. The payoff (importing user-readable files into the user's own vault) does not justify platform-specific openat/fstatat machinery. The accidental case — a symlink sitting in the tree — is handled: it is classified, reported, and skipped, and the final open refuses to follow links regardless. The one explicit exception is a source argument whose final component is a symlink to a directory, which is common for cloud-storage roots. Docbank resolves that link before walking and continues on the resolved path, so retargeting the user-facing link after resolution cannot redirect the walk. Descendant symlinks are still skipped, while virtual naming and provenance retain the spelling the user supplied.

Watched inboxes use a stronger traversal boundary because they run repeatedly without an operator supervising each scan, and accidentally entering the live vault would create a self-ingestion loop. A watcher pins its resolved source with os.Root, opens every descendant relative to held directory descriptors, rejects symlink or reparse-point components, and refuses to cross a filesystem mount. Linux requires a kernel-provided mount ID; it does not substitute a device number that would mistake a bind mount for the source mount. Before any ingest, the watcher also compares every descended directory with the pinned vault-root identity. This catches a vault physically beneath the source even when separate bind aliases hide that ancestry from path comparison; the same identity check remains active during later scans.

Pre-release schema freedom

Docbank has not yet established a public storage compatibility boundary. Until the first public release, incompatible schema and JSONL changes replace the development shape directly while the format identifier remains version 1. Developer vaults created by earlier commits are disposable; do not build migrations, compatibility decoders, cutovers, or downgrade fences for them.

The first public release freezes that v1 contract. Any later incompatible change must begin by defining an explicit compatibility policy and real released-vault fixtures; that work is deliberately deferred until it is needed.

One vault lock holder

The daemon holds the portable vault file lock exclusively for its whole lifetime, acquired non-blocking at startup — a second daemon is refused immediately, never queued. Ordinary commands don't touch the lock at all: they are HTTP clients of the daemon. Maintenance (gc --run, verify, trash empty) is serialized against ordinary mutations by the daemon's in-process maintenance gate. See Ownership & Concurrency.

Next: Backup & Recovery covers the snapshot architecture these guarantees extend to; Troubleshooting applies them when something looks wrong.