Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.
- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
and leaves the receipt filed, unlinked. The receipt policy holds: an
entry that requires a receipt can't lose its last one (structured
receipt_required error), so a replacement is attach-new, then
unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
attachment.withdrawn for an unlinked receipt or a filed document: hidden
from list_receipts (unless include_withdrawn), never linkable again. The
blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
Before, re-linking silently moved a receipt off a posted entry,
bypassing the policy; evidence now leaves an entry only through a
recorded unlink. dispose_asset checks its receipt ids before posting
instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
stores migrate on open and replay rebuilds the projection.
Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
8.3 KiB
Receipt & document store + persistence spec
Three pieces: the journal (the source of truth), the blob store (the bytes), and SQLite (the queryable projection). All immutable-first, matching the ledger's philosophy: the books never forget, and neither does the filing cabinet.
The journal: the source of truth
SQLite is the query engine, not the source of truth. The source of truth is an append-only journal of domain events: one JSON object per line (JSONL), hash-chained. This is the deliberate answer to "insanely durable, easily backupable, journable":
- Durable. Every state change is an immutable fact appended to
journal.jsonl, fsync'd per append. Each line carries the SHA-256 of the previous line's canonical bytes (prev), forming a tamper-evident chain: bit rot, torn writes, and casual editing are detected on read/verify, not discovered at tax time. Honest scope: this detects corruption and casual tampering. It does not stop a determined attacker with full write access from rewriting the chain — nothing local does. - Backupable. The backup is
cp -r $WANDERBOOKS_DATA /backup: the journal file plus the blob directory. Plain files, no WAL-file timing windows, no special backup API. A torn tail from a mid-copy is detectable (the last line fails hash/parse) and recoverable (truncate to the last good seq — the projection never saw it). Better: the journal is git-friendly, so an optionalwanderbooks backupthat commits it to a private git repo gives versioned, distributed, deduplicated backup with full history. - Journable. It literally is a journal:
{seq, ts, type, payload, prev}. Greppable, diffable, replayable. The audit trail isn't a feature bolted on — it's the storage format.
Write path
There is exactly one write path: append to the journal, fsync, then apply
to SQLite. No dual writes, no background applier thread (the volume
doesn't justify it): the MCP server appends, fsyncs, applies the new lines
to the projection synchronously, and returns. A crash between append and
apply is safe — on startup the applier replays every journal line with
seq > last_applied_seq.
Line format
{"seq": 42, "ts": "2026-10-10T18:20:00Z", "type": "entry.posted",
"payload": { "...": "..." }, "prev": "abc123..."}
seq: 1-based, gapless, assigned by the writer.ts: UTC, RFC 3339. Recorded, never trusted for ordering (seqorders).type:account.added,entry.posted,entry.reversed,receipt.attached,receipt.linked,receipt.unlinked,document.filed,attachment.withdrawn(plus the A/R, A/P, asset, period, payroll, and class events listed onJournalLine).payload: the full domain facts (account fields, entry with lines, blob sha, ...). Everything the applier needs; nothing it must look up elsewhere.prev: hex SHA-256 of the previous line's canonical JSON (keys sorted, no whitespace). The genesis line uses"prev": "000...0"(64 zeros).
Drafts are journaled (durable)
Draft entries are durable: entry.created and entry.line_added journal
lines record the draft as the agent builds it, so drafts (and receipts
attached to them) survive server restarts. Drafts never touch the SQLite
projection until posted — reports only see posted entries. A draft that
never posts leaves its created/line_added lines in the journal, which
is correct: the journal records what happened, including abandoned work.
Verification
wanderbooks verify (a ledger-store API, later a CLI subcommand):
re-read the journal, recompute the hash chain, replay every line into a fresh
in-memory Ledger, and compare its reports against the SQLite projection's.
Any mismatch is a loud failure. Run it on startup (cheap at this scale) and
before backups.
Blob store
- Content-addressed. The key is SHA-256 of the exact bytes. Path:
$DATA/blobs/<first2>/<next2>/<full-hex>. Same bytes, same path, forever. - Immutable. Blobs are never modified or deleted by the application. A retention/deletion policy is an explicit future human decision, not an accident of implementation.
- Deduplicated. Attaching the same receipt twice stores one blob and two
attachment rows.
attach_receipt/file_documentreturndeduplicated: truewhen the blob already existed. - Limits. 25 MB per file by default, configurable. MIME type is accepted from the client and stored; server-side sniffing is future work.
- Metadata lives in SQLite (
blobstable), not in the filesystem:sha256 PK, size_bytes, mime_type, original_name, created_at.
Attachments & documents
attachments:id PK, blob_sha FK, entry_id FK NULL, category, description, created_at.entry_idis nullable: a receipt can be filed before its journal entry exists, then linked withbooks.link_receipt.categorymirrors today's Drive layout:formation,legal,tax,accounting(today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting), plusreceipts.
- Documents filed via
books.file_documentand receipts viabooks.attach_receiptshare the blob store; they differ only in metadata. - Receipts change by events, never by edits.
receipt.unlinked(attachment_id,entry_id,reason) detaches a receipt from its entry;attachment.withdrawn(attachment_id,reason) retires an unlinked receipt or a document (withdrawn_at,withdrawn_reasoncolumns, schema v5). Linking requires an unlinked, non-withdrawn attachment, so evidence only leaves an entry through a recorded unlink. Blobs are untouched: a withdrawn file is still hash-verified and readable, and the journal shows when it was filed, linked, unlinked, and withdrawn, and why. - The store is policy-free: it records attachments faithfully. Whether a
posting requires a receipt is decided by the MCP layer's receipt policy
(
docs/mcp-interface.md), which reads theattachmentstable.
ledger-store: the SQLite projection
SQLite is the read-optimized projection of the journal. Queries, reports,
the MCP server's reads, and the future GUI all hit SQLite — and SQLite is
disposable by design: rebuild drops it and replays the journal.
Suspected corruption is fixed by rebuild, never by forensics.
- Location.
$WANDERBOOKS_DATA/wanderbooks.db, falling back to~/.local/share/wanderbooks/wanderbooks.db. The journal lives next to it at$WANDERBOOKS_DATA/journal.jsonl; blobs under$WANDERBOOKS_DATA/blobs/. WAL mode on,synchronous=FULL,integrity_checkon startup. - Schema.
meta(key, value)—schema_version,base_currency,last_applied_seq;accounts,entries,lines,blobs,attachmentsas above;events(seq, type, payload, created_at)— the applier's output, a queryable mirror of the journal stream and the feed the future GUI tails. (PollWHERE seq > ?; later SSE over the HTTP transport. This is the correct answer to "watch its files to update live" — watch the event stream, not the files.) - Amounts as TEXT, never REAL. Unchanged and still load-bearing: a REAL column would silently reintroduce binary floats into the money path.
- Append-only, three layers. (1) The engine's API never mutates posted state. (2) The journal is append-only by file discipline plus the hash chain. (3) The applier only ever INSERTs into the projection. The SQLite triggers from the earlier draft are deliberately dropped: they protected the projection, which is disposable — the journal is the trust anchor, and piling mechanisms on the disposable layer adds complexity without adding safety. Simplicity is a durability feature too.
- Migrations. Numbered SQL files, applied in order, bumping
meta.schema_version. Every migration is tested by rebuilding a projection from the journal and comparing reports before/after. A newer schema version than the binary understands is a hard error, not a guess.
Migration from Google Drive
Backfill walks the Drive Wanderland Labs LLC folders and files each
document via the equivalent of books.file_document with its category.
The Drive folders stay exactly as they are until the capability-parity
cutover rule (see docs/capability-parity.md) is satisfied. Migration is an
implementation task, not part of this spec — but the spec is written so the
migration is a straightforward script, not a redesign.