# MCP interface spec — `ledger-mcp` ## Goal The primary interface to the books is an AI agent driving MCP tools. The engine (`ledger-core`) stays pure, `ledger-store` persists, and `ledger-mcp` exposes both over the Model Context Protocol — the open standard, so the books are drivable by any MCP client (Muse, Claude Code, IDE agents), not locked to one assistant. ## Crates | Crate | Role | Spec | |---|---|---| | `ledger-store` | the hash-chained journal (the source of truth), the SQLite projection, the blob store | `docs/receipt-store.md` | | `ledger-mcp` | the books server: the tools, resources and handbook over store + core | this document | | `ledger-host` | the agent bridge (`wanderbooks mcp`), the host and its leases, tokens | `plan/served.md` 3.3, 3.9 | (The overview sections of this document date from the first MCP phase; P5 rewrites them. The per-tool sections are held to the code by a test.) Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting logic: it translates tool calls into `Ledger` commands and formats results. If a behavior can't be expressed through `ledger-core`'s public API, the core is missing something — fix the core, don't work around it in the MCP layer. ## Company settings The books keep their own settings (journal format 2, plan/served.md P1): each change is a `settings.set` line with who decided it and when, replayed into `ledger_core::settings::Settings` by the server and the viewer alike (rules.md B-29). Every key is in `ledger_core::settings::KEYS`, with what it takes and means: the company's facts (`company.name`, `.entity_type`, `.tax_classification`, `.tax_form`, `.fiscal_year_start` ("MM-01"; the year's end is derived, never stored), `.timezone` (what "today" means for the books), `.formation_state`, `.formed`, `.state_entity_number`, `.ein` (the one identity number a setting keeps, shown masked: "**-***5893"), `.irs_name_control`, `.duns`, `.address`, `.registered_agent`, `.partnership_representative`, `.cpa`, `.members` (name, share as "50%" or "0.5", capital account, optional distributions account and `files_jointly_with`; shares sum to exactly 1)); the books' (`books.accounting_basis`, `.cash_accounts`, `.financing_accounts` (loans, and money the owners advanced: ASC 230-10-45-14(b)), `.investing_accounts` (ASC 230-10-20), `.close_into` (single owner only), `.close_sweep`, `.asset_tag_prefix`, `.schedule_convention` (daily, the default, or monthly_even)); backups (`backup.git_remote`, `backup.folder`); and policy (`policy.evidence.threshold` (receipts strictly above it; "0.00": every entry), `.required_accounts`, `.exempt_accounts`, `policy.parties` (off, suggest, require), `policy.require_tax_category` (warn, the default, or refuse), `policy.approval_rules` (off, the default, or enforce)). A value is checked by its key and kept in one spelling (a time zone as the database spells it, an amount without leading zeros, a return as `schedule_c`, `1065`, `1120s` or `1120`); a change is all or nothing, and the settings after it must hold together (members and `close_into` never both, a classification that names one return, a fiscal year that doesn't move under a closed period). No setting holds nine digits that read as an SSN or EIN (bare, or printed 3-2-4 or 2-7) except `company.ein` and `company.duns`. No settings file is ever read (plan D-44): books keep their own settings, the defaults until the first is set. Books from before P1, which kept theirs in a `wanderbooks.toml`, bring it in as they are adopted into the data root (`wanderbooks books adopt`, plan 5.5). A vault key file is never a books setting: a book in the data root keeps its own (`keys/legacy.key` in its folder). - `books.settings {}` → `{ timezone, today, approval_rules, keys: [{ key, doc, identity, value, set_by, seq }], company, fiscal_year_start, tax_form, basis, receipt_rule, receipt_required_accounts, receipt_exempt_accounts, parties, members, cash_accounts, financing_accounts, investing_accounts, close_into, close_sweep, problems }`: the settings in force, every key the books know with its value (null: unset, its default applies) and who set it, and `problems`, settings that disagree with the chart, each saying what to change (conventions naming an account no longer in the chart among them); `books.digest` repeats them as `settings_problems`. Settings are named by key. - `books.set_setting { changes: [{ key, value }], decided_by, decided_on?, citation?, note? }` → `{ changed: [{ key, was, value }], problems }`: one or more changes, together or not at all (`value` null clears a key back to its default; a value already set changes nothing, idempotent; destructive, since it can clear or switch off a setting). - `books.export_settings {}` → `{ toml_text }`: the settings as a settings file, each with who decided it as a comment, the EIN masked; a copy: editing it changes nothing (`ledger_config::codec`). No tool reads a settings file: old books bring theirs in once, whole, when the owner adopts them (`wanderbooks books adopt`), and only `books.set_setting` changes the books' settings after that. The accounts invoices, bills, payroll, and closes post to are not settings: they are control roles in the journal (rules.md B-23). - `books.roles {}` → `{ roles: [{ role, purpose, account_type, default_code, assigned, account_code?, account_name? }] }`: every control role and the account filling it. - `books.assign_role { role, account? }` → `{ roles }`: assign a role one of the chart's accounts (`account` null: back to the default code). Refuses the wrong type, a placeholder, an account already filling another role, and moving a balance sheet role while its account holds a balance. Journaled as `role.assigned`. ## Tools — how the books are kept Conventions, decisions, open items and approvals live in the books too (`ledger_core::conventions`, rules.md B-29), each with who decided it and when; `books.start` lists them, and the handbook's settings-and-conventions section says how to use them. - `books.start { focus?, agent? }` (read-only, at most 8 KB; `_meta` `anthropic/alwaysLoad: true`, so a client that defers tools loads it up front) → `{ book: { id, url, name, timezone, today, fiscal_year, base_currency, basis }, you?: { principal, kind, scopes, client_id? }, head: { seq, hash }, state ("needs_onboarding" or "active"), rules: { evidence, parties, approval_rules }, settings_summary: [{ key, value, set_by, seq }], conventions: [{ id, kind, title, summary }], open_items: [{ id, title, due }], projects: [{ id, name, status, kind? }], drafts: { building, awaiting_evidence: [{ entry_id, date, amount, note }] }, reconciliation: { tracked, never_reconciled, behind } (account codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args }], needs_attention: { items: [{ id, severity, kind, title, tool?, args? }], counts: { kind: n } }, truncated?, handbook_sections, format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool | prompt, args?, why, required }], isolation }`. The connect text names it as the first call. `book.url` is the book's path on this server (`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts (its name, the kind `agent`, its scopes) and the client program's name; it is absent on the `books://start` resource, which no token's call made. `agent: { model?, reasoning_effort?, harness? }` is what the agent says about itself: self-reported, never checked, written to the call's access record (`books.access_log` shows it as `agent`) and used for nothing else. `drafts` lists the first ten drafts waiting on their evidence; `reconciliation` the accounts reconciled to statements and which are behind or never reconciled; `needs_attention` the twelve most urgent items (problems first) with every item counted by kind: the same list the viewer draws, from one function (`ledger_package::attention`). If the answer would pass 8 KiB it shortens, in this order: convention summaries, attention items (to four), waiting drafts (to three), then the lists below, each saying how many more in `truncated`. `format_upgrade` is there only for books waiting for a format upgrade (below); its next step comes first, before anything else, at every start until it is done: `books.handbook {section: "format-upgrade"}`, "Tell the human first: …". `isolation` is `same_user (advisory)` locally (D-32): the books are hidden from agents in the owner's own account, not locked against them. On the owner's own machine, where the server runs as a separate account and agents have none, the unit passes `--isolation enforced` and it is `service_user (enforced)`; the server never guesses (P9a, 3.8). Lists cut to fit say so in `truncated` ("conventions: 4 more (books.conventions)"). `projects` lists the active and paused projects by name, at most 20 ("projects: N more (books.projects)" past that). `next_steps`, in order: the format upgrade's (when waiting); the onboarding steps (the `onboard` prompt, `books.set_setting`, `books.chart_template` and `books.handbook {section: "contract"}`, when the chart is empty or `focus` is `onboarding`); `books.open_items` (an item due within 30 days or overdue); `books.backup` (none set up, or one stale, failing, paused or failing its drill); then Needs attention, by severity, at most 12, each one `{tool, args?, why}` whose `why` is the item's title (the same list the viewer draws: `ledger_package:: attention`, so the two can't differ; `books.verify` items are the server's own damage check and are not repeated); then the focus's step (`close`: `books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then `books.draft_entry` (the books are set up), `books.handbook {section: "contract"}` and `books.digest`. Attention steps are `required` when the item is a problem. `focus` is `daily` (the default), `onboarding`, `close` or `audit`; anything else is refused. Also `books://start`. **The format upgrade** (plan 3.9, D-39, rules.md B-27). Books an older wanderbooks wrote are in an older journal format: they are served on it, and every tool that needs nothing new works as before. A tool whose change needs the newer format (on main: settings, conventions, decisions, open items and approvals need journal format 2; later formats add features) is refused before anything is journaled, its error beginning `format_upgrade_pending:` and saying that only the owner can give the go-ahead, with the owner passphrase, in the app or with `wanderbooks books upgrade ID` at a terminal, and that no tool can. No tool consents: none is offered, and the server takes the go-ahead only on its admin socket, from the host's lease intent `consent_format_upgrade` (the app's dialog, `books upgrade`), with the passphrase it checks. Before the upgrade the server commits the books to their own backup repository and proves the copy holds them exactly (journal, stored files, vault, keys); then one `journal.format_set` declares the whole format, and the same session's next call can make the change. Books this version creates are in its format from their second line, and an adoption's copy moves to it as it is made. - `books.conventions { kind?, include_retired? }` → every convention with its full text, structure, revision and source (`books://conventions`). `books.set_convention { id, kind, title, body_md, structured?, decided_by, ... }` sets or revises one (kinds: `posting_template`, structured `{ description?, lines: [{ account, side, share }] }`, each side's shares summing to 1, on accounts in the chart that take postings or on slots (`"{expense}"`) each draft names; `approval_rule`, structured `{ approver, accounts?, amount_over? }`; and `policy`, `close_step`, `calendar`, `mapping`, `note`, in words). An id is a slug, revised in place (same kind), never reused once retired: `books.retire_convention { id, reason, decided_by, ... }` (destructive). **Correcting a source** (P5, journal feature `source_revisions`): `books.set_convention` with `reason` and the text exactly as it stands changes only who decided it, when and on what (`decided_by`, `decided_on`, `citation`, `note`), journaled as `convention.source_revised {id, source, reason}`; the revision number and the words do not move. Without `reason` the same call is refused ("already says exactly that; to correct only who decided it, when or on what, add reason"), and with `reason` and different words it is refused ("changes more than its source"). Books that have not had the owner's upgrade refuse it with `format_upgrade_pending`. - `books.draft_from_template { template_id, date, amount, accounts?, description?, party?, no_party_reason? }` → `{ entry_id, description, lines }`: a draft whose lines split the amount (at most a line's) by the template's shares, each side summing to it exactly, odd cents by largest remainder; `accounts` names each slot's account (`{"expense": "6300"}`), every slot and nothing else. A refused draft journals nothing (a disk failing midway could leave a partial draft, as for any draft built line by line). - `books.record_decision { subject, decision_md, decided_by: [..], decided_on, evidence_attachment?, supersedes?, citation?, projects? }` → `{ id }`; `books.decisions { include_superseded?, project? }`. A decision supersedes at most one live decision; its evidence is a document still in the books. `projects` (ids or names, each once, resolved before anything is written) names the projects it is about (rules.md B-37); a decision without them journals the bytes it always did, and one with them needs the journal feature `projects`. `books.decisions` gives each decision's `projects: [{ id, name }]` (left out when none); `project` filters to the decisions naming one. `books.record_decision { revises: ID, reason, decided_by: [..], decided_on, citation? }` corrects only that decision's source (who decided it, when, on what; its words stay, and `subject` and `decision_md`, if given, must be the ones it has): `decision.source_revised {id, source, reason}`, returning `{ id }`. - `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }` (`books.raise_item { revises: ID, reason, decided_by, ... }` corrects only that item's source: `open_item.source_revised`), `books.resolve_item { id, resolution, decided_by, ... }`, `books.open_items { include_resolved? }`. - `books.approve_entry { entry_id, approver, note?, approved_on? }`: the approver approved the draft as it stands (its fingerprint: date, description, every line and class; a line added or a class changed afterwards voids it). Under `policy.approval_rules = enforce`, `books.post_entry`'s preview lists `approvals_missing` and a confirmed post is refused with `approval_required` until each covering rule's approver approved (the store refuses it too), and a tool that posts at once (an invoice, a bill, a payment, payroll, a remittance, a disposal) is refused when a rule covers the entry it would post: draft it instead. Reversals, closes, depreciation and amortization are exempt. A rule names accounts that take postings; an account a rule names can't be renumbered until the rule is revised, and `books.renumber_account` lists the conventions that name the old code. Under `off` (the default) rules block nothing. ## Transports Books are served one way (plan D-44): by the books server, which agents reach through the bridge. - **Served books** (`wanderbooks server`, started by the host; an agent connects with `wanderbooks mcp --book ID`, the bridge: stdio to the agent, the server's socket upstream): every book in the data root, on a socket in the root's private `run/` folder that only this account can connect to. Every request carries a bearer token live for that book (refused 401 without one, 403 with another book's). Refusals at the door, tool calls, uploads, downloads and exports are recorded in the book's access log; when a record can't be written the book stops taking requests (an upload, download or export is then refused, 503). - `/b/{book}/mcp`: the book's MCP service. A request is at most 1 MiB. - `PUT /b/{book}/u/{upload}`: an upload's bytes (`books.begin_upload`), from the token that began it; what `wanderbooks upload --book B [--token NAME] UPLOAD FILE` sends. Streamed to the disk, kept only if exactly the size and SHA-256 declared (so at most 25 MiB), once. The receive and its access record are one blocking task: bytes are never kept without their record (if it can't be written they are thrown away, 503 `not-recorded`, and the books stop). 200 with `{ received, size, sha256 }`; else, with nothing kept and the code in `x-wanderbooks-refused`: 404 `no_such_upload` (unknown or lapsed, also if it lapsed while being sent), 403 `not_yours`, 409 `already_sent` (its bytes came, or a call holds them), 409 `in_use` (another request is sending them), 400 `not_as_declared` (short, more than declared, or the wrong hash), 503 `stopped` (the books stopped after an internal error), 500 `failed`. A refused request's body is read and dropped (up to 25 MiB), so the client gets the answer, not a cut connection. Logged as action `upload` with the upload's id, a refusal at the door (no token, another book's) too. An upload's id is `up_` and 32 letters `a`-`p`: no digits, so the access log's TIN masking never hides it. - `GET /b/{book}/a/{attachment}`: a stored file's bytes, for a token with `books:read` (403 `insufficient_scope` without it; 404 `no_such_attachment`); the read and its access record are one blocking task. Sent with `Content-Type` (the stored MIME type, or `application/octet-stream` if it can't be a header), `Content-Disposition: attachment; filename="…"; filename*=UTF-8''…` (the plain name: anything but visible ASCII and spaces, and `"` and `\`, made `_`; then the whole name, RFC 8187 percent-encoded), `X-Content-Type-Options: nosniff`, and `Repr-Digest: sha-256=::` (RFC 9530). A stored file that can't be read whole is 500 `failed`; books that stopped, 503 `stopped`. GET only (a HEAD is 405, unrecorded). Logged as action `download` with the attachment's id, a refusal at the door too. - `GET /b/{book}/x/{export}`: a file the books made for an agent (the CPA package, a 1099 worksheet, a W-9, a report export, a label sheet, a receipt's copy: a `HandedFile`, "Tools — the TIN vault"), as `/a/` (the same door, the same headers, `Repr-Digest` included, GET only), but only for the token it was made for (403 `not_yours`), with no scope of its own to check (the tool that made it checked its scope); 404 `no_such_export` (unknown or lapsed), 503 `stopped`. An export's id is `ex_` and 32 letters `a`-`p`; it is kept 15 minutes, fetched as often as wanted until then, at most 64 per token (every file an agent takes away is one, D-46), and swept like uploads. Logged as action `export` with the export's id. - `wanderbooks download --book B [--token NAME] ID --to DIR` takes an attachment's id (a number, `/a/`) or an export's (`ex_…`, `/x/`), checks the digest before saving, and saves it under the server's name (`filename*` first) made plain: separators and control characters made `_`, invisible (Unicode format) characters and leading dots and spaces dropped, at most 200 bytes with the extension kept. Beside a file of that name it is `name (1).ext`, and so on; `attachment-` or `export-` if the folder takes no such name. It is written to a hidden file and hard-linked in: never a partial file under the real name, never over another file. ## Server instructions The MCP `initialize` response carries an `instructions` string, the **connect text** (`ledger-mcp/src/handbook.rs`), composed in `get_info` from the settings in force when the client connects (the books' settings are taken first, so a client connecting after a change is told the new rule). It is short on purpose: clients cut long instructions (one cut the old text at 4,096 characters), so it is at most **1,400 characters**, and its first paragraph, at most **512**, stands alone: the entry point (`books.start` first each session, and `books.handbook` for every procedure), the rule that these tools are the only way to the books (never local files), decimal-string amounts, and the draft → lines → preview → confirmed post flow. The second paragraph adds that every entry balances and uses real accounts from `books.chart`; the parties rule in force (`[books] parties`: under "require" every entry the agent posts names its party or a no-party reason, since closes, depreciation and amortization post with none; under "suggest" name it where there is one; under "off" nothing); corrections by reversal; the receipt rule in force (`ReceiptPolicy::rule`: the exact threshold in the books' base currency, never rounded, the accounts that always need a receipt and those exempt, and that reversals need none); the exemptions (depreciation and amortization, whose schedules are the evidence, and closes, derived from the books); that the invoice, bill, payment and payroll tools post at once, unchecked (in those words, naming no tool); TINs only into the vault; show the numbers, and after posting check `books.digest` and offer it to the human; and where settings live: in the books, changed only through the tools. When a long rule (many accounts, a huge threshold, a long name) would push it past 1,400 characters, it drops the tracing hint first (the handbook's tracing-figures section has it), then the reasons in parentheses, then the tracing hint for good and lists fewer account codes (at last only how many there are; `books.settings` lists every one, uncut, in `receipt_required_accounts` and `receipt_exempt_accounts`), then words that only help; never the rule itself. A company with no name yet (the settings' default) is "a new company". The company name in it is one line of plain text (control characters and line breaks become spaces, Unicode format characters such as bidi overrides and zero-width spaces are dropped), cut to 56 characters. `instructions_budget` (the longest rule, every parties setting) and `the_company_name_is_one_plain_line` check it. The long guidance is the **handbook**, compiled in from `ledger-mcp/handbook/*.md`, with the receipt rule in force rendered in: - `books.handbook { section? }` (read-only) → `{ read_first, sections: [{ id, title, uri, chars }], section: { id, title, text } | null }`. Without `section`, the index (`read_first` says to read `contract` first); with a section id, that section's Markdown; with `"all"`, the whole handbook in reading order. The input schema lists the ids: 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, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures. - The same as resources: `books://handbook` (the index, JSON) and `books://handbook/{section}` (Markdown, `text/markdown`), one listed per section. No text the agent is given (tool JSON with its schemas, resources, connect text, handbook, and every refusal fixed in code: each error variant's template in the crates under the server, scanned from source, built error texts, and the server's refusal constants) names a helper command, a home path, the data dir or its flag, the old import-root flag, a file of the books (`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull request, a repo doc (`docs/`, `.md`), or tells the agent to read the books' files; no tool title or description, connect text or handbook section names `wanderbooks.toml` (`tool_text_lint`). A rule set by law that the books don't know for a year names its official source (the statute or publication, with its address) and says to tell the human. Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool that changes anything also has a `destructiveHint`, true for these 31: `books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.renumber_account`, `books.rename_account` (the label every past report prints), `books.assign_role`, `books.close_period`, `books.reconcile`, `books.void_statement`, `books.void_reconciliation`, `books.set_w9`, `books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.void_document`, `books.write_off_invoice`, `books.run_payroll`, `books.remit_payroll_taxes`, `books.consolidate_duplicates`, `books.discard_draft`; and an `idempotentHint` on the ten that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.set_party_roles`, `books.set_setting`, `books.set_vendor_1099`, `books.set_entry_instrument`, `books.update_project` and `books.back_up_now` (a setter such as `books.update_party`, `books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin` 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, 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 2026-10-16: no added prompts). `annotations_complete` pins all of this. The read-only hint is true in behaviour: `spec_mcp` checks, on every call, that a read-only tool leaves the journal and the contents of every file of the books (their folder, its vault, the database by every row of every table; SQLite's -wal and -shm files, which move pages and hold read marks on their own, aside, and what every call writes by design: the access log, the viewer's side channel, the books' own backup, files handed over) as they were (`read_only_tools_change_nothing`). `books.show` is read-only though it writes the viewer's request into the books' `ops/` folder: that folder is a side channel, never journaled, and moves only the view. **Failing closed.** Tool bodies run on blocking threads, one at a time on the store. If a request panics (an internal error), the journal may hold a line the memory doesn't, so the server stops taking requests: every later call, on every session sharing the store, is refused with "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server", never the panic's own text. A restart re-verifies the journal (`a_request_that_panics_stops_the_server`). So a panic an input can reach would be a denial of service, and none can be: the dates a tool takes are four-digit years and real days, its amounts at most a journal line's, and a recurring run bounded; books an earlier version wrote past those bounds still open (replay accepts what that version accepted), so every figure the server derives from them (a draft's total, a schedule's plan, aging, a party's totals, the asset register) is computed or refused ("too large to compute exactly", or a date outside the calendar the books keep), never a panic (rules.md B-28). `tests/hostile_args.rs` drives every tool with generated hostile arguments and every resource with generated URIs, checks after each call that the server still serves, restarts it after them to check the books reopen, and drives every tool over every book in ledger-store's `tests/fixtures/old-books`. ## Tool design principles - **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent supplies dates, accounts, sides, amounts; the server validates. - **Amounts are decimal strings** in requests and responses. Never floats, never locale-formatted numbers. - **Every mutating tool echoes the exact resulting state.** The "show the numbers" rule from AGENTS.md applies over the wire too. - **Preview-before-post.** `post_entry` and `reverse_entry` take `confirmed: bool`. Without confirmation they return a preview (full lines + resulting account balances) and post nothing. The agent must present the preview to the human before confirming. This encodes the standing fact-check rule in the protocol itself. - **The preview digest (P4, D-50).** Every tool called without `confirmed` returns `preview`, the SHA-256 of what it shows: for a draft entry, its canonical state (lines, classes, party, vendor, instrument, the evidence linked) and the settings seq; for any other operation, the tool, its arguments, the settings seq and the journal head seq. Every call with `confirmed: true` takes that `preview` and is refused, before anything is written, with the guard `{ code: "stale_preview", message, remedy_tool }` when it is missing or no longer matches: an argument changed, or another session wrote in between (a post by another session forces a re-preview, by design). No key is involved: it detects drift; it does not prove a human looked. The tools that take `preview` are all those with `confirmed`: `books.post_entry`, `books.reverse_entry`, `books.run_schedules`, `books.dispose_asset`, `books.reconcile`, `books.close_period`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.write_off_invoice`, `books.purge_tin`, `books.consolidate_duplicates` and `books.discard_draft`. (`books.void_document`, `books.run_depreciation` and `books.run_recurring` post at once and have no `confirmed`.) - **The result envelope (P5, plan 4.6).** Every tool result, whatever the tool, is given the same four fields in one place (`call_tool`, `next_steps.rs::apply`), after the tool has answered; a tool's own field of the same name is kept, never overwritten: `next_steps: [{ tool | prompt | command, args?, why, required }]` (what to do next: after a preview, the same call with `confirmed: true` and the returned `preview`; after a post, `books.show` of the entry, plus the Needs-attention items about that entry or invoice, the viewer's own, `required` when they are problems; after a close, `books.verify`; after `books.export_cpa_package`, a `command` step carrying the download command; every other tool that can change the books ends with `books.digest`), `guards: [{ code, message, remedy_tool, remedy_args? }]` (empty on success; on a refusal that is a guard, the refusal's `structuredContent` is `{ guards: [..] }` and its text is unchanged), `warnings: [..]` (a tool's own `warnings` is that field), and, on every tool that is not read-only, `head: { seq, hash }`, the journal's head after the call's last write (or as it stands, when the call wrote nothing). The guard codes are the enum in `guards.rs`, each with its remedy tool: `stale_preview` (the tool again, previewed), `format_upgrade_pending` (`books.start`), `receipt_required` (`books.attach_receipt`), `approval_required` (`books.approve_entry`), `party_shares` and `party_required` (`books.set_entry_party`), `open_drafts` (`books.list_drafts`), `untagged_control_line` (`books.control_check`), `duplicate_vendor_ref` (`books.search_entries`) and `first_number_locked` (`books.settings`). Tests: `every_mutating_result_has_head_and_next_steps`, `the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`, `every_guard_names_an_existing_tool`. - **Errors are typed strings** mirroring `LedgerError` variants, with the offending values included (e.g. unbalanced totals). ## Tools — books - `books.handbook { section? }`: the guide (see "Server instructions"). - `books.chart` → `{ accounts: [{ id, code, name, account_type, parent, placeholder, balance, tax_category, deductible_percent }], base_currency }`. Balances included: one call answers "what do we have." - `books.add_account { code, name, account_type, parent?, placeholder?, tax_category?, deductible_percent? }` → `{ account_id, warnings? }`. Codes are unique; a name another account under the same parent has (top-level accounts are siblings too; ignoring capitals and surrounding space), one holding a run of nine digits, or one that isn't one line of at most 200 characters, is refused (rules.md B-31); the name is kept trimmed. A revenue or expense account (not a header) without `tax_category` is created with a warning ("6150 Software has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."), or, under `policy.require_tax_category = refuse`, refused (rules.md B-32). The category is checked against the account's type and the return, as `books.set_tax_category` checks it; journaled as `account.added` then `account.tax_set`. - `books.draft_entry { date: "2026-10-10", description: "...", vendor?, party?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`. `party` tags the counterparty (see "Tools — parties"); given with `vendor`, both must name the same vendor record, else nothing is drafted. `parties` names several, each with a share (as `books.set_entry_party`), in place of `party` and `vendor`. - `books.add_line { entry_id, account: "1000" | , side: "debit" | "credit", amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or class, bad amount) adds nothing. - `books.post_entry { entry_id, confirmed?, preview?, expected_total? }` → `{ status, preview, entry_id, date, description, total, lines (with resulting balances), receipts, receipt_required, receipt_missing, split_detected, party_policy, party_id?, party?, parties, parties_error?, no_party_reason?, party_missing, party_suggestions, approvals_missing? }`. `expected_total`, when given, must be the entry's total debits, else nothing posts. Without `confirmed: true`, `status` is `"preview"` and nothing changes; with it, the receipt policy is enforced and `status` is `"posted"`. `parties` lists every party the entry names with its share resolved to the cent (`{ party_id, party, share: "amount" | "percent" | "derived", amount?, percent?, resolved }`); when the shares don't add up to what the entry moved, `parties_error` says why and confirming fails with code `party_shares`. - `books.reverse_entry { entry_id, date, description, confirmed, preview? }` → same preview/confirm pattern as post. A reversed depreciation run or schedule month is un-posted (its month is due again); refused, at the preview already (P3): an asset's disposal entry (it would stay disposed), a depreciation run of a disposed asset (its disposal took it as final), and a reversal whose reversal would post a depreciation period or a schedule month a second time (naming the entry that posts it now), or re-instate a run a later run caught up (carrying the books past the schedule); and a reversal of a depreciation run or a schedule month's posting (or of a reversal in its chain) dated before that entry's own date (a correction comes after what it corrects, B-9). - `books.get_entry { entry_id }` → full entry with lines (each `{ account_id, code, name, side, amount, class_id?, project? }`: a tagged line's project, its id and name), receipts, and its `vendor`, `party_id`, and `party` (the party's label, when it names exactly one) tags, `parties` / `parties_error` as in the `post_entry` preview, and `no_party_reason` when it is marked as having no counterparty. `total` (its debits) is null, with `total_error` saying why, for a draft an earlier version journaled whose lines exact arithmetic can't sum; a draft line on an account the chart doesn't have (older books hold such drafts) shows an empty code and "(no account N)". - `books.search_entries { account_id?, date_from?, date_to?, text?, limit?, offset? }` → `{ entries: [{ entry_id, date, description, total, reversal_of, receipt_count }] }`, posted entries, newest first (no lines: `books.get_entry` has them). `limit` defaults to 50 and caps at 500. - **Omitted dates (rules.md B-30).** One rule everywhere (`ledger_core::calendar::PeriodDefaults`), stated in every report tool's description and date parameters in the same words: a period report with no dates covers the fiscal year to date (`company.fiscal_year_start`): `end` defaults to today (the company's time zone), `start` to the first day of the fiscal year that holds `end`; a `start` after the `end`, given or derived (`start` after today with no `end`), is refused, never swapped. An `as_of` defaults to today; a party's `year` to this calendar year. Every result names the dates it covers. - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced` (the trial balance as of the date, default today). - `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`, `equity`, `current_earnings`, `balanced`) plus `asset_accounts`, `liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of the date (default today). - `books.profit_and_loss { start?, end?, class? }` → totals plus `revenue_accounts` and `expense_accounts` for the range (omitted dates: the fiscal year to date). Closing entries are excluded, so a closed period still shows what it earned. - `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to the books' settings, `books.cash_accounts` and so on; cash is required one way or the other) → the statement of cash flows, direct method (rules.md B-14, P3 defect 1; omitted dates: the fiscal year to date): `{ method: "direct", start, end, operating_by_class, operating, cash_from_operations, investing_by_kind, investing, cash_from_investing, financing_by_kind, financing, cash_from_financing, net_change_in_cash, cash_beginning, cash_ending, noncash_disclosures, operating_reconciliation, reconciles, warnings }`. - Lines (`operating`, `investing`, `financing`) are one per entry, section and kind: `{ entry, date, label, kind, amount, on_behalf?, accounts, reverses?, class? }`; every transaction is listed (D-35), each under its kind. An entry's interest is its own operating class, (b) or (e), apart from the rest of its operating cash. A payment to a party with the `government_agency` or `tax_agency` role is `other_payments`, (g), on any account (rules.md B-14 rule 5; payroll remittances excepted); one shared between such a party and a supplier gives two operating lines for the one entry, `other_payments` and `to_suppliers_and_employees`, split by the parties' shares and summing to the payment. What a member or a lender paid or received for the company with no cash account moving is cash borrowed (or contributed) and spent, on the entry's date (D-42, rules.md B-14 rule 11): an expense a member paid personally through Due to Members is a "Proceeds from borrowing — " line and an operating line, each with `on_behalf`, the part of its amount so handled (absent when none; an entry's `on_behalf` parts net to zero). Labels never come from a reversal's sign: "Reversal of #N — …", "Reinstatement of #A (reverses #B)". An entry and its reversal in the same period drop out together. - `operating_by_class`, `investing_by_kind`, `financing_by_kind`: `{ kind, class?, amount, entries }`, how the printed statement shows the sections: operating by ASC 230-10-45-25 class (`from_customers`, `interest_and_dividends_received`, `other_receipts`, `to_suppliers_and_employees`, `interest_paid`, `income_taxes_paid`, `other_payments`), investing and financing by kind ("Contributions from members — ", "Repayments of borrowing — ", "Purchases of property and equipment", …). - `noncash_disclosures`: `{ entry, date, label, amount, operating, investing, financing, exchanged, reverses? }`, the investing and financing activities no cash settled (ASC 230-10-50-3): property contributed, debt converted to equity, a purchase on the seller's credit, interest charged into a loan, the noncash part of a part-cash purchase. - `operating_reconciliation`: `{ net_income, lines: [{ kind, label, amount, account?, entries }], net_cash_from_operating }`, net income reconciled to operating cash (ASC 230-10-45-30), kinds `depreciation`, `investing_gain_loss`, `noncash_through`, `other_noncash`, `operating_balance_change`. - `reconciles`: `{ sections_sum, net_change, difference }`; `difference` is always `"0.00"`: a statement that wouldn't foot is an engine error, never output. - `warnings`: `{ entry, kind, message }` for an entry whose later payment or collection the books can't classify (`asset_on_operating_payable`, `asset_sold_on_operating_receivable`), with the fix. - Refused, naming the setting, when the roles contradict the chart or each other (cash and investing accounts must be assets, financing accounts liabilities or equity; no account in two lists or twice in one; no registered asset's account as cash). - Changed in P3 (breaking): `net_income`, `depreciation` and `working_capital` are gone (net income is in `operating_reconciliation`). - `books.digest { since: "2026-10-01" | since_seq: 412, as_of? }` → `{ since, since_seq?, head_seq, entries: [...], totals_by_account: [...], zero_net_accounts: [...], drafts_by_state: { building?, awaiting_evidence? }, reconciliation_tracked: [...], never_reconciled: [...], stale_rules: [...], journal_repairs: [...], settings_problems: [...], unreconciled_accounts: [...], uncategorized_accounts: [...] }`. `since` selects entries by their date; `since_seq` selects the entries posted after a journal line (the `head_seq` of an earlier answer), backdated entries included, which a date would miss (P4, defect 6). `as_of` (default today) bounds the movement. An account the entries touched whose net movement is zero is listed under `zero_net_accounts` rather than dropped silently. `drafts_by_state` counts the open drafts; `reconciliation_tracked` and `never_reconciled` split the cash and card accounts (`books.cash_accounts`) by whether a statement was ever recorded for them. Each entry's `receipt_required` is the engine's one decision (`ledger_core::evidence::requirement`): a document's entry, a close, a depreciation run and a reversal need none of their own. The human review surface: "what did the AI do with my books lately." The digest is the answer to "trust, but verify." `stale_rules` lists legal amounts wanderbooks may no longer know as of today (`{ name, known_through, source, why }`): research the current value or ask the human, then update it upstream (pull request or issue at github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash and card accounts due for reconciliation (`{ code, name, reconciled_through?, due_through, next_step }`): the cash accounts (`[books] cash_accounts`, else asset accounts named for cash: cash, bank, checking, savings, money market), the card accounts (the business-card payment methods' liabilities, else liabilities named "card", not "gift", before their first statement: `ledger_core::parties::card_accounts`), and every other liability with a recorded statement, that have posted activity through the end of last month but no reconciliation (not voided) through it. The viewer's Needs attention uses the same rule (`ledger_store::reconcilable`). `uncategorized_accounts` lists every revenue and expense account (not a header) with no tax category, active or not, `"CODE Name"`: the list Needs attention shows (rules.md B-32). ## Tools — fixed assets Straight-line **book** depreciation only; tax depreciation stays with the CPA. Depreciation accrues by the day (rules.md B-15): a partial first month, the life's final month (which takes what the others leave, evenly over its days), and any month a schedule stops in (through a date, or a disposed asset's disposal day, held) are prorated by days, never plugged; the lifetime total is exactly (cost − salvage), rounded to the currency's minor unit. A new asset's cost and salvage are in the minor unit, and its life's last day is 9999-12-31 at the latest. - `books.register_asset { description, in_service_date, cost, salvage_value?, useful_life_months, cost_account, accum_depr_account, expense_account, tag?, serial?, acquisition_entry?, basis_rule?, contributor_adjusted_basis?, fmv_at_contribution?, sec704c_method?, basis_source? }` → `{ asset_id, tag, in_service_date, acquired_date, basis?, notes? }`. Accounts resolved by id or code. Without `tag`, the next free `PREFIX-0001` (`[books] asset_tag_prefix`, else the company name's initials without LLC/Inc; "FA" when none). `in_service_date` (the day it is ready and available for its use) starts depreciation (P3, defect 13; B-15); `purchase_date` is its alias for one release (both given and meaning different days: refused; the alias noted in `notes`). The `acquisition_entry` (posted, debiting the cost account, neither a reversal nor reversed, dated on or before the in-service date) gives `acquired_date`, which puts the asset on the register; with none, the acquired date is the in-service date. The basis facts are `books.set_asset_tax_basis`'s (`tax_basis_rule` and `contributor_basis`, with the old rule names, are aliases for one release); a bad one registers nothing, and so do basis facts given without `basis_rule`. - `books.label_asset { asset, tag?, serial?, clear_serial?, description?, acquisition_entry? }` → `{ asset_id, tag, serial, acquisition_entry, description }`: set or change the tag (unique ignoring case, never reused by another asset even after a retag or disposal), the manufacturer's serial, the description, or the linked acquisition entry (posted, debiting the cost account, not reversed). Refused with nothing to change, or with both `serial` and `clear_serial`. `asset` is the id or current tag. Journaled as `asset.tagged`, `asset.serial_set`. - `books.asset_labels { assets?, skip? }` → `{ file, tags }`: an Avery 5160/8160 label sheet (PDF), handed over (`file` a `HandedFile`, D-46): company, tag, QR code of the tag, description, serial. Default: every active tagged asset; `skip` reuses a partly used sheet. - `books.asset_schedule { asset_id | tag, through? }` → `{ asset_id, description, cost, salvage_value, useful_life_months, status, schedule: [{ period, amount, accumulated, book_value }] }`; a disposed asset's ends on its disposal day. A schedule an earlier version's asset can't compute is a tool error saying why, never a panic. - `books.list_assets { as_of? }` → `{ as_of, assets }` (default today, the company's time zone): per asset `in_service_date`, `acquired_date`, `cost`, `status`, `held` (acquired by `as_of` and not disposed), `accumulated_posted` (what the books carry: its depreciation runs, less reversals, a reversal of a reversal reinstating), `accumulated_scheduled_through` (what the schedule says is due by `as_of`), `unposted_depreciation` (the difference for this asset, its schedule less its runs: run `books.run_depreciation`; the register's total `unposted_depreciation` compares the schedules with the general ledger instead, so a hand entry in accumulated depreciation shows there, as `unattributed_accumulated`, not here), `book_value` (cost less posted: what the books carry), and `basis` (as `books.set_asset_tax_basis` reports it). `cost` is always the capitalized cost; `held` says whether the books carry it. Disposed by `as_of`: every other figure 0.00; acquired after `as_of`: those figures null. The rows are the register's (`fixed_asset_register`). An asset whose figures can't be computed (one an earlier version took) lists with them null and `schedule_error` saying why: one bad asset never fails the list. Before P3, `accumulated` was the schedule's figure and `book_value` cost less it. - `books.set_asset_tax_basis { asset, rule, contributor_adjusted_basis?, fmv_at_contribution?, sec704c_method?, basis_source? }` → `{ asset_id, basis: { rule, contributor_adjusted_basis, fmv_at_contribution, fmv_recorded, tax_basis_723, depreciable_basis, built_in_gain_704c, sec704c_1c_note?, sec704c_method, source?, sources }, notes? }`. `rule`: `purchase` (the company bought it: depreciable basis is its cost, IRC §1012), `carryover_723` (a member contributed business property: the company's §723 basis is the member's adjusted basis, depreciated from), or `personal_use_conversion` (a member contributed personal-use property: the §723 basis as for carryover, depreciable basis the lesser of fair market value and that basis, Treas. Reg. §1.168(i)-4(b); D-41). A contribution needs `contributor_adjusted_basis` (cost plus improvements, less depreciation allowed or allowable, IRC §1016(a)) and `fmv_at_contribution` (a fact the human gives: never assumed from the cost); `built_in_gain_704c` = value − §723 basis at contribution (Treas. Reg. §1.704-3(a)(3)(ii)), with the IRC §704(c)(1)(C) note when it is a loss. `sec704c_method`: `traditional`, `traditional_with_curative`, `remedial` (Treas. Reg. §1.704-3(b)-(d)). `basis_source` `{ decided_by, decided_on?, citation?, note? }`: who gave the facts. `sources` names each figure's authority. The figures are at contribution or purchase; tax depreciation and what remains of a built-in gain later are the CPA's. The old names (`cost`, `carryover`, `contributor_basis`) are aliases for one release, noted. Journaled as `asset.basis_set` (the journal feature `asset_basis`: books an older version wrote refuse it, `format_upgrade_pending`, until the owner's format upgrade); a record written before P3 (`asset.tax_basis_set`) reads its value as the book cost (`fmv_recorded: false`). - `books.correct_asset { asset, cost?, salvage_value?, useful_life_months?, in_service_date?, reason }` → `{ asset_id, tag, cost, salvage_value, useful_life_months, in_service_date, acquired_date, basis, notes? }`. Refused while the books carry depreciation for it (a run posted and not reversed), for a disposed asset, for a blank `reason`, and for an in-service date before its acquisition entry's date; `in_service` is the alias of `in_service_date` for one release. A recorded value at contribution, and so the built-in gain, never moves; a basis recorded before P3 has none and reads it as the book cost, so a cost correction moves its built-in gain, and `notes` says so. - `books.run_depreciation { period: "YYYY-MM", asset_id? }` → `runs: [{ asset_id, description, period, amount, caught_up, entry_id, already_posted }]` and `skipped: [{ asset_id, description, reason }]`. A run posts what the schedule says is due through the month's end, so a month skipped or reversed is caught up in the month run (`caught_up`: the part beyond the month's own; a material miss is a restatement for the CPA, ASC 250), and never more than the schedule at a later month end. What the books carry is read entry by entry, each in its own month, so a month whose run was reversed after it ended posts nothing (it carried the month then; the run of the reversal's month catches it up). Idempotent per asset and period. Nothing due (the month rounds to nothing, a later run caught it up, the life is over and posted) posts nothing and is listed under `skipped` with why, as are a run-all's assets not yet in service and any whose schedule can't be computed. A run before the in-service date is refused. - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, gain_loss_account, buyer?, confirmed?, preview?, receipt_attachment_ids? }` (the date never before the acquired date) → without confirmation: `{ status: "preview", asset_id, entry_id (0 in a preview), accumulated, book_value, proceeds, gain_loss, final_depreciation, excess_depreciation, catch_up_depreciation, lines, receipt_required }` and posts nothing; with `confirmed: true`: auto-posts the final depreciation (through the disposal day, held), then posts the balanced disposal entry. Book value is cost less the schedule through the disposal day, whatever the runs posted: depreciation a run posted past it (the disposal month's run is dated its end) is credited back to depreciation expense in the disposal entry (`excess_depreciation`); depreciation still due when the disposal month already has a run is debited there (`catch_up_depreciation`); the entry clears everything the runs carry. Refused, at the preview already, unless every run carries at the end of the disposal's month what it carries now: a run of a later month that stands (reverse it first), or a reversal (or re-instatement) of an earlier run dated after the disposal's month that a later one hasn't undone (undo it, on any open day). Subject to receipt policy (bill of sale); monthly depreciation is exempt as an allocation. `buyer` (a party) is the disposal entry's party; under `[books] parties = "require"` a sale with proceeds without one is refused before anything posts (`party_required`); a retirement with no proceeds and no buyer is marked as having no counterparty. - `books.export { report: "fixed_asset_register", format: json | csv, as_of? }`: every asset acquired by the date (tag, serial, acquired and in-service dates, cost, accumulated depreciation scheduled and posted, book value as cost less posted, disposal, the basis facts and figures: rule, contributor's adjusted basis, value at contribution and whether it was recorded, §723 basis, depreciable basis, §704(c) built-in gain, method, who gave the facts), totals (`total_accumulated_scheduled`, `total_accumulated_posted`, `total_book_value`, `total_tax_basis_723`, `total_depreciable_basis`, `missing_basis`), and the general ledger's balances of the assets' cost and accumulated depreciation accounts, with `unposted_depreciation` (scheduled less books), `unattributed_accumulated` (books less the runs: an entry made by hand) and `unregistered_cost` (zero when register and books agree; `ledger_core::fixed_asset_register`). CSV columns: `tag, description, serial, acquired, in_service, cost, salvage, useful_life_months, accumulated_scheduled, accumulated_posted, book_value, status, proceeds, basis_rule, contributor_adjusted_basis, fmv_at_contribution, fmv_recorded, tax_basis_723, depreciable_basis, built_in_gain_704c, sec704c_1c, sec704c_method, basis_source`, then a TOTAL row; `sec704c_1c` is `true` for a built-in loss (IRC §704(c)(1)(C)), blank without a contribution basis. - `books.show { asset }` opens an asset in the viewer; `page: "assets"` and `report: "fixed_asset_register"` open the register. ## Tools — schedules and recurring entries Amortization spreads a prepaid expense (asset → expense) or deferred revenue (liability → revenue) over the time it covers, posting on each month end, to the minor unit (rules.md B-16). A new schedule's convention is `books.schedule_convention` (unset: `daily`, D-11/D-37): `daily` spreads by the day from `start`, the first day covered, as depreciation does, so a mid-month start posts a small first and last month (a 12-month policy from March 15 is thirteen month ends: 54.84, eleven of 100.00, 45.16 for 1,200.00); `monthly_even` is equal months, the last absorbing rounding, and starts on the 1st (D-37: revenue is never earned early, ASC 606-10-25-23). Schedules registered before P3 stay `monthly_even` (mid-month starts included), forever. Like depreciation, the schedule is the evidence: its postings are exempt from the receipt policy (the original payment carries the receipt). Recurring entries never post: they generate drafts, so every real receipt still goes through the policy. - `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue", description, total, start, months, balance_account, pl_account, convention?: "daily" | "monthly_even" }` → `{ schedule_id, ..., convention, start, end, next, total, remaining, months: [{ date, amount, posted }], plan_error }`. Prepaid needs an asset and an expense account; deferred revenue a liability and a revenue account. `start` is the first day covered (for a monthly_even schedule an earlier version took mid-month, the 1st of its month), `end` the last day covered, `next` the next month end to post (null once every month has). Refused: a convention but those two (naming them); a `monthly_even` schedule not starting on the 1st (naming the 1st, and `daily`); one whose months aren't whole minor units (yen); `months` outside 1 to 600; one whose last day is after 9999-12-31; and, in books an older version wrote, a daily one (it needs the journal feature `schedule_convention`: `format_upgrade_pending` until the owner's format upgrade). `plan_error` is null, except for a schedule an earlier version accepted whose months run past the last date the books can compute: then it says why, `remaining` and `next` are null and `months` empty, and `books.run_schedules` refuses until it is dealt with. - `books.list_schedules {}` → `{ schedules: [...] }`, same shape. - `books.run_schedules { through?, confirmed?, preview? }` → `{ status: "preview" | "posted", postings: [{ schedule_id, date, description, amount, entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every unposted month ending on or before `through` (default today). Idempotent; a month reversed within its month is due again; a month dated in a closed period, or whose posting was reversed after the month ended (posting it again would recognize it twice until then), is left and listed under `skipped` with why (book it in an open period by hand, or not at all), never a failed run. - `books.create_recurring { description, frequency: "weekly" | "monthly" | "quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }` → `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and longer occurrences are computed from the start date each time, so a month-end start stays on month ends (Jan 31, Feb 28, Mar 31). - `books.list_recurring {}` → `{ recurring: [...] }`. - `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date, entry_id }], skipped_closed_period: [{ recurring_id, date }] }`: one draft per occurrence on or before `through`, once; an occurrence dated in a closed period is not generated and is listed in `skipped_closed_period` for the human to decide. ## Tools — the viewer - `books.show { entry_id | account | report | attachment_id | asset | party | project | reconciliation | search | page, start?, end?, columns?, compare? }` → `{ shown, viewer_open, note }`. Asks the human's viewer (`ledger-gui`) to open an entry, an account register or a report (for start/end dates if given), a stored file, an asset, a party (by id or name, resolved as in "Tools — parties"), a project (by id or name: viewer target `{show: "project", class_id}`; page `projects` its list), a reconciliation (by id: its account's page, with its statements and reconciliations; viewer target `{show: "reconciliation", id}`), a transactions search, or a page (`parties` among them). Exactly one target; entries, accounts, attachments, assets, parties, and reconciliations are checked to exist. Never journaled: the request is a line `{seq, at, target}` of the books' own `ops/show.jsonl` (at most 32 lines, replaced whole, so a reader sees each line whole), which the viewer reads and never writes. The server makes `ops/` and `ops/viewer.lock` when it opens the books (and on a request); the viewer holds a shared lock on `ops/viewer.lock`, through a read-only descriptor, while it has the books open, and `viewer_open` says whether one does. The note says "The viewer shows …", or "No viewer has these books open: ask the human to open it (opened within a couple of minutes, it shows …)". - **Dates (rules.md B-30).** For an account or a report, the dates are resolved by the one default (omitted: the fiscal year to date; only `end`: its fiscal year to it) and sent to the viewer resolved, so `shown` and the screen can't differ: "the register of 1000 Business Checking, 2026-01-01 to 2026-10-20", "the profit and loss report, 2026-01-01 to 2026-10-20", "the trial balance report, as of 2026-10-20" (trial balance, aging, fixed asset register, a balance sheet in one total column), "the vendor 1099 report, for 2026". A start after the end, given or derived, is refused and nothing is sent. - **Options a target ignores are refused**, naming what takes them: `start`, `end`, `columns` and `compare` for any target but an account or a report; `columns` and `compare` for an account; `columns` but for `profit_and_loss`, `balance_sheet` and `cash_flow`; `compare` but for those and `tax_summary`, and never with a split; `start` for a report as of a day (the trial balance, the agings, the fixed asset register, a balance sheet in one total column unless compared with the prior period) or for `vendor_1099` (its year is `end`'s); and a split into more columns than the viewer shows (`ops::MAX_REPORT_COLUMNS`, 240: past it the viewer shows the total). - The viewer takes a target's dates as sent; one it can't honour (text that isn't a date, a start after the end from an earlier writer or a hand-edited `ops/show.jsonl`, an unknown split) is ignored whole, as an unknown page is. ## Tools — integrity - `books.verify { blobs? }` → `{ status: "ok" | "healed", events, head, healed?, blobs_verified?, database, other_databases?, checked }` (`database`: the projection file verified, one for each schema, `wanderbooks.v{N}.db`, or the older `wanderbooks.db` when it holds this version's schema; `other_databases`: other schemas' files beside it, left for the versions that use them). Verifies the journal's hash chain, replays it against the live ledger, and compares every table of the SQLite database row for row against a fresh projection of the journal. A drifted database is rebuilt from the journal (the old file kept as `wanderbooks.v{N}.mismatch.