P2 slice 7: backups and restore (D-30, D-31)
The server backs each book up itself: a per-book git archive, pushed fast-forward only to a git remote and to a folder, when the books open, after a quiet spell, before they close and on request; every commit, push and drill is a record in ops/backup.jsonl. Drills clone each target and verify the chain, the blobs, the vault and the head, weekly and after a failure. `wanderbooks books restore` previews, then restores beside or (with --replace) over, never moving books something holds. The archive refuses anything but a plain bare repository and pins git against the owner's configuration. books.backup and books.back_up_now for the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
5bc0633cdd
commit
019d8d76a3
29 files changed
+6443
-136
No files matched your search
+30
-21
@@ -66,8 +66,8 @@ Paths are relative to `/home/swaits/Code/wanderbooks/crates/` unless they are ab
|
||||
| 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. 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. | Test `backup_does_not_advance_head`. Test `clone_verify_expect_head`. |
|
||||
| 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. |
|
||||
@@ -92,7 +92,7 @@ Paths are relative to `/home/swaits/Code/wanderbooks/crates/` unless they are ab
|
||||
| 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. 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. |
|
||||
| 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)"`. |
|
||||
@@ -135,7 +135,7 @@ The data root is `$XDG_DATA_HOME/wanderbooks` on Linux (default `~/.local/share/
|
||||
|---|---|---|---|
|
||||
| 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>` | the server's account, a separate process started beside the server | read-only journal and blobs |
|
||||
| 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). |
|
||||
@@ -195,7 +195,7 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has
|
||||
- **Tokens** are `wb_` and 64 hex digits (32 random bytes), one book and one name each (`agent` unless named), kept only as a SHA-256 in `auth/principals.jsonl`: a chain of `token.created` (with the token it replaces) and `token.revoked` records, each with its own hash besides the chain's (so a change to the last record is found too), replayed at start; one that can't be read refuses every agent with why (the list of books still answers). Checked in constant time against every live token. Made, listed and revoked only through the admin socket: `wanderbooks token create --book ID [--name N] [--scopes read,write,post,admin] [--replace] | list | revoke | print`. A token made is kept in the keyring before anything is said of it, or revoked again; `print` is the only way its text is shown. `connect` (slice 8) will mint through the same path.
|
||||
- **Kept in the OS keyring** (Secret Service on Linux, through keyring-core; macOS and Windows stores come with P11), under service `wanderbooks` and an account naming the root by a hash of its path, the book and the token's name. The bridge (`wanderbooks mcp --book ID [--token NAME]`) reads it before each new session 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. Tests keep tokens in files under the root's `run/` (the `file_keyring` knob).
|
||||
- **The door** (`/b/{id}/mcp`): no token or an unknown one, 401 with `WWW-Authenticate: Bearer` (the scheme in any case); another book's, 403; a session is bound to its token when opened and used only with it (403), forgotten when it ends, is found gone (an idle one, when next used), or its token is revoked or replaced; an id the server never opened binds nothing; past 100,000 bindings an initialize is refused, never a binding dropped. Each refusal at the door is recorded in that book's access log (action `connect`).
|
||||
- **Scopes** (`ledger-mcp/src/scopes.rs`): a table of every tool. Read-only tools need books:read; a tool with `confirmed` needs books:write to preview and books:post to act; tools that post at once, settings commits, voids, renumbering, corrections and role changes need books:post, and so do the controls on posting (setting an approval rule, retiring a convention, approving an entry) and sealing a TIN (`set_tin`, `set_w9`, which can replace one); `books.access_log` and the tools that can still put a full TIN out (`vendor_1099`, `export_cpa_package`, `get_w9`) need books:admin. 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.
|
||||
- **Scopes** (`ledger-mcp/src/scopes.rs`): a table of every tool. Read-only tools need books:read; a tool with `confirmed` needs books:write to preview and books:post to act; tools that post at once, settings commits, voids, renumbering, corrections and role changes need books:post, and so do the controls on posting (setting an approval rule, retiring a convention, approving an entry) and sealing a TIN (`set_tin`, `set_w9`, which can replace one); `books.access_log` and the tools that can still put a full TIN out (`vendor_1099`, `export_cpa_package`, `get_w9`) need books:admin, and so do backing up now and choosing where backups go (a `backup.*` setting, by `set_setting` or an imported `[backup]` table; slice 7): a backup is the whole book. A token is offered only the tools it can call (the list is then cached per caller, `cacheScope: private`); a call beyond its scopes is refused, unrun, naming the scope. Reading a resource needs books:read. A local token gets all four unless told otherwise.
|
||||
- **The access log** (`books/<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`, or `refused` with a code), and the journal's head after the call's own last write, if it wrote, whatever it answered (taken under the store's lock as the call lets go). A call's record is written on its own blocking thread while it still counts as running (one that panics part way included, with what it wrote), so a close waits for it too, and before the answer goes; one that can't be written stops the book (no more requests until a restart) and the answer says the call ran (a call refused as not run, by a close or a stopped server, keeps its "not run" code). A tool no table names is refused unrun. Known gap: a crash of the process between a call's journal write and its record leaves the journal ahead of the log for that call. A damaged log, or one holding a record that isn't an access, keeps its book closed, with why. `books.access_log` (admin) reads it newest first, filtered by token, action or outcome, paged with `before`; 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). `setup` at a terminal asks for it (twice); elsewhere it says to run `wanderbooks passphrase`, which works only at a terminal, unechoed, and asks for the one in force before a change. (Advisory, like everything local: a process running as the human can drive a pseudo-terminal and set the first one.) A stored hash of another Argon2 version, with a short salt or output, or asking a check for more than 1 GiB, 16 passes or 8 lanes is refused as changed by hand. The command line wipes token texts and passphrases from memory once used (the server and the bridge, which hold tokens for their lifetime, don't). `doctor` says whether it is set and lists the tokens. Its uses (`/h/` pages, consents) come with the slices that build them.
|
||||
- **Known, product-wide (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.
|
||||
@@ -221,9 +221,9 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has
|
||||
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 staged backup: kept until its restore ends, at most 24 h)
|
||||
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 ciphertext; age-encrypted on push; destination Q-1
|
||||
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.
|
||||
@@ -236,7 +236,7 @@ The server, the CLI and the GUI find the data root with one shared function (`le
|
||||
| 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) | archive pusher |
|
||||
| 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
|
||||
@@ -279,7 +279,7 @@ The archive process (`ledger-mcp archive`: locally started by the host after the
|
||||
It writes `ops/backup.jsonl`. `books.backup {status|run}` reports it, and Needs attention flags a target more than 24 h behind. Head handoff:
|
||||
- every mutating result carries the head;
|
||||
- `ops/heads.jsonl` records each 10-minute quiet period;
|
||||
- the customer clones the archive and runs `verify-archive --expect-head`.
|
||||
- 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).
|
||||
|
||||
@@ -289,12 +289,20 @@ Email or webhook head delivery and a witness log belong to the hosted service (s
|
||||
3. **The vault comes back only with its key.** The backup carries the vault sealed; the passphrase or the printed recovery key (P7) unlocks it on any machine. Until P7 the v1 key is a separate file: a backup counts as complete only when that key is saved too, and Needs attention says so when it isn't.
|
||||
4. **Restore drills.** On a schedule (default weekly, or at the next start if the books were closed when it fell due) and after each close, the archive process restores the latest backup into a scratch folder, verifies it as in step 1, compares its reports with the live book's as of the backup head, and throws it away. A failed drill is a Needs-attention item; `books.backup status` reports the last drill.
|
||||
|
||||
**As built (P2 slice 7), and where it departs (D-30, D-31):**
|
||||
- **No archive process, no encryption.** D-31 made a backup the book as it is, in git, so there is nothing to encrypt and nothing to segment: the server itself backs each book up (`ledger_store::backup`, `ledger-mcp backup.rs`). Each book keeps a bare `archive.git` beside its journal; a backup is one commit of its journal, `blobs/`, `vault/`, `keys/` and `access.jsonl` exactly as they are, made under the store's lock (so whole lines and a vault that matches), its message `seq N head H` with `book <id>` below, then pushed outside the lock, fast-forward only (a target that moved on is refused and said, never forced), to `backup.git_remote` and to `backup.folder/<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 (never `ext::` or `fd::`, whatever the owner's own configuration says), and a limit of five minutes without a word (pushes and clones report progress; a git stopped is stopped with all it started); the owner's own `~/.gitconfig` still applies otherwise, for credential helpers. The stored files are staged outside the store's lock (they never change; a failure there is only a lost head start) and the commit, under it, is checked to hold every file there is, by name; bytes are kept as they are because no attributes rule of the owner's, the system's or the archive's is read, and a `.gitattributes` in a book's folder refuses the backup. A repository on this computer (a folder target, or an address that is a path) is pushed to or read only when plain: a bare repository at that very path (never one git would find beside or inside it), nothing in it sending git to another repository (a `.git`, `commondir` or `gitdir` file, objects borrowed from elsewhere, a link where it or its own parts are, a `<path>.git` or `<path>.bundle` beside it), its configuration only what `git init --bare` writes (and, for a copy made with `git clone --bare`, where it came from), since a setting of its own could run a command on a push; repositories are made with the files ref format and SHA-1 whatever the owner's own defaults; one is read as a far one is (`--no-local`, so its copying reports progress). A folder target's repository is made only while the target never took a backup: one gone since is a disk not connected (a mount point with nothing on it), said, never made again where the disk should be. A lock a stopped git left in the archive is cleared once older than any git may run quiet. The host now passes the owner's `SSH_AUTH_SOCK` (only a socket the account owns, not a link to one) so a private repository over ssh is reachable. `backup.folder` must be absolute and `backup.git_remote` can't read as an option; setting either (or importing a `[backup]` table) needs books:admin, as backing up does (a backup is the whole book, vault and key included).
|
||||
- **When.** When the books open; a while after they were last written (`--backup-quiet`, 30 s, the `backup_quiet` knob for tests); before they close (at most 60 s); and on `books.back_up_now`. A failed commit holds every run off for 15 minutes; a target whose push failed is left out of the backups after writes for 15 minutes and then tried again, written to or not, while the others carry on. `period.closed` needs no trigger of its own: a close is a write. No head handoff files (`ops/heads.jsonl`) yet: the backup's commits are the head record.
|
||||
- **The record and the tools.** Every commit, push and drill is a record of `ops/backup.jsonl` (a chain log; a backup never moves the journal's head), each target's keyed by a digest of where it is (a new folder or address starts with no standing). Stale means behind, with the first try to back up what it lacks more than a day old (not a day since the last good push: quiet books aren't stale the moment they're written); a failed commit is every target's error until a push succeeds. The chain isn't verified at each run: the server verifies it when it opens the books, and drills prove what reached each target. Two tools, not `{status|run}`: `books.backup` (books:read) for the standing of each target (last good backup, through which seq, stale after a day behind, the last error, the last drill and why it failed) and `books.back_up_now` (books:admin, the one open-world tool). `books.start` adds a step when none is set up, or one is stale, failing or failed its drill. 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`, the `drill_first` knob, for tests): clone, check the tree is plain files under the backed-up paths before any is written out (a link or submodule could otherwise point the checkout, or the store opened on it, at the live books), replay the chain, re-hash every stored file, check every TIN record the books name is there with its key, check the head against the commit's, and check the journal is a byte prefix of the live one (the reports then follow from the same journal, so no report comparison). Not after each close: weekly is the floor and a close changes nothing a drill would see.
|
||||
- **Restore** is `wanderbooks books restore BACKUP [--id ID] [--replace] [--yes]` (without `--id`, the id of the books the backup holds, as its preview names them, else the repository's name), the admin socket's `restore`, previewed then confirmed against the previewed head. The server clones the backup into `books/.restore-*` (the address or folder crosses the owner-only admin socket as an adoption's path does; no staging through `uploads/`), proves it as a drill does (but for the live-journal check), one restore at a time, builds the book as an adoption builds (every stored file re-hashed, opened, verified, synced; the vault and its key come with it, D-31), gives it the backup's history as its own `archive.git` (so its next backups fast-forward), and renames it into place. Over existing books it is refused unless `--replace`: then the server lets go of the open books (their writer lock released; a session still holding them is refused, its client's next session sees the restored books), waits for their last backup run and starts no other until the restore is done (one in flight could otherwise push past the backup being restored), and moves them whole into `legacy/<id>-<time>/`; a restore that fails puts them back and opens them again. `books.purge_tin` now 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 leaves `ops/restored-from` in 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 by `books.backup`, until another is named. A damaged `ops/backup.jsonl` is 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's `restore` intent 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 restore` from 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 and its archive process, 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` |
|
||||
| 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 |
|
||||
@@ -313,7 +321,7 @@ The books are open while something holds them; there is no background service. T
|
||||
**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 and `wanderbooks doctor` list 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), and `consent_format_upgrade` (a book). `consent_format_upgrade`, and a restore that replaces a book, carry the owner passphrase typed in the viewer, checked by the server (Argon2id, as on `/h/`); no MCP tool can give either, so an agent never consents. Nothing else is accepted.
|
||||
- `run/lease.sock`: served by the **host**, leases only. Each lease records its kind (app, bridge, serve, cli) and PID; the app and `wanderbooks doctor` list 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), and `consent_format_upgrade` (a book). `consent_format_upgrade` carries 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`, or `wanderbooks serve --http`) for a client that needs HTTP; it then binds 127.0.0.1:7411, a failed bind is reported by `doctor` and 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.
|
||||
|
||||
@@ -321,11 +329,11 @@ The books are open while something holds them; there is no background service. T
|
||||
|
||||
**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; 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).
|
||||
**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, and only then the archive process, so two archive 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.
|
||||
**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, every writer lock is released, the archive process finishes its push, 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.
|
||||
**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.
|
||||
|
||||
@@ -479,7 +487,7 @@ Report results also carry `as_of_status {drafts_open_on_or_before, unreconciled,
|
||||
| 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 {status\|run}`, `books.access_log` (admin), `books.invite {kind: cpa\|member\|viewer, email}` (admin; remote people need the self-host server, P9) |
|
||||
| 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?}` |
|
||||
@@ -828,8 +836,8 @@ The first company keeps working between phases.
|
||||
- **Every server-path parameter deleted:** `import_local_file`, `save_to`, `save_csv_to`, `w9_file_path`, get_w9 `save_to`. W-9 upload moves to `/h/w9/{party}` (sealed with the v1 key until P7). TIN-bearing outputs come only through `request_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 `--root` from `wanderbooks gui <book>`, never a project directory or `wanderbooks.toml`; `books.show` through `ops/show.jsonl` (delete `ledger-store/src/viewer.rs`); presence through the `ops/viewer.lock` shared 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`.
|
||||
- **Restore (3.7, D-19):** `books restore`, proven before use, never destructive, onto a new or an existing install; restore drills; the vault key's backup reported until P7.
|
||||
- Archive process and module; `books.backup`; `ops/heads.jsonl`; `verify-archive`; `fetch`. (As built, slice 7, D-31: the server's backup module, `books.backup` and `books.back_up_now`; no archive process, `ops/heads.jsonl`, `verify-archive` or `fetch`: 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 over `run/mcp.sock` to 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, sending `notifications/tools/list_changed` only 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.
|
||||
@@ -843,7 +851,7 @@ The first company keeps working between phases.
|
||||
**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 -9` included: closed 10 s after the last, not before); `bridge_opens_closed_books` and `bridge_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_HOME` and `LD_PRELOAD` included, 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 makes `doctor` report a failed bind and sees no bridge traffic); `lease_socket_accepts_only_owner_intents` (exactly the five; consent and replace without the passphrase refused); `restart_is_not_close` (bridges carry on across a restart; openers wait on `update.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 after `wanderbooks 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` (a `restore` intent naming a path is refused); `bridge_initializes_during_cold_start`; `archive_catches_up_at_start` (a push that failed before a close is made at the next open).
|
||||
- `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 -9` included: closed 10 s after the last, not before); `bridge_opens_closed_books` and `bridge_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_HOME` and `LD_PRELOAD` included, 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 makes `doctor` report 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 on `update.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 after `wanderbooks 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: the `restore` intent 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, extending `tests/view_model.rs` from 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).
|
||||
@@ -1187,7 +1195,7 @@ Run from clients that hold only a URL and a credential: Claude Code started in `
|
||||
7. **The books are open only while something holds them** (D-21, D-23: the app, an agent session, a `serve` terminal; 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 archive process pushes anything left over, and a drill that fell due, at the next open.
|
||||
- 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.
|
||||
8. **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.
|
||||
@@ -1238,8 +1246,9 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form
|
||||
| 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 only the encrypted archive. 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-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 the lease socket); 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. |
|
||||
|
||||
### 10.2 Deferred
|
||||
|
||||
@@ -1270,7 +1279,7 @@ These are the only open owner questions. Ask them just before the move, one at a
|
||||
- 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 doctor` is 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).
|
||||
*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.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user