On the live books a reconnected bridge got no token from a keyring that couldn't be read, was told to "make a token", and after `token create --replace` kept on with the new token in memory while `download` read a revoked one from the keyring. - tokens.rs INVARIANT 4: keeping a token removes every item for its account in every collection, makes one and reads it back; reading finds every item and, finding several, refuses with how to keep one again; a locked or unreachable keyring is KeyringError::Unavailable, never "no token". The logic is shared by the OS keyring and the test files (now a folder per collection a test can lock, make unreachable, or plant a second item in). - make_token reads the token back before saying anything, or revokes it. - The bridge tells the agent the keyring is locked or unreachable and the token is fine (or that it holds several items), never to make a token; it reads the keyring again before every new session, even with a token held. - `wanderbooks token check --book ID [--name N]`: whether the token kept here is the books' live one, asked by its SHA-256 (admin op token_check), never its text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
237 KiB
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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
claudein 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 removed from every connect path (dev-stdio refuses any data dir inside the data root). 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 reversal drafts that need evidence. | Existing kernel, Kani and Lean. New Step.draftReversal and Step.discard, with preservation proofs. |
| I-5 | Evidence gate. One function, ledger_core::evidence::requirement, decides whether an entry may post. Store, MCP and GUI all call it. Reversals are not exempt. Depreciation, amortization and closes are exempt by origin, and the exemption is sourced. |
The gate lives in Store::post_entry. Test reversal_needs_evidence. Test gui_and_server_gate_agree. |
| I-6 | A confirmed commit is bound to the server's most recent preview of that exact draft (stale or altered drafts are refused). It does not prove that a human saw the preview, and locally the key is readable by any process running as the human (advisory). | preview_token = HMAC-SHA256 over (entry id, canonical draft state, settings seq, evidence-link set, policy result), keyed with a server secret held in keys/. A post with a stale token is refused with guard stale_preview. |
| I-7 | The agent never holds a full TIN or vault key material. | Vault records are sealed with HPKE to a public key whose sha256 is journaled. The private key exists only wrapped in keyslots held by the customer. Unwrap code is reachable only from human-page handlers and the GUI unlock path (module-privacy test). A pattern-based TIN scanner fails closed on every tool result. A canary TIN/key fuzz test covers responses, errors, logs and the access log. |
| 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 <book>, 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. |
import_settings {toml_text, config_json_text?} only. A legacy fallback (today's settings::Live) survives only in dev-stdio, until a book is adopted. 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. Legacy books keep legacy_files mode in dev-stdio (P1). Daemon books always have journaled settings (enforced by adopt and create). |
| Preview/post | A: confirmed. C: split tools plus preview_token. B: seven read-only twins. |
Keep confirmed, add preview_token. Add one read-only twin, books.preview_entry, for the high-frequency post path, so claude.ai doesn't prompt on every preview. Close, reconcile, dispose, run_* and purge keep confirmed plus the token. confirmed without a token is accepted for one release with a deprecation warning. This limits churn in mcp_stdio.rs, oracle_mcp.rs and spec_mcp.rs. |
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 import_settings carry destructiveHint plus preview_token, 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. |
| 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 (X25519-HKDF-SHA256, ChaCha20-Poly1305) (B). The public key's sha256 is journaled. W-9 files are sealed into the vault and never become blobs. |
| Vault unwrap | A: model A first. C: client-side default. | Model A, locally and on self-host. The server unwraps inside one human request, or the local GUI unwraps in its own process after the human types the passphrase. 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 requires human presence: a passkey with user verification, or the owner passphrase set at install. That passphrase becomes vault keyslot 1 in P7. |
| Replica keyring route | A: /replica/keyring serves wrapped keyslots to books:view. |
Removed. No route ever serves wrapped keyslots. Local GUI TIN click: the GUI reads the wrapped keyslot from the data root (read-only), unwraps in its own process after the human types the passphrase, copies the value to the clipboard only, and zeroizes. A remote replica (P9) carries no keyslot material; remote TINs are seen only on /h/ pages. |
| 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. |
| Archive vs Landlock | 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/<id>/{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 (depends only on ledger-core), holding attention::find, next_steps, the evidence gate view, and a BooksView trait implemented by Store and Snapshot. A pure-Rust evidence projection (dropping SQLite from the viewer) 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. /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): ledger-mcp serve is its server mode, ledger-gui its viewer, ledger-extract its extractor, each run as its own process. 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
<data root>/books/<id>/ 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 <id> (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) |
inherits, sandboxed | the single input blob, via fd |
| Viewer | wanderbooks gui <book>, the viewer mode (the ledger-gui crate; local, direct, the default; what opening the app shows); wanderbooks gui --books <url> (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 <id> (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 |
| Test/demo | wanderbooks dev-stdio --data-dir (also the ledger-mcp binary until the scaffold goes) |
any | refuses inside the data root; wanderbooks connect never configures it (D-18) |
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_hoststaken fromserver.toml(deployment config only).enforce_origin_validation()with an explicit list: loopback by default, plushttps://claude.aionce OAuth exists in P9. Today's empty list turns the Origin check off.json_response=true.legacy_session_mode=truewhile 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<http::request::Parts>. Long calls (CPA package, audit, extraction backfill, 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.
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). 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 bywanderbooks connect, and revocable withwanderbooks 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 <book>(also the app's "Connect Claude Code") runsclaude mcp add --scope user wanderbooks-<book> -- <absolute path of wanderbooks> mcp --book <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 apermissions.allow mcp__wanderbooks-<book>line. -
Codex:
wanderbooks connect codex --book <book>writes:[mcp_servers.wanderbooks-acme] command = "<absolute path of wanderbooks>" args = ["mcp", "--book", "acme"]The bridge answers
initializewithin Codex's startup timeout, opening the books first if they are closed. A client that needs HTTP can be pointed athttp://127.0.0.1:7411/b/<book>/mcpwithwanderbooks token printas its header helper once the owner turns TCP on (3.9); it holds no lease and needs the books already open. Nodefault_tools_approval_modeis written. Codex documentswritesas 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 (P9): the same static bearer over TLS for clients that send headers (Claude Code, Codex). claude.ai and ChatGPT need OAuth, so the self-hosted server offers OAuth 2.1 on this remote path only (D-7):
- RFC 9728 PRM per book; 401 with
resource_metadata. - 403
insufficient_scopefor 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_accessonly 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.
- RFC 9728 PRM per book; 401 with
Human factor for /h/: passkey with user verification (webauthn-rs; localhost is a secure context), or the owner passphrase (Argon2id, RFC 9106, m=64 MiB, t=3, p=1). Locally the passphrase is the default. Cookies are __Host- and SameSite=Strict, with CSRF tokens and CSP default-src 'self'. URL-mode elicitation is used where the client declares it, and the server requires that 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 (agentunless named), kept only as a SHA-256 inauth/principals.jsonl: a chain oftoken.created(with the token it replaces) andtoken.revokedrecords, 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;printis the only way its text is shown.checksays 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 optoken_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
wanderbooksand 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 whiledownloadread 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'srun/(thefile_keyringknob): 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 withWWW-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 (actionconnect). - Scopes (
ledger-mcp/src/scopes.rs): a table of every tool. Read-only tools need books:read; a tool withconfirmedneeds 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_logand 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 (abackup.*setting, byset_settingor 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/<id>/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, orrefusedwith 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 withbefore; over stdio (no log) the tool isn't offered. - 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).setupat a terminal asks for it (twice); elsewhere it says to runwanderbooks 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).doctorsays whether it is set and lists the tokens. Its uses (/h/pages, consents) come with the slices that build them. - Known, product-wide (for P7): 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)
<root>/ 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/<id>-<date>/ old copies moved here at adoption if the owner agrees (Q-3); the server never deletes them
books/<id>/ == today's data dir layout, unchanged (Store/Snapshot/verify_and_heal untouched)
journal.jsonl blobs/aa/bb/<sha> wanderbooks.v<N>.db(+wal/shm, one per schema; P3) wanderbooks.lock
vault/{tin,w9}-<party>.bin HPKE-sealed (v2); moved from <books>/vault (S:511)
keys/keyring.json {pubkey, slots:[passphrase|recovery|passkey_prf|member_passkey]}, all wrapped
keys/preview.key HMAC key for preview_token (0600)
keys/legacy.key v1 raw key, P2..P7 only, deleted at rekey
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/<id>.part staging, swept after 15 min (a backup being restored is cloned into books/.restore-* instead: 3.7 as built)
derived/<sha>/page-N.png disposable page-raster cache
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 public key | keyring.json; sha256 journaled (vault.created/vault.rotated) |
server, to seal new TINs. A swap fails books.verify and verify-archive. |
| Vault private key | wrapped per keyslot; plaintext only inside one /h/ request (server) or one GUI unlock (in the GUI process, local), then zeroized |
the human holding the passphrase, passkey PRF, recovery key, or an optional CPA keyslot |
preview.key |
keys/ |
server |
| 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 <book>runsledger-gui --root <root> --book <id>. Without--root, the GUI resolves the platform data root itself (3.4). It never looks in a project directory orwanderbooks.toml.- It reads
journal.jsonland blobs into aSnapshotas 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}toops/show.jsonl(bounded, rotated, not journaled). The GUI watches it with the same watcher it uses for the journal. This replacesviewer::requestand the$XDG_RUNTIME_DIRviewer 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
flockonops/viewer.lock, opened read-only. The server probes the lock to reportviewer.openand whether a show request was delivered. - Locally there is no socket, no peer-credential check and no executable check.
- Remote: replica (P9, self-host books only),
ledger-gui --books <url>with the static token or OAuth, andbooks:view:- It pulls
/replica/journal?after_seq=N(SSE of raw canonical lines) intoSnapshot::from_lines(LineSource, BlobSource). This reuses the parse, chain-check and replay loop ofsnapshot.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/eventscarriesbooks.showtargets per principal, and presence.- The replica carries no keyslot material.
- It pulls
- 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
ledger_view::attention::find, so it is the same list asbooks.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 and the sealed record from the data root, unwraps in its own process, and returns the value to the clipboard-only path (
tests/tin_copy.rsbehaviour), then zeroizes. This is not access-logged, because the GUI never writes; the docs say so. Remotely, TINs appear only on/h/pages. [viewer] open_at_startbecomes 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.rspaints 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 onesetuprecorded (Root::find), never the working directory or a toml; the book named, else the root's only one (or the first by id).--data-dirstill opens an older books dir'sbooks-data(tests, and the first company's books until the cutover), and--books-tomlnames its settings file, read only while its journal keeps none. - Settings come from the journal. A book of the data root never reads
wanderbooks.tomlorconfig.json: books that keep no settings show the defaults. An older books dir whose journal keeps none is read as its server reads it, from itswanderbooks.toml(or--books-toml) andconfig.json, followed live, until its settings move in (books.import_settings). The demo company journals its settings. - The vault. For a book in the data root (
inside_a_root), its ownvault/andkeys/legacy.key, whatever a setting says; for an older books dir, as before (--vault-key-file, the setting, the default). No passphrase: TINs get no handling beyond the vault (D-29); a click copies one to the clipboard as before. books.showand presence (ledger_store::ops, which replacesviewer.rs): the server makes the book'sops/(0700) andops/viewer.lock(0600) when it opens the books; a show request is a line ofops/show.jsonl({seq, at, target}, at most 32, the file replaced whole); the viewer watchesops/once it exists and holds a sharedflockonops/viewer.lockthrough a read-only descriptor while it runs, let go of when it closes;books.showanswersviewer_openand 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 ofserver.rsinto a crate both the server and the viewer use, overStoreorSnapshotalike (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 namename (2).extinstead 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) andget_w9hand their files over as exports (GET /b/{book}/x/{export},wanderbooks download ex_…), inline over stdio; 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 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:
- Verifies the chain.
- Commits the new journal lines and blobs to
archive.git(one commit per push; the message is the seq range plus the head). - Pushes to the targets: git remote, S3, dir, or
pull(the customer runswanderbooks 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.jsonlrecords 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 <backup> [--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.
- 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.
- 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/<id>-<date>/, kept, never deleted, and only on the owner's explicit choice. - 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.
- 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 statusreports 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 barearchive.gitbeside its journal; a backup is one commit of its journal,blobs/,vault/,keys/andaccess.jsonlexactly as they are, made under the store's lock (so whole lines and a vault that matches), its messageseq N head Hwithbook <id>below, then pushed outside the lock, fast-forward only (a target that moved on is refused and said, never forced), tobackup.git_remoteand tobackup.folder/<book>.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 (neverext::orfd::, 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~/.gitconfigstill 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.gitattributesin 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,commondirorgitdirfile, objects borrowed from elsewhere, a link where it or its own parts are, a<path>.gitor<path>.bundlebeside it), its configuration only whatgit init --barewrites (and, for a copy made withgit 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'sSSH_AUTH_SOCK(only a socket the account owns, not a link to one) so a private repository over ssh is reachable.backup.foldermust be absolute andbackup.git_remotecan'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, thebackup_quietknob for tests); before they close (at most 60 s); and onbooks.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.closedneeds 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) andbooks.back_up_now(books:admin, the one open-world tool).books.startadds a step when none is set up, or one is stale, failing or failed its drill. Over stdio neither is offered. 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, thedrill_firstknob, 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'srestore, previewed then confirmed against the previewed head. The server clones the backup intobooks/.restore-*(the address or folder crosses the owner-only admin socket as an adoption's path does; no staging throughuploads/), 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 ownarchive.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 intolegacy/<id>-<time>/; a restore that fails puts them back and opens them again.books.purge_tinnow says backups made before keep their copies (D-29, D-31). Each backup commit names its books (book <id>in its message), so a restore knows whose books it brings back (and the command line, given no--id, restores them under that id, deciding after one preview, which only reports; a restore leavesops/restored-fromin the books, noted by the server when it opens them, so the note lands with the books): the same books count their targets' backups as their own (a backup folder gone since is a disk not connected, never made again); books restored beside the books they were, under another id, never push to those books' repository (it would stop their backups): it is paused for them, said bybooks.backup, until another is named. A damagedops/backup.jsonlis set aside and begun again, never keeping the books shut, what it knew of whose backups the targets hold carried over; a backup folder that took the books' backups and holds none now says so (a disk not connected, or a new one:books.back_up_now {new_disk: true}begins it again, only when the human says so). One book's drill holds the others' backups until it ends; a backup at close waits at most a minute for it. No passphrase for a replacing restore (D-31: the old books are kept). Viewer and helper: "Restore…" and "Back up now" come with the menu intents (slice 8); the lease socket'srestoreintent now names the backup and the id, and both intents point to the terminal and the agent until then. - Tests:
crates/ledger-cli/tests/backups.rs(both targets after a write; a write not backed up before the quiet; close, and catching up at the next open; admin only and the head unmoved; a target that moved on; restore beside, on a clean install, of the previewed backup only, with its vault and key, refusing five kinds of damage and writing nothing, never overwriting and keeping the replaced books, a failed replace leaving the books open; a drill on open; a failed drill flagged),open_while_held.rs(books restorefrom the command line),ledger-store(backup unit tests: links and strangers refused before checkout, target hooks never run, ignore rules never leave a file out, stale locks, drills;root.rs: never moving books something holds), the knobs, the host's environment, the scopes, the settings' checks, and the loop's and the standing's unit tests. The suite passed nine of nine runs three at a time (the replace race above showed only under load).
3.8 Local vs self-host
| Local (T1, the default) | Self-host (T2, P9) | |
|---|---|---|
| Where | the human's computer | another machine the owner controls: home server, NAS or VPS |
| Processes | one server (as built, slice 7: which backs the books up itself), open while the app, an agent session or a terminal holds them (D-23) | the same binaries under system units, plus TLS (Caddy) and, optionally, wanderbooks tunnel |
| Runs as | the human | a dedicated account; agents have no account on that machine |
| Isolation | same_user (advisory): hidden, not locked |
service_user (enforced): machine and user-ID boundary |
| Auth | static bearer in the keyring; /h/ uses the owner passphrase (or a passkey) |
static bearer over TLS for header-capable clients; OAuth 2.1 for claude.ai and ChatGPT (D-7); /h/ uses a passkey or the passphrase |
| Vault unwrap | model A (server, inside the human request), plus the in-process GUI unlock | model A |
| Viewer | reads the data root directly, read-only | replica over books:view |
| Residual risk | a process running as the human (for example an agent that deliberately goes hunting) can read the data root, the agent token and the wrapped keyslots; TIN plaintext still needs the passphrase. The server's Landlock and seccomp confine only the server | whoever administers that machine |
A multi-tenant hosted service is out of scope for this plan; see section 11.
3.9 How the books open and close (D-23)
The books are open while something holds them; there is no background service. This section is the mechanism; P2 builds it, P11 adds updates.
Who holds them. The app (the viewer), an agent session's bridge (wanderbooks mcp), a terminal running wanderbooks serve, and any CLI command that needs the server. Each holds a lease.
Three local channels, all in run/ under the data root (0700 on Linux and macOS; on Windows %LOCALAPPDATA%\wanderbooks, never the roaming profile, with an owner-only DACL; named pipes with an owner-only DACL, FILE_FLAG_FIRST_PIPE_INSTANCE, and a check of the client's SID):
run/lease.sock: served by the host, leases only. Each lease records its kind (app, bridge, serve, cli) and PID; the app andwanderbooks doctorlist them, so a stray holder keeping the books open can be seen and closed. Besides holding, the socket accepts exactly five owner intents, which is how the viewer hands work to the server:close,back_up_now,new_books,restore(a backup, as its own book beside the others), andconsent_format_upgrade(a book).consent_format_upgradecarries the owner passphrase typed in the viewer (a restore that replaces a book no longer does, D-31: the books it replaces are kept), checked by the server (Argon2id, as on/h/); no MCP tool can give either, so an agent never consents. Nothing else is accepted.run/mcp.sock: served by the server, MCP for the bridge. Local agents never use TCP, so no other account on the machine can squat a port and catch the token or the traffic. Both ends check the peer's user ID (SO_PEERCRED/getpeereid, the pipe's SID). TCP is off by default. The owner can turn it on (server.toml, orwanderbooks serve --http) for a client that needs HTTP; it then binds 127.0.0.1:7411, a failed bind is reported bydoctorand is not fatal, and such clients hold no lease, so the books must already be open.run/admin.sock: served by the server, admin only (create, adopt, tokens), as in 3.1. Neither the viewer nor the bridge opens it.
The server's process (P2 slice 3). The server is the hidden mode wanderbooks server, started by the host with a pipe on its standard input; it runs until that input ends (the host let go of it, or died) or it gets SIGTERM, SIGINT or SIGHUP (watched from before the books are opened). It takes run/server.lock before anything else (a second server waits for it, touching nothing, and leaves if told to close first), opens and verifies every book (a book that won't open is listed with why, and the others are served), binds both sockets, and only then prints ready on its standard output. run/mcp.sock speaks Streamable HTTP over the Unix socket, /b/{id}/mcp per book, so local and remote share one code path (3.2); run/admin.sock takes one JSON line and answers one (list, create, adopt). A socket path longer than the system allows is reached through /proc/self/fd on Linux. Closing: new requests are refused, unrun (HTTP 503 at the door, or, for a call already past it, JSON-RPC error -32050 "closing: this call wasn't run"; the bridge sends either again once the books are back); every request already running finishes its work, however long, whether or not its client is still there; answers on their way out get 10 s more (a second signal skips that wait, never the first); then the sessions end and the sockets go. A request is at most 1 MiB (64 MiB until slice 5 gave files their own route). A book in the root keeps its vault in its own vault/ and its v1 key in its own keys/legacy.key (0700 and 0600, made private whenever first written), and no tool writes a file anywhere in the root (the server keeps uploads on their way in in the book's own uploads/, slice 5). Adoption is a preview, then a confirm tied to the head the preview read; entries of a settings file the books don't know are refused unless skipped, its comments are shown, the vault key it names must be the one given, and the key must open every vault record. Open for slice 8: under Landlock the server can't read an adoption's source outside the root, so adoption needs another way in by then.
The host's process (P2 slice 3). wanderbooks host closes every descriptor above 2 it was handed, refuses to run confined, takes run/host.lock (waiting up to 30 s for a host that is closing; one that finds a live host exits at once), binds run/lease.sock, and starts the server from /proc/self/exe, naming the root on its command line only when the host itself was given one (tests): nothing in a process list names it. Ending leases, it tells each holder why and closes the server only once they have let go (at most 2 s), so holders stop sending before it closes; a call already on its way is refused at the door, unrun, and sent again once the books are back. A closing server is never killed, however long its last call runs; a new server waits for an old one still finishing (its host killed) rather than fail. A restart begins when the new version appears, not at a quiet moment: the server first finishes every call in progress (none is cut off), and calls arriving meanwhile wait for the new server (up to 60 s); waiting for idle comes with jobs (books.job), which are what can run long. On a restart the old host hands over: it lets go of host.lock and keeps update.lock until the new host answers, so waiting holders find it. A holder starts a host only when none holds the lock; one that died at once is noticed within 2 s; a server that fails soon after opening counts as failing (only 60 s open clears the count). Lease records carry the holder's process as the kernel reports it. The lease reasons are closed_by_owner, restarting, closing (the host is closing because nothing held it: hold again, a new host will answer; the owner's close, by the menu, wanderbooks close or a signal to the host, is always closed_by_owner, and a holder that had held stays closed) and server_failed. The bridge never sends a call the agent cancelled before it was sent (MCP owes no answer to a cancelled request), answers a ping itself while the books open, refuses JSON-RPC batches request by request, passes no client offer it can't carry back (sampling, elicitation, roots), reports a call answered with nothing as possibly run, and carries the per-request protocol headers of the 2026-07-28 era. Sessions on the server time out only after 24 h without a message (rmcp's idle timer ignores a call in progress, so a shorter one could cut a long call off); the bridge ends its own when its stdin closes or it is signalled. wanderbooks doctor (a first form, completed in slice 8) says whether the books are open, what holds them, and the host's and server's last log lines, naming no folder. Tests shorten the timings with a hidden --test-knobs flag, passed down from holder to helper to host.
Starting the host cleanly. Every mode that confines itself (the viewer, the bridge) first starts a small unconfined helper (wanderbooks helper, spawned as its own process over an inherited socket pair before any thread, window or Cocoa start; never a fork of a running GUI), then confines itself. The helper accepts a fixed message set and nothing else: start_host; open_with an attachment id (the helper itself copies that blob, read-only, into a 0700 per-user directory, $XDG_RUNTIME_DIR/wanderbooks-open/ or its per-system equivalent, swept when the helper exits, and hands it to the system's default app; it never accepts a path or a command line); stage_backup, whose file the helper asks the owner for in its own file dialog (never a path from the viewer), copying it into the data root's uploads/ and returning a staged id for the restore intent (as built, slice 7: a backup is a repository, and the restore intent names its folder or address, which the server clones; 3.7); and start_updater, with no arguments. A confined mode starts the host only through its helper; unconfined holders (the CLI commands, serve) use the same clean start directly, and a restarting host starts its successor itself, already clean, in its own session and scope (P2 slice 3). The clean start is a re-exec: environment scrubbed to a fixed allow-list (keeping XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS, which systemd-run --user needs, only after checking they point at the host's own user's runtime directory), stdin, stdout and stderr to null (a log file under run/), a new session (setsid), a fixed umask. On Linux under systemd (uwsm on Omarchy puts every app in its own scope, which would take the host down with the app) it re-execs through a transient systemd-run --user --scope, which installs nothing, persists nothing, and disappears with the host; elsewhere setsid alone. On Windows, CREATE_BREAKAWAY_FROM_JOB where the job allows it, and DETACHED_PROCESS. The host resolves the data root itself from the user's account (getpwuid home, or the known folder on Windows), never from the opener's environment, unless wanderbooks setup recorded another root. It refuses to run if it finds itself confined (seccomp in /proc/self/status, a Landlock write probe).
One host, even in a race. The host takes run/host.lock (an exclusive flock) before anything else; a second host exits and its opener attaches. The host starts the server, which takes every book's writer lock (as built, slice 7: and backs the books up itself, one run at a time per book, so two pushers never meet on one archive.git). An opener that finds no live host and an unlocked host.lock starts one; one that finds a host shutting down (the lease socket refuses during shutdown) waits on host.lock, then starts a new one.
Closing. When the last lease ends, the host waits 10 s; a lease arriving in that time cancels the close. Then: no new requests, the one in progress finishes, the server makes a last backup of each book (as built, slice 7, at most 60 s), every writer lock is released, and the host exits. "Close the books" (the app's menu, wanderbooks close) ends every lease at once; a bridge then tells its agent the owner closed the books and reopens them only in a new session. Before it ends leases the host says why on each one (closed_by_owner, restarting, closing, server_failed; P2 slice 3). A holder that sees the connection end with no reason (a crash, a kill) starts a new host and re-takes its lease, with backoff (1, 2, 4 … 60 s, at most five tries); a host that keeps failing to start (a damaged journal, a panic) becomes a Needs-attention and doctor item, and a bridge answers its agent with a clear error instead of retrying forever. host_respawns and respawn_backs_off test it.
Restarting (an update, or the executable replaced by a package manager) is not a close. What lives between calls survives it: upload grants, download links, /h/ sessions and finished job results are kept in run/state/ (0600, the session key with them), and a running job delays the restart until it finishes (jobs, books.job, come later; until then a restart begins at once and the server finishes every call in progress first, P2 slice 3). The host takes run/update.lock, closes the books as above but keeps the lease socket's holders informed, swaps (or, for a package-managed install, finds the new executable already in place), and restarts from the new executable. Openers that arrive meanwhile wait on update.lock. Bridges reconnect and re-initialize upstream without the agent noticing. A package-managed upgrade is noticed by the host (its executable's inode changed) and restarts it as above.
One version at a time. Children (server, archive, extractor) are started from the executable the host itself is running (/proc/self/exe or fexecve on Linux), never by path, so a swap mid-run can't mix versions. Where that isn't available (macOS, Windows) every child, bridge and lease handshake carries the version, and a mismatch triggers a restart. The lease, helper and bridge protocols carry protocol versions (not release versions, since a staged update can skip releases); each accepts its current version and the one before, and outside that range refuses with a plain message ("restart this agent session").
The bridge and slow starts. The bridge waits up to 5 s for the server and returns its real initialize (the connect text composed from the book, 4.1). If the server isn't ready by then (a cold start, a projection rebuild), it answers initialize itself, within a client's startup timeout (Codex allows about 10 s), from the tool list built into the same executable and a short generic text that points at books.start, which returns the book's rules. Calls are queued until the server is ready; a queued call fails after 60 s with a clear error that names wanderbooks doctor.
Format changes are the server's gate, not the updater's. A server that would write a newer journal format than a book has opens that book read-only, with guard format_upgrade_pending, until the owner consents in the app (or on a /h/ page). Before it asks, it makes a verified local backup that includes the keys (legacy.key until P7). Consent comes only from the owner (the passphrase, through the viewer's consent_format_upgrade or a /h/ page), never from an agent. Declining leaves the book as it was, and before consent the way back to a version that can write it is always there: a self-updating install keeps the previous verified app and offers "Go back to version X" (wanderbooks rollback); a package-managed one downgrades through its package manager (our apt and dnf repositories keep every release, and the AUR keeps old PKGBUILDs). This holds however the new version arrived: our updater, a package manager, or a hand install. After consent the previous version can no longer open that book, so "Go back to version X" refuses for it; the only way back is restoring the pre-upgrade backup, which would drop every entry since, so that restore says how many entries would be lost, arrives as its own book beside the current one (never replacing it), and needs the passphrase.
As built (P3, the format upgrade, D-39), and where it departs:
- Waiting is a fact of the journal. This version's format is the newest it reads with every feature it knows (
ledger_store::format::current); a book declaring less waits (JournalFormat::upgrade), format 1 included, so the gate covers the step to format 2 as much as P3's features. New books declare the format in their second line (Store::create) and never wait; an adoption's copy is new books of this version and moves to it as it is made (the original keeps its own). The lazy declarations (ensure_formatdeclared format 2 at the first setting) are gone:ensure_formatandensure_feature(the other P3 branches' call, same signature) now only refuse. - Served on the old format, not read-only. The book opens and is served as before; only a write that needs the newer format or a feature it hasn't declared is refused, before anything is journaled (
StoreError::FormatUpgradePending, guardformat_upgrade_pending; the guards envelope is P5's, so the guard leads the error text andbooks.start'sformat_upgrade). "Read-only" above is narrower than D-39's "declining never stops the books opening on the old format", which this follows. - The consent path. The viewer's dialog and
wanderbooks books upgrade ID(the owner passphrase typed at a terminal) send the lease intentconsent_format_upgrade; the host, which reads no books, hands it to the server's admin socket (upgrade_format, the passphrase redacted from Debug), which checks the passphrase (Argon2id,passphrase::verify) before anything else. No MCP tool reaches either socket. No/h/page (P5). - Asked at every open. The viewer asks once each time it opens waiting books, with a standing notice above every page and a Books-menu item; the owner's commands that open the books (
serve,books,token,connect) ask once each run where the question is seen and answered (input and output a terminal, in the foreground: a command whose output goes to a file or a pipe would wait unseen, and mix the question into its output), and elsewhere print a note;books listanddoctorshow it;books.startputs it first. - The backup first. Made at the go-ahead, not before asking (as above): a backup made when the owner is asked could be behind the books by the time they agree, and this one is the books exactly as they are moved. Under the books' lock throughout: the books are committed to their own
archive.git(whatever targets the settings name, or none), the archive cloned and proven as a restore proves a backup, its head the books', its journal theirs byte for byte, its vault and keys file for file (backup::prove_archive); only then the onejournal.format_set. Both are records ofops/backup.jsonl(format_upgrade, with the backup's commit and head); the targets take the pre-upgrade commit, as history, at the next backup after the write. - Not built: "Go back to version X" (
wanderbooks rollbackdoesn't exist yet: P11's updater); restoring the pre-upgrade backup as its own book that says how many entries it lacks (the backup is a commit in the archive's history;books restorebrings back a repository's latest, so this needs a restore of a named commit, its own book id, the passphrase and the count: not small, left for P11 with the rollback). - Each projection schema in a file of its own (the lead's decision, 2026-10-21), so going back to an older version stays possible until the owner consents: an older version refuses a database of a newer schema (
SchemaTooNew), so a version that migrated the one file in place would have kept P2 from opening books that declined the upgrade. The projection's file iswanderbooks.v{N}.dbfor schema N (ledger_store::db_file_for, the name depending on the number alone, so the P3 branches' v13s merge to one name). A version opens or creates only its own; its own missing, it builds it from the journal (and catches it up at open, as ever); P2'swanderbooks.dbis used only while it already holds this version's schema (no rebuild for nothing); any other schema's file is never migrated, written, refused or deleted.books.verify(database,other_databases) andwanderbooks doctor(database: …, with the others left there) say which; stale files are listed, never deleted. Old books dirs kept in git (before P2) ignorewanderbooks.db, not the new name: served bydev-stdioonly.
As built (P2 slice 8), and where it departs (D-32):
- No confinement, no helper. No mode confines itself (no Landlock, no seccomp), so the helper is gone (
wanderbooks helper,ledger-hosthelper.rs): the bridge, like every holder, starts the host itself with the clean start (start.rs). The host still refuses to run when it finds itself confined (started from a sandboxed app). Isolation is said assame_user (advisory)inbooks.start(isolation) andwanderbooks doctor. - The lease socket's intents are two:
closeandconsent_format_upgrade. Backing up, restoring and new books were intents only so a confined viewer could hand them over; the viewer now does them as the command line does, through the server's admin socket (back_up_now,restore,create), holding a lease while it asks. - The viewer holds the books open (a lease of kind app) while it shows books of the data root, on a thread of its own, taken only once its own flags are good and its book is found (the window never waits; with no server it still opens and follows the books from disk). It lets go only when the owner closes the books; books that stop or won't open are tried again (a fresh try every 30 s after five failures). Its Books menu (ledger-gui owner.rs, beside Export): Back up now; Restore… (the backup's address or folder typed, checked first: what it would do is shown, then done for that backup at that head); New books…; Connect Claude Code, Connect Codex; Close the books (asked first). Each is done off the UI thread by the
Ownerledger-cli gives the viewer, over the same ledger-host functions the command line uses; the viewer writes nothing of the books; the menu waits while one runs, and a restore's fields can't change while it is checked (a result for anything since edited is dropped). Older books dirs and the demo have no Books menu. - New commands:
wanderbooks connect claude|codex --book ID(the books' agent token kept in the keyring, made if none is, replacing the server's, which no one can be using; then the agent's ownclaude mcp add --scope userorcodex mcp addofwanderbooks-<book>runningwanderbooks mcp --book <book>; the agent's command found on the PATH, else where its installers put it under$HOME, or/usr/local/bin,/opt/homebrew/bin: a viewer started from the desktop has a short PATH), andwanderbooks books back-up ID [--new-disk](the admin socket'sback_up_now). - Scaffold deleted:
wanderbooks new/initand its templates (justfile, AGENTS.md, CLAUDE.md, wanderbooks.toml, .gitignore, .mcp.json, Claude and Codex settings), and its tests; the settings file's own export is ledger-config's (books.export_settings).wanderbooks new/initnow says it is gone and what to run instead.install.shinstalls the one executable, runssetup, and given a name starts those books and connects Claude Code: three commands to the first post, no sudo. The website (site/index.html) says the same; its built copy must be redeployed (just deploy) when this change is pushed: the old installer runswanderbooks new. The first company's own books dir keeps its files until the cutover (slice 9). - Tests:
ledger-gui/tests/owner_menu.rs(each item through a fake owner, the restore checked first, nothing of the books written; busy states and a restore's fields frozen while checked; no menu for an older books dir; screenshots),open_while_held.rs(connect with fake agents and the command it registers reaching the books, a token the keyring lost, an agent that refuses; back up now from the command line,--new-disk; the viewer's hold, new books and close through the owner's functions; the two intents),one_executable.rs(the gone scaffold),spec_kept.rs(isolation).
4. The MCP surface after the change
4.1 Connect instructions (composed in get_info; replaces INSTRUCTIONS S:57 and the string replace at S:8179)
Hard cap 1,400 characters. The first 512 contain books.start. Composed from journaled state:
wanderbooks: the double-entry books of {company.name|"a new company"}. Call books.start first in every session: it returns the rules in force, this company's conventions, what needs attention, and your next steps. These tools are the only way to the books; never look for them in local files. Amounts are decimal strings ("123.45"). Entries: books.draft_entry, books.add_line, books.preview_entry, show the human the preview, then books.post_entry with its preview_token. Posted entries never change; books.reverse_entry makes a reversal draft.
{evidence_rule}. No receipt yet? books.set_draft_state awaiting_evidence keeps the draft visible until it arrives. Files go in with books.begin_upload (inline base64 only under 256 KiB, with its sha256) and come out of books.get_receipt as text, page images or download links. Every change returns next_steps, guards and the journal head: follow them and show the human the affected balances. Never ask for, accept or repeat a full TIN: books.request_tin_entry gives the human a secure link. Procedures are prompts (onboard, month_end_close, cpa_handoff, audit); the guide is books.handbook or books://handbook.
{evidence_rule} comes from evidence::rule(&settings), which replaces ReceiptPolicy::rule() (ledger-config/src/receipt_policy.rs:147) and now names the required and exempt accounts. The first company example: "Every entry needs a receipt attached or linked before posting; depreciation, amortization and closes are exempt; a reversal needs its own receipt or the original's linked".
4.2 books.start (read-only, _meta["anthropic/alwaysLoad"]=true, ≤ 8 KB)
The v1 ships in P1 with the fields marked †. The full version ships in P5.
Input: {focus?: "daily"|"onboarding"|"close"|"audit", agent?: {model, reasoning_effort, harness}}. The agent block is self-reported and goes to the access log.
{ "book": {"id","name","url","timezone","today","fiscal_year":{"start","end"},"base_currency","basis"}, // †
"you": {"principal","kind","scopes","client_id","verified":["principal","client_id"],"self_reported":["model","reasoning_effort"]},
"isolation": "same_user (advisory)|service_user (enforced)",
"head": {"seq","hash","last_handoff"}, // †
"state": "needs_onboarding|active", // †
"rules": {"evidence","parties","capitalization","attestation_bar","approval_rules":"off"|[...]}, // † (evidence, parties)
"settings_summary": [{"key","value","set_by","seq"}], // †
"conventions": [{"id","kind","title","summary"}], // †
"open_items": [{"id","title","due"}],
"needs_attention": [{"id","severity","title","detail","tool","args","prompt"}], // top 12 + counts by kind
"drafts": {"building":n, "awaiting_evidence":[{"entry_id","date","amount","note"}], "reversal_drafts":[...]},
"reconciliation": {"tracked":[...], "never_reconciled":[...], "behind":[...]},
"attestation": {"bar":{...}, "due":[...], "attested_through"},
"viewer": {"open":bool, "remote":[principal...], "how_to_open":"wanderbooks gui <book>"}, // remote: P9 replicas
"backup": {"last_at","through_seq","stale":bool},
"handbook_sections": [{"id","uri","chars"}], "prompts": [...],
"next_steps": [{"tool"|"prompt","why","args","required"}] }
When state=needs_onboarding, next_steps = [{prompt:"onboard"}]. Conventions are presented as company policy data, not as directives, which keeps the server eligible for directory review.
4.3 Handbook
books.handbook {section?} (read-only) and books://handbook, books://handbook/{section}. The source is ledger-mcp/handbook/*.md, compiled in with include_str!. It is generic and carries no the first company leaks ("FL-0001" and "Due to Shareholders" become neutral examples).
Sections: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, closing, attestations-and-audit, cpa-handoff, settings-and-conventions, viewer, rules-that-change, control-roles, tracing-figures.
tracing-figures replaces "the journal is the evidence — read it" with "trace through books.get_entry, books.search_entries, books.journal_history". Each section ends with this company's conventions that apply to it, rendered from journaled convention.set events. The long tool descriptions move here: export_cpa_package (1,677 characters), set_entry_party (1,453), set_w9 (1,378), vendor_1099 (1,055).
4.4 Prompts (#[prompt_router]; the prompts capability is enabled at S:8172-8176; arguments are single tokens)
| Prompt | Args | Drives |
|---|---|---|
onboard |
none | company facts → set_setting previews; chart template by entity type; members; timezone; evidence policy; attestation bar; opening entry; trial-balance check; offer migrate; offer to open the viewer |
migrate |
source=qbo|csv|sheet|other |
import → compare_reference to the cent → record_decision cutover |
process_inbox |
none | each upload left on the human drop page → find or create the entry → attach → post when ready, or leave a draft awaiting evidence |
receipts_from_email |
none | attach_email flow |
reconcile |
account=CODE month=YYYY-MM |
statement → record_statement → mark_cleared → reconcile |
month_end_close |
month=YYYY-MM |
data-driven: tracked accounts, financing-account review, schedules, depreciation, recurring, close_step conventions, books.audit scope=month, close_period, month-end attest_period |
cpa_handoff |
year=YYYY |
full audit at the bar → attestations → export_cpa_package (no TINs) → request_human_download |
annual_compliance |
year=YYYY |
calendar by entity type, 1099 work, open items |
audit |
scope=month|annual|cpa_handoff|full |
the adversarial procedure: separate verifier agents, per-receipt extraction against page images, attest_receipt, review_entry, attest_period |
4.5 Resources (advertised templates; URIs repeated in structuredContent and in text)
| Resource | Contents |
|---|---|
books://start |
— |
books://handbook/{section} |
— |
books://conventions |
— |
books://settings |
— |
books://reports/{kind}{?start,end,as_of} |
today these take no dates (S:8189) |
books://digest{?since,as_of} |
the bare form works; the default is since the last session head (fixes types.rs:1520) |
books://entries/{id} |
— |
books://attachments/{id} |
metadata plus extracted text |
books://attachments/{id}/page/{n} |
PNG |
books://attachments/{id}/file |
BlobResourceContents up to 1 MiB; larger files return an error naming get_receipt view=link |
books://journal/{seq} |
— |
books://workpapers/{id} |
— |
books://receipts/{id} (S:8142, base64) remains for one release as an alias of /attachments/{id} with text.
4.6 Result envelope (single choke point in call_tool, S:8160)
{ ...payload,
"next_steps": [{"tool","args","why","required":bool}],
"guards": [{"code","message","remedy_tool","remedy_args"}],
"warnings": [...],
"head": {"seq","hash"} } // on every mutating result
next_steps::decorate(tool, &result, &view) takes items from ledger_view::attention filtered to the touched subject, plus tool-specific steps. Examples:
- After a post: attach or attest evidence if it is missing,
books.show entry, the affected balances. - After a reversal draft: add evidence.
- After a close:
attest_period. - After
export_cpa_package: the human download link.
Report results also carry as_of_status {drafts_open_on_or_before, unreconciled, attested_through, settings_problems}. Properties: every mutating result has a head and a non-empty next_steps; every guard names a remedy tool that exists.
4.7 Files in and out
In:
books.begin_upload {sha256, size, mime, file_name, purpose: receipt|document|import}returns{upload_id, put_url, curl, human_url, expires_at, max_bytes}. If the bytes are already stored it returns{existing_attachment_id}.PUT /b/{book}/u/{upload_id}is single-use, valid 15 minutes, bound to the principal, sha and size, capped at 25 MiB by default, and re-hashed before acceptance.human_urlis a drop zone on/h/inboxfor clients with no shell (claude.ai).attach_receipt,file_document,import_bank_csv,import_chart_csvandattach_emailtakeupload_id | inbox_id | sha256 | content_base64 (≤256 KiB + required sha256).books.inbox/books.discard_inbox_itemmanage the drop zone.openai/fileParamsis declared on the attach tools (P9). An MCP App upload widget comes in P9.
Out:
books.get_receipt {id, view: meta+text (default) | text | page_image{page, max_px:1568} | link | bytes (≤256 KiB, with sha)}.books.download_link {attachment_id|export_id}returns{url, expires_at, sha256, size}. Links are single-use, valid 10 minutes, access-logged, and served withContent-Disposition: attachmentandnosniff.export,asset_labelsandexport_cpa_packagereturn a link, plus inline JSON/CSV when ≤ 50K characters.- TIN-bearing outputs come only from
books.request_human_download, which returns an/h/link.
Typical receipt flow (Claude Code, the daily case in 7.2), as planned; slice 5 built it with a command instead of curl (below): sha256sum → books.begin_upload → curl -T the file to put_url → attach_receipt {upload_id} → get_receipt (text) → draft_from_template or draft_entry → preview_entry, shown to the human → on "yes", post_entry with the preview_token → next_steps and the head come back, and books.show moves the viewer to the entry. No step raises a client permission prompt beyond the client's normal rules. "Save it as a draft" is set_draft_state awaiting_evidence. On claude.ai, files ≤ 256 KiB go inline with their sha256; larger ones go through human_url and the process_inbox prompt.
As built (P2 slice 5), and where it departs:
- No path on the server's computer is read.
books.import_local_file, the import roots and--import-rootare gone, and so isset_w9'sw9_file_path; a test (no_tool_takes_a_path) keeps any path-like parameter out, except the three outputs with full TINs (export_cpa_packageandget_w9save_to,vendor_1099save_csv_to), which keep theirs, written only outside the books, until the viewer takes them over in slice 6 (D-28: the viewer reads and downloads; it never writes). - An upload, for books served from the root:
books.begin_upload {sha256, size, mime_type, file_name, purpose: receipt|document|import|w9}returns{upload_id, command, expires_at, max_bytes}. Noput_url,curlorhuman_url: the server listens on a Unix socket, so the agent runscommand,wanderbooks upload --book ID UPLOAD FILE, which reads its token from the keyring (--token NAMEis in the command when the caller's token isn't the defaultagent) and PUTs the file to/b/{book}/u/{upload}(D-28: the agent does it all; no/h/inboxdrop zone, noinbox_id; the hosted server of P9 adds a URL). The bytes stream touploads/<id>.partand are kept only if exactly the size and SHA-256 declared, by the token that began it, once (409 after); 25 MiB at most; 15 minutes; at most 16 under way per token; lapsed ones swept when the books open, whenever a grant is made, and every 10 seconds (owner, 2026-10-19: a W-9 or a file with a TIN "shouldn't just linger there forever... EVENTUALLY cleaned up is fine"). One lock per book orders every change to its uploads (grants, sweeps, a send's start and end, a take, a give-back), and a grant is written aside and renamed. Refusals carry their code (no_such_upload,not_yours,already_sent,in_use,not_as_declared,stopped), and a refused body is read and dropped (up to 25 MiB) so the client gets the answer. Every PUT to books the server holds open is in the access log (actionupload, with the upload's id, refusals at the door included; a request for books it doesn't hold has no log to go in), written in the same blocking task as the bytes, so a client hanging up never leaves bytes kept without their record, and bytes whose record can't be written are thrown away. An upload's id isup_and 32 lettersa-p: with no digits, the access log's TIN masking never hides one. - Filed once, and only when filed. A tool takes the upload by renaming its bytes aside (so two calls never both have it), hashes them again, and spends it only once they are in the books (an import: once its rehearsal passed and the writing begins); a call refused after taking it, or one that panics, gives it back, and a server stopped mid-call gives it back when the books next open. A tool takes only an upload of its own purpose (receipt or document:
attach_receipt,file_document; import: the two CSV imports; w9:set_w9).existing_attachment_idis offered only for a receipt or document, and never names a withdrawn attachment. - Inline:
content_base64(≤ 256 KiB, its length checked before it is decoded) with a requiredsha256; never both, never neither. Over stdio only inline, and no larger file comes back: stdio is the development transport now (the owner's books run released code until the cutover,just buildbuildingmain), so it gets no upload route. Names and types are checked when a file is filed, whichever way it came: a name of 1 to 255 bytes, no folder in it, no control or invisible characters; a MIME type of visible ASCII.attach_emailandinbox_idaren't built.set_w9takesw9_upload_idorw9_content_base64+w9_sha256, judged a PDF, PNG or JPEG by its first bytes, with an optionalw9_file_name. A request on/b/{book}/mcpis at most 1 MiB now that files have their own route (it was 64 MiB). - Out:
books.get_receipt {attachment_id, view: meta (default) | bytes}: bytes inline for a file ≤ 256 KiB; a larger one answers withdownload, the commandwanderbooks download --book ID ATTACHMENT --to FOLDER, which GETs/b/{book}/a/{attachment}with the agent's token (books:read;Content-Dispositionwith the name in ASCII and RFC 8187 UTF-8,nosniff, and the SHA-256 as an RFC 9530Repr-Digest, which the command checks before it saves), into a new file beside any of that name (name (1).pdf), never over one: written to a hidden file and linked in whole, so the name never holds part of it. The name it saves under is the server's made plain (no folder, no leading dot, nothing invisible, at most 200 bytes), elseattachment-N. The read and its record are one blocking task, as for uploads. Thebooks://receipts/{id}resource is meta only. Not built yet:text,page_imageandlinkviews,books.download_link, and links fromexport,asset_labelsandexport_cpa_package(slice 6, with the viewer's downloads).
4.8 Drafts waiting on evidence
- Draft states are
building | awaiting_evidence{note}, plus reversal drafts. - Open drafts appear in
books.start.drafts, the digest, Needs attention ("Draft #52 awaiting receipt: Figma annual plan, noted 2026-10-16") and every report'sas_of_status. - A close is refused while a draft in the period is open (existing Lean
hdrafts).discard_draftis the way out. export_cpa_packagerefuses while a draft dated on or beforeas_ofis open, unless givenallow_open_drafts {reason}.
4.9 Tool changes
New:
| Area | Tools |
|---|---|
| session | books.start, books.handbook, books.job, books.journal_history {after_seq?, types?, subject?, limit} (the only way to "read the journal"), books.viewer_status |
| settings / conventions | books.settings (from the journal, with provenance and schema), books.set_setting, books.import_settings {toml_text, config_json_text?}, books.export_settings, books.conventions, books.set_convention, books.retire_convention, books.record_decision, books.decisions, books.open_items, books.raise_item, books.resolve_item, books.draft_from_template {template_id, ...} |
| drafts | books.preview_entry (read-only), books.list_drafts {as_of?, state?}, books.set_draft_state, books.discard_draft {reason}, books.inherit_evidence {entry_id, from_entry, reason} |
| chart | books.rename_account |
| evidence | books.begin_upload, books.inbox, books.discard_inbox_item, books.attach_email, books.find_attachment {sha256|md5|receipt_number|text}, books.annotate_attachment, books.consolidate_duplicates, books.add_instrument, books.instruments, books.set_entry_instrument, books.download_link |
| assurance | books.audit {through, scope} (job, deterministic workpaper), books.workpaper, books.attest_receipt, books.review_entry, books.attest_period, books.attestations |
| ops | books.backup, books.back_up_now (admin), books.access_log (admin), books.invite {kind: cpa|member|viewer, email} (admin; remote people need the self-host server, P9) |
| migration | books.compare_reference {reference_attachment, as_of, mapping_convention}, books.record_inventory {source, items|upload_id}, books.check_inventory {listing} |
| reports | export kinds members_equity, general_ledger, general_journal (JSON with entry_id) |
| vault (human links) | books.request_tin_entry {party, kind: ssn|ein|itin|w9}, books.request_human_download {what: cpa_package_with_tins|vendor_1099_with_tins|w9, party?, year?}, books.set_w9_facts {party, name, classification, address, signed_on, exempt_payee?} |
Changed:
post_entryrequirespreview_token; the gate moves into Store; the result carriesposted_seqandposted_at.reverse_entrycreates a reversal draft, withevidence: "link_original" | {attachment_ids} | "later".attach_receiptandfile_documentdeduplicate by sha: identical bytes link the existing record, so "deduplicated" becomes true. Text is extracted at attach.get_receipt: views as in 4.7.digest(defect 6).- Reports: one default range,
as_of_status, andstrict. cash_flow:reconciles,noncash_disclosures,operating_reconciliation.vendor_1099: instrument-aware;include_tinsandsave_csv_todropped.set_vendor_1099takes an explicitnot_reportableplus a reason.register_asset:in_service_date(withpurchase_datekept as an alias for one release) and basis fields; it reportsacquired_date, which comes from the acquisition entry it is linked to (settled in design, 2026-10-20), so it takes none.list_assets:accumulated_postedandaccumulated_scheduled_through.list_schedules: start, end, convention, next, remaining.parties: last four fromparty.tin_set.add_account: tax-category warning, or refusal under the policy.show: refusesstart > end; appends the target toops/show.jsonlin the book's data root (locally) or to the replica event channel (P9, remote viewers), and reports whether a viewer is open and was reached. The result never names a path.verify: also checksaccess.jsonl, the vault public-key sha and extraction blobs.export_cpa_package: no-TIN package returned as a link; becomes a job.
Annotations (P0): every tool gets a title and readOnlyHint or destructiveHint, plus idempotentHint where true. Corrections: export_cpa_package is no longer marked read-only; verify, list_periods, ar_aging and ap_aging are annotated. No tool gets requiresUserInteraction (2.1; owner decision 2026-10-16).
Removed:
import_local_file(S:4511): restricted in P0, deleted in P2.get_w9.set_tin'stinandset_w9'stin/w9_file_path: guarded in P0, deleted in P7.- Every
save_to/save_csv_to(types.rs:368, 2563, 2686, 2758-2764): deleted in P2. include_tins.- The
INSTRUCTIONSconst. viewer::request(replaced byops/show.jsonl).--books-toml/--configin serve mode.- Every mention of
justorwanderbooks.tomlin tool text: S:340, 390, 525, 546, 637, 640, 1902, 3061, 3251, 3374, 4060, 4315, 4568, 5194, 5829. server.rs(8,529 lines) is split intotools/<group>.rsas each phase touches a group.
The tool count goes from 93 to about 125. Only books.start and books.handbook load upfront under tool search.
5. State migration
5.1 Format gate (P0, before any new event)
- An unknown type is no longer
JournalCorrupt(store.rs:4419-4423,:4640, Snapshot, GUI). It becomesStoreError::NewerJournal {seq, type, need}, shown as "update wanderbooks". - A one-time
journal.format_set {format: 2, features: [...]}is written before the first v2 event. - Fixture journals from every phase are added to
books_written_under_older_rules_still_open. - There is one executable (D-22), so an update never mixes versions; the host starts its children from its own executable (3.9).
wanderbooks updateupdates a self-updating install (dmg, Windows installer, AppImage) and refuses on a package-managed one, saying to use the package manager. The local GUI reads the journal directly, so it hits the sameNewerJournalgate; the replica protocol (P9) carries a minimum version header.
5.2 New events (all additive; legacy events replay unchanged)
Every event may carry an additive actor {principal, client_id, kind}, which replay ignores.
| Event | Phase | Replay target |
|---|---|---|
settings.set {key, value, effective_from?, source{decided_by, decided_on, citation?, note?}} / settings.cleared |
P1 | ledger_core::Settings (Lean Step.setSetting: balances and drafts unchanged) |
convention.set {id, kind: posting_template|approval_rule|policy|close_step|calendar|mapping|note, title, body_md, structured?, source, supersedes?} / convention.retired |
P1 | Conventions |
decision.recorded {id, subject, decision_md, decided_by[party], decided_on, evidence_attachment?, supersedes?} |
P1 | Conventions |
open_item.raised / open_item.resolved |
P1 | Conventions |
account.renamed {account_id, code, name, previous, reason} (keyed by id, as account.renumbered: a code can change, an id can't; code is informational; the journal feature account_renamed is declared before the first) |
P3 | chart |
instrument.added {id, kind: bank|business_card|member_card|member_cash_or_check|platform, label, last4?, owner_party?, account?} / entry.instrument_set |
P3 | Ledger (1099) |
asset.basis_set {asset_id, rule: purchase|carryover_723|personal_use_conversion, contributor_adjusted_basis, fmv_at_contribution, sec704c_method, source, tax_basis_723, depreciable_basis, built_in_gain_704c} (the facts; the figures for readers, never recomputed at replay; a purchase's depreciable_basis null; the journal feature asset_basis declared before the first; legacy asset.tax_basis_set still replays) |
P3 | assets |
schedule.registered + convention (absent = monthly_even) |
P3 | schedules |
vendor.1099_set value not_reportable{reason}; legacy null replays as undetermined |
P3 | vendors |
period.closed with close_to: MembersCumulative{since_seq} (old variants replay as before) |
P3 | periods |
entry.draft_state_set {id, state, note} / entry.draft_discarded {id, reason} |
P4 | Ledger drafts (Step.discard; draftsOpen at Ledger.lean:133) |
entry.reversal_drafted {id, reverses, date, description} (lines derived from the original) |
P4 | Step.draftReversal; posted later by an ordinary entry.posted |
entry.evidence_inherited {entry_id, from_entry, attachment_ids, reason} |
P4 | evidence |
entry.posted + actor, preview_digest |
P4 | ignored by replay |
attachment.text_extracted {id, text_sha256, extractor, version, pages, status: ok|none|failed} |
P6 | evidence |
attachment.rendered {id, from: eml, pdf_sha256, headers{message_id, from, to, date, subject}, source_sha256} |
P6 | evidence |
attachment.annotated {id, fields{receipt_number?, payee?, amount?, date?, instrument?, category?, source?, reimbursable_to?}, note} |
P6 | evidence |
entry.settles {entry_id, settles[]} |
P6 | derived reimbursed |
inventory.recorded, reference.compared {reference_sha256, as_of, mapping, workpaper, differences} |
P6 | migration |
vault.created / vault.rotated {pubkey_sha256, scheme:"hpke-x25519-chacha20poly1305-v2"}, vault.keyslot_added / vault.keyslot_removed {slot_id, kind, label}, vault.resealed {party, kind, old_hash, new_hash} |
P7 | evidence; vault_tin (S:1326-1378) accepts the newest hash |
audit.run {id, through, scope, program_version, through_seq, tb_hash, findings, workpaper} |
P8 | assurance |
receipt.attested, entry.reviewed, period.attested {..., method{model, reasoning_effort, adversarial, verifier_agents}, verified{principal, client_id}}. counts is derived against the bar in force at that seq; it is never stored. |
P8 | assurance |
Not journaled: the access log, backup runs, head handoffs, viewer preferences, page rasters, and any key material or key path.
Projection (schema v12, schema.rs:8):
entriesgainscreated_seq, posted_seq, posted_at (= ts of the posting line), draft_state, posted_by.- New tables: settings, conventions, decisions, open_items, instruments, annotations, extractions, attestations, audits.
- Core keeps a side map
Ledger.entry_meta: id → {created_seq, posted_seq, posted_at}, filled during replay, so the Snapshot sees it. projection_rows/compare_projectionsinclude all of them.
5.3 Setting keys (typed registry ledger-core/src/settings.rs; validators moved from ledger-config/src/lib.rs:434-622)
Each key has a validator, a docstring (which becomes the books.settings schema) and a sensitivity class: public, identity (the full value appears only in reports and packages; books.start shows it masked), or never (cannot be set). TIN-shaped values are refused in every key except company.ein.
| Area | Keys |
|---|---|
| company | company.name, .entity_type, .tax_classification, .tax_form, .fiscal_year_start, .timezone (drives today(), replacing chrono::Local at S:1943), .legal{formation_state, formed, state_entity_number, address, registered_agent, partnership_representative, irs_name_control, duns}, .ein (identity; D-5, decided), .cpa (party), .members (effective-dated [{party, share, capital_account, distribution_account, files_jointly_with}]) |
| books | books.accounting_basis, .cash_accounts, .financing_accounts, .investing_accounts, .asset_tag_prefix, .close_into, .close_sweep, .schedule_convention |
| policy | policy.evidence{threshold, required_accounts, exempt_accounts} (replaces config.json receipt_policy), policy.parties, policy.capitalization{de_minimis, twelve_month_rule, sources}, policy.require_tax_category: warn|refuse (default warn; the journal feature require_tax_category is declared before the first change naming it), policy.attestation_bar (default per D-9), policy.cpa_package_requires_audit: refuse|warn (default refuse, overridable per export with a reason; D-9), policy.audit_cadence, policy.approval_rules: off|enforce (default off: approval_rule conventions are recorded but never block a post) |
base_currency stays in store.created. [books] data_dir, [vault] key_file and [viewer] are dropped on import with a note. Unknown keys are reported as problems instead of being silently ignored.
5.4 toml import/export
ledger-config shrinks to a codec:
toml_to_events(&str, &Settings) -> Result<Vec<Event>, Problems>emits only the diff.settings_to_toml(&Settings, &Conventions)writes provenance as comments.
The WANDERBOOKS_TOML template (ledger-cli/src/main.rs:237-347) becomes the export format. ledger-cli/tests/init.rs:305,354 become round-trip properties: export then import yields zero events, and every example imports. The config_parse fuzz target is kept, and fuzz_settings_replay is added.
5.5 Migrating existing books (generic)
wanderbooks books adopt <data-dir> --id X [--toml FILE] [--config-json FILE]runs as the human, with no sudo, through the admin socket. The server, which also runs as the human, does the copy:- Takes the source flock, so it refuses while a stdio server holds it.
- Verifies the chain and records head H.
- Copies
journal.jsonlandblobs/into a staging folder (a cut-off last line or a link among the blobs is refused), opens the copy (rebuilding the projection), checks that the head equals H, verifies it end to end and re-hashes every blob; only then is it renamed intobooks/X/, and on any failure the staging folder is removed and nothing appears. A source inside the root, or holding it, is refused. - Imports settings from the given texts (preview printed, confirmation at the TTY) unless the journal already has
settings.setevents. - Copies the vault's records into
books/X/vault/, and the v1 key intokeys/legacy.key(0600). Copied, not moved (P2 slice 2): adoption never changes the source, so a refused or abandoned adoption leaves it exactly as it was; step 7 is what moves the old copies away. - Seeds
archive.gitfrom the source's git history, filtered to the journal and blob paths, and records the old repository's last commit hash. The push destination is the owner's answer to Q-1. - If the owner agrees (Q-3), moves the source data dir, its vault and the project folder's git history into
<root>/legacy/X-<date>/. Nothing is deleted. If the owner says no, the source stays where it is, read-only, and Needs attention lists it as an exposed old copy.
ledger-cliinit/newstop scaffolding and callbooks create.dev-stdiokeepslegacy_filesmode for unadopted data dirs for one minor release;books.startreturnsnext_steps: adopt.- Legacy semantics kept:
entry.reversedstays a posted reversal. During the re-audit the original's receipts are linked withinherit_evidence(D-12, decided); any not yet linked are reported aslegacy_reversal_exempt.- Old close variants keep their rounding.
- Old schedules stay
monthly_even. - A
null1099 type replays asundetermined. - Duplicate attachment records stay until consolidated.
5.6 the first company (staged; reversible until Q-3)
| When | Step | Acceptance |
|---|---|---|
| P0 | Install new binaries (still stdio). Capture golden JSON of TB, P&L, BS, CF, members' equity, GL, journal, vendor_1099, parties, assets, schedules and digest, as of 2026-10-11, every month-end, FY-to-date and today, through the old binary over MCP. By decision (2026-10-16) the first company's goldens are kept privately in its own books dir, not in this public repo: export_cpa_package zips plus a canonical JSON form made by that dir's goldens/canon.py (which normalizes the text each export varies, such as its timestamp), with the comparison protocol in its README. |
Goldens stored |
| P1 | The agent reads /home/swaits/Documents/the first company-Books/wanderbooks.toml (and books-data/config.json only by asking the owner to state its values, since it sits under books-data/) and calls import_settings. Expected diff:• the 15 [company] keys; EIN as identity• members 50/50 → 3000/3001 with files_jointly_with• accounting_basis=accrual, policy.evidence.threshold=0.00 (the owner, 2026-10-12)• cash_accounts=["1000"], financing_accounts=["2100"]; the citation is corrected to ASC 230-10-45-14(b)• parties=require (2026-10-15), timezone=America/Denver (D-13, decided)• policy.approval_rules stays offConventions are proposed in one batch preview from AGENTS.md:74-227:• 9 posting templates: joint contribution as two entries; expense from joint personal funds → 2100; expense from Quillmere; household reimbursement ( Dr 2100 / Cr 1000) with a note that the owner gives the go-ahead before money moves, recorded with its source and not an enforced gate; 50/50 distribution; revenue 4000; card 2050 expense and payment; cash entries dated by bank posting date• policies: numbering, one file per receipt (PDF preferred), email as PDF, capitalization ( policy.capitalization plus decisions 2026-10-11 and 2026-10-16)• close_step: "ask the owner whether a member paid anything personally"• calendar: Utah annual report around Oct 4, renewal fee to taxes & licenses• memory-only rules become conventions: post when ready; decisions to the owner go one question at a time; TINs only in the vault Decisions: one file per receipt (10-11), threshold (10-12), cutover with Sheet frozen as of 10-11 and reference attachment 37 (10-14), renumber 6100→6050 and card at 2050 (10-14), TINs in vault (10-15); light governance, meaning no consent paperwork, spending limits, signed memos or amendments for the two-member LLC, with decisions recorded with their source (10-16); local isolation "you, hidden away", the viewer reading the disk directly, no added permission prompts, and self-host now with hosting later (10-16). Open items: • IRS name control "WALN" discrepancy; letter = attachment 70, mailing pending • deferred revenue to be raised with the CPA before the first subscription Per-entry notes: entries 4 and 5 dated Oct 7 to match the Sheet. |
Every golden report and export is byte-identical before and after the import. GUI shows the first company correctly with wanderbooks.toml renamed away. No approval_rule convention is created, and every daily-flow post still succeeds without an approval |
| P2 (pre-cutover) | Run verify-sheet and drive-manifest verify a final time and file their outputs as workpaper documents. Recapture goldens. |
Both pass |
| P2 cutover (about 10 min) | 1. Ask the owner the two open cutover questions (section 10.3: Q-1 backups, Q-3 old copies; Q-2 is answered), one at a time, in plain English. 2. Quit the agent sessions and the GUI. 3. wanderbooks setup, with no sudo: it creates the data root (0700) and asks for the owner passphrase. Nothing is installed to run in the background (D-21).4. Nothing to start: each command below opens the books while it runs, and afterwards the app or Claude opens them (D-23). 5. wanderbooks books adopt /home/swaits/Documents/the first company-Books/books-data --id fernhill (archive target from Q-1; old copies moved per Q-3).6. wanderbooks connect claude-code --book fernhill (removes the old stdio registration made by just mcp-add, adds the bridge's stdio registration, wanderbooks mcp --book fernhill).7. wanderbooks connect codex --book fernhill.8. wanderbooks gui fernhill.9. Remove justfile, .claude/settings.json hook, .codex/hooks.json, scripts/ (archived under the data root's legacy/); AGENTS.md/CLAUDE.md become a 3-line pointer ("connect the wanderbooks MCP; call books.start").10. The company/ records are already filed (Q-2, D-20); the local copies stay until P6. |
books.verify over MCP equals H. Goldens byte-equal. books.start works from Claude Code started in ~ and in /tmp/empty, and reports isolation: "same_user (advisory)". No connect text, tool text, handbook section or result in a recorded cutover session names the data root. No step asked for sudo. If Q-3 is "move": the project folder holds no journal, blobs, vault or git history containing them. Viewer updates within 1 s. With every holder closed the books close cleanly (books.verify ok with the same head at the next open), and a Claude Code session started with the books closed opens them by itself, with no reconnect. Rollback (nothing posted on the server): close the books (wanderbooks close), move legacy/fernhill-<date>/ back to the project folder (or use the untouched source if Q-3 was "no"), and re-register the P1 binary's stdio server as before cutover. Rollback (entries posted): wanderbooks books export-legacy fernhill writes a data dir with the same head check, then the same re-registration |
| P3 | Defect fixes change reports (cash flow, 1099 sections, asset fields, digest). Each difference is hand-adjudicated in the P3 change log. 1099 classification and 704(c) fields are shown to the CPA. | Change log signed off |
| P4 | list_drafts. Leftover drafts are discarded or set to awaiting_evidence. |
Zero unexplained drafts |
| P6 | • consolidate_duplicates resolves the 11 redundant records (preview first).• Extraction is backfilled for all 66 attachments. • Receipt numbers 1000–1029 are annotated from the "Receipt ID" in each description. Anomalies become annotations: 1009 unissued; 1011/1020 byte-identical pairs (merged); 43/45/47 have no ID. The next number is derived as 1030. • Instruments: Quillmere, joint Amex, member cards, Chase Ink 2050. • verify-sheet.py's ALIASES/RECLASSIFIED/RENUMBERED tables (scripts/verify-sheet.py:52, 69-108) become a mapping convention, then compare_reference runs against attachment 37 as of 2026-10-11.• drive-manifest.json text goes to record_inventory; the Drive connector listing goes to check_inventory. |
compare_reference gives 0 differences. Inventory has 0 gaps. Drive deletion becomes possible (still the owner's call) |
| P7 | wanderbooks vault init --book fernhill (on the human page, behind the owner passphrase): generates the HPKE keypair; keyslots for passphrase, recovery key (printed) and optional passkey PRF; reseals every record; journals vault.created, vault.keyslot_added×N, vault.resealed. A TIN is checked in the viewer, then keys/legacy.key and ~/.config/wanderbooks/vault.key are shredded (the owner confirms). |
Last-four and kind match the journal. No vault key material remains on disk except the wrapped keyslots in the data root (no v1 key, no ~/.config/wanderbooks/vault.key) |
| P10 | Re-audit (section 8). | — |
6. Security and threat model
This section covers the local (T1) and self-host (T2) deployments. Threats specific to a multi-tenant hosted service (other tenants, a hostile operator, SSN compliance) are in section 11.
Adversaries and controls
| Adversary | Goal | Controls |
|---|---|---|
| The agent (careless or prompt-injected; locally it runs as the human's user ID, the same as the server) | read or alter books outside the MCP; see TINs | I-2, I-7, I-12: no text ever names the data root, which sits outside every project directory. Every call is access-logged. Destructive hints are set. preview_token. Locally this is advisory: an agent that deliberately hunts can read the data root, just as the human can, and books.start, wanderbooks doctor and the docs say so. TIN plaintext stays sealed even then, because unwrap needs the passphrase. Enforcement is the self-host deployment, proven by agent_uid_cannot_read_tenant. Where a company turns on approval-rule conventions (off by default), they are asserted by the agent (approval{by_party}): a policy, not proof, which the access log records |
| Network attacker or browser | DNS rebinding, CSRF against localhost | Host allowlist; Origin enforced (403); bearer tokens only on /mcp; /h/ cookies __Host- and SameSite=Strict, with CSRF tokens; no tokens in query strings |
| Compromised OAuth client (self-host remote path, P9) | over-broad access | PKCE, exact redirect match, iss, CIMD SSRF guard (public IPs, 5 s, 64 KiB), consent per client per book, minimal base scopes with step-up, 1 h tokens with rotating refresh |
| History rewrite or fork (any deployment) | alter or fork the books unnoticed | head in every result, quiet-period handoffs, verify-archive --expect-head against an independent clone |
| Stolen disk or backup | read books | full-disk encryption on the desktop, an encrypted volume in the self-host recipe; archive encrypted with age to a customer recipient; the vault is always ciphertext |
| Untrusted files (PDF/XLSX/EML) | parser exploit inside the data-owning process | the ledger-extract subprocess sandbox; fuzz targets |
| A compromised server process | reach beyond the data root into the human's account | the server confines itself where available: Landlock (filesystem limited to the data root; on ABI ≥ 4, all TCP bind and connect denied once its own listening socket on 127.0.0.1 is bound), seccomp, rlimits; extraction runs in a further sandbox. This limits the server; it does not restrict the agent |
| Other processes running as the human, on the desktop | read the books; drive the viewer | no mechanical control locally (same user). A same-user process can alter anything the GUI reads (journal, blobs, keyslots). Journal tampering is detected by the hash chain and head handoff, not prevented; a vault public-key swap fails books.verify; a forged show request can only navigate. The GUI never writes. Stated as the advisory residual in books.start, doctor and the docs; the self-host deployment is the enforced option |
Immediate hole (P0). import_local_file (S:4511) does an unrestricted std::fs::read, so it can file ~/.config/wanderbooks/vault.key (ledger-vault/src/lib.rs:216-222), and get_receipt then returns the key as base64. P0 changes this:
- An explicit
--import-rootallowlist (default:$XDG_DOWNLOAD_DIRonly). - Canonicalize, then refuse anything under the data dir, the books dir, the vault dir or the key directory, symlink escapes, and non-regular files.
- Test
import_local_file_refuses_key_and_journal, plus a property over generated paths. - No
requiresUserInteractionis added (owner decision 2026-10-16). The interim TIN-bearing tools (get_w9,set_tin,set_w9withtin,vendor_1099 include_tins,export_cpa_packagewith TINs) keep today's behaviour (S:1198-1266, S:6111-6117). - P2 deletes the path parameters and makes TIN-bearing outputs human-only. P7 deletes full TINs from the MCP entirely.
Access log record:
{seq, ts, prev, principal{id, kind: agent|human|viewer|cpa|system, label},
client{client_id (verified), name, version (asserted)}, model? (asserted), request_id,
action: tool|upload|download|replica.connect|human.tin_entered|human.tin_viewed|
human.vault_unlocked|human.cpa_package|token.created|backup.run|keyslot.changed,
subject, args_digest (sha256 of canonical args), result: ok|refused(code), head_after?}
Each record is fsynced, and a failed write fails the request. Readable through books.access_log (admin) and the viewer's Access page. The CPA package carries a summary. Its head is part of the head handoff. replica.connect occurs only for remote viewers (P9). The local viewer's direct reads, including its in-process TIN unlock, are not logged, because the GUI never writes.
Security test suite (ledger-mcp/tests/security_*.rs):
- auth on every route; wrong audience → 401; bad Origin → 403; bad Host → 400; body over limit → 413
- expired or replayed grant → 410; upload sha mismatch refused
scope_step_up_403;handles_bound_to_sub;tin_entry_requires_same_subunwrap_only_in_human_handler(module privacy plus grep gate; the GUI unlock path is the one other caller)vault_pubkey_swap_detected_by_verifyno_mcp_output_contains_full_tin: canary TIN and key; session fuzz across responses, errors, server logs and access-log recordsno_result_names_a_path: the same session fuzz with the data root as the canary, across connect text, tool text, handbook, prompts, results and errorsaccess_log_one_record_per_call, refusals includedisolation_label_is_advisory: a local install reportssame_user (advisory)inbooks.startanddoctoragent_uid_cannot_read_tenant, run against the two-user self-host deployment in CI (the desktop default is advisory and is not claimed to pass it);no_tool_touches_outside_tenant(Landlock harness)
New fuzz targets: fuzz_http_mcp_request, fuzz_upload_put, fuzz_settings_replay, fuzz_extract_pdf, fuzz_extract_xlsx, fuzz_eml, and fuzz_replica_stream (P9). New mutation recipes and scheduled CI for ledger-vault, ledger-cli, ledger-view, and ledger-mcp/http; none of these have a recipe today.
Retention: 7 years. A local or self-hosted book keeps SSNs, sealed, on a machine the owner controls.
7. User flows
What you will see and do. The technical details are in sections 3 and 4.
7.1 Setting up on your computer
- You install Wanderbooks the usual way for your system: a
.dmgon a Mac, an installer on Windows, your package manager on Linux (D-24). No developer tools, and no administrator password (on Linux your package manager asks for yours, as it does for everything). On first open it:- sets up the books server. It installs nothing that runs in the background, and it does not ask for an administrator password;
- asks you to choose a passphrase. You need it to type or see someone's Social Security or tax ID number, to download anything that contains them (such as the CPA package with them), and whenever the books open a private page for you, such as the page where you drop a big file;
- creates your company's books;
- connects Claude Code to the books, and Codex too if you have it;
- tells you how the books open: opening Wanderbooks, or starting an agent, opens them; when the last of those closes, so do the books. Nothing runs in the background.
- You type
claudein any folder and say "set up my books". The agent sees that the books are new and walks you through setup: company facts, accounts, members, opening balances, and a check that everything balances. Every answer is recorded in the books.
7.2 Day to day
- You say: "The receipt is in Downloads, inv-883.pdf, the Figma annual plan." The agent sends the file to the books, reads it, drafts the entry, and shows you exactly what it will post. You say yes, it posts, and the viewer jumps to the new entry. Posting brings up no extra permission pop-up.
- You say: "Save it as a draft, I'll find the receipt." The draft stays on the to-do list until the receipt arrives. You see it in the viewer's Needs attention, and the agent sees it at the start of every session.
- You say: "Show me Q3 against last year." The viewer opens that report.
- Backups happen on their own while the books are open. Every change the agent makes comes back with a fingerprint of the books, so any tampering would show.
Compared with today: no helper commands, no project folder, it works from any folder, you answer the same number of questions, and installing needs no administrator password (on Linux, the package manager asks as it always does).
7.3 How private your books are
- The books live in a private folder that only your account can open, outside every project folder. Nothing the agent is told or shown names that folder, so an agent cannot wander in by accident.
- It is a curtain, not a lock. The server runs as you, so an agent that deliberately went looking could find the folder. The books say so whenever they report on privacy ("same user, advisory"), and
wanderbooks doctorshows it too. - Social Security and tax ID numbers stay sealed even then. Opening one needs your passphrase.
- For a real lock, run the books on another machine you own (7.4). The agent then has no way to reach the files at all.
7.4 Your own server, and claude.ai on the web
- You install the same programs on a home server, a network drive (NAS) or a rented server by following the recipe. Two commands move your books there, one on each machine, and the books' fingerprint must match before and after the move.
- One command gives that server a web address claude.ai can reach. Anyone can find the address, but only you can sign in. In claude.ai you go to Settings → Connectors → Add custom connector, paste the address and sign in.
- Then you chat as usual, for example "what needs attention?"
- Small receipt files go straight through the chat. For bigger ones, you drop them on a private upload page and say "process my inbox".
- Downloads come as one-time links.
- When someone's tax ID number is needed, the agent gives you a link to a private page and you type the number there. The chat only ever sees "last four 1234 recorded".
- The viewer on your computer can show the books on that server too. It streams a copy that it cannot change.
7.5 Codex
The installer offers to connect Codex, or you can run wanderbooks connect codex --book <name> later. After that you run codex from any folder. Codex gets the same rules and procedures as Claude Code.
7.6 Your CPA
- On your computer, you send the CPA the package the agent prepares. With your own server (7.4), you can also invite the CPA to look at the books directly. The CPA signs in with their phone or computer's built-in sign-in (a passkey), with no password, and sees the reports, documents, journal and audit papers, all read-only.
- The CPA package without tax ID numbers is an ordinary download.
- The version with tax ID numbers comes only through a one-time link. You open it, unlock it with your passphrase, and send it on. Every download is recorded.
- If the CPA wants, they can connect their own agent with read-only access.
7.7 What stays easy (acceptance)
- At most three commands from a new computer to the first posted entry, with no administrator password beyond the Linux package manager's usual one.
- Nothing to do at the start of a session.
- Everything works from any folder.
- The viewer catches up within a second.
- No new permission pop-ups in daily work.
wanderbooks doctorshows on one screen: the server, your books, the connected apps, the viewer, the last backup, the books' fingerprint, and how private the books are.- A test proves that an agent that knows nothing about the books can finish the everyday jobs just by following what the books tell it to do next.
8. Phased milestones
Every phase is one merge train. Each one:
- passes
just check(fmt, clippy-D warnings, rustdoc-D warnings, all tests) andjust prove(Kani with no vacuous covers; Lean with standard axioms only, nosorry); - updates
docs/parity.md,docs/spec/rules.md(SOURCE / CONVENTION / DECISION lines),docs/mcp-interface.md,docs/testing.md,docs/verification.mdanddocs/architecture.md; - runs mutation testing on the crates it touches, with every survivor killed or documented as equivalent;
- builds features whole: core, store, MCP, viewer, exports, docs, tests.
The first company keeps working between phases.
P0: Guard rails (days)
Scope:
import_local_fileallowlist (defect 19, interim).NewerJournalandjournal.format_setreader support.titleplus a read-only or destructive hint on all 93 tools, with the corrections in 4.9. NorequiresUserInteractionon any tool (owner decision 2026-10-16: zero added friction; TIN handling moves to human pages in P2 and P7).get_infocallsrefresh().- Bare
books://digestworks. INSTRUCTIONSis replaced by an interim connect text (≤ 1,400 characters) plusbooks.handbookand handbook resources holding the scrubbed old text by section.tokio::sync::Mutexplusspawn_blocking(S:553).- Pin
cargo-mutantsandcargo-fuzzinmise.toml:21-22. - Remove the duplicate Transports section (
docs/mcp-interface.md:78/:728) and the bearer-token claim. - Template text fix ("trace through tools").
- Capture the first company goldens (5.6).
Tests: import_local_file_refuses_key_and_journal (property); annotations_complete (every tool has a title and a hint, and none carries requiresUserInteraction); instructions_budget; tool_text_lint; digest_resource_bare_reads; old_reader_gets_newer_journal.
Accept:
- A live Claude Code session receives the connect text untruncated.
- The first company's daily flow is unchanged, with no new permission prompts.
P1: Journal-native settings and conventions (B, part 1)
Scope:
ledger-core/src/settings.rsandconventions.rs.- Events
settings.*,convention.*,decision.recorded,open_item.*. Ledgerfold, so Snapshot and Store shareSettings.ledger-configbecomes the codec.- Tools:
settings,set_setting,import_settings,export_settings, conventions, decisions, open items,draft_from_template. - Approval-rule conventions: supported by the engine for companies that want them, off by default (
policy.approval_rules = off). They are enforced at post only after a company turns them on. The the first company import creates none (owner decision 2026-10-16: light governance; decisions are recorded with their source instead). books.startv1.company.timezonedrivestoday().- New crate
ledger-view:attention::findmoved fromledger-gui/src/attention.rs:68-571together with its helpers (books.rs:643-650missing_receipt,depreciation_due,w9_status,reconciliation_due,party_infos), over aBooksViewtrait. - GUI reads settings from the Snapshot; journal-page
describearms (pages/journal.rs:825); Company page shows provenance; copy fixescompany.rs:59,cash.rs:51,attention.rs:562; the "due to" heuristic usesfinancing_accounts(attention.rs:349). dev-stdiokeepslegacy_filesmode.- The first company P1 steps.
Defects: none directly. P1 enables 8 and 16.
Proofs and tests:
- Lean
Step.setSettingpreservation (balances anddraftsOpenunchanged). settings_replay_is_pure(2,000 cases, Store = Snapshot = view).toml_round_trip_zero_events.fuzz_settings_replay.attention_server_equals_gui(golden onfixture_company).- Sensitivity: a TIN-shaped value is refused outside
company.ein. approval_rules_off_by_default: with anapproval_ruleconvention present and the default policy, a post succeeds without an approval; withenforce, it is refused until one is given.
Accept:
- The first company goldens are byte-identical before and after the import.
- The GUI is correct with the toml renamed away.
books.startv1 is at most 8 KB.
As built (2026-10-17), and where it departs from the scope above (engineering calls):
- One event per change set:
settings.set {changes: [{key, value|null}], source}is all or nothing (an import is one line); there is no separatesettings.cleared(a null value clears).effective_fromis not built: no key needs it yet, and effective-dated members are the varying-interest rule (§706(d)), which changes figures and belongs with the P3 allocation work. A later format adds it. - Keys are flat (
company.formation_state,company.irs_name_control, ...), not onecompany.legal{}object: each fact gets its own source, which is what "facts carry their source" asks.company.memberskeeps member names (matched to parties as before);company.cpais text.policy.capitalization,require_tax_category,attestation_bar,cpa_package_requires_audit,audit_cadenceandbooks.schedule_conventionarrive with the phases that use them (P3, P8). ledger-view(attention::findmoved out of the viewer) moves to P5, where its first user (books.start's needs_attention,next_steps) lands; P1 has no server-side consumer, and moving 700 lines of the viewer for nothing would be risk without use.attention_server_equals_guigoes with it.- The "due to" heuristic keeps reading account names:
financing_accountsalso holds bank loans, which aren't "paid personally". - Approval rules are checked by the engine (
Ledger::approvals_missing) and enforced by the store:Store::post_entryrefuses a covered draft until approved, and the operations that post at once (invoices, bills, payments, payroll, remittances, disposals) are refused when a rule covers the entry they would post (approval_gate: draft it instead). Reversals, closes, depreciation and amortization are exempt. An approval records the draft's fingerprint (date, description, lines, classes); any change voids it. A rule names accounts that take postings, and an account a rule names can't be renumbered until the rule is revised. books.import_settingstakes only the file the server reads, whole (compared key by key with the server's own reading), never turns approval rules on, and on books that keep their own settings changes nothing (review round 1: an agent-transcribed text could drop the EIN).books.startv1 leavesbook.id,book.url,head.last_handoff, theagentinput andalwaysLoadfor P5 (they need the served book identity and tool search).- Posting templates take slots (
{expense}): an account each draft names, so "an expense a member paid: Dr / Cr 2100" is one template. - A vault key file is never a books setting:
--vault-key-fileon the server and the viewer, and an import is refused unless the key in force stays the same. - The connect text's lead names
books.startfirst, then the handbook.
P2: One executable, open while held, hidden away, and the the first company cutover
Scope:
ledger-mcp serve --rootwith multiple books at/b/{book}/mcp; explicit transport config (3.2); bearer tokens and scopes; rate limits (tower-governor).- One executable (D-22).
wanderbooksis the only program: a thin dispatcher over the existing crates whose modes are the viewer (gui), the books server, the host, the agent bridge (mcp), the archive process, the extractor, and the CLI commands. Each mode that runs as a process confines itself at start, after forking its unconfined helper (3.9) and before it opens anything. The viewer, on Linux, confines itself read-only on the data root, with writes allowed only to its preferences folder and the Downloads folder (exports, Save a copy); Open-with and the updater go through its helper. Aguicargo feature, off in the self-host server build (P9), leaves the window libraries out. P11 then only packages it. - Open while held, no service, no sudo (D-21, D-23).
wanderbooks setupcreates the data root (3.4) with an explicitset_permissions(0o700)(an owner-only DACL on Windows) and sets the owner passphrase. The books open when something holds them and close when nothing does, as section 3.9 describes: leases on the host'srun/lease.sock, the helper's clean start of the host,host.lock, the 10 s close, "close the books", restarts, one version at a time, the bridge's owninitialize, and the server'sformat_upgrade_pendinggate. Nothing is installed or persistent: no systemd unit file, no LaunchAgent, no socket activation, no login autostart (the transient scope of 3.9 disappears with the host), and nothing runs after the last holder closes. There is no system account, no container and no root step. - Honest isolation label.
books.start, Needs attention,wanderbooks doctorand the docs reportisolation: "same_user (advisory)". There is no--same-userflag and no acknowledgement gate. - Self-confinement. Where available: on the server, Landlock (filesystem limited to the data root; on ABI ≥ 4, all TCP bind and connect denied once it has bound its own listening socket on 127.0.0.1) and seccomp; on the archive process, read-only Landlock on the journal and blobs, TCP connect limited to the target's port (443 or 22) on ABI ≥ 4, and seccomp. An unprivileged process cannot filter egress by host. If they cannot be applied,
doctorreports it; it is not fatal. The docs describe them as protecting the human's account from a compromised server, not the books from the agent. - Access log plus
books.access_log. - Owner passphrase for
/h/(Argon2id). - Files transport:
begin_upload, the PUT route, the/h/inboxdrop page plusbooks.inboxand theprocess_inboxprompt,download_link. - Every server-path parameter deleted:
import_local_file,save_to,save_csv_to,w9_file_path, get_w9save_to. W-9 upload moves to/h/w9/{party}(sealed with the v1 key until P7). TIN-bearing outputs come only throughrequest_human_download→/h/, behind the owner passphrase. This closes defect 19. get_receipt view=link|bytes(≤256 KiB+sha)(defect 3, transport part).- Viewer, direct and read-only (3.6): data-root discovery through
ledger-store::paths::data_root()or--rootfromwanderbooks gui <book>, never a project directory orwanderbooks.toml;books.showthroughops/show.jsonl(deleteledger-store/src/viewer.rs); presence through theops/viewer.lockshared flock; Save-a-copy and Open-with (defect 3, viewer part). Locally there is no replica; the viewer's only channels are the lease socket's owner intents and its helper (3.9). - Archive process and module;
books.backup;ops/heads.jsonl;verify-archive;fetch. (As built, slice 7, D-31: the server's backup module,books.backupandbooks.back_up_now; no archive process,ops/heads.jsonl,verify-archiveorfetch: a backup is a repository, cloned and proven by a restore's preview and the drills.) - Restore (3.7, D-19):
books restore, proven before use, never destructive, onto a new or an existing install; restore drills; the vault and its key travel in the backup (D-31). - Viewer menu (D-19): "Export for CPA" (done by the viewer, into Downloads); "Back up now", "Restore…", "New books…", "Close the books" and format-upgrade consent handed over as the lease socket's owner intents (3.9); "Connect Claude Code" and "Connect Codex". Nothing in the viewer writes a book.
wanderbooks mcp, the agent bridge (D-23, 3.9): stdio to the agent, MCP overrun/mcp.sockto the local server (or HTTPS with--url, P9), the token from the keyring. It opens the books if they are closed and holds a lease for the session. Across a server restart (an update) it reconnects and re-initializes upstream without the agent noticing, sendingnotifications/tools/list_changedonly if the tool list changed.- CLI subcommands and modes:
setup,serve,close,mcp,host,helper,rollback,books create|adopt|list|export-legacy,connect,token,gui,doctor,verify-archive,fetch,update.books export-legacy <id>(rollback only) writes a P1-format data dir outside the data root, verifies its head equals the server's, and stops the server's writes to that book. - Scaffold deleted:
JUSTFILE,AGENTS_MD,CLAUDE_SETTINGS,CODEX_HOOKS,GITIGNORE(ledger-cli/src/main.rs:43-521);install.shrewritten, with no sudo. - The test harness drives HTTP (
mcp_stdio.rskeeps the bridge). - Two-user CI deployment: a container that runs the server as a dedicated user and the agent as another, in the configuration P9 turns into the self-host recipe. It is the target of
agent_uid_cannot_read_tenant. - The first company cutover, preceded by the two open cutover questions (10.3).
Defects: 19 (closed), 3 (partial).
Tests:
agent_uid_cannot_read_tenant(the two-user CI deployment; not the desktop default).setup_needs_no_root(setup, serve, adopt, connect and gui as an unprivileged user in a clean container; nothing is left installed to run in the background).books_close_cleanly(the last lease ending, "close the books", SIGTERM and SIGHUP of the host, all during a burst of posts: every request either completes or is refused, the head verifies at the next open, the lock is released);openers_share_one_server(the app,serve, a CLI command and two bridges started together: one host, one writer);books_close_with_last_holder(each kind of holder ending,kill -9included: closed 10 s after the last, not before);bridge_opens_closed_booksandbridge_survives_server_restart(the agent's session carries on, no reconnect);close_now_ends_every_lease;host_starts_clean(started from a confined viewer or bridge with a hostile environment,XDG_DATA_HOMEandLD_PRELOADincluded, the host is unconfined, finds the real root, and holds no inherited descriptor);host_respawns(kill the host: a holder starts a new one);host_survives_app_scope(under a systemd app scope, as uwsm sets up, closing the app that started the host leaves it running for the other holders);no_local_agent_tcp(the bridge never connects to TCP; TCP is off by default; with it on, a squatter on 7411 makesdoctorreport a failed bind and sees no bridge traffic);lease_socket_accepts_only_owner_intents(exactly the five; consent without the passphrase refused);restart_is_not_close(bridges carry on across a restart; openers wait onupdate.lock);children_match_host_version(replace the executable mid-run: no child of the new version joins the old host);format_upgrade_waits_for_consent(read-only until the owner's consent, a backup with the keys made first, no MCP tool can consent, declining leaves the book openable afterwanderbooks rollback);no_rollback_after_consent("Go back" refuses for an upgraded book; the pre-upgrade restore arrives beside it, states the entries it lacks, and needs the passphrase);restore_reads_only_staged_backups(superseded by D-31: therestoreintent names the backup's folder or address);bridge_initializes_during_cold_start;archive_catches_up_at_start(a push that failed before a close is made at the next open).one_executable(every mode from one binary; the viewer mode under read-only Landlock opens and follows a book);server_build_has_no_gui(the self-host build links no window libraries).viewer_shows_the_engines_numbers(the viewer's two musts, owner 2026-10-18: it never writes the books, and every figure it shows is correct): on random books, every report page, account register, entry and Needs-attention count the viewer renders equals the engine's report for the same books and dates, extendingtests/view_model.rsfrom the overview to every page. No mutation runs on the viewer.viewer_without_server(with the server stopped or failing to start, the viewer still opens and follows the books from disk; "Restore…" and "New books…" say why they can't run).isolation_label_is_advisory.no_path_paramsschema lint;no_result_names_a_path.no_tool_touches_outside_tenant.security_*(auth, Origin, Host, body limit, grants, upload sha).access_log_one_record_per_call.gui_works_under_read_only_landlock;gui_finds_root_without_project_dir(started in an empty directory with no toml);show_request_reaches_gui(≤ 1 s);viewer_presence_reported.clone_verify_expect_head;backup_does_not_advance_head.restore_on_clean_installandrestore_beside_existing_books(same head, identical goldens);restore_refuses_damaged_backup(a flipped byte in the journal, a missing blob, a head that doesn't match: each refused, nothing written);restore_never_overwrites(replace moves the old book tolegacy/);restore_drill_failure_is_flagged;restored_vault_needs_its_key.viewer_menu_never_writes_a_book(backup and export leave the data root byte-identical; restore and new books go through the server).http_concurrencyextended to 3 books × 6 clients while the GUI reads.upload_round_trip_byte_exact(20 MiB).- GUI
read_only: no writes under the data root, and no files under$HOMEexcept eframe preferences. fuzz_http_mcp_request,fuzz_upload_put.
Accept:
- The 5.6 P2 checks.
- A clean machine reaches the first post in at most 3 commands, and no step asks for sudo.
- Claude Code from
~, and Codex, each recorded under its own principal. - A Codex post, close and reconcile raise no approval prompt beyond Codex's defaults (no
default_tools_approval_modein the generated config). - Two concurrent agents post 12 entries each with no lost writes.
- Viewer updates within 1 s.
- A backup of the first company restores on a clean machine and beside the live books, each with the same head and byte-identical goldens.
P3: Engine correctness (defects 1, 7, 21, 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 22)
Defect 1, cash flow (ledger-core/src/reports.rs:1231-1560):
- Partition. Every non-cash balance-sheet account belongs to exactly one section: investing (fixed-asset cost and accumulated depreciation, plus
investing_accounts), financing (equity, plusfinancing_accounts), or operating (all others, including P&L). - Scope of the body. Only entries that touch a cash account. Entries with no cash line go to
noncash_disclosures(ASC 230-10-50-3/4), for example a member's property contribution (Dr equipment / Cr capital). - Section totals. Each section's total is minus the movement of its accounts within cash-touching entries. Gain or loss on disposal of investing assets is attributed to investing (named rule, test
disposal_proceeds_all_investing). - Lines. Gross direct-method lines, one per cash-touching entry and section, including credits, so disposal proceeds count.
- Reversals. An original and its in-period reversal net out of the gross lines. Reversal lines are labelled "Reversal of #N", never "Owner distributions" (:1437).
- Supplementary schedule.
operating_reconciliation(net income to operating cash), required when the direct method is used (ASC 230-10-45-30; sourced inrules.md). - Footing.
reconciles {sections_sum, net_change, difference}. A nonzero difference isCashFlowDoesNotFoot, an engine error that is never output. - Description. Tool text (S:2202) is corrected.
- Proof. Lean
cashflow_sections_sum_to_cash_changeinStatements.lean, derived from the per-entry balance law. Property test (5,000 cases). Kani on the section-sum kernel function. - The first company. FY2026 must foot (it didn't: its figures are in the company's private change log); the golden is recomputed by hand.
- As built (2026-10-20).
ledger-core/src/cashflow.rs(re-exported fromreports), the kernel'stake,apply_cash,entry_flows,constructiveandexchanged. The section rule is refined by ASC 230-10-50-5 ("only the cash portion"): each entry's cash is applied to the sections moving against it, operating, investing, financing, each up to its own movement (CONVENTION: the order), and the rest is disclosed (rules.md B-14 rule 3); the plan's wording holds exactly for entries whose cash settles all they move (prop_pure_entries_sections_are_movements). D-35's layout (operating by class, an entry's interest its own class; investing and financing by kind with each kind's total, each transaction under its kind), the bill split and the warnings are built; reversals pair along the chain from its root, and exchanges inside one section are disclosed (the review's two highs). What a member or a lender pays or receives for the company is cash borrowed (or contributed) and spent, on its date, per D-42 (rules.md B-14 rule 11, the kernel'sconstructive, Kaniconstructive_nets_to_zeroandconstructive_mirrors, Leanconstructive_nets,constructive_neg): ASC 230 doesn't address it and the firm guides present it so; an expense a member pays into capital is a constructive contribution (BDO 3.4's parent paying a subsidiary's expenses; the K-1 worksheet already calls it cash); property a member owned, the seller's own credit, and interest or guaranteed payments charged to what the company owes stay noncash. D-35's noncash presentation was replaced the same day. The tool text is atledger-mcp/src/server.rs(books.cash_flow). The first company's change log is kept with its goldens, outside this repository (5.6).
Defect 7, IIF (ledger-import/src/iif.rs:169,243): DOCNUM = entry_id. Output encoded in Windows-1252; characters outside it are transliterated (deunicode) and listed in warnings.
Defect 21, CPA package IIF and as_of (found in P0, 2026-10-16): the CPA package's general-journal.iif ignores as_of (an as-of package includes entries dated after it); its xlsx and csv files respect it. The IIF export takes the same as_of, and a test pins every file in an as-of package to it.
Defect 22, depreciation through an asset's final month (found in P0 review round 4, 2026-10-17): the depreciation schedule plugs to the full depreciable amount whenever the date it is computed through falls in the asset's final month, even mid-month. A disposed asset's schedule therefore shows full depreciation in its disposal month, and mid-final-month figures are overstated. Resolve from the standard (straight-line, partial months prorated by days, as the rest of the schedule already does), then adjudicate the changed figures in the P3 change log. Related, found in the same round and fixed in P0: the final-month true-up compared the month with chrono's %Y, so assets whose life ran past 9999 (or before year 0) never trued up. Assets whose life runs past 9999 also can't post months after 9999-12 (periods are bounded to years 1-9999); decide in P3 whether to refuse such lives at registration. Also noted: the viewer recomputes a very long-life asset's whole schedule every frame (slow; pre-existing).
Defect 23, a vendor marked "no 1099" looks undecided (found on the first company's books, 2026-10-18): books.set_vendor_1099 with form_1099: "none" stores and returns null, the same as a vendor nobody has decided on, so books.list_vendors, the 1099 worksheet and Needs attention can't tell "the owner decided: no 1099" from "never asked". Store the decision as its own value (none, with who decided and when), and let Needs attention ask about vendors with no decision. P3 (it changes what the 1099 worksheet says).
Defect 8, tax categories: add_account warns on P&L accounts without a tax category. Needs attention flags all P&L accounts regardless of activity. policy.require_tax_category makes it a refusal.
Defect 9, schedules:
convention: daily|monthly_even. New schedules usebooks.schedule_convention, defaultdaily(D-11, decided). Legacy schedules staymonthly_even.- Shared kernel function
prorate_by_dayswith depreciation. Kani: parts sum to the total; each part equals days × rate within 0.01; the last part absorbs the remainder. list_schedulesexposes start, end, convention, next and remaining.
Defect 11: parties takes tin_last4 from party.tin_set or W-9 facts.
Defect 12, 1099 worksheet (ledger-core/src/form1099.rs):
- Instruments are added.
- Payments by card or third-party network are excluded and listed under
excluded_card_payments(IRS Instructions for Forms 1099-MISC and 1099-NEC, payment-card exception; vendored indocs/spec/sources/). - A member's personal cash or check through a financing liability stays reportable, because the entity is the payer.
- An unknown instrument on a financing-routed payment is
undeterminedand raised in Needs attention. - README wording fixed. The untagged note is quieter: one count plus a tool hint.
Defect 13, assets:
accumulated_postedvsaccumulated_scheduled_through(as_of).in_service_date+acquired_date.asset.basis_set:tax_basis_723is the carryover basis (IRC §723);depreciable_basis= lesser of FMV and adjusted basis for personal-use conversion (Treas. Reg. §1.168(i)-4(b));built_in_gain_704c = fmv_at_contribution − tax_basis_723(IRC §704(c), Treas. Reg. §1.704-3), with §704(c)(1)(C) noted when the result is negative;sec704c_method.
built_in_gain()(assets.rs:219) is redefined to match.
Defect 14, party shares: resolved uses the line sign convention (debit-positive) plus an explicit direction. Totals take basis: cash|all, default cash, with the basis labelled.
Defect 15: rename_account; show refuses start > end.
Defect 16: one period_defaults(kind, settings, today) used by tools, the GUI and generated descriptions.
Defect 17, odd cent:
CloseTo::MembersCumulative. Each member's cumulative allocation = round(cumulative net × share); each close posts the delta.- Extends
kernel::apportion(kernel.rs:309) and replacessplit_first(shares.rs:197) for closes. - Kani: exact sum; |cumulative − exact| < 0.01.
- Lean
Apportion.lean: bounded-drift theorem. - Posted closes never change. The cumulative count starts at inception (D-10, decided), so the first close after the upgrade carries any historical true-up of a cent or two, listed in the P3 change log.
Defect 18: not_reportable{reason} is distinct from undetermined.
Settled in design (2026-10-20; each design's reviewed recommendation, told to the owner, who may revisit any): party totals count a member's entry by what moved their capital (so "all" equals the members' equity statement and "cash" means what it means on the K-1); money is cash, cards, the members' financing and loans, and a liability credit never counts as paying that liability's own creditor (defect 24, found in design: accruals through other liabilities counted twice, fixed here); a report asked for without dates covers this fiscal year so far, everywhere, and an account's register opens on it with "Show all dates" one click away; P&L accounts without a tax category warn by default (header accounts exempt), and the first company's books are fully categorized at the P3 cutover and then set to refuse; a new account named like a sibling (ignoring capitals), or holding a nine-digit number, is refused; books.show refuses options its target ignores; an asset's acquired date comes from the entry it is linked to; a month of depreciation skipped is caught up in the month the run is made (an immaterial miss; a material one is restated, ASC 250).
Tests and accept:
- A named regression test per defect, built from its 2026-10-16 reproduction (anonymized fixture).
- Each of those regression tests is also written as a bookspec example (P13, D-34) as it is built: the rule it shows, its source, the setup, the events and the exact expected results. They are the spec's first examples.
- Goldens recomputed by hand.
- GnuCash differential re-adjudicated.
- 0 unexplained mutation survivors in
ledger-coreandledger-import(run in P12, D-33). - The the first company change log is adjudicated.
P4: Drafts, reversal as draft, the evidence gate, digest (defects 2, 5, 6)
Scope:
- Core: draft states;
entry.draft_state_set,draft_discarded,reversal_drafted,evidence_inherited.reverse_entry_steps(ledger.rs:1355-1380) stops posting inline.check_reversal(ledger.rs:1319) also refuses a second open reversal draft.ledger_core::evidence::requirementis the single gate, used byStore::post_entry(every path), MCP and GUI. - Exemptions removed: S:561-568,
receipt_policy.rs:6, S:2540, GUIbooks.rs:648. Digest, settings and docs now agree. - Defect 5: schema v12 columns;
list_drafts;discard_draft;set_draft_state;as_of_statusplusstricton reports; the CPA package refuses an open draft unless given a reason. - Defect 6, digest:
since/since_seqbyposted_seq;as_of; zero-net touched accounts;reconciliation_tracked;never_reconciled; drafts by state. - Preview:
preview_entrypluspreview_tokenon all confirm tools. - Needs attention: "draft awaiting evidence" and "reversal draft needs evidence", distinct from "draft not posted".
Proofs:
- Lean
Step.draftReversal,Step.discard, each with preservation. Reachable.reversal_reverses_posted: every posted reversal reverses a posted original, at most once.draftsOpenaccounts for discard.tests/model.rsextended.
Tests:
reversal_needs_evidence,inherited_evidence_posts,legacy_reversed_replays_identically,discard_unblocks_close,report_warns_on_draft_before_as_of,cpa_package_refuses_open_draft.digest_since_is_posting_time(an entry backdated but posted today appears).stale_preview_refused;gui_and_server_gate_agree.fuzz_op_sequencegains the new ops; store refusal law atSTORE_CASES=5000.
Accept: legacy fixtures open; the first company has 0 unexplained drafts.
P5: Agent surface complete (B, guidance)
Scope:
- Final connect text.
books.startv2 (4.2).- Full prompt set; resource templates.
- Result envelope (
next_steps,guards,warnings,head) incall_tool. books.journal_history;books.job.- Correcting a record's source. A convention's citation (or who decided it, or when) can't be corrected today unless its words change too:
set_conventionwith only a new source is refused as "already says exactly that" (found at the the first company P1 cutover, 2026-10-17, correcting an unchecked citation). A source-only revision, journaled with its reason; the same for decisions and open items. - Tool descriptions ≤ 700 characters; handbook complete; URL-mode elicitation where the client declares it.
docs/mcp-interface.mdrewrite (24-95, 580-648, 723-747, 909-931).- Agent-eval harness
ledger-mcp/tests/agent_evals/: scripted, with no LLM in CI. A zero-context transcript that follows onlybooks.startandnext_stepsmust complete onboarding, a receipt-backed post, a draft awaiting a receipt, a reversal draft with inherited evidence, a reconciliation and a month-end close.
Tests:
- Every mutating result has a head and a non-empty
next_steps. - Every guard's remedy tool exists.
instructions_budget(first 512 characters containbooks.start).tool_text_lint.- Golden render per prompt.
books.start≤ 8 KB on the the first company fixture.
Accept:
- A fresh Claude Code session in
/tmp/emptyand a Codex session each follow journaled conventions to post an entry. (claude.ai needs the tunnel and OAuth, so its leg is in P9's acceptance.)
P6: Evidence pipeline and scripts retired (defects 3, 4, 10, 20)
Scope:
- Extraction. Crate
ledger-extract, run as a sandboxed subprocess:- PDF text layer (lopdf);
- XLSX cells as exact decimal strings from the sheet XML (zip + quick-xml; never floats, fixing
verify-sheet.py:118-119); - CSV and text;
- EML via mail-parser;
- page raster with hayro;
- no OCR in core for now (D-15, decided): image-only receipts rely on page images plus attestation.
attachment.text_extractedat attach time, plus a backfill job.get_receiptdefault meta + text, andpage_image. Text and page resources. Defect 3 complete.- Dedup (defect 4): identical bytes return the existing record and add a link;
find_attachment;consolidate_duplicates(links move to the first record, duplicates withdrawn with "duplicate of #N"). - Annotations (defect 10):
annotate_attachment; engine receipt numbers (next = max + 1);reimbursedderived (entry.settleswhen given, otherwise FIFO per party on the financing account, labelledderived: fifo). - Email.
attach_email:- an embedded PDF is preferred;
- otherwise a deterministic Typst render of headers and body, with fixed PDF metadata and inline images kept;
attachment.renderedis journaled;- the
.emlitself is never filed.
- Company records (D-20). Categories for notices and correspondence, plans, and history beside formation, legal and the rest; the viewer's Documents page grouped by category and subject; the books dir's
company/folder retired (the first company: its files are filed, attachments 73–80, two more after the EIN fix). With extraction, the TIN scan reads PDFs' text too: the own-EIN exemption applies there as well. - Migration tools (defect 20):
compare_referenceandrecord_inventory/check_inventoryreplacescripts/verify-sheet.pyandscripts/drive-manifest.py.eml2pdf.pyis retired.
Tests:
same_bytes_one_record(property: N attaches, 1 record, N links).get_receipt_default_has_no_bytes,page_image_is_png_of_page,inline_base64_requires_sha.extraction_deterministic,eml_render_deterministic.xlsx_cells_are_decimal_strings(property).extractor_sandboxed(network and filesystem escape attempts fail).compare_reference_detects_one_cent.fuzz_extract_pdf,fuzz_extract_xlsx,fuzz_eml.
Accept:
get_receiptdefault output is < 8 KB for every the first company attachment (the earlier audit reached 415K tokens).- The first company 5.6 P6 checks: 0 differences, 0 inventory gaps.
P7: Vault v2 and the human channel
Scope:
ledger-vaultv2:- HPKE sealing;
keyring.jsonwith keyslots: passphrase (the owner passphrase from P2), recovery key, passkey PRF, member_passkey;rekey;- W-9 files sealed.
- Events:
vault.created,vault.rotated,vault.keyslot_*,vault.resealed. Verify checks the public-key sha. - Human pages:
/h/tin/{party},/h/w9/{party},/h/unlock,/h/tin-view,/h/download(CPA package and 1099 worksheet with TINs, W-9), keyslot management. - Tools:
request_tin_entry,set_w9_facts. - Removed from the MCP:
set_tin,set_w9 tin|w9_file_path,include_tins, all of them. - Scanner: pattern-based TIN scanner on every result.
- GUI: in-process passphrase unlock (3.6): it reads the wrapped keyslot and the sealed record from the data root read-only, unwraps, copies to the clipboard only, and zeroizes. No socket.
wanderbooks vault init|rekey.
Tests:
no_mcp_output_contains_full_tin(session canary fuzz).unwrap_only_in_human_handler;vault_pubkey_swap_detected_by_verify;reseal_preserves_last4_and_kind;cpa_package_with_tins_human_only.- Lost passphrase is recoverable with the recovery key.
- GUI
tin_copy;gui_unlock_zeroizes(no plaintext TIN or private key left in the GUI's memory after the copy). - Mutation testing on
ledger-vault(in P12, D-33).
Accept:
- The the first company K-1 member worksheet and 1099 worksheet with TINs, produced through the human link, match the P0-era output byte for byte apart from timestamps.
- No vault key material remains on disk except the wrapped keyslots in the data root.
P8: Audit, attestations, access-log tooling
books.audit (ledger-core/src/audit.rs plus the store's evidence view; deterministic; no LLM; run as a job):
- Integrity: chain, projection and blobs; access-log chain; vault public-key sha.
- Statements: TB balances; A = L + E; P&L = current earnings; cash flow
reconciles; cash equals the last statement for each tracked account; reconciliation coverage. - Evidence: the gate holds over every posted entry;
legacy_reversal_exemptis informational; no drafts dated on or beforethrough; duplicates. - Closes and schedules: closes in order, with member deltas to the cent; schedules and depreciation posted through.
- Assets: register = cost accounts; tags and basis present.
- Tax and parties: tax categories; 1099 payees with W-9 or TIN and their kind; party policy.
- Settings and freshness: settings problems; 2100 aging; backup freshness.
- Attestations: coverage against the bar.
Output: a workpaper (JSON plus Typst PDF) filed as a workpaper document, and audit.run journaled. It is idempotent on (through, through_seq, program_version).
Attestations: attest_receipt, review_entry and attest_period.
verified(principal, client_id) is kept separate fromasserted(model, effort, adversarial, agents).- The engine cross-checks extracted amount and date against the entry; a mismatch never counts.
- A period attestation requires an
audit.runat the current head. countsis derived againstpolicy.attestation_barin force at that seq, so a bar change applies only going forward.
Needs attention items: "month-end attestation due" after each close; "full audit due before CPA handoff" (export_cpa_package refuses without a counted full audit unless given a reason, per policy.cpa_package_requires_audit = refuse; D-9, decided); "annual audit due". Each states the bar.
Tests:
audit_is_deterministic(same head gives the same sha).audit_detects_each_seeded_defect(one fixture per check).below_bar_not_counted,bar_change_is_prospective,amount_mismatch_never_counts,stale_head_period_attestation_refused.- Lean: counting lemma (counted ⇒ meets the bar at seq) as a decidable predicate.
Accept: an injected fixture with an unfooted cash flow, a missing receipt, an open draft and a duplicate blob produces exactly those findings.
P9: Self-host on another machine, and remote access
This is the documented path for anyone who wants mechanical enforcement, and it is how claude.ai on the web reaches the books. Multi-tenant hosting is not built here (section 11).
Scope:
- Self-host recipe: the same binaries on a machine the owner controls (home server, NAS, VPS), under system units and a dedicated account.
wanderbooks-server.servicewithIPAddressDeny=any(loopback allowed; when the built-in authorization server fetches CIMD documents, D-7 a, connect to port 443 only, with private and link-local ranges denied), seccomp@system-serviceand Landlock; the archive unit withIPAddressAllowlimited to its configured targets; an encrypted volume; Caddy for TLS. A container image is an alternative on that machine.books.startreportsisolation: "service_user (enforced)". - Moving books there:
wanderbooks fetchfrom the desktop server, thenbooks adopton the self-host machine, with the head check. Adopting an archive clone works the same way. - Remote access:
wanderbooks tunnel --book <book>(a Tailscale Funnel or cloudflared recipe). The static bearer over TLS for header-capable clients (Claude Code, Codex). OAuth 2.1 (3.3) for claude.ai and ChatGPT, built in (D-7 a) or delegated (D-7 b). OAuth is forced on whenever the tunnel is public. The Origin allowlist gainshttps://claude.ai. - Remote viewer: replica routes,
books:view,Snapshot::from_lines, GUI--books <url>, and/replica/eventsforbooks.showand presence (3.6). The replica carries a minimum-version header and no keyslot material. - Remote people:
books.invite {kind: cpa|member|viewer, email}sends a passkey enrolment link; thecparole is read, view and archive./h/pages are served over TLS behind a passkey or the passphrase. - File inputs: MCP App upload widget (an app-only tool);
openai/fileParams.
Tests:
agent_uid_cannot_read_tenant, rerun against the recipe's deployment.self_host_adopt_same_head.- OAuth: PRM and 401, audience, PKCE, exact redirect,
iss, CIMD SSRF guard, step-up; an external Origin, Host and CSRF scan. replica_snapshot_equals_store_snapshot;replica_carries_no_keyslot;fuzz_replica_stream.- An external security review of the built-in authorization server before the remote path is opened publicly (not needed under D-7 b).
Accept:
- claude.ai custom connector via CIMD →
books.start→ upload through the drop page → post → visible in a replica GUI on another machine. codex mcp loginworks, and the static token works over TLS.- The audit and the CPA package each finish in < 60 s on the first company, or return a job handle.
P10: Acceptance, the full the first company re-audit through the MCP alone
Run from clients that hold only a URL and a credential: Claude Code started in /tmp/empty, Codex, and claude.ai through the tunnel if a self-host machine (T2) is running by then; otherwise the re-audit runs from Claude Code and Codex only, and the claude.ai leg is recorded as not applicable. The agents use the tools alone. On the desktop (T1) this separation is advisory, because the agents run as the same user as the server: the evidence is the access log plus session transcripts that never read the data root or name its path. If the owner runs a self-host machine (T2) by then, the re-audit runs against it, and the separation is enforced.
books.audit scope=full through=<month-end>andscope=cpa_handoff. Every finding is fixed through journaled corrections: reversal drafts with evidence, annotations, decisions. Legacy reversals posted without their own evidence inherit the original's withinherit_evidence(D-12, decided).- Every receipt is attested from
get_receipttext plus page image by a pair at the bar: high reasoning effort, adversarial, at least 2 independent verifier agents (D-9, decided). No base64 enters context. review_entryfor every posted entry;attest_periodmonthly since formation andcpa_handoffannually.- Each of the 22 defect reproductions (20 from the 2026-10-16 audit, and defects 21 and 22 from P0) is re-run, and each shows fixed.
compare_referenceagainst the Sheet as of 2026-10-11 shows 0 differences.check_inventoryagainst the Drive listing shows 0 gaps.- CPA package: no TINs through the agent; with TINs through the human link.
- The head is handed to the owner and verified against a fresh
fetchwithverify-archive --expect-head.
Exit criteria:
- Zero failing workpaper checks; cash flow difference 0.00.
- 100% of receipts and posted entries carry a counted attestation, and 0 disagree.
- The access log shows every action under the right principal. No transcript reads the data root or names its path; the only other reader is the read-only viewer.
- No result carried a full TIN or more than 256 KiB of inline bytes.
- Agent context on receipts never exceeded 100K tokens.
- The owner's sign-off is journaled as
decision.recorded.
P11: One app for Windows, Mac and Linux (D-19, D-22, D-24, D-27)
Scope:
- Tooling: cargo-packager (D-25). Its
cargo packagerbuildsWanderbooks.appand the.dmg, the NSIS setup.exe(in its defaultCurrentUsermode: no admin, metadata underHKCU), the.deb, the AppImage, and the pacmanPKGBUILDthe AUR package starts from;cargo-packager-updaterupdates the formats that update themselves (app,nsis,appimage;wixtoo, if an.msiis ever made). It has no.rpm, so cargo-generate-rpm makes that. Both tools are pinned inmise.toml, like every tool. Checked 2026-10-18 against its source (packager 0.11.8, updater 0.2.3, both pre-1.0): the updater verifies each bundle's minisign (Ed25519) signature against one public key and by default takes only a newer semver than the running one. It has no expiry, no second key and no key rotation, so our signed release manifest (below) is checked first, and the bundle's own minisign signature after it; the primary release key is the minisign key. P11's first step is a short trial of all six formats on clean machines; anything it can't meet goes in the change log before we build around it. - macOS: a
.dmgholdingWanderbooks.app(the one executable inside the bundle), signed with the company's Developer ID, notarized and stapled. Drag to Applications, or~/Applicationsfor a standard user who can't write/Applications; no admin password. While macOS runs the app translocated (straight from the.dmgor Downloads), it says to move it first and refuses to connect agents, so no temporary path lands in Claude's or Codex's config. The app updates itself in place. - Windows: a per-user installer that asks for no admin password: a signed setup
.exeinto the user's app folder; an.msionly if a managed install ever needs one. The app updates itself. Signing through Azure Artifact Signing if the company or the owner qualifies, else an OV certificate; SmartScreen warnings fade as the download builds a reputation. - Linux: Arch (an AUR
wanderbooks-binpackage), Debian/Ubuntu (.deband our signed apt repository), Fedora (.rpmand our signed dnf repository), each installed and updated by the system's package manager (withsudo, as every package there is); and an AppImage that updates itself and needs no admin password, for every other distribution. The packages install the one executable, a.desktopentry and the icon. - One updater rule set (D-24), whatever the tooling:
- Every update is verified twice: against our own release key (Ed25519 over a release manifest listing each file's sha256) and the system's code signing. Either failing refuses the update; nothing is half-installed.
- The updater restarts the books, it doesn't close them (3.9):
update.lock, a clean stop after the request in progress, an atomic swap, a restart from the new executable; the viewer reopens and the agent bridges reconnect by themselves. - When updates happen: the host checks for one daily while the books are open, downloads and verifies it, and stages it; it is applied at the next restart the owner accepts in the app, or by the helper just before it next starts the host. An install whose books are never opened is never touched.
- Format changes are the server's gate (3.9,
format_upgrade_pending), not the updater's: the owner consents in the app after a verified backup with the keys. A failed update rolls back to the previous app, which can still open the books as long as no newer format was written. After consent there is no rollback for that book: only restoring the pre-upgrade backup as a separate book, which shows how many entries it lacks (3.9). - Updates never start during a long job (a CPA package, an audit, an extraction backfill). Multi-call work like a reconciliation is journaled as it goes, so a restart between calls loses nothing.
- Release keys. Two Ed25519 keys, a primary on a hardware token held by the owner and a backup kept offline; CI builds and code-signs but holds neither, so it cannot sign a manifest. Rotating or revoking a key is a statement signed by the other. Each manifest carries a version that only increases and an expiry: from the feed, an older version than the one installed is refused (no rollback attack), and so is an expired manifest (no frozen feed). Going back to the kept previous app is a local action by the owner, not a feed update, and stays allowed.
- Package-managed installs (AUR, apt, dnf) are updated by the package manager, with its own signatures (our repository keys) and
sudoas usual on Linux; our updater stays out, andwanderbooks updaterefuses there. The host notices the new executable and restarts when idle (3.9); the format gate still protects the books.
- Connecting agents from the app: the menu offers "Connect Claude Code" and "Connect Codex" (P2's
connect, with the absolute path of the installed executable, which updates keep stable), so no one edits aPATH. - The agent dock and books homes (D-27; owner, 2026-10-19: "a one stop shop … it boots up, you get the chat interface"). The app runs the agent itself, in a terminal docked inside its window, so a person who installs Wanderbooks needs nothing else open:
- The CLIs are found or installed, never bundled. The app looks for an installed
claudeandcodexand offers the missing one through its vendor's official installer. Bundling is ruled out: Claude Code isn't redistributable, both release far faster than we do, and a second copy means a second login and a second set of permission rules. - The dock is a full-width panel along the bottom of the main window (not the sidebar), resizable and collapsible, so the reports stay in view while the agent works: posts appear within a second and
books.showmoves the view, which is the point. Its header picks the agent: Claude Code by default, Codex if installed, remembered with the viewer's preferences. The terminal isalacritty_terminalrendered in egui (egui_term, or our own view over it), on a pseudo-terminal: Unix PTYs on Linux and macOS, ConPTY on Windows 10 1809 and later./login, slash commands and the agents' own permission prompts work as in any terminal; we build no chat UI of our own (a native one over the Agent SDK orcodex app-serverstays a later option). - The agent is started by the viewer's helper (3.9), not as the viewer's child: the viewer stays read-only and confined (3.6) while the agent runs unconfined, as the human, like one started in a terminal.
- Each book has a books home, the agent's working folder: a visible, ordinary folder named for the company (
~/Documents/Wanderbooks/<Company>/; the user's Documents folder on Windows), never the data root and nothing inside it. It holds only what makes an agent started there work with that book: anAGENTS.md/CLAUDE.mdpointer ("callbooks.start"), the agent's project-level MCP configuration for that book (wanderbooks mcp --book <id>), and.claude/settings.jsonallowing the wanderbooks tools so the daily flow doesn't prompt. No books data, no keys, no token. The agents' "trust this folder?" question is then about a harmless folder, and is answered by the person, once; the app never edits Claude's or Codex's own settings to pre-trust it (whetherwanderbooks connect codex, which already writes Codex's configuration, may mark the books home trusted is asked of the owner when it is built). The books home is a convenience: agents started anywhere else keep working (I-11). - The first company's project folder becomes its books home. After the P2 cutover clears its crumbs (10.3),
~/Documents/the first company-Booksholds exactly the pointer and the agent configuration: the first books home, and the templateNew books…makes the others from. - Windows. The pseudo-terminal is ConPTY (it rewrites some escape sequences and has resize quirks; neither stops a terminal app). Each agent's own Windows support is checked in the first-step trial on a clean machine: Claude Code's need for Git for Windows, Codex's native support (historically WSL-first). An agent that isn't good enough there isn't offered in the dock on Windows, and the app still works as a viewer with agents started elsewhere.
- Open questions for the trial: whether Codex reads a per-folder MCP configuration (historically only the global
~/.codex/config.toml; if not,connect codexwrites one named server per book there); the exact layout of each agent's project files at that time.
- The CLIs are found or installed, never bundled. The app looks for an installed
- Data root in each system's usual app-data folder (Application Support,
%LOCALAPPDATA%(never the roaming profile),~/.local/share), as in 3.4 and 3.9. - Release builds and the full test suite on all three systems in CI; package builds for Arch, Debian and Fedora in their own containers.
- The viewer menu of P2 (back up, export, restore, new books, close the books, connect an agent) is the app's whole surface beyond the viewer; there is no separate launcher app.
- Costs (company expenses, recorded with their receipts): the Apple Developer Program (the company's enrollment, attachments 76 and 77); Windows code signing (Azure Artifact Signing, about $10 a month where eligible, or an OV certificate, about $150 to $300 a year).
Tests: install_needs_no_admin (dmg, Windows installer, AppImage, clean machines); translocated_app_refuses_to_connect; manifest_rollback_and_expiry_refused; key_rotation_statement_verified; package_upgrade_restarts_host; update_is_atomic; failed_update_rolls_back; unsigned_update_refused (bad release-key signature, bad code signature, a file whose sha256 differs from the manifest: each refused, nothing changed); update_closes_books_cleanly (during a burst of posts; head verifies after); update_waits_for_long_jobs; format_change_update_backs_up_and_asks (declining leaves the books untouched and openable by the old version); bridge_survives_update; linux_packages_install_and_upgrade (AUR, apt and dnf repositories in clean containers; AppImage self-update); server_runs_with_viewer_closed; dock_runs_the_chosen_agent (on each system: the dock starts Claude Code, and Codex where offered, in the book's books home, through the helper; the viewer stays confined); books_home_holds_no_books_data (only the pointer and the agents' configuration; no path into the data root); agent_missing_offers_the_installer; P2's restore tests on all three systems.
Accept:
- On a clean Windows and Mac machine, and on Linux with the AppImage: install, open, onboard and post a first entry with no terminal and no admin password. On Arch, Debian and Fedora: the same after the package manager's usual install.
- An update, on each system, leaves every book's head and goldens unchanged, and an agent session open across it carries on.
- A backup made on one system restores on each of the others.
P12: Max testing, again (D-33)
The whole max-testing pass of 2026-10-14 (docs/testing.md), run again over everything built since, on the owner's desktop once it is set up (the laptop stays usable: owner, 2026-10-20). Building comes first; this phase holds the testing every phase from P2 slice 3 on deferred to it.
Scope:
- Mutation testing of every crate but the viewer (no mutation runs on the GUI, owner 2026-10-14; its two musts are tested instead) over the whole diff from
mainas P2 slice 3 found it (168494b) to the tip: every survivor killed by a test or documented as equivalent indocs/testing.md, one section per phase. Each crate's own tests first, then the survivors again with the end-to-endledger-clitests (--iterate). - Carried in from P2: slices 3–6 had pass 1 on every crate and pass 2 on all but
ledger-host; its survivors were partly triaged (ledger-cli's 11 are#[cfg(not(unix))]stubs, equivalent on Linux; one area ofledger-mcpkilled, which found and fixed the admin reply-length bug). Slices 7 and 8 had none. The triage notes go indocs/testing.md. - Fuzzing: every
cargo-fuzztarget, the old and the new (5.x), each for a set time without a crash; any crash becomes a regression test. - Property tests at their full case counts (
STORE_CASESand the rest at the documented maximum), and the GnuCash differential re-run. - Proofs:
just prove(Kani with no vacuous covers, Lean with standard axioms only), every harness and theorem added since. - Where: the desktop, or GitHub Actions' scheduled workflow (
.github/workflows/scheduled.yml, already sharded to fit its six-hour jobs), or both. - Load: the runs never take every core (owner, 2026-10-20): cargo-mutants with an explicit
--jobserver-tasks, one mutation run at a time, leaked test daemons reaped.
Accept: 0 unexplained mutation survivors workspace-wide (the viewer aside); every fuzz target clean for its set time; just prove and just check green; docs/testing.md records each crate's counts and every equivalent mutant with its reason.
P13: bookspec, a formal accounting specification (D-34)
bookspec is a specification first. A normative, formally written statement of how books are kept, every rule sourced to its authority, independent of any engine. Built on it, second and third: its own formal validation, and a conformance harness that any accounting engine implementing its interface can be tested with. Owner, 2026-10-20: "a FORMALLY written accounting spec … properly sourced with actual references, it's thorough, it might even have its OWN formal validation. It includes symbolic specs as well as concrete examples. It's useful for ANY accounting engine testing", and "bookspec is a SPEC first, and then it can also include formal methods/validation/proofs as well as a test harness that could be run against an accounting engine that conforms to the interface."
What it grows from: docs/spec/rules.md (every rule with its AUTHORITY / CONVENTION / DECISION status and source), the vendored sources in docs/spec/sources/, the Lean theorems in verify/lean/ (entries, ledger, statements, closing, apportionment), the goldens, and P3's named regression tests, written as bookspec examples from the start (P3).
1. The specification. A document in numbered sections and stable rule identifiers (an identifier never changes meaning; the spec is versioned):
- Definitions and the model: accounts (types, normal sides), money (exact decimal, currency, scale), entries and lines, periods and the fiscal year, basis (cash, accrual), parties and instruments, assets; the statements and reports defined over them.
- Rules, each with:
- its statement in normative language (MUST, MUST NOT, MAY);
- its source, pinned exactly (an IRC section, a Treasury Regulation, IRS form instructions or a publication with its revision year, a FASB ASC topic-subtopic-section-paragraph) and its status: AUTHORITY (a source states it), CONVENTION (common practice, best reference given), or CHOICE (the authorities allow options: direct or indirect cash flow, FIFO or average cost, an apportionment rule; the spec names the options and an engine declares its own);
- its symbolic form: the rule as a formula over the model, with variables and their constraints (A > 0, 0 ≤ r ≤ 1) and the result as an expression;
- its concrete examples: worked cases (setup: the chart and settings, rounding mode and scale included; events in order; the exact expected results to the cent, or a refusal with its reason), with the edges that matter (odd cents and rounding, reversals, mid-period dates, the first and last day of a period, zero amounts, disposals, closed periods, multiple currencies);
- its applicability: cash or accrual, book or tax, jurisdiction, and for every tax rule the tax years it holds for (thresholds and tables change by year).
- Three kinds of rule: laws that hold for all books (debits equal credits, A = L + E, the trial balance balances, net income articulates to equity, the cash-flow sections sum to the change in cash); sourced rules with definite answers (the de minimis safe harbor, the 12-month rule, MACRS tables and conventions, 1099 thresholds by year, 704(c) built-in gain, 723 carryover basis); and choices, parameters rather than one right answer.
- Coverage, written incrementally in order of need: double entry and the account model; journals, reversals and corrections; trial balance, balance sheet and income statement; accrual and cash basis; cash flows, direct and indirect (ASC 230); periods, closes and the equity roll-forward; partnerships (capital accounts, allocations, 704(b) and 704(c), 723, K-1 figures); fixed assets and depreciation, book and tax (Pub. 946, MACRS); prepaids, amortization and capitalization (Treas. Reg. 1.263(a)-1(f), -4(f)); receivables, payables and aging; revenue (ASC 606) and deferred revenue; 1099 reporting; then payroll, sales tax, inventory and multiple currencies (ASC 830), each large on its own; rounding and apportionment throughout. Interchange formats (IIF, CSV) are an appendix, marked as formats, not accounting.
2. Formal validation of the spec.
- The model and every symbolic rule in Lean 4: the model is inhabited, the rules are consistent with each other (no two demand different results on the same books), and the laws are theorems (standard axioms only, no
sorry). - Every concrete example is derived from its symbolic rule by the spec's own checker (Rust, exact decimal money): an example whose written expectation disagrees with its derivation fails the spec's build. No expected value rests on one engine's output (the GnuCash-is-not-the-authority lesson, applied to every engine, wanderbooks included).
- Every citation resolves to a vendored or linked source, and no rule lacks one.
3. The conformance harness.
- Rust throughout (owner, 2026-10-20: "We are rust people"): the checker, the generator and the harness are Rust crates in bookspec's own Cargo workspace; no JavaScript anywhere.
- The interface an engine implements is a Rust trait (
Engine): load a setup, apply events, answer queries (balances, report lines) or refuse with a reason, and declare its choices and the parts of the spec it claims. An engine not written in Rust is reached through an adapter that implements the trait (over its API, its files or its command line). - The cases are the spec's examples plus cases generated from the symbolic rules (variables drawn within their constraints, expectations computed from the expressions, as
propteststrategies), written as TOML files the Rust checker validates against their schema. - The runner reports per rule: pass, fail, or not claimed. A disagreement between engines is a lead, settled from the sources.
- Engines: wanderbooks first (its engine crates directly, and end to end through its MCP); GnuCash through the oracle (
docs/oracle.md), hledger and Beancount feasible, QuickBooks through its API perhaps.
Sources: United States first, canonical only (owner, 2026-10-20: "the source should come from that national … association thing that we used earlier in this project. Or any other reputable, canonical source of accounting rules and practices and procedures"). The first edition is US GAAP and US federal tax; other jurisdictions and IFRS are later editions. Every rule cites the highest source that settles it, in this order:
- Accounting: the FASB Accounting Standards Codification (the Financial Accounting Standards Board, read at asc.fasb.org as
docs/spec/sources/fasb-asc.mdwas in this project). By its own terms the Codification is the single source of authoritative nongovernmental US GAAP (ASC 105-10-05-1), so an accounting rule's source is an ASC topic-subtopic-section-paragraph. - Tax: the law and the IRS. The Internal Revenue Code, Treasury Regulations, then IRS revenue rulings and procedures, notices, form instructions and publications (with their revision year).
- Where the Codification leaves a question open, the nonauthoritative sources ASC 105-10-05-3 itself names, in its order: practices widely recognized and prevalent, FASB Concepts Statements (the conceptual framework), AICPA Issues Papers, IFRS, pronouncements of professional associations and regulators, the AICPA's Technical Practice Aids, and accounting textbooks, handbooks and articles (the list is checked against the paragraph when the spec is written). The Big Four firms' published guides (Deloitte's DART roadmaps among them) may help with reading a paragraph, but never stand as a rule's source. A rule resting only on these is marked CONVENTION.
- Never a source: what one engine does (wanderbooks and GnuCash included), what one accountant says, or an unsourced website.
Copyright. Rules and facts aren't copyrightable; their expression is. The Codification is copyrighted by the Financial Accounting Foundation, so bookspec states each rule in its own words with the paragraph cited, and quotes no more than the Foundation permits. IFRS text is treated likewise. IRS and Treasury text is public domain and may be quoted. Textbooks are used only under a compatible license (no NonCommercial or ShareAlike text). Public: the spec under CC BY 4.0, the checker and harness under MIT OR Apache-2.0.
Where it lives: its own public repository (bookspec), separately versioned, which wanderbooks consumes: docs/spec/rules.md points each rule at its bookspec identifier, the engine's SOURCE: blocks cite bookspec, and the harness runs against wanderbooks in just check for the parts it claims.
Accept: the spec's build green (Lean as above; every example matching its derivation; every citation resolved); wanderbooks passes every case for the parts it claims, with each unclaimed part listed; a second engine (GnuCash, through the oracle) runs through the harness, its differences settled from the sources.
9. Risks and mitigations
- Journal forward compatibility. Old GUIs or CLIs cannot read v2 events.
- The P0
NewerJournalgate ships first. The local GUI reads the journal directly, so it goes through the same gate. - One executable (D-22): an update replaces it whole; the server's
format_upgrade_pendinggate (3.9) protects the books however it arrived. - The replica (P9) carries a minimum version.
- Fixtures from every phase are kept.
- Downgrade is impossible after the first v2 event, so back up first (P1 says so).
- The P0
- The settings migration shifts numbers. Byte-identical goldens gate P1.
company.memberseffective dating must reproduce theclose_tohistory exactly. - P3 fixes change the first company's reports and tax output. Every difference is hand-adjudicated in the change log, and the 1099 and 704(c) changes are shown to the CPA. Posted entries never change. The cumulative close rounding counts from inception (D-10), so the first new close may carry a one- or two-cent true-up; it is listed in the change log.
- The P2 cutover is large and could disrupt daily use.
- The scripted
adoptchecks the head hash. - If the owner agrees (Q-3), the old copies move into the data root's
legacy/area. Nothing is deleted. - Rollback is documented.
- The final runs of
verify-sheetanddrive-manifestare filed before cutover; Drive deletion waits for P6.
- The scripted
- The P2–P7 interim: TINs still cross the MCP on
set_tin/set_w9, with no added prompt (owner decision: norequiresUserInteraction).- Mitigation: P7 is scheduled before mid-January (1099s are due Jan 31, K-1s by Mar 15). TIN-bearing outputs are human-only from P2.
- Desktop isolation is advisory. A process running as the human, including an agent that deliberately hunts, can read the data root, the agent token and the wrapped keyslots.
- TIN plaintext still needs the passphrase.
- The path is never named, the root sits outside every project directory, and every place that reports isolation says "advisory".
- The server's own Landlock and seccomp are never presented as protecting the books from the agent.
- Enforcement is the self-host deployment (P9), tested in CI from P2.
- The books are open only while something holds them (D-21, D-23: the app, an agent session, a
serveterminal; no background service).- An agent session opens them itself through its bridge, so it needs no reconnect. A client connected over HTTP instead of the bridge holds no lease and needs them already open.
- The viewer reads the data root directly, so it works even if the server can't start; "Restore…" and "New books…" say why they can't run then.
- Backups run only while the books are open. The books change only then, so nothing goes unbacked; the server pushes anything left over, and a drill that fell due, at the next open.
- The host outlives the opener that started it, by design, until the last lease ends; it is never left running with no holder for more than 10 s. A crash or power loss is what the append-only journal already survives.
- The OAuth authorization server is security-critical code written in-house (rmcp has no server-side auth). It is needed only on the self-host remote path.
- Small surface: passkeys only, no password grant.
- Built on audited crates (webauthn-rs, oauth2 types); fuzzed; externally reviewed before the remote path is opened publicly.
- Alternatively an external IdP (D-7).
- Client variance.
- claude.ai: no elicitation, about 150K-character results, 240 s per call.
- Cursor: legacy protocol versions only.
- Codex: a 512-character budget.
- The
resource_linkcontent type is undocumented, and the files extension is experimental. - Mitigations: links repeated in text, both protocol eras served, jobs for long work, the drop page, and an adapter for
io.modelcontextprotocol/filesonce it stabilizes.
- Extraction quality. There is no OCR in core (D-15), so image receipts rely on page images plus attestation. The extractor version is journaled, and re-extraction appends events and never rewrites.
- Attestation
methodis self-reported. The bar is a policy on claims by an authenticated client. Verified and asserted fields are separated, and the deterministic cross-checks are the hard floor. - Performance. One mutex per book serializes writers. Mitigations: reader snapshots,
spawn_blocking, jobs, SSE backpressure, the blob LRU. server.rssize (8,529 lines). It is split per tool group as each phase touches it, to keep review cost bounded.- Tool surface churn (
preview_token, removed parameters). Theconfirmedalias and the removed-tool errors that name their replacement last one release, and the agent-eval harness catches guidance regressions. - Proof obligations (cash-flow footing, cumulative drift, reversal-draft transitions) may take time. A failing proof is a finding and blocks its phase by design.
Hosted-service risks (operator-served unwrap code, the legal exposure of hosting SSNs) are in section 11.
10. Decisions
10.1 Settled
The owner decisions of 2026-10-16 (at the top of this document) replace the former questions D-1 (local isolation default), D-6 (hosting timeline), D-8 (posting friction) and D-14 (viewer on the desktop). The engineer settled the following from the standards:
| # | Question | Decision |
|---|---|---|
| D-5 | Company EIN | (a) A journaled company.ein with sensitivity identity: printed on every 1099, masked in books.start, full in packages. |
| D-9 | Attestation bar default | (a) Receipts and month-end: reasoning effort ≥ high. Annual and CPA handoff: adversarial verification with ≥ 2 independent verifier agents. No model allow-list. The CPA package refuses without a counted full audit unless given a reason (policy.cpa_package_requires_audit = refuse). |
| D-10 | Odd-cent cumulative apportionment start | (a) From inception, so the next close carries any historical true-up of a cent or two; lifetime drift stays under one cent while members and shares are unchanged (a change of members or shares starts a new count, §706(d); P3 review R-8). |
| D-11 | Default proration for new prepaid schedules | (a) Daily, matching depreciation; existing schedules stay monthly_even. |
| D-12 | Legacy reversals already posted without their own evidence | (a) During the re-audit, link the original's receipts with inherit_evidence, so the audit is fully clean. |
| D-13 | The first company timezone | (a) company.timezone = America/Denver. |
| D-15 | OCR for image-only receipts | (a) None in core for now: page images plus agent attestation. |
| D-16 | CPA access to TINs | (a) To start, the owner prepares and sends a single-use human link each time. |
| D-18 | dev-stdio |
(a) Kept permanently for tests and demo. It refuses inside the data root and is never offered by connect. |
| D-19 | One app for Windows, Mac and Linux (owner, 2026-10-17) | Planned: backup and restore in P2, the app itself in P11. One installer and auto-updater per system. The server, the viewer and the CLI ship and update together; since D-22 they are one executable that runs each as its own process: the agent needs the server while the viewer is closed, and the viewer stays read-only as a separate process. No separate launcher app, which would be a third program and a second writer (the helper of 3.9 is a mode of the one executable, not an app). The viewer is the app's face and gains menu items: "Export for CPA" (read-only; the viewer does it) and "Back up now" (handed to the archive process, 3.9), "Restore…" and "New books…" (they change books, so the viewer hands them to the server), and the CPA package with TINs through the human path (P2 /h/, P7 vault). The books live in each system's usual app-data folder (the data root of 3.4): hidden, not locked; a little more protected on macOS. Backups must restore, into an existing install or a new one, proven before use and never destructive (3.7). |
| D-20 | Company records (owner, 2026-10-17: "It's just too damn convenient to keep it all together in one place.") | Every company document goes into the books, not only what the CPA needs: formation papers, registrations, plans, notices, drafts, history. Done for the first company 2026-10-17 with the existing categories; the filing scan passes the company's own EIN printed 2-7 (rules.md B-26). P6 adds what makes it pleasant (below). |
| D-21 | How the local server runs (owner, 2026-10-18: "Not sure I want a background service at all. Running in the foreground for now works for me.") | Started by hand in a terminal, not a background service: wanderbooks serve opens the books (the server and its archive process) and runs until stopped; agents connect over HTTP while it runs, several at once; Ctrl-C or closing the terminal closes the books cleanly. Nothing is installed to run in the background: no systemd unit file, no LaunchAgent, no socket activation, no login autostart. The viewer works with the server stopped (it reads the data root). Amended by D-23 (same day): the books also open when the app or an agent starts, and close when the last of them ends. |
| D-22 | One app or several (owner, 2026-10-18: "My objective is simplifying installation and updates.") | One app, one executable, separate processes. wanderbooks is the only program installed; each role is a mode of it, run as its own process: the viewer (what opening the app shows), the books server and its host, the agent bridge (wanderbooks mcp), the archive process and the extractor. One thing to sign, notarize, ship and update. Mixed versions are still possible across a swap, so the host starts its children from its own executable and every handshake carries the version (3.9). The viewer's read-only rule never rested on a separate file: ledger-gui already links ledger-store and stays read-only through Snapshot and tests/read_only.rs. As its own process it keeps that, and on Linux it also confines itself read-only (Landlock). Each mode confines itself at start. The crates stay as they are; the executable is a thin dispatcher (P2). One exception: a server-only build (the gui feature off) for self-host (P9), so a headless machine carries no window libraries. |
| D-23 | When the books are open (owner, 2026-10-18: "Also when an agent starts") | Amends D-21. The books are open while anything that opened them runs: the app, an agent session (its bridge opens them, windowless, for the session), a terminal running wanderbooks serve, or a CLI command. Each holds a lease on the host's lease-only socket, so a crashed holder lets go by itself; local agents speak to the server over a peer-checked local socket, never TCP (3.9). The first opener starts the one host; later ones attach; when the last lease ends the books close 10 s later, cleanly. "Close the books" ends every lease at once. Still no background service: nothing starts at login and nothing runs after the last holder. An agent session started with the books closed needs no reconnect (P2). |
| D-24 | Installers and updates (owner, 2026-10-18: "A dmg for macOS, msi or whatever for windows, and packaged for Linux distributions. Win and Mac with updaters.") | macOS: a signed, notarized .dmg; the app updates itself. Windows: a signed per-user installer with no admin prompt (.msi only if a managed install needs one); the app updates itself. Linux: AUR, .deb with our apt repository, .rpm with our dnf repository (all updated by the package manager), and a self-updating AppImage. Every self-update is verified against our own release keys and the system's code signing; Linux packages are verified and updated by their package managers. An update restarts the books cleanly. A newer books format is the server's gate whatever the install: read-only until the owner consents, after a backup with the keys (3.9). "No admin password" holds for the dmg, the Windows installer and the AppImage; Linux packages use sudo as usual. Details and tooling in P11. |
| D-25 | Packaging tool (owner, 2026-10-18: "Cargo packager looks good. Would like to use that.") | cargo-packager and its updater (crabnebula-dev, MIT/Apache-2.0) for the .app/.dmg, the per-user NSIS installer, .deb, AppImage and the AUR's PKGBUILD; cargo-generate-rpm for .rpm, which it lacks. Our release manifest (two keys, rotation, a version that only increases, an expiry) wraps its single-key minisign check (P11). |
| D-26 | Where releases are hosted (owner, 2026-10-19: "I'm assuming the GH releases page will do the hosting?") | GitHub Releases on the public mirror (swaits/wanderbooks): each release carries the .dmg, the setup .exe, the AppImage, the .deb and .rpm, and the signed release manifest; the self-updaters read the manifest at the fixed releases/latest/download/ address, so no server of ours is needed. CI builds and code-signs on release tags on GitHub's macOS, Windows and Linux runners and holds no release key (P11). The apt and dnf repositories' signed indexes are served from GitHub Pages on the same repository; the AUR package points at the Release assets. |
| D-27 | The agent dock and books homes (owner, 2026-10-19: bundle the agents "then it'd be a one stop shop … a chat window maybe full span across the bottom"; "Yeah I like the idea of the embedded alacritty launching claude/codex … maybe MVP defaults to claude"; "you'd want like a 'books home' dir somewhere that makes sense so it has a place to work"; "add it to the plan!") | The app docks a terminal (alacritty_terminal; ConPTY on Windows) along the bottom of its window and runs Claude Code (the default) or Codex in it, found or installed through the vendors' installers, never bundled; started by the viewer's helper, in the book's books home: a visible folder per book holding only the agent's pointer and configuration for that book, never the data root. Trust in that folder is the person's to give. Never required (owner, 2026-10-19: "we wouldn't want to prevent people from using their OWN AI agent. They should never HAVE to use our built in tty+claude session! Ideally they could even use the claude.ai browser version to connect to it. And of course, eventually we'd host the entire MCP (and gui!) remotely ... use any agent they prefer to remote connect into that.. and they'd view the GUI in a browser."): the books stay fully usable from any agent the person runs themselves, locally or (P9) remotely, claude.ai in a browser included; the dock's one extra is pasting the app's suggested instructions straight into its session. A hosted MCP and a browser viewer are the later service (section 11). P11 (the agent dock and books homes). |
| D-28 | Who does what, locally (owner, 2026-10-19: "Just have the agent ask for them and the user replies. The interface is through claude/codex and that's that. Don't fucking stray from it. TIN, W-9 is NOT critical or super secret PII." and "for downloading the CPA package or other assets, I don't mind if that can happen through the GUI app ... the GUI is for READING the data. The claude/codex is for MUTATAING (and reading) the data!!!") | Two interfaces, no third. Every change to the books goes through the agent (Claude Code or Codex) over the MCP; the agent asks the human for what it needs, TINs and W-9s included, and seals them in the vault (books.set_tin, books.set_w9): no human web pages, terminal commands or open ports for that locally. Reading and downloading (the CPA package and other exports, receipts, documents) happen in the viewer, which still never writes the books. This supersedes, for local books, the /h/ pages of 3.3, 4.7 and P7's TIN entry: I-7's "the agent never holds a full TIN" becomes "a TIN passes through the agent only on its way into the vault; no result shows one". /h/ pages remain for self-hosted books reached remotely (P9). |
| D-29 | The full-TIN outputs, from both doors (owner, 2026-10-19, asked whether the agent's CPA export should go away once the viewer has one: "Both WITH TINs. Stop acting like TINS MATTER SO MUCH THEY DONT") | The agent and the viewer both make them, TINs included. books.export_cpa_package (TINs by default), books.vendor_1099 with include_tins and books.get_w9 keep working for the agent; their files are made by the server and handed over by a command, wanderbooks download, like a receipt too large for a call (no path parameter, nothing in the agent's context). The viewer's menu makes the same files into Downloads. Over stdio (the development transport) the file comes back inline. TINs get no handling beyond the vault: no passphrase, no extra prompts, no special cleanup. |
| D-30 | Q-1 answered: where the backups go (owner, 2026-10-19, asked "Where should the books' automatic backups go?" with a private git repo, a folder or other disk, or both: chose "Both") | Two targets. The archive pushes to a private git repository the owner names and also copies to a folder the owner names (an external disk or a synced folder); both hold the archive (not encrypted since D-31). The repository's address and the folder are asked for when backups are first set up (slice 7, or at the cutover for the first company), and kept as the book's backup settings. |
| D-31 | Backups are not encrypted (owner, 2026-10-19, asked where to keep the backup's recovery key: "Why encrypt them? How about we let the backup system handle the security???? You're over complicating everything.") | A backup is the book as it is, in git. No age encryption, no archive key, no recovery key: the archive is a plain git repository of the book's journal, receipts, vault (as the vault keeps it) and keys, one commit per backup, pushed to the owner's private git repository and to a git repository in the owner's folder (D-30). The destinations are trusted to keep it private. This replaces 3.7's and 3.4's age encryption on push and 3.5's archive key. |
| D-32 | No self-confinement (owner, 2026-10-20, asked how much of the planned Landlock/seccomp sandboxing of the server and the viewer, with the viewer's helper process, to build now that the server runs git for its backups: chose "None") | No Landlock, no seccomp, no helper process for confinement. The viewer stays read-only by design and by test (it never writes the books; its menu hands work to the server, through its admin socket as the command line does); agents still reach the books only through the MCP. Isolation is reported everywhere as same_user (advisory). This replaces the self-confinement of P2's scope, 3.9's confined modes and their helper, and the archive-vs-Landlock decision row. |
| D-33 | Mutation testing waits for P12 (owner, 2026-10-20: "Let's just put a pause on all the mutation testing. Let's add a P12 that runs the whole "max test" workflow again. By then maybe I'll have my (much beefier) desktop PC setup and we can run all that shit over there. For now I want to focus on the building.") | Each phase from P2 slice 3 on is built, reviewed until clean and passes just check and just prove, without its mutation run. P12 runs the whole max-testing pass (mutation, fuzzing, full property runs, proofs) over everything since P1, on the desktop. Each phase records in docs/testing.md what P12 must cover. |
| D-34 | bookspec (owner, 2026-10-20: "Let's add a P13 which is a bookspec project. This is a FORMALLY written accounting spec … properly sourced with actual references, it's thorough, it might even have its OWN formal validation. It includes symbolic specs as well as concrete examples. It's useful for ANY accounting engine testing." Then: "bookspec is a SPEC first, and then it can also include formal methods/validation/proofs as well as a test harness that could be run against an accounting engine that conforms to the interface.") | P13 writes bookspec, its own public project: first the specification (every rule stated normatively, sourced, with a symbolic form and concrete examples), then its formal validation (Lean model, consistency, the laws as theorems, every example derived), then a conformance harness for any engine implementing its interface. wanderbooks is the first engine tested; docs/spec/rules.md and the Lean theorems seed it; P3's regression tests are its first examples. |
| D-35 | The cash flow statement (P3, defect 1; owner, 2026-10-20, asked three questions at once with the design's recommendation first, chose each recommendation) | Expenses a member pays personally are cash the company borrowed and spent (revised by D-42: first decided as noncash on a wrong reading of ASC 230-10-50-3). The statement lays out by kind: operating cash one row per kind ASC 230-10-45-25 requires, investing and financing as kinds too ("Contributions from members — " and the member's name), each transaction listed under its kind; every transaction stays in books.cash_flow and the exports. Equipment paid for later is investing where the books can tell: a bill payment carries its bill's split (the equipment part investing), a card purchase follows the card's classification, and a sale on an IOU is flagged with a fix. |
| D-36 | The QuickBooks file (IIF) in the CPA package (P3, defects 7 and 21; owner, 2026-10-20, after research: only QuickBooks Desktop reads IIF, QuickBooks Online imports CSV and Excel instead, and Desktop works in Windows code pages, its default IIF import turning anything past ASCII into "?") | Kept, and written as plain ASCII: a character it can't hold is transliterated ("René" → "Rene") and every changed name is listed in the export's warnings and the README; every other file in the package stays full Unicode. CR LF line endings, as Intuit's sample file. The 1099 worksheet stops at the package's as-of date like every other file in it. Closing entries are left out of the IIF: QuickBooks keeps no closing entries (it works out retained earnings itself; its "closing date" only locks a period), so imported closings would zero each closed period's profit; the package's K-1 worksheet and members' equity statement show the split by member. Whether the IIF holds every entry since the company began or only the fiscal year goes to the CPA before the 2027 package, with whether she uses QuickBooks Desktop at all. |
| D-37 | Schedules (P3, defect 9; owner, 2026-10-20) | New schedules spread by day (D-11 confirmed: a 12-month policy from March 15 posts 13 entries, a small first and last month; existing schedules keep equal months). An equal-months schedule must start on the 1st of a month, for prepaid expenses and deferred revenue alike (asked "what would a CPA/CFO say?": revenue is never recognized before it is earned, ASC 606-10-25-23, so equal-month revenue must start on the 1st; prepaid expenses could take a full first month as an immaterial convention, but one rule for both is simpler and never early). |
| D-38 | Odd cents everywhere alike (P3, defect 17; owner, 2026-10-20) | The members' equity statement, the K-1 worksheet and the viewer split not-yet-closed earnings the same running way as the closes (cumulative, largest remainder), so a member's capital on a given day never moves by a cent when its month is later closed. |
| D-39 | Pushing the format upgrade (owner, 2026-10-20: "at first open, but not limited to 'once', I think we should push users harder into the format upgrade") | After an update that needs a newer books format, the app asks for the go-ahead (after a verified backup) at every open until it is given, the viewer shows a standing notice, and books.start puts the upgrade first among its next steps. Declining never stops the books opening on the old format; nothing is upgraded without the owner's yes. |
| D-40 | 1099s (P3, defects 23, 12 and 18; owner, 2026-10-20) | A bank payment with no method recorded counts toward 1099s (presumed check, ACH or transfer; debit-card payments are tagged as card). Anthropic, PBC is recorded "not reportable: a corporation (Delaware public benefit corporation, from the public record)"; Namecheap, Apple and OpenRouter "a corporation by name" (Treas. Reg. §1.6041-3(p)(1)), USPS "the United States" (§1.6041-3(p)(3)); Google LLC stays undecided until a W-9 shows what it is. Needs attention asks only about vendors paid in a way that could need a 1099. A vendor's 1099 status is dated (owner: "I think legally this has to be 2 dated statuses, right? If someone incorporates on Dec 30 …"): each status carries the date it takes effect (the W-9's date, or a tax election's effective date), and the worksheet applies the status in force on each payment's date. A payee that incorporates is a new legal person (a new vendor record with its own W-9); an LLC electing corporate taxation keeps its EIN and changes status on the election's date: dated statuses cover both. A status changed for a closed year warns. |
| D-41 | The contributed devices' tax basis (P3, defect 13; owner, 2026-10-20) | Depreciable basis is the lower of fair market value and the member's adjusted basis at conversion (Treas. Reg. §1.168(i)-4(b); Pub. 946, property changed from personal use): for the four contributed devices, the CPA to confirm at year end. §704(c) method: traditional, recorded now and confirmed with the CPA before the first Form 1065 (for members filing jointly every method gives the household the same tax). The four devices' values on 2026-10-15 are the books' amounts, citing the entries that recorded them (figures in the company's private change log). New P3 item: the K-1 worksheet reports tax-basis capital (Form 1065 instructions, Item L, since 2020), the contributed property at its §723 carryover basis, beside the book figures. |
| D-42 | A member paying for the company, on the cash flow statement (P3, defect 1; owner, 2026-10-20, revising D-35's first point after the review found ASC 230 silent on a payment a third party makes on the entity's behalf) | Constructive receipt and disbursement: when a member (or a lender) pays a company expense or purchase directly, the statement shows a financing inflow (borrowed from the member) and the operating or investing outflow, on that date; paying the member back is a financing outflow. Source: ASC 230 does not address it; practice fills the gap by substance (Deloitte DART Roadmap 7.2 with AICPA TQA 1300.16 and the SEC staff's view; PwC, BDO and EY guides alike), the way a CPA would present it; KPMG accepts noncash only for equipment a lender pays for. Property a member contributes that it already owned stays noncash (ASC 230-10-50-4: no cash moved). Total cash is the same either way; the first company's figures are in its private P3 change log. |
10.2 Deferred
- D-7. OAuth for remote access. This now applies only to the self-host remote path (P9). The options: (a) build a minimal authorization server into wanderbooks (passkeys, CIMD/DCR), self-contained and portable for self-hosters, which is recommended; or (b) delegate to an external IdP (Authentik, Ory, Keycloak) and only validate tokens. Settled when P9 starts.
- D-17. Remote file intake beyond the drop page (an email-in address) is deferred with the hosted service (section 11). P9 ships the MCP App upload widget only.
10.3 To ask the owner at the P2 cutover
These are the only open owner questions. Ask them just before the move, one at a time, in plain English.
Q-1. Where should the automatic backups go? The books back themselves up after every change. Where should that copy live: in a private git repository you name (for example on your own git server), or on another disk? Either way, the backup includes the whole history and the sealed tax ID numbers, and it is locked with a key only you hold.
Q-2. Should the papers in the company/ folder be filed into the books? These are the formation papers, notices and company history. Once they are in the books, every agent and the viewer can find them, and if you later run the books on your own server, claude.ai and your CPA can reach them too. If they stay as files, they live only on this computer. Recommended: file them.
Answered 2026-10-17 (D-20, the owner): "I would like to have all the non books docs be able to be in there too." Every company/ record was filed at the P1 cutover (attachments 73-82, linked to the company); only the local copies remain, until P6 moves the Drive manifest in. Don't ask Q-2 again.
Q-3. After the move, should the old copies of the books leave the project folder? The project folder still holds the old journal and receipts (books-data/), the old sealed tax ID numbers (vault/), and the folder's git history, which contains the journal and receipts too. Recommended: move them into the private folder's legacy area. That is what "hidden away" means; otherwise an agent working in the project folder can still read the old copies. Nothing is deleted.
Answered 2026-10-20 (the owner), one question at a time: Q-3: move them aside (into the root's legacy/, nothing deleted). Q-1's addresses: the git remote is a new private repository on his server, ssh://git@git.swaits.com:2222/swaits/fernhill-books-backup.git (made private, with his go-ahead), and the folder is ~/Sync/wanderbooks-backups (his Syncthing folder: a local disk, copied to his other devices; only this machine writes it).
Crumbs to clear at the cutover (owner, 2026-10-19: "migrate the first company books into the new scheme, data directory, clear out the crumbs"). After the adoption verifies, nothing of the old arrangement may be left where an agent or a later setup trips on it; every item is moved per Q-3 (into the root's legacy/, nothing deleted, by wanderbooks books put-aside ID DATA-DIR [PATH...], added at the cutover: only once the adopted books hold everything the old copy holds, only while nothing holds it open, only on the root's own disk, and a move cut short puts back what it moved) or removed if it holds no books data:
books-data/(journal, blobs, database, lock,gui.log) andvault/, and the project folder's git history holding them (Q-3);- the v1 vault key outside the books,
~/.config/wanderbooks/vault.key(adoption copies it to the book'skeys/legacy.key; the old one goes tolegacy/with the vault); wanderbooks.toml(its settings are in the books since P1; only[viewer] open_at_startremains, which the app's own preferences replace);- the scaffold:
justfile,scripts/,.claude/settings.json's SessionStart hook,.codex/hooks.json;AGENTS.md/CLAUDE.mdbecome the 3-line pointer; - the agents' old registrations: Claude Code's stdio
wanderbooksserver fromjust mcp-add(replaced bywanderbooks connect), and Codex's; - old binaries:
ledger-mcp/ledger-guifromjust buildorcargo install(the onewanderbooksexecutable replaces them); - the
livejj workspace (~/.cache/wanderbooks/live-ws) thatjust buildbuilds the releasedmainin: until the cutover these books run only released code, since the work on top ofmaindrops what their stdio server needs (slice 5: files over 256 KiB only by upload, which needs the data root); - the viewer's old preferences file, eframe's
app.ronin$XDG_DATA_HOME/wanderbooks(the data root's default place): the viewer moves it on its first run (ledger-gui prefs.rs), andsetupmust not refuse the default place for holding only that file (slice 8 handles it: moved to the viewer's own folder first); - the agent's own notes that name the old paths (its memory), rewritten to the new arrangement.
The cutover's check adds: the project folder holds none of the above, and
wanderbooks doctoris clean.
For the engineer: Q-1 was D-3, Q-2 was D-4 and Q-3 was D-2. Q-1's technical side is settled: archive.git is seeded from the existing git history, filtered to the journal and blob paths, includes the vault ciphertext, and is age-encrypted as a whole on push (3.4, 5.5). (Since D-31 not encrypted, and slice 7 begins each book's archive at its first backup: whether to seed it from the old history is the cutover's question.)
11. Later: hosted service (not built in this plan)
A paid, multi-tenant service in which an operator runs the books for customers. P0–P12 build none of this. It is recorded here so the design stays compatible with it.
Shape.
- Per-tenant pair.
front(ledger-mcp front --tenant <t>, user IDwb-f-<t>) does OAuth 2.1 (3.3), passkeys, consent, the human pages and token validation; its Landlock deniesbooks/, so it never reads tenant disk.worker(ledger-mcp worker --tenant <t> --socket, user IDwb-t-<t>,DynamicUser=) serves the MCP, uploads, downloads and the replica for its own tenant only, and learns identity only fromX-WB-Principalon its 0660 socket. Caddy handles TLS and routing and holds no authority. No component holds authority across tenants. Both roles ship in one binary. - Workers run from the systemd template
wanderbooks-worker@.servicewith seccomp@system-serviceand Landlock; a container image is the alternative. The archive unit runs per tenant. - Data root
/srv/wanderbooks/t/<tenant>, with the 3.4 layout, on a LUKS volume per tenant. Books are athttps://host/t/<tenant>/b/<book>/mcp. Each tenant's front is its own issuer. - Tenant provisioning, quotas and invitations.
Vault model B (required before any paid launch). Unwrap happens on the client, in the signed native viewer or in reproducible WASM with a published SRI manifest. Keyslot material is released only inside an unlock exchange authenticated by a passkey assertion. The web viewer is egui→WASM on ledger-core + ledger-view; it needs a pure-Rust evidence projection (no SQLite), reproducible builds and a published SRI manifest. The signed native viewer is the reference implementation. Hosting is not marketed as "operator cannot read TINs" until model B ships, because model B depends on operator-served code.
Tamper evidence. Opt-in head delivery by email or webhook, and an optional transparency witness.
File intake. A per-book email-in inbox (D-17).
Threats specific to hosting.
| Adversary | Goal | Controls |
|---|---|---|
| Other tenants | cross-tenant access | per-tenant user IDs and processes; Landlock; per-tenant issuer; token audience is the book URI; capability handles keyed <sub>:<book>:<256-bit> |
| Hosting operator (assumed hostile) | read TINs; rewrite or fork history | TINs: HPKE to a journaled public key; customer keyslots; model B before any paid launch. History: head in every result, quiet-period handoffs, emailed heads (opt-in), verify-archive --expect-head, optional witness. Ledger confidentiality is not provided (the server is the engine); the terms say so; self-hosting is the zero-trust option |
Customer flow.
- Sign up with a passkey and create books (tenant provisioned: front, worker and archive units, plus a volume).
- Vault ceremony: passphrase, recovery key shown once and confirmed saved, optional passkey PRF.
- "Connect" cards for claude.ai, Claude Code, Codex and ChatGPT. claude.ai: Settings → Connectors → Add custom connector →
https://books.example.com/t/acme/b/main/mcp; OAuth: passkey, then consent with scope checkboxes. - Optional: archive to the customer's own git or S3; opt-in head emails.
- Viewer:
wanderbooks gui https://books.example.com/t/acme/b/main(OAuthbooks:view, in memory), or the web viewer. - Verify at any time:
wanderbooks fetch … && wanderbooks verify-archive <dir> --expect-head <hash>. - Leave: download the archive bundle,
books adoptit on a self-host, and get the same head.
Before launch.
- Tests:
tenant_isolation;front_cannot_open_books(Landlock);client_side_unwrap_server_never_sees_plaintext(instrumented);wasm_viewer_equals_native_view;witness_detects_fork; cross-tenant → 401/403. - An external security review of the authorization server.
- Load: 1,000 idle and 50 active tenants per node, p95 non-report latency < 300 ms.
- The directory review checklist passes.
- Compliance: state breach statutes and the FTC Safeguards Rule apply to stored SSNs, and data residency matters, so counsel is needed before launch. Retention is 7 years. Account deletion is crypto-shred plus purge.