# wanderbooks Served: the plan ## Decisions of 2026-10-16 Tobin Harlow made these decisions on 2026-10-16. Where anything below conflicts with them, they win, and the plan has been revised to match. 1. **The books live hidden away, as you.** Your computer gets no separate system account, no sudo and no containers. The books server runs as you, only while something you started is using it: the app, an agent session, or a terminal (amended 2026-10-18, D-21 and D-23: no background service). It keeps the books in a private folder that only your account can open, outside every project folder. Nothing the agent is told or shown ever names that folder. This means an agent cannot stumble into the books. An agent that went looking on purpose could still find them, and the books say so plainly ("same user, advisory"). Anyone who wants a hard wall runs the same server on another machine they control, such as a home server, a NAS or a rented server. 2. **The viewer reads the books directly, read-only, as it does today.** It never changes anything and never goes through the agent's connection. When the agent asks it to show you something, the request reaches the viewer through the books' own private folder, not through the agent's tools. 3. **No extra pop-ups.** Posting, closing and reconciling add no new permission prompts. The agent still shows you each entry before posting it, and a hidden check makes sure that what gets posted is exactly the preview the agent was given to show you. 4. **Your own server now, a paid hosted service later.** This plan builds the recipe for running the books on another machine you control, and claude.ai on the web can reach that machine. A hosted service for many customers is described separately at the end (section 11). This plan does not build it. 5. **Decided by the engineer from the standards:** - The company EIN is kept in the books' settings and shown masked. - Audits use the recommended review bar. The CPA package refuses to go out without a full counted audit unless a reason is given. - Odd-cent splits between members are tracked from the company's start. - New prepaid schedules are spread by the day. - During the re-audit, old reversals posted without their own receipt borrow the original entry's receipt. - The company's time zone is America/Denver. - Photographed receipts get no text recognition for now. - The CPA gets tax ID numbers through a one-time link that you send. - Emailing receipts in waits for the hosted service. - The test-only direct mode stays for tests and demos. 6. **Three questions wait for the move to the server (P2):** - Where should the automatic backups go? - Should the papers in the `company/` folder be filed into the books? - Should the old copies of the books in the project folder move into the private folder? This is recommended, and nothing is deleted. 7. **Light governance.** The two-member LLC gets no consent paperwork, spending limits, signed memos or amendments. Decisions are recorded with who made them and when. Getting your go-ahead before household reimbursements is a note, not a lock on posting. Approval rules exist in the engine for other companies, but they stay off unless a company turns them on. 8. **Plain language.** the owner reads the Summary and the User flows, so both are written in plain English. --- Wanderbooks becomes a books server that owns its own data, and any agent can use the books from anywhere through the MCP. This plan starts from the winning proposal ("Served Books, built incrementally": `ledger-mcp` grows into the server in place). It adds the best ideas from the "Books as a Tenant" proposal (B) and the "One MCP, any agent" proposal (C), fixes every weakness and decision violation the three judges named, and states each resolution. Paths are relative to `/home/swaits/Code/wanderbooks/crates/` unless they are absolute. `S:` means `ledger-mcp/src/server.rs`. These references were spot-checked against the source: `INSTRUCTIONS` S:57, `lock()` with a std `MutexGuard` at S:553, `open_vault` S:521, `redact_refusal` S:870, `today()` S:1943, `import_local_file` S:4511, `call_tool` S:8160, `get_info` S:8171, `cash_flow` `ledger-core/src/reports.rs:1231` with the "Owner distributions" label at :1437, `split_first` `ledger-core/src/shares.rs:197`, `apportion` `ledger-core/src/kernel.rs:309`, `built_in_gain` `ledger-core/src/assets.rs:204`, 20 `#[kani::proof]` in `ledger-core/src/proofs.rs`, and `verify/lean/Wanderbooks/{Apportion,Statements,Ledger}.lean`. This session's own copy of the wanderbooks instructions arrived cut off at "Prepaid expenses and deferred revenue: record the original… [truncated]", which confirms the 4,096-character client limit. --- ## 0. How to read this document Written 2026-10-26, when the plan was reworked for a team of engineers working in parallel. Everything below it is the plan; this section says how its parts fit. **What is where.** Sections 1 to 7 are the design: what the system is, the invariants it enforces, its architecture, the agent surface, how state migrates, the threat model, and the owner's flows. Section 8 is the work: 8.0 how it is organized (status, dependencies, shared files, the unit template, the recipes), 8.1 what has landed, 8.2 every remaining unit in merge order, 8.3 the P4b reference design, 8.4 what is deliberately not scheduled. Section 9 is cross-cutting risks, section 10 the decisions and the open questions, section 11 the hosted service this plan does not build. `plan/STATUS.md` says where the live books stand. **Precedence.** The owner's decisions win: the eight at the top of this document (made 2026-10-16), then every D-n row in 10.1 in order of its number (a later decision amends an earlier one where they conflict, and says so). Then this section 8. Then sections 1 to 7, which describe the design as planned and carry **As built** notes and strike-throughs where the build or a decision departed from it. Where section 8 and an earlier section still disagree, section 8 is right and the earlier text is a documentation defect: fix the earlier text, never the unit. **Superseded text.** A sentence a decision made false is struck through in place with the decision that did it: `~~the old words~~ → D-46`. A longer passage carries a bold bracket instead: **[Superseded → D-32: no confinement was built.]** An **As built (date, commit)** block under a section lists departures only and never rewrites the scope above it. A reader implementing anything reads the As-built block before the scope. **Code references** are file paths and symbol names (`crates/ledger-core/src/ledger.rs::Ledger::stands_on`), never line numbers. Line numbers that remain in sections 1 to 7 (`S:553`, `ledger.rs:1355`) date from 2026-10-16 and are wrong today; treat them as "search for the symbol". **Abbreviations.** - **D-n**: a decision, 10.1 (settled), 10.2 (deferred); **Q-n**: an open question for the owner, 10.3; **A-n**: an owner answer about receivables and payables, 8.3.1. - **I-n**: an invariant the code enforces, section 2. **B-n**: a rule in `docs/spec/rules.md` (B-1 double entry … B-38 documents; B-39 to B-45 are allocated in 8.0.3). **R-x**: a recipe in 8.0.5. - **Pn**: a phase; **P4b Sn**: a slice of P4b (S1 to S7); **P2 slice n**: a slice of P2 (1 one executable, 2 the data root, 3 opening and closing the books, 4 access, 5 files in and out, 6 the viewer and `ledger-package`, 7 backups and restore, 8 the viewer's menu and setup, 9 the cutover). **U0a, U0b, U0c**: the pre-step units of 8.2. - **S:**: `crates/ledger-mcp/src/server.rs` (its line numbers are stale; see above). **T1 / T2**: the local desktop deployment / the self-host deployment (3.8). - **defect n**: a finding of the 2026-10-16 audit of the first company's books (1 to 20), of P0 (21, 22) or of P3 (23, 24): 1 cash flow (P3), 2 reversals posted without their own evidence (closed by D-12 and D-47), 3 receipts as blobs (P2 transport part; P6 text), 4 duplicate attachments (P6), 5 drafts (P4), 6 the digest (P4), 7 the IIF (P3), 8 tax categories (P3), 9 schedules (P3), 10 annotations (P6), 11 party TIN last-four (P3), 12 the 1099 worksheet (P3), 13 assets' bases (P3), 14 party shares (P3), 15 rename (P3), 16 period defaults (P3), 17 the odd cent (P3), 18 `not_reportable` (P3), 19 `import_local_file` (P0, P2), 20 the migration scripts (retired at the cutover; no tool), 21 the IIF ignores `as_of` (P3), 22 depreciation's final month (P3), 23 a vendor marked "no 1099" (P3), 24 accruals through other liabilities counted twice (P3). - **GL** general ledger; **TB** trial balance; **A/R, A/P** accounts receivable, payable; **EOM** end of month; **EM** the engineering manager who assigns units and merges; **the first company**, **the owner's books**: the books the plan is proven on, whose figures never appear in this repository (5.6). - `a | b` in a tool's arguments means exactly one of the two. **What changed on 2026-10-26.** A nine-stage review (`plan/review-2026-10-26/`) found the plan sound as a design record and not executable as a hand-off. Section 8 was rewritten: 8.0 to 8.4 are new; P4 was rewritten against the P4b slices built after it and D-47 (reversals stay posted); P6, P7, P8, P9, P10, P11 were cut to what the owner's decisions ask for; superseded sentences in sections 1 to 7 and 9 were struck through; the owner's P4b answers were numbered; decisions D-47 to D-54 and questions Q-4 to Q-7 were added. The review's reports list every finding and what was done with it. --- ## 1. Summary **Today.** Your books are a folder inside your project directory. That folder holds the journal, the receipts, the settings, a list of helper commands and the agent's instructions. The agent opens the books by starting a helper program itself, so it can also read every file in the folder directly. That is how an agent ended up reading the journal file instead of asking the books. **After this plan.** The books move into a private folder that belongs to a small books server. The server runs as you, only while the app, an agent session or a terminal is using it. Agents are never told where the folder is, and they only talk to the server. In practice: - You install once, with at most three commands and no administrator password (on Linux, the package manager asks as it always does). After that you just type `claude` in any folder. There is no project folder, there are no helper commands, and there is nothing to do at the start of a session: starting the agent opens the books. - Claude Code and Codex reach the same books the same way. Later, claude.ai on the web does too. - When an agent connects, the books tell it the rules, your company's conventions, what needs attention and what to do next. Step-by-step procedures also come from the books: setting up, the month-end close, handing off to the CPA, and the audit. - Receipts reach the agent as readable text and pictures of the pages, not as giant blobs of data. Files go in and out through short-lived links. - Social Security numbers and other tax ID numbers are typed and viewed only on a private page that asks for your passphrase. The agent only ever sees the last four digits. - Backups and a tamper check run on their own. - The viewer works as it does today. It reads the books directly and never changes them. Within a second it jumps to whatever the agent shows you. - Posting, closing and reconciling bring up no new permission pop-ups. The agent still shows you each entry before it posts. **How private is it?** Hidden, not locked. An agent cannot stumble into the books. Because the server runs as you, though, an agent that deliberately went hunting could find them, and the books say this plainly wherever privacy is reported. If you ever want a hard wall, run the same server on another machine you own. This plan includes that recipe, and the same setup is how claude.ai on the web reaches your books. **Along the way**, the 20 problems found in the 2026-10-16 audit get fixed, and a 21st found while capturing the goldens in P0. The finish line is a full re-audit of the first company, done only through the books' own tools, in which every receipt and every entry is checked and signed off. --- ## 2. Principles: invariants the code enforces | # | Invariant | Enforced by | |---|---|---| | I-1 | **The MCP is the only agent interface to the books.** Locally this is **advisory** (owner decision 2026-10-16): the server runs as the human, and the books are hidden, not locked. Mechanical enforcement is the self-host deployment on another machine, where agents have no access to the data root. | A data root outside every project directory (3.4), mode 0700, created with an explicit `set_permissions` call (not the umask). No tool, instruction, handbook text or result names a filesystem path (I-2, I-12). The stdio data-dir transport is gone (D-44): books are served only by the books server. `books.start`, `wanderbooks doctor` and the docs report `isolation: "same_user (advisory)"`. Landlock and seccomp confine the server itself where available; they do not restrict the agent and are never described as if they did. Test `agent_uid_cannot_read_tenant`, run in CI against the two-user self-host deployment, not the desktop default. | | I-2 | **No tool takes or returns a server filesystem path.** | Schema lint `no_path_params`: the build fails on any parameter matching `*path*`, `save_to` or `*_file`. Landlock harness `no_tool_touches_outside_tenant`, run with fuzzed arguments. Static check: no `std::fs` in `ledger-mcp/src/tools/**` outside the ledger-store APIs. | | I-3 | **The journal holds all books state.** Settings, conventions, decisions, draft states, evidence links, annotations, extraction records and attestations are journaled. Reports are pure functions of the journal. `wanderbooks.toml` is import/export text only. The server never reads a toml file. | `ledger-core::settings` replay. Property test `settings_replay_is_pure` (Store, Snapshot and the view are identical). Test `toml_round_trip_zero_events`. The daemon binary contains no toml file-read code path. | | I-4 | **Money is exact Decimal, every entry balances, and posted entries are permanent.** Corrections are posted reversals; a reversal's evidence is its original's (D-47). ~~Corrections are reversal drafts that need evidence.~~ → D-47 | Existing kernel, Kani and Lean. New `Step.discard` (P4) with its preservation proof. ~~`Step.draftReversal`~~ → D-47 | | I-5 | **Evidence gate.** One function, `ledger_core::evidence::requirement` (P4), decides whether a draft entry may post. Store, MCP and GUI all call it. Reversals are exempt: their evidence is the original's (D-47, D-12). Depreciation, amortization, closes and the entries the document, payroll, payout and disposal commands post are exempt by origin, and the exemption is sourced. ~~Reversals are not exempt.~~ → D-47 | The gate is called from `Store::post_entry` (P4 step 2). Test `gui_and_server_gate_agree`. Test `a_reversal_needs_no_receipt_of_its_own`. ~~`reversal_needs_evidence`~~ → D-47 | | I-6 | **A confirmed commit is bound to the server's most recent preview of that exact draft or operation** (a stale or altered preview is refused). It does not prove that a human saw the preview; it detects drift (D-50). | `preview` = SHA-256 of the canonical preview payload (a draft's state and the settings seq; or the tool, its canonical arguments, the settings seq and the journal head seq), returned by every preview and required by every confirm from P4 on; a mismatch is refused with guard `stale_preview`. ~~`preview_token` = HMAC-SHA256 … keyed with a server secret held in `keys/`~~ → D-50 (a same-user key proves nothing; a digest detects the same drift). | | I-7 | **A TIN passes through the agent only on its way into the vault; no result shows one except the TIN-bearing exports the owner chose (D-28, D-29).** ~~The agent never holds a full TIN or vault key material.~~ → D-28 | The v1 vault (`ledger-vault`, one key in `keys/legacy.key`, carried in every backup: D-31, D-48). A pattern-based TIN scanner (`has_full_tin`, `redact_full_tins`; P7 fixes its separators) fails closed on every result and masks the access log. The canary TIN tests in `crates/ledger-cli/tests/spec_vault.rs`. ~~HPKE, keyslots, human-page handlers, the GUI unlock path~~ → D-48 | | I-8 | **The GUI never mutates and never uses the MCP.** Locally it reads the data root directly, read-only, as today. It finds the root from the platform's well-known location or from `wanderbooks gui `, never from a project directory or `wanderbooks.toml`. `books.show` reaches it through `ops/show.jsonl` in the book's own data root, never through the MCP. Remote books (self-host) are viewed through a read-only replica stream (P9). | `ledger-gui/tests/read_only.rs`. Tests `gui_works_under_read_only_landlock` and `gui_finds_root_without_project_dir`. Test `replica_snapshot_equals_store_snapshot` (P9). The GUI writes nothing under the data root and leaves no files under `$HOME` except eframe preferences. | | I-9 | **Every access through the server is logged, and logging fails closed.** | A hash-chained `access.jsonl` (own genesis). Property: exactly one record per tool call, upload, download, human action, remote replica connection and backup, refusals included (as built, slice 7: a backup's own runs are records of `ops/backup.jsonl`; `books.back_up_now`, a tool call, is in the access log). The local viewer's direct reads are not logged, because the GUI never writes; the docs say so. | | I-10 | **Tamper evidence.** Every mutating result carries `head {seq, hash}`. The head is handed off when a session goes quiet. The customer can clone the archive and run `wanderbooks verify-archive --expect-head`. Backup and access records stay out of the journal, so they never advance the head they report. (As built, slice 7, D-31: each backup commit records its head; a restore's preview proves a clone and names its head, and weekly drills prove each target; no separate `verify-archive`.) | Test `backup_does_not_advance_head`. Test `clone_verify_expect_head` (as built: `a_restore_is_of_the_backup_previewed_only`, the drills). | | I-11 | **No loss of ease of use.** Local setup is at most 3 commands on a clean machine, with no sudo, no system account and no container, beyond installing the app itself (D-24: on Linux the package manager asks for `sudo` as it does for every package; the dmg, the Windows installer and the AppImage ask for nothing). There is nothing to do per session, it works from any directory, the daily flow adds no client permission prompts (no `requiresUserInteraction`), and the viewer updates within 1 s of a post. | Acceptance criteria in P2 and P5. Tests `setup_needs_no_root` and `annotations_complete`. The agent-eval harness. | | I-12 | **Guidance survives client truncation, and never names a path.** Connect text is at most 1,400 characters, and its first 512 stand alone and name `books.start`. Tool descriptions are at most 700 characters. No tool text mentions `just`, `wanderbooks.toml`, `~/`, git, GitHub PRs or any filesystem path, the data root included, and no result names one. | Tests `instructions_budget`, `tool_text_lint` and `no_result_names_a_path`. | | I-13 | **Formal-methods bar.** Kani harnesses cover kernel arithmetic. Every new ledger command is a Lean `Step` with a preservation proof. A failing proof is a finding. All tools are pinned in `mise.toml`. Clippy reports zero warnings. Every mutation survivor is killed or documented as equivalent. | `just check` and `just prove` pass in every phase. | ### 2.1 Conflict resolutions (explicit) | Question | Proposals | Resolution | |---|---|---| | Process shape | A: single process. B: gateway plus per-book worker. C: daemon, with a gateway when hosted. | **One `ledger-mcp serve` process** (A), both locally and on a self-host machine. Locally it runs as the human, only while something the human started holds it open: the app, an agent session, or a terminal. Nothing is installed to run in the background (owner decisions D-21 and D-23, 2026-10-18). Every local program is a mode of the one `wanderbooks` executable (D-22). It has no OAuth authorization server and only a minimal human-page surface. The multi-tenant front/worker split (B) belongs to the hosted service, which this plan does not build (section 11). | | Isolation, and when it lands | A: P5. B: P6. C: P3. All three assumed a service user. | **Owner decision 2026-10-16: "you, hidden away."** The desktop gets no service user, sudo, system account or container. P2 moves the books into a server-owned data root (0700, outside every project directory) whose path no text ever names. This isolation is advisory and is labelled that way. Mechanical enforcement is the self-host deployment on another machine (P9). From P2 on, CI proves it with `agent_uid_cannot_read_tenant` against a two-user deployment. P2 needs journaled settings first (P1), because the server never reads toml. | | Settings import | A: server reads legacy toml (`from_legacy:true`). B/C: agent passes text. | **No import tool** (D-44: `import_settings` is gone, owner 2026-10-23). No settings file is read by any server or the viewer: old books bring theirs in, once, by `books adopt`, and `books.set_setting` changes settings after. `books adopt` refuses a journal with no `settings.set` unless `--toml FILE` is given. The human's CLI reads that file and passes the text. | | Before import | C: writes blocked by a `settings_not_imported` guard. | **Never block.** Daemon books always have journaled settings (enforced by `adopt` and `create`); the `legacy_files` mode of `dev-stdio` is gone (D-44). | | Preview/post | A: `confirmed`. C: split tools plus `preview_token`. B: seven read-only twins. | **Keep `confirmed`, add `preview` (D-50).** Add one read-only twin, `books.preview_entry`, for the high-frequency post path. Every tool with `confirmed` returns `preview` when called without it and requires it when called with it, from P4 on, in one sweep (P4 step 4). ~~`preview_token` … `confirmed` without a token is accepted for one release~~ → D-50: a digest, no key, no grace period. | | `requiresUserInteraction` | B: on every post. | **Not used anywhere in this plan** (owner decision 2026-10-16: no added friction). Post, reverse, close, reconcile, dispose, `run_*`, `purge_tin`, `consolidate_duplicates` and (until D-44 removed it) `import_settings` carry `destructiveHint` plus `preview` (D-50), and the client's normal permission rules apply. ~~Full TINs leave the agent's path through human pages: TIN-bearing outputs from P2, and all TIN handling in P7.~~ → D-28, D-29, D-48 | | Harness deny rules | C: P0 `.claude/settings.json` deny rules. | **Rejected.** That is plan A, which is dropped. P0 corrects only the sentence in the server text and the generated template that caused the `cat` (`/home/swaits/Documents/the first company-Books/AGENTS.md:68-69`, "the journal is the evidence — read it"). This is a content fix, not a guard. | | Defect 12 rule | A: everything through `financing_accounts` is excluded. | **Classified by instrument (C).** Payment-card and third-party-network payments are excluded (1099-K) and listed. A member's personal cash or check through 2100 stays reportable. An unknown instrument is `undetermined`. | | Odd cent (17) | A: fair ties. C: rotating tie-break. | **Cumulative apportionment (B)**, extending `kernel::apportion` (kernel.rs:309), its existing Kani proofs, and `Apportion.lean`. | | Cash flow (1) | A/C: per-entry heuristics. | **Structural partition plus direct-method lines (B).** Footing becomes an identity derived from trial-balance balance. A nonzero difference is an engine error. | | Vault crypto | A: bespoke X25519/HKDF/XChaCha. | ~~**HPKE, RFC 9180** … The public key's sha256 is journaled.~~ → D-48: the v1 vault stays (XChaCha20-Poly1305 under one key). W-9 files are sealed into the vault and never become blobs (built). | | Vault unwrap | A: model A first. C: client-side default. | ~~**Model A** … after the human types the passphrase.~~ → D-28, D-29, D-48: no passphrase and no human pages locally; the server opens the vault with the book's key for the tools the owner chose. Client-side unwrap (model B) is a requirement of the hosted service (section 11). | | Human-session bootstrap | A: `wanderbooks open-link`, which a same-user-ID agent can mint. | **Rejected.** ~~Every `/h/` page needs a factor … That passphrase becomes vault keyslot 1 in P7.~~ → D-28, D-48: no `/h/` pages locally; the owner passphrase (P2) guards the format-upgrade consent only. Remote `/h/` pages are P9c, not planned (D-51). | | Replica keyring route | A: `/replica/keyring` serves wrapped keyslots to `books:view`. | **Removed.** ~~Local GUI TIN click: … after the human types the passphrase~~ → D-29: the viewer copies a TIN to the clipboard with the book's key, no passphrase (3.6 as built). The replica itself is P9c, not planned (D-51). | | TIN output scanner | A: match decrypted vault records. | **Pattern-based** (extend `has_full_tin`/`redact_full_tins` behind S:870 to every result, with the vault's last-four set as a hint). Fails closed. Documented as a pattern check. | | Extraction | In-process (A/B). | ~~**Sandboxed subprocess** `ledger-extract` (C): rlimits, seccomp, no network, Landlock read-only on the input blob.~~ → D-32, D-53: in-process, in `ledger-import` (P6), with `catch_unwind` and size caps; no new crate. | | Archive vs Landlock **[Superseded → D-31 (no archive process), D-32 (no Landlock, no seccomp); kept for the record]** | A: server shells out to git, which contradicts its own Landlock rules. | **A separate archive process, `ledger-mcp archive`,** that the host starts beside the server, after the server holds every book's lock, and stops with it (3.9); each confines itself on its own. The archive process has read-only Landlock on `books//{journal.jsonl,blobs}`. Locally, Landlock ABI ≥ 4 limits its TCP connect to the target's port (443 or 22); it cannot filter by host, and an unprivileged process has no host-level egress filter, so that exists only under a system unit on self-host (`IPAddressAllow`). Locally the server listens on Unix sockets (3.9); if the owner turns TCP on, it binds 127.0.0.1 first. Either way Landlock ABI ≥ 4 then denies all further TCP bind and connect, so the local server has no egress. As built (slice 7, D-31): no archive process; the server runs git itself (3.7 as built), so slice 8's Landlock must let it run git and reach the backup folder and repository. On self-host, a system unit adds `IPAddressDeny=any` (loopback allowed); if the built-in authorization server fetches CIMD documents (D-7 a), connect is allowed to port 443 only and `IPAddressDeny` narrows to private and link-local ranges (loopback still allowed), matching the CIMD SSRF guard. This confines the server only; it does not restrict the agent. | | Attention engine location | A: `ledger-store/attention.rs`. B: wasm-clean `ledger-view`. | ~~**New crate `ledger-view`** … a `BooksView` trait implemented by Store and Snapshot.~~ → D-53: `ledger-package` already holds that trait (`PackageBooks`, over `Store` or `Snapshot`, built in P2 slice 6); Needs attention and `next_steps` move beside it (U0b). A pure-Rust evidence projection is deferred to the hosted service's web viewer (section 11). | | Inline bytes | A: 256 KiB. B: 1 MiB without sha. C: 256 KiB plus sha. | ~~**256 KiB with a mandatory matching sha256.** A mismatch is refused with guard `base64_corrupt`, whose remedy is `books.begin_upload`.~~ **Superseded by D-46 (2026-10-23): no inline bytes at all, in or out; files come only by upload and go only by download.** `/mcp` body cap is 1 MiB. | | Same-user mode | C: an announced tier. B: refuse to start without an acknowledgement flag. | **The local default, with no gate flag** (owner decision 2026-10-16). `books.start`, Needs attention, `wanderbooks doctor` and the docs report `isolation: "same_user (advisory)"`. | | Viewer channel | A: replica over a peer-credential socket. | **Direct read-only disk access locally.** This is the owner's rule: "the GUI has r/o access to ALL state on disk; it never mutates; it doesn't go through the MCP." `books.show` requests reach the GUI through `ops/show.jsonl` in the book's own data root, which the server writes and the GUI watches. Presence is a shared `flock` that the GUI holds on a read-only descriptor of `ops/viewer.lock`. Locally there is no socket, no peer-credential check and no executable check. The replica protocol exists only for viewing remote books (P9). | | Local token | A: literal header in the Claude config. | **OS keyring, read by the bridge** (D-23): Claude Code and Codex run `wanderbooks mcp --book X` over stdio, and the bridge reads the token itself, so no client config holds it. `headersHelper`/`http_headers_helper` running `wanderbooks token print` remain for clients that choose HTTP. | --- ## 3. Architecture ### 3.1 Processes From P2 every local program here is a mode of the one `wanderbooks` executable (D-22): `wanderbooks server` is its server mode (the `ledger-mcp` crate), `wanderbooks gui` its viewer (`ledger-gui`), each run as its own process. ~~`ledger-extract` its extractor~~ → D-53. **[The figure and table below are the 2026-10-16 design; superseded parts: Landlock/seccomp and the archive process (D-31, D-32), `/h/*` and `/replica/*` (D-28, D-51), the extractor subprocess (D-53).]** The names below are the crates'. Claude Code and Codex reach a local book through the bridge, `wanderbooks mcp` (stdio, D-23), which speaks MCP to the server over a local socket (3.9). ``` agents (any directory, any client) human Claude Code (any dir) Codex claude.ai / ChatGPT stdio-only browser ledger-gui | | (P9: self-host or (wanderbooks | (as the human) | wanderbooks mcp | bridge tunnel) mcp --url) | owner | | (stdio, lease) | | OAuth or token | | passphrase | v v v v v | +---------------------------------------------------------------------------------+ | | ledger-mcp serve local: open while held (D-23), as the human | | | self-host (P9): same binary on another machine | | | confines itself where available (Landlock, seccomp, no outbound TCP) | | | /b/{book}/mcp /b/{book}/u/{id} PUT /b/{book}/d/{id} GET | | | /b/{book}/h/* (human pages) /b/{book}/replica/* (P9, remote books only) | | +---------------------------------------------------------------------------------+ | | sole writer (0700; the path is never named) ^ read-only Landlock | read-only, v | | direct /books// journal.jsonl blobs/ ops/show.jsonl ... <-----------------------------+ ^ | | sandboxed subprocess (rlimits, seccomp, no net) +-- ledger-mcp archive --------> git / s3 / dir / pull ledger-extract (pdf / xlsx / eml / page raster) ``` The data root is `$XDG_DATA_HOME/wanderbooks` on Linux (default `~/.local/share/wanderbooks`) and `~/Library/Application Support/wanderbooks` on macOS. It sits outside every project directory, and no tool, instruction, handbook section or result names it. Because the server runs as the human, this isolation is **advisory**: an agent cannot stumble into the books, but an agent that deliberately hunts for them could read them. The server's own Landlock and seccomp confinement limits what a compromised server can do; it protects nothing from the agent and is never described as if it did. Mechanical enforcement is the self-host deployment (3.8, P9). | Process | Binary | Runs as | Reads tenant disk | |---|---|---|---| | Books server (local) | the server mode of `wanderbooks` (the `ledger-mcp` crate), listening on `run/mcp.sock` (and on 127.0.0.1:7411 only if the owner turns TCP on), started by the first opener (the app, an agent's bridge, or `wanderbooks serve`) through the host mode, and running while any opener holds it (D-21, D-23, 3.9; the host reads no books: it starts the server and the archive process, each confining itself, and stops both). TCP is off by default (3.9) | the human | yes, sole writer, holds the flock (`ledger-store/src/store.rs:74-89`) | | Books server (self-host, P9) | the same binary under a system unit on another machine | a dedicated account on that machine | yes, sole writer | | Archive pusher | `ledger-mcp archive --book ` (as built, slice 7, D-31: none; the server pushes) | the server's account, a separate process started beside the server | read-only journal and blobs | | ~~Extractor~~ | ~~`ledger-extract` (subprocess)~~ → D-53: extraction runs inside the server (P6) | | | | Viewer | `wanderbooks gui `, the viewer mode (the `ledger-gui` crate; local, direct, the default; what opening the app shows); `wanderbooks gui --books ` (remote replica, P9) | the human | local: yes, read-only, never writes. Remote: none | | CLI | `wanderbooks` (ledger-cli) | the human | **by design never**. Talks to `run/admin.sock` (peer-credential check: same user ID as the server). The admin socket cannot read books data; it can create or adopt books and mint agent tokens, admin-scope ones included. The peer-credential check keeps other local accounts out; any process running as the human, agents included, can use it (advisory, like the rest of the desktop deployment). | | Agent bridge | `wanderbooks mcp --book ` (stdio to the agent; `--url` for a remote book, P9) | the agent's user | never; it has no data-dir flag. It may start the host (D-23), whose server then reads the disk as itself | ### 3.2 Transport (one code path, local and remote) Replaces `StreamableHttpServerConfig::default()` in `serve_http` (`ledger-mcp/src/main.rs:154-180`) with an explicit configuration: - `allowed_hosts` taken from `server.toml` (deployment config only). - `enforce_origin_validation()` with an explicit list: loopback by default, plus `https://claude.ai` once OAuth exists in P9. Today's empty list turns the Origin check off. - `json_response=true`. - `legacy_session_mode=true` while Cursor and claude.ai stay on ≤ 2025-11-25. - `max_request_body_bytes = 1 MiB`. Both protocol eras are served: `initialize` and `server/discover` are both built from `get_info`, which calls `refresh` first, with `cacheScope: private` and `ttlMs: 60000`. rmcp features gain `elicitation` and `request-state` (`ledger-mcp/Cargo.toml:22`). Tools read the principal through `Extension`. Long calls (CPA package, audit, consolidation) run under `spawn_blocking`. ~~and return `{job_id}` when they exceed 20 s; `books.job` polls. The 240 s claude.ai limit and Codex's 60 s are therefore never hit.~~ → D-53: no jobs locally; the extraction backfill is a command (P6). **Concurrency.** One `Store` per book behind `tokio::sync::Mutex` (replacing the std mutex at S:553). Tool bodies use `blocking_lock` inside `spawn_blocking`. Readers use a `Snapshot` refreshed after each write, so reports never wait on the writer. ### 3.3 Auth and scopes | Scope | Allows | Default holders | |---|---|---| | `books:read` | reads, reports, `start`, handbook, `get_receipt` text and images | all | | `books:write` | drafts, lines, attach, annotate, upload, attest, settings preview | agent | | `books:post` | confirmed post, close, reconcile, dispose, `run_*`, settings commit | agent (local default) | | `books:admin` | tokens, access log, backup run, consolidate, invite | owner's agent (local default), owner | | `books:view` | remote replica only (P9) | viewer client only, never agents | ~~No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has just presented a human factor, or inside the local GUI process after the human types the passphrase (3.6).~~ → D-28, D-29: the server opens the vault for `set_tin`, `set_w9`, `get_w9`, `vendor_1099 include_tins` and `export_cpa_package`, which need books:post or books:admin as the As-built notes say; the viewer reads it with the book's key. *Until P7 moves TINs to the human pages, the three tools that can still put a full TIN out (`vendor_1099` with `include_tins`, `export_cpa_package`, `get_w9`) need books:admin, and sealing one (`set_tin`, `set_w9`) needs books:post (P2 slice 4, review).* - **Local:** a static bearer `wb_` + 32 random bytes, stored only as a sha256, compared in constant time, kept in the OS keyring, minted by `wanderbooks connect`, and revocable with `wanderbooks token revoke`. Agent and server run as the same user, so the token identifies the client and keeps other local users and web pages out. It is not a wall against processes running as the human. - Claude Code: `wanderbooks connect claude-code --book ` (also the app's "Connect Claude Code") runs `claude mcp add --scope user wanderbooks- -- mcp --book `: stdio, through the bridge (D-23). The bridge reads the token from the keyring itself, so Claude's config holds no token and no helper. It offers a `permissions.allow mcp__wanderbooks-` line. - Codex: `wanderbooks connect codex --book ` writes: ```toml [mcp_servers.wanderbooks-acme] command = "" args = ["mcp", "--book", "acme"] ``` The bridge answers `initialize` within Codex's startup timeout, opening the books first if they are closed. A client that needs HTTP can be pointed at `http://127.0.0.1:7411/b//mcp` with `wanderbooks token print` as its header helper once the owner turns TCP on (3.9); it holds no lease and needs the books already open. No `default_tools_approval_mode` is written. Codex documents `writes` as prompting for every tool not marked read-only, which would prompt on every post, close and reconcile (against owner decision 3); leaving the key unset keeps Codex's normal rules. - **Self-host, remote (P9a):** the same static bearer over TLS for clients that send headers (Claude Code, Codex). **[P9b, after Q-6 (D-51): claude.ai and ChatGPT need OAuth 2.1; the recommendation is an external identity provider (D-7 b), so the list below is the design only if the owner chooses D-7 a.]** - RFC 9728 PRM per book; 401 with `resource_metadata`. - 403 `insufficient_scope` for step-up. - RFC 8707 audience equal to the book URI. - PKCE S256, RFC 9207 `iss`, CIMD preferred (SSRF-guarded), DCR fallback. - Exact redirect match (`https://claude.ai/api/mcp/auth_callback`, loopback on any port). - `offline_access` only in the authorization server metadata. - 1 h access tokens with rotating refresh tokens. - Signing keys held through systemd-creds. - The self-hosted server is its own issuer (D-7 a), or it validates an external IdP's tokens (D-7 b). Codex uses `codex mcp login`. **Human factor for `/h/`:** **[Superseded → D-28 (no local `/h/` pages), D-51 (remote `/h/` pages not planned). The owner passphrase (Argon2id, as built below) guards the format-upgrade consent only.]** ~~passkey with user verification … the session's `sub` equal the token's `sub`.~~ **As built (P2 slice 4), and where it departs:** - **Tokens** are `wb_` and 64 hex digits (32 random bytes), one book and one name each (`agent` unless named), kept only as a SHA-256 in `auth/principals.jsonl`: a chain of `token.created` (with the token it replaces) and `token.revoked` records, each with its own hash besides the chain's (so a change to the last record is found too), replayed at start; one that can't be read refuses every agent with why (the list of books still answers). Checked in constant time against every live token. Made, listed, checked and revoked only through the admin socket: `wanderbooks token create --book ID [--name N] [--scopes read,write,post,admin] [--replace] | list | revoke | check | print`. A token made is kept in the keyring, and read back from it as kept, before anything is said of it, or revoked again; `print` is the only way its text is shown. `check` says whether the token kept here is the books' live one (exit 0), an old one, one the books no longer have, or none, asking the server by its SHA-256 alone (admin op `token_check`). `connect` (slice 8) will mint through the same path. - **Kept in the OS keyring** (Secret Service on Linux, through keyring-core; macOS and Windows stores come with P11), under service `wanderbooks` and an account naming the root by a hash of its path, the book and the token's name. One account is one item (2026-10-22, after a bridge on the live books ran on a token the keyring no longer gave while `download` read a revoked one): keeping a token first removes every item for its account in every collection, then makes one and reads it back; reading finds the items in every collection and, finding several, refuses with how to keep one again (`token create --replace`) instead of giving one of them; a keyring that is locked or can't be reached is an error, never "no token". The bridge (`wanderbooks mcp --book ID [--token NAME]`) reads it before each new session (even with a token held: the keyring's is used, and a change is said on stderr; a slow keyring leaves the held one in use after 2 s) and each request until it has one, and again when the door refuses it, so a token made or replaced mid-session is used from the next call (with a new session: a session belongs to the token that opened it); without one, the server's refusal says how to make one, but when the keyring couldn't be read the agent is told that instead (locked or unreachable, the token itself fine; or several items), never to make a token. Tests keep tokens in files under the root's `run/` (the `file_keyring` knob): a folder per collection, which a test can lock, make unreachable, or plant a second item in. - **The door** (`/b/{id}/mcp`): no token or an unknown one, 401 with `WWW-Authenticate: Bearer` (the scheme in any case); another book's, 403; a session is bound to its token when opened and used only with it (403), forgotten when it ends, is found gone (an idle one, when next used), or its token is revoked or replaced; an id the server never opened binds nothing; past 100,000 bindings an initialize is refused, never a binding dropped. Each refusal at the door is recorded in that book's access log (action `connect`). - **Scopes** (`ledger-mcp/src/scopes.rs`): a table of every tool. Read-only tools need books:read; a tool with `confirmed` needs books:write to preview and books:post to act; tools that post at once, settings commits, voids, renumbering, corrections and role changes need books:post, and so do the controls on posting (setting an approval rule, retiring a convention, approving an entry) and sealing a TIN (`set_tin`, `set_w9`, which can replace one); `books.access_log` and the tools that can still put a full TIN out (`vendor_1099`, `export_cpa_package`, `get_w9`) need books:admin, and so do backing up now and choosing where backups go (a `backup.*` setting, by `set_setting` or an imported `[backup]` table; slice 7): a backup is the whole book. A token is offered only the tools it can call (the list is then cached per caller, `cacheScope: private`); a call beyond its scopes is refused, unrun, naming the scope. Reading a resource needs books:read. A local token gets all four unless told otherwise. - **The access log** (`books//access.jsonl`, own genesis) records every tool call and resource read, refusals included: who (token id, name), the client as it named itself (cut to 64 characters, any full TIN masked), the request id, the tool or resource, a SHA-256 of the arguments' canonical JSON with what may be a TIN masked first (a value under a key naming one, any key or text holding one in a shape the TIN scanner knows, any number of eight digits or more: an unsalted digest of a TIN whose last four the books show is broken by 10^5 guesses; a file's base64 is hashed as it is, never spelling a TIN in the clear), what came of it (`ok`, `error`, or `refused` with a code), and the journal's head after the call's own last write, if it wrote, whatever it answered (taken under the store's lock as the call lets go). A call's record is written on its own blocking thread while it still counts as running (one that panics part way included, with what it wrote), so a close waits for it too, and before the answer goes; one that can't be written stops the book (no more requests until a restart) and the answer says the call ran (a call refused as not run, by a close or a stopped server, keeps its "not run" code). A tool no table names is refused unrun. Known gap: a crash of the process between a call's journal write and its record leaves the journal ahead of the log for that call. A damaged log, or one holding a record that isn't an access, keeps its book closed, with why. `books.access_log` (admin) reads it newest first, filtered by token, action or outcome, paged with `before`. - **The owner passphrase** is an Argon2id hash (m=64 MiB, t=3, p=1, its own salt) in `auth/owner.json`, replaced whole; 12 to 1024 characters, no other rule (NIST SP 800-63B); a check refuses a hash weaker than that (changed by hand). `setup` at a terminal asks for it (twice); elsewhere it says to run `wanderbooks passphrase`, which works only at a terminal, unechoed, and asks for the one in force before a change. (Advisory, like everything local: a process running as the human can drive a pseudo-terminal and set the first one.) A stored hash of another Argon2 version, with a short salt or output, or asking a check for more than 1 GiB, 16 passes or 8 lanes is refused as changed by hand. The command line wipes token texts and passphrases from memory once used (the server and the bridge, which hold tokens for their lifetime, don't). `doctor` says whether it is set and lists the tokens. Its uses (`/h/` pages, consents) come with the slices that build them. - **Known, product-wide (P7, 8.2):** the TIN scanner (`has_full_tin`) takes hyphens and spaces as separators, not commas or parentheses, so "123,45,6789" passes it in party fields and the access-log digest alike. - **Departures:** rate limits (tower-governor) wait for P9: on a socket only this account can reach, nothing could be limited that couldn't read the files instead. Principals are per book, not lists of books. `initialize` (the connect text, composed from the book's settings) is given to any valid token and isn't recorded. Changing the token record holds its lock across one fsync, briefly delaying token checks. ### 3.4 Data layout (server-owned data root, 0700) ``` / Linux: $XDG_DATA_HOME/wanderbooks (default ~/.local/share/wanderbooks) macOS: ~/Library/Application Support/wanderbooks self-host (P9): /srv/wanderbooks server.toml deployment only: listen, public_url, allowed_hosts/origins, archive targets, issuer auth/principals.jsonl principals, sha256(token), scopes, labels; passkeys (hash-chained) run/admin.sock legacy/-/ old copies moved here at adoption if the owner agrees (Q-3); the server never deletes them books// == today's data dir layout, unchanged (Store/Snapshot/verify_and_heal untouched) journal.jsonl blobs/aa/bb/ wanderbooks.v.db(+wal/shm, one per schema; P3) wanderbooks.lock vault/{tin,w9}-.bin sealed with the book's v1 key (D-48: no v2) keys/legacy.key the book's vault key (D-31: travels in every backup; D-48: stays) (keys/keyring.json, keys/preview.key: not built → D-48, D-50) access.jsonl hash-chained access log, own genesis "wanderbooks-access-v1" ops/backup.jsonl ops/heads.jsonl not journaled ops/show.jsonl books.show requests: the server appends (bounded, rotated), the GUI watches; not journaled ops/viewer.lock created by the server; the GUI holds a shared flock on a read-only descriptor (presence) uploads/.part staging, swept after 15 min (a backup being restored is cloned into books/.restore-* instead: 3.7 as built) derived//page-N.png disposable page-raster cache (P6) archive.git/ bare; journal.jsonl + blobs/ + vault/ + keys/ + access.jsonl, as they are (D-31, not encrypted); destinations D-30 ``` The server, the CLI and the GUI find the data root with one shared function (`ledger_store::root::Root::find`, built in P2 slice 2): `--root` if given, else the root `wanderbooks setup` recorded in `wanderbooks.location` beside the default, else the platform default under the account's home, taken from the password database on Unix, never `$HOME`. Finding it never reads the environment, the working directory, a project directory or a toml file; `$XDG_DATA_HOME` is honoured once, by `setup`, which records the result. The root and every folder made in it are created with an explicit 0700, not the umask, and a root that is a symlink, belongs to another user or that others can enter is refused (`setup` tightens one of ours). `setup` leaves a `.wanderbooks-root` marker, and a folder without one is never opened as a root nor, if it holds anything, made into one; every folder above the root must be one no other user can swap it in (owned by this account or the system, not writable by others unless sticky). Run again, `setup` keeps the root it recorded, and it never makes a second root or moves books while the current one holds any. Neither `setup` nor any refusal prints the root's location: nothing an agent is shown names it. Books are created and adopted one at a time, under `run/books.lock`. Windows (P11): `%LOCALAPPDATA%` from the known-folder API and an owner-only DACL; until then the Windows paths are read from the profile and carry no ACL. Extracted text is stored as a content-addressed blob, recorded in the journal by `attachment.text_extracted`, and archived with the books. Page rasters are disposable. Deleted assumptions: `books_dir = data_dir.parent()` (S:1155, `ledger-gui/src/books.rs:1474`, `ledger-mcp/src/main.rs:102`), the `.gitignore` check (S:531-543), and the `$XDG_RUNTIME_DIR` viewer file (`ledger-store/src/viewer.rs:98-130`), which `ops/show.jsonl` replaces. ### 3.5 Keys | Key | Location | Usable by | |---|---|---| | Vault key (v1) | `keys/legacy.key` (0600), in every backup (D-31) | the server (sealing and the TIN-bearing exports the owner chose, D-29) and the viewer (clipboard copy) | | ~~Vault public key / private key (keyslots)~~ | → D-48: not built | | | ~~`preview.key`~~ | → D-50: not built | | | Agent tokens | sha256 in `auth/`; plaintext in the user's keyring, which any process running as that user can read (advisory locally) | the agent's user | | Archive credentials | OS keyring entry read by the archive process (local); systemd-creds for the archive unit (self-host). As built (slice 7): the owner's own ssh agent and git credential helpers, through the server | archive pusher | | At-rest encryption | full-disk encryption (desktop); encrypted volume (self-host recipe) | protects against stolen disks | ### 3.6 GUI - **Local: direct and read-only** (desktop default). This is the owner's rule: "the GUI has r/o access to ALL state on disk; it never mutates; it doesn't go through the MCP." - `wanderbooks gui ` runs `ledger-gui --root --book `. Without `--root`, the GUI resolves the platform data root itself (3.4). It never looks in a project directory or `wanderbooks.toml`. - It reads `journal.jsonl` and blobs into a `Snapshot` as today, tailing the journal so that a post appears within 1 s. Every open is read-only, and the GUI runs correctly with the data root under read-only Landlock (test). - **`books.show`:** the server appends `{seq, ts, target}` to `ops/show.jsonl` (bounded, rotated, not journaled). The GUI watches it with the same watcher it uses for the journal. This replaces `viewer::request` and the `$XDG_RUNTIME_DIR` viewer file (`ledger-store/src/viewer.rs`). A show request only navigates, so a stray writer can do no more than move the view. - **Presence:** the GUI holds a shared `flock` on `ops/viewer.lock`, opened read-only. The server probes the lock to report `viewer.open` and whether a show request was delivered. - Locally there is no socket, no peer-credential check and no executable check. - ~~**Remote: replica**~~ **[P9c, not planned → D-51; the design is kept for the record]** (self-host books only), `ledger-gui --books ` with the static token or OAuth, and `books:view`: - It pulls `/replica/journal?after_seq=N` (SSE of raw canonical lines) into `Snapshot::from_lines(LineSource, BlobSource)`. This reuses the parse, chain-check and replay loop of `snapshot.rs:137-183`. - Blobs are fetched on demand, hash-verified, and kept in a 512 MiB in-memory LRU, never on disk. "Not fetched" is a separate state from "damaged" (fixes `books.rs:456-478`, `attention.rs:551-568`). - `/replica/events` carries `books.show` targets per principal, and presence. - The replica carries no keyslot material. - **Settings come from the journal.** Deleted: `data.rs:76-87,157-162,188-195`, `books.rs:536-542`, `main.rs:47-53`, `--books-toml`. - **Needs attention** comes from one function in `ledger-package` over `PackageBooks` (U0b; ~~`ledger_view::attention::find`~~ → D-53), so it is the same list as `books.start`. - **Save a copy / Open with system app** on any document (`Snapshot::attachment_bytes`, snapshot.rs:328). Fixes the viewer half of defect 3. - **TIN click:** ~~locally, the GUI prompts for the passphrase, reads the wrapped keyslot … then zeroizes.~~ → D-29 (as built below): a click copies the TIN to the clipboard with the book's key, no passphrase. This is not access-logged, because the GUI never writes; the docs say so. ~~Remotely, TINs appear only on `/h/` pages.~~ → D-51 - `[viewer] open_at_start` becomes a per-human eframe preference (`app.rs:833`), not journaled. - **Color schemes** (owner, 2026-10-19: "full blown colorscheme support … all the top neovim style colorschemes", "on the order of 20 families, each with a flyout of all its variants", a light/dark/all filter; Ayu included). The button in the sidebar's lower corner opens a menu: the filter, then, alphabetically and all on screen at once, the viewer's own family (Wanderbooks Light and Dark) and twenty families of editor schemes (Ayu, Catppuccin, Cyberdream, Dracula, Everforest, GitHub, Gruvbox, Kanagawa, Material, Melange, Monokai Pro, Night Owl, Nightfox, Nord, One Dark, Oxocarbon, Rosé Pine, Solarized, Sonokai, Tokyo Night), each a flyout of all its variants, alphabetical, each a row with a strip of its colors that chooses it; a row rested on for 50 ms is worn live until the pointer moves on (owner, 2026-10-19), and only a click keeps it. The choice and the filter are per-human eframe preferences like `open_at_start`; nothing is journaled and no figure changes (`schemes.rs` paints nine pages in all 80 schemes and checks every painted word and figure is the same, in the same place, and readable on what is under it). Every palette keeps every text color (figures, gains and losses, warnings, links, the faintest text) at WCAG AA contrast (4.5:1) on every background it is drawn on: a scheme's own color that falls short keeps its hue and only changes lightness, and gains, losses and text stay plainly different to the eye. The scheme pins egui's light or dark theme, so the system flipping changes nothing. The palettes come from each scheme's official source (`docs/color-schemes.md`). **As built (P2 slice 6), and where it departs:** - **Finding the books.** `wanderbooks gui [BOOK]` (`ledger-gui --root PATH --book ID`): the data root from `--root`, else the one `setup` recorded (`Root::find`), never the working directory or a toml; the book named, else the root's only one (or the first by id). `--data-dir` still opened an older books dir's `books-data` (tests, and the first company's books until the cutover), and `--books-toml` its settings file, until D-44 removed both (owner, 2026-10-23): the viewer opens only books of the data root. - **Settings come from the journal.** A book of the data root never reads `wanderbooks.toml` or `config.json`: books that keep no settings show the defaults. An older books dir whose journal kept none was read from its `wanderbooks.toml` and `config.json`, followed live, until D-44 removed that mode: the viewer reads every book's settings from its journal alone. The demo company journals its settings. - **The vault.** For a book in the data root (`inside_a_root`), its own `vault/` and `keys/legacy.key`, whatever a setting says; for an older books dir, as before (`--vault-key-file`, the setting, the default), until D-44: now every book's vault is its own. No passphrase: TINs get no handling beyond the vault (D-29); a click copies one to the clipboard as before. - **`books.show` and presence** (`ledger_store::ops`, which replaces `viewer.rs`): the server makes the book's `ops/` (0700) and `ops/viewer.lock` (0600) when it opens the books; a show request is a line of `ops/show.jsonl` (`{seq, at, target}`, at most 32, the file replaced whole); the viewer watches `ops/` once it exists and holds a shared `flock` on `ops/viewer.lock` through a read-only descriptor while it runs, let go of when it closes; `books.show` answers `viewer_open` and says so. `ops/` is not the books: the read-only tool check leaves it out. - **Save a copy / Open** on every document, **Save the W-9** on a party whose W-9 is in the vault, and the **Export** menu (the CPA package as of today, the 1099 worksheet for this year or last, full TINs included): each a new file in the Downloads folder (`ledger_store::saving`: never over a file, written aside and linked in; the viewer's report PDFs now save the same way instead of overwriting), what came of it shown beside the button. The menu's files are built off the UI thread from a fresh read-only snapshot; a document's copy and a W-9 (one stored file each, hash-checked as read) are saved at the click. "Open" is a copy in Downloads opened with the system's app (the one folder the viewer may write once confined, slice 8). - **One builder** (`ledger-package`): the CPA package, the 1099 worksheet and the report helpers the server's tools share moved out of `server.rs` into a crate both the server and the viewer use, over `Store` or `Snapshot` alike (`PackageBooks`), the TINs passed in. Proven unchanged: 91 tool responses, 19 packages and 366 entries byte-identical before and after (only the XLSX and zip timestamps, which differ between any two runs, aside). The package now names files of the same name `name (2).ext` instead of failing, its README names only what it holds, and a K-1 worksheet whose rows don't match the members is left out with the reason. - **The agent's side (D-29):** see 4.7's slice 5 list; `export_cpa_package`, `vendor_1099` (`csv: true`) and `get_w9` hand their files over as exports (`GET /b/{book}/x/{export}`, `wanderbooks download ex_…`), never inline (D-44); no tool takes a path. - **Not in this slice:** the menu's owner intents ("Close the books", "Back up now", "Restore…", "New books…", "Connect Claude Code / Codex") need the viewer's helper and the lease socket, and come with backups (slice 7) and setup (slice 8); so does running the viewer under read-only Landlock and Open-with through its helper. ### 3.7 Backup and tamper evidence **[The three paragraphs below are the 2026-10-16 design; the As-built notes (D-30, D-31) are what exists: no archive process, no encryption, no `fetch`, no `verify-archive`, no `ops/heads.jsonl`.]** The archive process (`ledger-mcp archive`: locally started by the host after the server, and stopped with it, 3.9; a system unit on self-host) runs on a head-advance signal (debounced 30 s), on `period.closed`, on a timer while it runs, and once when it starts, for anything left unpushed. The books change only while they are open, so a backup is never missed while they are closed. "Back up now" in the viewer is the lease socket's `back_up_now` intent (3.9), opening the books first if they are closed. Each run: 1. Verifies the chain. 2. Commits the new journal lines and blobs to `archive.git` (one commit per push; the message is the seq range plus the head). 3. Pushes to the targets: git remote, S3, dir, or `pull` (the customer runs `wanderbooks fetch`). It writes `ops/backup.jsonl`. `books.backup {status|run}` reports it, and Needs attention flags a target more than 24 h behind. Head handoff: - every mutating result carries the head; - `ops/heads.jsonl` records each 10-minute quiet period; - the customer clones the archive and checks it (as built: a restore's preview, or the drills; no `verify-archive`). Email or webhook head delivery and a witness log belong to the hosted service (section 11). **Restore (owner, 2026-10-17: "these backups need to be restorable, both in existing and new installations").** A backup is complete: the journal, every blob, the vault records (sealed), and the head `{seq, hash}` at backup time. `wanderbooks books restore [--id X]` (and the viewer's "Restore…", which hands it to the server) works the same on a clean install and on an existing one. The server's Landlock keeps it inside the data root, so it never opens the backup by path: the CLI, or the viewer through its helper, stages the backup into the data root's `uploads/` (re-hashed on arrival), and the `restore` intent carries only that staged id. 1. **Proven before use.** The server replays the whole chain, re-hashes every blob and checks that the head equals the one the backup records. Any mismatch refuses the restore with a plain reason; nothing is half-restored. 2. **Never destructive.** On an existing install a restore arrives as its own book beside the current ones. Replacing a book moves the old one into `legacy/-/`, kept, never deleted, and only on the owner's explicit choice. 3. **The vault comes back only with its key.** The backup carries the vault sealed; the passphrase or the printed recovery key (P7) unlocks it on any machine. Until P7 the v1 key is a separate file: a backup counts as complete only when that key is saved too, and Needs attention says so when it isn't. 4. **Restore drills.** On a schedule (default weekly, or at the next start if the books were closed when it fell due) and after each close, the archive process restores the latest backup into a scratch folder, verifies it as in step 1, compares its reports with the live book's as of the backup head, and throws it away. A failed drill is a Needs-attention item; `books.backup status` reports the last drill. **As built (P2 slice 7), and where it departs (D-30, D-31):** - **No archive process, no encryption.** D-31 made a backup the book as it is, in git, so there is nothing to encrypt and nothing to segment: the server itself backs each book up (`ledger_store::backup`, `ledger-mcp backup.rs`). Each book keeps a bare `archive.git` beside its journal; a backup is one commit of its journal, `blobs/`, `vault/`, `keys/` and `access.jsonl` exactly as they are, made under the store's lock (so whole lines and a vault that matches), its message `seq N head H` with `book ` below, then pushed outside the lock, fast-forward only (a target that moved on is refused and said, never forced), to `backup.git_remote` and to `backup.folder/.git` (made bare on first use; a folder that isn't there, a disk not connected, is said plainly). git runs with no system config, no hooks (in a repository on this computer it pushes to too, through `--receive-pack`), no prompts, signing, ignore or attributes files or file watchers, its own identity, only the file, ssh and https protocols (never `ext::` or `fd::`, whatever the owner's own configuration says), and a limit of five minutes without a word (pushes and clones report progress; a git stopped is stopped with all it started); the owner's own `~/.gitconfig` still applies otherwise, for credential helpers. The stored files are staged outside the store's lock (they never change; a failure there is only a lost head start) and the commit, under it, is checked to hold every file there is, by name; bytes are kept as they are because no attributes rule of the owner's, the system's or the archive's is read, and a `.gitattributes` in a book's folder refuses the backup. A repository on this computer (a folder target, or an address that is a path) is pushed to or read only when plain: a bare repository at that very path (never one git would find beside or inside it), nothing in it sending git to another repository (a `.git`, `commondir` or `gitdir` file, objects borrowed from elsewhere, a link where it or its own parts are, a `.git` or `.bundle` beside it), its configuration only what `git init --bare` writes (and, for a copy made with `git clone --bare`, where it came from), since a setting of its own could run a command on a push; repositories are made with the files ref format and SHA-1 whatever the owner's own defaults; one is read as a far one is (`--no-local`, so its copying reports progress). A folder target's repository is made only while the target never took a backup: one gone since is a disk not connected (a mount point with nothing on it), said, never made again where the disk should be. A lock a stopped git left in the archive is cleared once older than any git may run quiet. The host now passes the owner's `SSH_AUTH_SOCK` (only a socket the account owns, not a link to one) so a private repository over ssh is reachable. `backup.folder` must be absolute and `backup.git_remote` can't read as an option; setting either (or importing a `[backup]` table) needs books:admin, as backing up does (a backup is the whole book, vault and key included). - **When.** When the books open; a while after they were last written (`--backup-quiet`, 30 s, the `backup_quiet` knob for tests); before they close (at most 60 s); and on `books.back_up_now`. A failed commit holds every run off for 15 minutes; a target whose push failed is left out of the backups after writes for 15 minutes and then tried again, written to or not, while the others carry on. `period.closed` needs no trigger of its own: a close is a write. No head handoff files (`ops/heads.jsonl`) yet: the backup's commits are the head record. - **The record and the tools.** Every commit, push and drill is a record of `ops/backup.jsonl` (a chain log; a backup never moves the journal's head), each target's keyed by a digest of where it is (a new folder or address starts with no standing). Stale means behind, with the first try to back up what it lacks more than a day old (not a day since the last good push: quiet books aren't stale the moment they're written); a failed commit is every target's error until a push succeeds. The chain isn't verified at each run: the server verifies it when it opens the books, and drills prove what reached each target. Two tools, not `{status|run}`: `books.backup` (books:read) for the standing of each target (last good backup, through which seq, stale after a day behind, the last error, the last drill and why it failed) and `books.back_up_now` (books:admin, the one open-world tool). `books.start` adds a step when none is set up, or one is stale, failing or failed its drill. No error names a path in the data root. - **Drills** run weekly per target that holds a backup, and at the next look after a failed one once 15 minutes have passed (looked for a minute after the books are first seen open, so also at the first start after one fell due, then hourly; `--drill-first`, the `drill_first` knob, for tests): clone, check the tree is plain files under the backed-up paths before any is written out (a link or submodule could otherwise point the checkout, or the store opened on it, at the live books), replay the chain, re-hash every stored file, check every TIN record the books name is there with its key, check the head against the commit's, and check the journal is a byte prefix of the live one (the reports then follow from the same journal, so no report comparison). Not after each close: weekly is the floor and a close changes nothing a drill would see. - **Restore** is `wanderbooks books restore BACKUP [--id ID] [--replace] [--yes]` (without `--id`, the id of the books the backup holds, as its preview names them, else the repository's name), the admin socket's `restore`, previewed then confirmed against the previewed head. The server clones the backup into `books/.restore-*` (the address or folder crosses the owner-only admin socket as an adoption's path does; no staging through `uploads/`), proves it as a drill does (but for the live-journal check), one restore at a time, builds the book as an adoption builds (every stored file re-hashed, opened, verified, synced; the vault and its key come with it, D-31), gives it the backup's history as its own `archive.git` (so its next backups fast-forward), and renames it into place. Over existing books it is refused unless `--replace`: then the server lets go of the open books (their writer lock released; a session still holding them is refused, its client's next session sees the restored books), waits for their last backup run and starts no other until the restore is done (one in flight could otherwise push past the backup being restored), and moves them whole into `legacy/-