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:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent 5bc0633cdd
commit 019d8d76a3
29 files changed
+6443 -136

No files matched your search

+75 -1
View File
@@ -352,7 +352,8 @@ seals a new vault record, every time, so none carries it;
`idempotent_tools_change_nothing_when_repeated` calls each candidate twice
to check). Every tool's
`openWorldHint` is false: the books are a closed world, and no tool
reaches beyond them. `books.export_cpa_package`, `books.vendor_1099`
reaches beyond them, but `books.back_up_now`, which pushes the books to
the owner's backups (true). `books.export_cpa_package`, `books.vendor_1099`
and `books.get_w9` keep a file for the agent (an export) and record the
ones with TINs, so they are not read-only. No tool carries
`requiresUserInteraction` (owner decision
@@ -584,6 +585,79 @@ through the policy.
every stored file. The server runs the same check, and heals the same
way, every time it starts.
## Tools — backups
Books served from the data root back themselves up (plan 3.7, D-30,
D-31): each keeps `archive.git`, a bare repository whose commits are its
journal, stored files, vault, keys and access log as they were (one
commit per backup, made under the store's lock, its message
`seq N head H` and, below, `book <id>`), pushed fast-forward only to the targets the settings
name: `backup.git_remote` (the owner's private repository) and
`backup.folder` (a repository `<book>.git` in a folder on another disk).
Not encrypted: the destinations keep it private. Backups run a little
after the books are last written (`--backup-quiet`, 30 s), when they
open, before they close, and on request; a failed one is retried after
15 minutes. Each target is drilled weekly: cloned, its chain replayed,
every stored file re-hashed, every TIN record the books name there with
its key, its head the one its commit records, its journal a byte prefix
of the live one (a failed drill is tried again at the server's next
hourly look, once 15 minutes have passed). A backup's tree is checked
before any of it is written out: plain files under the backed-up paths
only, never a link or a submodule. git runs with no hooks (in a
repository on this computer it pushes to as well), no ignore or
attributes rules of anyone's (a `.gitattributes` in a book's folder
refuses the backup) and no command-running transports; a repository on
this computer is used only if plain (a bare repository at that very
path, no settings of its own but where a copy came from, nothing sending
git elsewhere, no link where it or its parts are), and a backup folder that took backups and is gone is
reported as a disk not connected, never made again (restored books count
the backups of the books they were). Books restored beside the books they
were (under another id) don't push to those books' repository, which
would stop theirs: `books.backup` says it is paused for them until
another is named. A damaged backup record is set aside and begun again;
it never keeps the books shut. One book's drill holds the others'
backups until it ends (a backup at close waits at most a minute for
it). A target
whose push failed is left out of the backups after writes for 15
minutes, then tried again, written to or not; the other targets don't
wait for it. Every
commit, push and drill is a record in the book's `ops/backup.jsonl` (a
chain log; a backup never moves the books' head), each target's records
keyed by the target itself. Setting `backup.*` (books.set_setting, or
books.import_settings with a `[backup]` table) needs books:admin, as
backing up does: a backup is the whole book, its vault and key included.
Over stdio neither tool is offered.
- `books.backup {}` (books:read) → `{ head_seq, targets: [{ target:
"repository" | "folder", at, last_ok_at, through_seq, stale,
last_error, last_drill_at, drill_error, paused }], note }`. `paused`:
not backed up there, these books being restored as a copy of others
(its `last_error` says so). `stale`: the target
lacks the head, and the first try to back up what it lacks (since its
last good backup, or ever) is more than a day old. `last_error`: its
last push's, or a later failed commit's. `note`
says what to set when no target is. `books.start` adds a next step when
none is set up, or one is stale, failing, paused or failed its drill.
- `books.back_up_now { new_disk? }` (books:admin) → the same, after
backing up to every target now. `new_disk`: the backup folder's disk is
new, so a folder that took these books' backups and holds none now is
begun again (otherwise it is a disk not connected). The one tool whose
`openWorldHint` is true. A target paused for books restored as a copy
of others shows `paused` (and is no failure to retry).
Restoring is the owner's, from a terminal: `wanderbooks books restore
BACKUP [--id ID] [--replace] [--yes]` (the admin socket's `restore`,
previewed then confirmed against the previewed head; without `--id`, the
books keep the id of the books the backup holds, which the preview names,
else the repository's name; under another id they are a copy, and don't
push to those books' repository). The backup is
cloned into the root, proven whole as a drill proves it (but for the
live-journal check: there may be no live books), and built as an
adoption builds; it arrives as new books, or replaces books (`--replace`,
which are let go and moved whole into `legacy/<id>-<time>/`). The
restored books keep the backup's history, so their next backups carry
on from it.
## Tools — payouts
- `books.record_payout { date, description, deposit_account, deposit,
+2 -1
View File
@@ -273,7 +273,8 @@ damn CPA package."
takes a path. The MCP server
never returns a TIN in any other tool, log, or error. Tests check that
no other output contains a nine-digit run.
- **Books without a vault** (another machine, a restored backup) still
- **Books without a vault** (another machine, books adopted without
theirs; a restored backup brings its vault and key, D-31) still
open and verify; the 1099 worksheet and the CPA package then say which
TINs are missing (`tin_gaps`), and the package is still built.
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
+10 -6
View File
@@ -27,10 +27,13 @@ easily backupable, journable":
bytes to `journal.jsonl.torn.<time>`, truncates to the last newline, and
journals a `journal.tail_repaired` line; `books.digest` reports it.
Damage before the last newline is corruption and refuses to open: that
is for a human, never repaired automatically. Better: the journal is
git-friendly, so an optional `wanderbooks backup` that commits it to a
private git repo gives versioned, distributed, deduplicated backup with
full history.
is for a human, never repaired automatically. The server does better
(plan 3.7): it commits the journal, the blobs, the vault, its key and
the access log to each book's own git repository (the blobs staged
ahead, the commit under the store's lock) and pushes it to the
owner's private repository and backup folder, versioned and
deduplicated, with full history (docs/mcp-interface.md, "Tools —
backups").
- **Journable.** It literally is a journal: `{seq, ts, type, payload, prev}`.
Greppable, diffable, replayable. The audit trail isn't a feature bolted
on — it's the storage format.
@@ -76,8 +79,9 @@ is correct: the journal records what happened, including abandoned work.
`wanderbooks verify` (a `ledger-store` API, later a CLI subcommand):
re-read the journal, recompute the hash chain, replay every line into a fresh
in-memory `Ledger`, and compare its reports against the SQLite projection's.
Any mismatch is a loud failure. Run it on startup (cheap at this scale) and
before backups.
Any mismatch is a loud failure. Run it on startup (cheap at this scale);
a backup is then proven where it lands, by a drill (docs/mcp-interface.md,
"Tools — backups").
## Blob store
+2 -2
View File
@@ -72,8 +72,8 @@ that would fail if it broke:
| Text filed into the books is refused when it holds a TIN the vault keeps (nine digits, 3-2-4 by hyphen, space or dot, 2-7 by hyphen or space, anywhere) or a stand-alone printed SSN or EIN (123-45-6789, 12-3456789), and only then: a bank export (routing numbers, long references, a date next to an amount) and a longer number holding an SSN shape go in; a locked vault leaves the printed shapes (rules.md B-26) | `tin_files.rs` (tests) `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`; unit tests `text_holding_a_vault_tin_is_refused_in_every_rendering`, `a_printed_ssn_or_ein_is_refused`, `a_longer_number_holding_a_tin_shape_passes`, `an_ordinary_bank_export_passes`, `binary_bytes_are_not_scanned` |
| Filing is no TIN oracle: text with more than two TIN-shaped numbers ending in a known TIN's last four (the books' TIN and W-9 facts, then the vault's own) is refused before the vault is opened, byte for byte alike whether one is the vault's TIN or none is, the vault open or locked, by every filing tool; one or two such numbers are compared against the vault, one number written many ways counts once, and other last fours never count (rules.md B-26) | `tin_files.rs` (tests) `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`; unit tests `text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it` (the vault never opened for a probe), `one_or_two_candidates_are_compared_against_the_vault` |
| The connect text fits its budgets, its lead stands alone, it states the receipt rule enforced (exact threshold in the base currency, required and exempt accounts, reversals) and the parties rule in force, and the company name can't break it (control and format characters) | `handbook.rs` `instructions_budget`, `a_typical_company_gets_the_whole_connect_text`, `the_company_name_is_one_plain_line`; `receipt_policy.rs` `the_rule_told_is_the_rule_enforced`, `a_long_or_odd_account_list_stays_short_and_plain`; a new session is told the settings in force: `a_new_session_is_told_the_settings_in_force` |
| No text the agent is given (whole tool JSON, resources, connect text, handbook, every error template and refusal) points at files, git, GitHub, repo docs, flags or helper commands; descriptions fit 700 characters (listed exceptions, P5) | `tool_text_lint`, `tool_descriptions_fit_700_characters`; `ledger-core` `an_unknown_year_threshold_is_never_guessed` |
| Every tool is annotated (openWorldHint false on all), and the read-only hint is true in behaviour (journal, every data-dir file's contents but `ops/`, the viewer's side channel for `books.show`, which is not the books; the database's rows, the vault and the settings unchanged) | `annotations_complete`; `spec_mcp.rs` `read_only_tools_change_nothing` (checked on every call in `Mcp::tool`) |
| No text the agent is given (whole tool JSON, resources, connect text, handbook, every error template and refusal) points at files, git, GitHub, repo docs, flags or helper commands; descriptions fit 700 characters (listed exceptions, P5; and what git itself said of a failed backup, which `books.backup` passes on to the human as git said it, its paths hidden) | `tool_text_lint`, `tool_descriptions_fit_700_characters`; `ledger-core` `an_unknown_year_threshold_is_never_guessed` |
| Every tool is annotated (openWorldHint false on all but `books.back_up_now`), and the read-only hint is true in behaviour (journal, every data-dir file's contents but `ops/`, the viewer's side channel for `books.show`, which is not the books; the database's rows, the vault and the settings unchanged) | `annotations_complete`; `spec_mcp.rs` `read_only_tools_change_nothing` (checked on every call in `Mcp::tool`) |
| A bare (or empty-`since`) digest covers this month so far, on any day of it, as the server counts it (the stdio test reads the server's own month, and starts over if it turns) | `a_bare_digest_covers_this_month_so_far`; `mcp_stdio.rs` `digest_resource_bare_reads` |
| The handbook: every section, "all" in order, the schema names every id | `handbook_sections_render_the_rule_in_force`, `the_whole_handbook_is_every_section_in_order`, `mcp_stdio.rs` `handbook_tool_and_resources_over_stdio` |