refactor!: one way to serve books: the stdio server is gone (D-44)
check / check (push) Canceled after 0s

The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

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-23 19:45:18 -06:00
1 parent 0df7f52ac3
commit 7741e96eb3
87 files changed
+3477 -6476

No files matched your search

+55 -96
View File
@@ -53,18 +53,15 @@ 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`.
Books from before P1 hold no `settings.set` and still read the books dir's
`wanderbooks.toml` (default: next to `--data-dir`; `--books-toml PATH`)
and `config.json`'s `receipt_policy`, reloaded whenever either changes, as
before. `books.import_settings` moves them in once; from then on no file
is read, not even at start (a broken file beside the books can't stop the
server). A vault key file is never a books setting: `--vault-key-file PATH`
names it to the server and the viewer (it wins over a file's `[vault]
key_file`).
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 {}` → `{ source ("books" or "file"), timezone, today,
- `books.settings {}` → `{ timezone, today,
approval_rules, keys: [{ key, doc, identity, value, set_by, seq }],
books_toml, books_toml_exists, config_json_present, reload_error, loads,
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,
@@ -73,39 +70,18 @@ key_file`).
`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 for books that keep their own, as the file spells them for
books that still read one; for those, `keys` shows the file's values,
with no journal line.
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). Refused while
the books still read a settings file (or `config.json`): import it first,
so no setting is lost.
- `books.import_settings { toml_text, config_json_text?, confirmed?,
skip_unknown?, decided_by, decided_on?, citation?, note? }` → `{ status
("preview" or "imported"), changes, notes, unknown, comments }`: exactly
the changes
that make the books' settings what the file (and `config.json`'s
`receipt_policy`, which wins as it does for the server) says. `notes` are
what isn't a books setting and where it goes (`[books] data_dir`, `[vault]
key_file`, `[viewer]`, `base_currency`); `unknown` are entries the books
don't know, not imported (confirm with `skip_unknown`); `comments` are
the file's comments, in order, which are not settings and are not
imported (record what matters in them as a decision, open item,
convention or a setting's note). The text must be
the file the server reads, whole (its import is compared with the
server's own reading of its files, key by key), and the server must read
every setting afterwards as it does now; the vault key must then come
from `--vault-key-file` (on the viewer too). A file never turns approval
rules on. Books that keep their own settings take no import that changes
them (an identical one is no change). Without `confirmed`, a preview that
records nothing. An identity value exported masked imports as
"unchanged".
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, and
importing it back changes nothing (`ledger_config::codec`).
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).
@@ -128,7 +104,7 @@ settings-and-conventions section says how to use them.
- `books.start { focus? }` (read-only, at most 8 KB) → `{ book: { name,
timezone, today, fiscal_year, base_currency, basis }, head: { seq, hash },
state ("needs_onboarding" or "active"), settings_source, rules: {
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? }],
@@ -144,8 +120,7 @@ settings-and-conventions section says how to use them.
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);
`books.import_settings` (books that still read a settings file); the
`next_steps`, in order: the format upgrade's (when waiting); the
onboarding step (`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,
@@ -224,20 +199,12 @@ as it is made.
## Transports
- **stdio** (default): `ledger-mcp --data-dir PATH`. For Claude Code,
Claude Desktop and other local agents. No auth: the OS user boundary is
the only boundary.
- **Streamable HTTP**: `ledger-mcp --data-dir PATH --http 127.0.0.1:8080`.
Serves MCP at `http://127.0.0.1:8080/mcp` via rmcp's tower integration.
**There is no authentication on this transport today**: no token and
no Origin check. The only guard is rmcp's default `Host` check, which
accepts `localhost`, `127.0.0.1` and `::1`. Any local process that can
reach the port can read and change the books. Bind it to loopback only;
binding a LAN, tailnet or public interface is an explicit human
decision, never a default. Auth, scopes and Origin validation arrive
with the served books (`plan/served.md`, P2).
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`, a stdio bridge): every book
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).
@@ -305,8 +272,9 @@ as it is made.
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 settings are
re-read first, so a client connecting after an edit is told the new rule).
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
@@ -327,9 +295,7 @@ 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, or, for books that still read a
settings file, that file, the one local file the agent may edit until
`books.import_settings` moves it in. When a long rule (many
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
@@ -368,17 +334,15 @@ 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` (the input and output schemas name it by
design, for `books.settings`' `books_toml`) (`tool_text_lint`). A rule set by law that the books
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 29:
`books.post_entry`, `books.reverse_entry`, `books.import_settings`,
`books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
that changes anything also has a `destructiveHint`, true for these 28:
`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`,
@@ -388,11 +352,10 @@ that changes anything also has a `destructiveHint`, true for these 29:
`books.set_tin` (both overwrite a vault record), `books.purge_tin`,
`books.create_invoice`, `books.create_bill`, `books.receive_payment`,
`books.pay_bill`, `books.run_payroll`, `books.remit_payroll_taxes`; and an
`idempotentHint` on the eleven that a repeat with the same arguments leaves
`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.import_settings`,
`books.set_vendor_1099`, `books.set_entry_instrument`,
`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`
@@ -407,11 +370,12 @@ 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, the contents of every file under
the data dir (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),
the vault and the settings file as they were
(`read_only_tools_change_nothing`). `books.show` is read-only though it
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.
@@ -927,12 +891,11 @@ through the policy.
`error` or `refused` (anything else is refused). `total` counts the
whole log; `more` says older records match too. The log is read whole
and its chain checked first: a damaged log is reported, never shown in
part. Books served over stdio keep no access log: the tool is not
offered there (and is refused if called).
part.
## Tools — backups
Books served from the data root back themselves up (plan 3.7, D-30,
Books 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
@@ -968,10 +931,9 @@ 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
keyed by the target itself. Setting `backup.*` (books.set_setting) 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,
@@ -1175,7 +1137,7 @@ first entry's).
the package's `as_of`; so `books.vendor_1099` given the package's
`cash_accounts`, `financing_accounts` and `through` = `as_of` (and its
`include_tins`) hands over the package's `1099-worksheet-<year>.csv`
byte for byte (the tool names one with TINs `-with-tins`; checked by `ledger-mcp` `oracle_mcp.rs`
byte for byte (the tool names one with TINs `-with-tins`; checked by `ledger-cli` `oracle_mcp.rs`
`cpa_package_holds_the_year_and_its_evidence`).
## Tools — parties
@@ -1420,8 +1382,8 @@ and the CPA package carries them.
missing.
- **Never out:** no tool output, error, or log holds more than a TIN's
last four, except `books.vendor_1099 { include_tins: true }`; the CPA
package carries them only in its zip, handed over as an export (or
inline over stdio), never written to a path.
package carries them only in its zip, handed over as an export, never
written to a path.
The tool arguments that take a TIN never echo it, even when refused.
- **TIN facts:** any party (any role) may have `tin` facts:
`{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`,
@@ -1460,13 +1422,12 @@ and the CPA package carries them.
- **Handed files** (D-29): no tool takes a path. A file the books make
for an agent (`books.get_w9`'s form, `books.vendor_1099`'s `csv`,
`books.export_cpa_package`'s zip) comes back as a `HandedFile {
file_name, mime_type, size_bytes, sha256, download?, expires_at?,
content_base64? }`. On books served by `wanderbooks mcp` it is an
file_name, mime_type, size_bytes, sha256, download, expires_at }`: an
export, kept 15 minutes for the token that asked (at most 16 per
token, swept like uploads), and `download` is the command that saves
it in a folder the human names: `wanderbooks download --book B
[--token NAME] ex_… --to <a folder>` (`GET /b/{book}/x/{export}`,
"Transports"). Over stdio it comes back inline as `content_base64`.
"Transports"). Its bytes never come back inline (D-44).
A file with full TINs never goes into the books: the server keeps, beside the vault, the SHA-256 of every
file it read or wrote with full TINs (the W-9 `books.set_w9` reads,
recorded once the call has passed every check, just before the vault
@@ -1544,9 +1505,7 @@ never pass through the agent), or `content_base64` with its `sha256`
(at most 256 KiB decoded; longer text is refused before it is decoded).
Never both, never neither. Inline bytes that aren't base64, or whose
SHA-256 isn't the one given, are refused (guard `base64_corrupt`): send
the file as an upload. Uploads exist only for books served from the
data root (the server's socket carries the bytes); over stdio, only
inline, and the refusal says so and to tell the human. On the socket an
the file as an upload. An upload is kept for the token that began it. An
MCP request is at most 1 MiB. A file's name and type are checked when
it is filed (every filing tool, `books.begin_upload`, and the W-9's
name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`,
@@ -1617,8 +1576,7 @@ and no control or invisible (Unicode format) characters; a MIME type
`download`, the command `wanderbooks download --book B <id> --to <a
folder>` (with `--token NAME` when the caller's token isn't the
default `agent`; `GET /b/{book}/a/{attachment}`, "Transports"), which
saves a copy as a new file in that folder. Over stdio there is no
download: a larger file is refused.
saves a copy as a new file in that folder.
- `books.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "statement" | "contract" | "photo" | "warranty" |
"import_source" | "other", file_name?, mime_type?, upload_id? |
@@ -1641,8 +1599,7 @@ posting.
(account codes whose entries always need one, e.g. meals and travel)
and `policy.evidence.exempt_accounts` (e.g. automated bank fees; an
entry is exempt only when every line is in one). Changed with
`books.set_setting`; books from before P1 read `config.json`'s
`receipt_policy`.
`books.set_setting`.
- **Preview.** Every `post_entry` preview includes the receipt checklist
(`receipts: [...]`, `receipt_required: bool`, `receipt_missing: bool`,
`split_detected: [...]`), so the human sees it before confirming. A
@@ -1759,16 +1716,17 @@ posting.
## Tools — chart templates
- `books.chart_template { entity_type, owners? }` → `{ csv, settings_toml,
- `books.chart_template { entity_type, owners? }` → `{ csv, settings,
notes }`: a starter chart for a sole proprietorship (incl. single-member
LLC), partnership (incl. multi-member LLC), S corporation, or C
corporation — the equity and owner-pay accounts that entity needs (owner's
capital and draws; per-partner capital and distributions plus guaranteed
payments; officer compensation and shareholder distributions; dividends
and income tax expense), common income and expense accounts with tax
categories, and `settings_toml`, the settings that go with it (tax form,
members, where closes go) as settings-file TOML for
`books.import_settings`. A proposal: review with the human, then `books.import_chart_csv`.
categories, and `settings`, the settings that go with it (tax form,
members, where closes go) as `[{ key, value }]` changes for
`books.set_setting`. A proposal: review with the human, then
`books.import_chart_csv` and `books.set_setting`.
## Tools — tax categories
@@ -1877,7 +1835,8 @@ Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
view `meta`; no bytes)
Resources exist so read-only clients (dashboards, the viewer,
auditors) never need tool credentials. Tested end-to-end over stdio.
auditors) never need tool credentials. Tested end to end through a real
books server (ledger-cli's tests).
## The agent operating contract