Files
wanderbooks/docs/receipt-store.md
T
swaitsandClaude Fable 5.1 af553a3706
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
docs: the 2026-10-28 drift review; the plan, status and docs brought to date
The planning session read plan/served.md, every design document and the
five branches the owner paused on 2026-10-27 against main (44ee4083),
with dry-run merges touching no branch. plan/2026-10-28_DRIFT_REPORT.md
lists every divergence and judges every recorded departure (all kept;
three owed back; one needs a decision: P6 made ledger-store depend on
ledger-import).

The plan: section 0 "What changed on 2026-10-28"; 8.0.1 and the unit
Status lines name S4.4, S5, S6, S7 and P6 as built but unmerged; 8.0.2
records the destination (D-56: the installed app, P11a starts now) and
the resume order with the known conflicts (the shared documents.rs
test file, the types.rs appends, P6 retaking schema 18, the law numbers
34 to 37); 8.0.3 lists the names the EM approved during the wave; P11a
gains step 0 (the macOS and Windows keyring stores, the Windows data
root and pipes, a cross-platform CI build: today the agent path runs on
Linux alone); Q-5 is due and Q-8 asks whether D-47 to D-54 stand; the
Landlock sentences in section 2 and server.toml are struck.

STATUS.md and HANDOFF.md say where things stand and how to resume;
roadmap.md and gui.md are rewritten to today. The docs lose their
stale facts: 122 tools, 2,700 tests, the parity floor at 21 with the
P4b notes the slices never wrote, the ten crates and their dependency
graph (jiff added to the core's allowed list), the cutover of
2026-10-20 recorded in capability-parity.md and parallel-run.md, the
P4b S1 law and its two Kani harnesses added to verification.md (law
34), the proof counts (51 and 9), and README, the site and install.sh
honest about macOS (the viewer runs; connecting an agent waits for
P11a's keyring store, and the installer now says so instead of failing).

just check on main at 44ee4083: exit 0 (167 suites, 2,871 passed, 0
failed, 7 ignored, the server-only build clean). No code changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 18:26:23 -06:00

196 lines
11 KiB
Markdown

# 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 copy taken mid-write, like a crash or
power loss mid-append, can end in a cut-off line (bytes after the last
newline). It was never acknowledged, so opening the books saves those
bytes to `journal.jsonl.torn.<time>`, truncates to the last newline, and
journals a `journal.tail_repaired` line; `books.digest` reports it.
Damage before the last newline is corruption and refuses to open: that
is for a human, never repaired automatically. The server does better
(plan 3.7): it commits the journal, the blobs, the vault, its key and
the access log to each book's own git repository (the blobs staged
ahead, the commit under the store's lock) and pushes it to the
owner's private repository and backup folder, versioned and
deduplicated, with full history (docs/mcp-interface.md, "Tools —
backups").
- **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
```json
{"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 (`seq` orders).
- `type`: `account.added`, `entry.posted`, `entry.reversed`, `invoice.created`,
`bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
period, payroll, and class events listed on `JournalLine`).
- `payload`: the full domain facts (account fields, entry with lines, blob
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
An `invoice.created` or `bill.created` written after the `documents_v2`
upgrade (plan P4b S4.3) carries only the new fields it names, so a
document naming none is byte for byte the payload every earlier version
wrote: the invoice's `number`, `terms`, `bill_to` and `seller` snapshots
(as they were at issue), `customer_ref` and `memo`; the bill's
`vendor_ref`, `received_on`, `terms`, `memo` and the `duplicate_reason`
that allowed a duplicate of a standing vendor invoice; and each line's
`account`, `quantity` and `unit_price`. The issued copy of an invoice is
**not** a field on the event (its attachment id is allocated after the
entry exists): it is a `receipt.attached` on the invoice's own entry
(`INV-0007.pdf`, `application/pdf`, "Issued invoice"), its SHA-256 the
`blob_sha`, linked to the invoice with `attachment.linked` and found
through that link (plan 8.3.11).
- `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);
a backup is then proven where it lands, by a drill (docs/mcp-interface.md,
"Tools — 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_document` return
`deduplicated: true` when the blob already existed.
- **Limits.** 25 MiB per file, as an upload (`books.begin_upload`): the
only way a file comes in (D-46: nothing is given inline). The name and MIME type are the client's, checked
when the file is filed: a name of 1 to 255 bytes, no `/` or `\`, not
`.` or `..`, no control or invisible characters; a `type/subtype` of
visible ASCII, at most 127 bytes (`docs/mcp-interface.md`, "Tools —
receipts & documents"). The type is stored as given; server-side
sniffing is future work (only a W-9 is judged by its first bytes).
- **Metadata** lives in SQLite (`blobs` table), 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_id` is nullable: a receipt can be filed before its journal entry
exists, then linked with `books.link_receipt`.
- `category` mirrors today's Drive layout: `formation`, `legal`, `tax`,
`accounting` (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting),
plus `receipts`.
- Documents filed via `books.file_document` and receipts via
`books.attach_receipt` share 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_reason` columns, 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 the `attachments` table.
## `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.v{N}.db`, falling back to
`~/.local/share/wanderbooks/wanderbooks.v{N}.db`, N the schema: each
schema keeps a file of its own, and a version never migrates, writes or
deletes another schema's (`ledger_store::db_file_for`; the legacy
`wanderbooks.db` is used only while it holds this version's schema;
rules.md B-27). The journal lives next to it
at `$WANDERBOOKS_DATA/journal.jsonl`; blobs under
`$WANDERBOOKS_DATA/blobs/`. WAL mode on, `synchronous=FULL`,
`integrity_check` on startup.
- **Schema.** `meta(key, value)` — `schema_version`, `base_currency`,
`last_applied_seq`; `accounts`, `entries`, `lines`, `blobs`, `attachments`
as 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. (Poll `WHERE 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.)
- **Document fields.** Since schema v16 (plan P4b S4.3) the documents'
new fields are columns: `invoices(number, terms, bill_to, seller,
customer_ref, memo)` and `bills(vendor_ref, received_on, terms, memo,
duplicate_reason)`, with terms and snapshots as canonical JSON TEXT and a
document written before them NULL there. A line's `account`, `quantity`
and `unit_price` ride its document's `lines_json`, as its project does.
The party's email and default terms are journaled and replayed into the
ledger; the projection keeps no party table to hold them.
- **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 (done)
The company's records were filed into the books at the P1 cutover
(2026-10-17, D-20: formation papers, notices, plans and history, through
`books.file_document` with their categories) and the books cut over on
2026-10-20. The Drive manifest and comparison tools once planned for P6
were struck (`plan/served.md` 8.4): the Drive folder is frozen history.
P6 (on its branch) adds the categories `notices`, `correspondence`,
`plans`, `history` and text extraction for every filed document.