docs: P0 review: server instructions, handbook, local files, fail closed

docs/mcp-interface.md: the connect text and its budgets, the handbook
(tool and resources), annotations, failing closed, import_local_file's
roots and refusals, the set_w9 path rule and where TIN-bearing files may
go. docs/testing.md: the P0 laws and the tests that check them.
docs/architecture.md: the journal format gate and the server's
fail-closed store lock.

Round-2 review (2026-10-16): mcp-interface.md lists all 26 destructive
tools (unlink_receipt, unlink_attachment and correct_asset were missing)
and openWorldHint false on every tool; the connect text states the
parties rule and the exact receipt rule, and how it fits its budget; the
lint covers every refusal; the import roots refuse /, the home folder and
folders holding the books or a key, the open is a component walk, every
missing or outside path gets one refusal, and the TIN-file registry and
text scan (and what they can't catch) are described. architecture.md:
the frozen envelope, the lenient next-line prev, no repair of newer
books. testing.md: the new laws and tests. verification.md and
parity.md: P0 notes (no new law to prove, no status moved).
The TIN text check, narrowed (2026-10-16; rules.md B-26,
mcp-interface.md): text is refused for a TIN the vault holds, in its
common renderings, or a stand-alone printed SSN or EIN shape, so bank
exports go in; the oracle a vault comparison gives is recorded as a
known limit for P7. testing.md: the P0 mutation run of the final diff,
per crate, and how each survivor was resolved (killed by a named test,
removed by simpler code, or argued equivalent).
The probe bound (rules.md B-26, mcp-interface.md): rule (a) answers
for at most two candidates a filing, so finding a TIN from its last four
takes on the order of 50,000 refused filings; the open limit now names
that path, closed by P2's access log and P7's human-only TIN channel.
testing.md: the law and its tests; tin_files.rs mutated again, 74 of
74 caught.

Review round 3: rules.md B-28 (four-digit years, amounts a line can
carry, checked products, bounded recurring runs) and B-26/B-27 updates
(BOM-less UTF-16, when set_w9 records); mcp-interface.md matches the
code (25 destructive tools, the five idempotent ones, no reverse_entry
receipt checklist, strictly above the threshold, no tool text names
wanderbooks.toml, dot names and roots inside the books refused, the new
settings lists); testing.md lists the new laws and this round's
mutation run.

Review round 4: rules.md B-28 says the bounds are for new commands
only, replay accepts every line an earlier version accepted, and derived
figures refuse rather than panic; mcp-interface.md documents plan_error
and the nullable totals; testing.md lists the old-books contracts and this round's
mutation run (core, store, MCP, config, GUI).

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-17 09:25:00 -06:00
1 parent b22a034746
commit c9772b70c1
6 files changed
+658 -37

No files matched your search

+151 -12
View File
@@ -589,19 +589,55 @@ filed back in as a receipt and read out again), and the package comes
back only as its path. No file holding a full TIN is filed into the books
(DECISION, P0 review round 2, 2026-10-16): 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 as it is read; each `books.get_w9` copy;
`books.set_w9` reads, recorded once the call has passed every check, just
before the vault keeps it, so a refused call marks no file; each
`books.get_w9` copy;
a 1099 worksheet with TINs; a CPA package with TINs and each file in it
that holds one), shared by every session and every later server for the
books, and every way a file enters the books (`books.import_local_file`,
`books.attach_receipt`, `books.file_document`) refuses those exact files,
and refuses any text file (by its type or name, or because it decodes as
UTF-8 or UTF-16) in which `has_full_tin` finds a TIN. What this can't
catch: a changed copy of a binary file (a W-9 PDF re-saved or re-scanned),
or a TIN in an image; `plan/served.md` P2 and P7 take the path tools and
full TINs out of the agent's reach. The text scan reads nine digits with
separators as a TIN, so a statement's text with a date run into an
amount, or a long reference number, is refused too; such a file is filed
as a PDF. A locked vault or a missing TIN never fails
UTF-8 or UTF-16, with a byte-order mark or, NUL-interleaved, without one)
holding (a) a TIN the vault holds for a party of the
books, anywhere in the text, as nine digits in a row, as 3-2-4 digits
joined by hyphens, spaces or dots, or as 2-7 digits joined by a hyphen
or a space (compared on the server against the decrypted vault, never
returned or logged; a vault that can't be opened leaves (b) alone), or
(b) a number printed as an SSN or EIN is, standing alone: 3-2-4 digits
joined by hyphens (123-45-6789) or 2-7 joined by a hyphen (12-3456789),
with no digit, and no hyphen or dot followed by a digit, on either side.
Full-width digits and Unicode hyphens and spaces count as their ASCII
kin (`tin_files.rs`). The text check is narrow on purpose (DECISION,
Stephen, 2026-10-16): bank exports are filed for reconciliation, and
their routing numbers, long references and dates next to amounts are
not TINs; the broad `has_full_tin` reading stays on what the server says
(refusals and results). Because (a) compares against the vault, its
answer would tell an agent whether a text it chose holds a vault TIN;
the tools show each TIN's last four, so an agent filing texts full of
nine-digit numbers ending in those four could find the TIN in about 17
filings. So (a) answers for at most two candidates a filing (DECISION,
P0 review, 2026-10-16): before the vault is opened, a text holding more
than two distinct TIN-shaped numbers (in any rendering (a) reads; one
number written several ways counts once) that end in the last four of a
TIN the books know (each party's TIN facts and W-9 facts, read from the
books; once the vault is opened, its own records' last fours too) is
refused with words of its own ("more than two numbers shaped like a
TIN"), the same byte for byte whether or not one of them is a vault TIN
and whether or not the vault is open; only a text with no more than two
is compared against the vault. With the last four known, a TIN is one of
100,000 numbers, so finding one this way takes on the order of 50,000
filings, each one refused and seen. What this can't catch: a changed
copy of a binary file (a W-9 PDF re-saved or re-scanned), a TIN in an
image, or a TIN the vault doesn't hold written other than (b);
`plan/served.md` P2 and P7 take the path tools and full TINs out of the
agent's reach. A known limit, open until then: (a) still answers for two
candidates a filing, so an agent that makes those 50,000 refused filings
can find a TIN; P2's access log records every call, refusals included,
and P7's human-only TIN channel takes full TINs, and with them this
check, off the agent's path. A bank export holding three numbers that
happen to end in a known last four is refused the same way (filed as a
PDF instead).
A locked vault or a missing TIN never fails
the package: it says which TINs are missing. `books.purge_tin` deletes a
party's vault records and journals that it did.
@@ -614,15 +650,18 @@ records, records_held, purge_records); `ledger-mcp` `books.set_w9`,
`books.set_tin`, `books.get_w9`, `books.purge_tin`, `books.vendor_1099`,
`books.export_cpa_package` (open_vault refuses a key file in the books
dir; outside_books for files with a TIN; vault_tin checks the vault
against the books); `ledger-mcp` `tin_files` (the registry and the text
scan, on every filing tool).
against the books); `ledger-mcp` `tin_files` (the registry, the text
scan and its probe bound, on every filing tool; `known_tin_last4` gives
the books' last fours).
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp`
`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,
and every file in the books dir outside `vault/` scanned for the TINs in
any formatting), `tests/tin_files.rs`
(`a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`),
and the unit tests in `tin_files.rs`.
(`a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`,
`text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`,
`text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`), and the
unit tests in `tin_files.rs`.
## B-27 The journal format gate: newer is not corrupt
@@ -689,3 +728,103 @@ lines before it pass, so an older version never alters newer books.
`ledger-gui` `tests/newer_journal.rs`; `ledger-mcp` `tests/startup.rs`;
the `fuzz_store_replay` target (the writer and the viewer agree on newer
versus bad).
## B-28 What new commands take: four-digit years, amounts a line can carry
**Status:** CONVENTION (ISO 8601's basic calendar dates have four-digit
years, as do the spreadsheet and PDF formats the books export to);
DECISION (P0 review round 3, 2026-10-16, finding S1: the server stops
for every session when a request panics, so no input may reach one);
DECISION (P0 review round 4, 2026-10-17: posted is forever, so the
bounds are the commands', never replay's)
**New commands.** Every date a command takes or derives is in
0001-01-01 through 9999-12-31 (`ledger_core::calendar`): a date given
to a tool is exactly YYYY-MM-DD, a real day in that range (no sign, no
space, no fifth year digit); a period is a month of it; a new schedule's
last month, a new asset's last day of depreciation, a new recurring
entry's start and end, a new period, a new invoice's or bill's date and
due date, a disposal's date stay in it. Every amount a command takes,
not only a posted line's, is at most a journal line's
(1,000,000,000,000,000, `kernel::MAX_LINE_AMOUNT`): a draft line (by
size; its precision is checked when it posts), a document line, a
payout's lines and deposit, every payroll figure, a statement's
balances, a party's share, a contributor's basis, disposal proceeds, a
recurring template's lines. One run of the recurring entries creates at
most 1,000 drafts (`MAX_RECURRING_RUN`); a later date is run in steps.
**Replay accepts every line an earlier version accepted.** The bounds
above came after books were written, and posted is forever (B-8): a
journal an earlier wanderbooks wrote opens, in the writer and the
viewer alike, and replays to the same figures, forever. Replay applies
each check exactly as the version before the bound did: a journaled date
is any date chrono reads as `%Y-%m-%d` (chrono's own range, about
262,000 years either way, written "+10000-01-01" past 9999); a draft
line, a recurring template, a party's share, a contributor's basis, a
document line, a payment, a payroll run and disposal proceeds carry no
upper bound beyond what their own rules always said (positive, cents,
balanced, and so on); a schedule's months are 1 to 600 and an asset's
life ends on a date chrono holds. The checks split in two: the
command's (`Ledger::add_line`, `register_asset`, `TaxBasis::check`,
`shares::check_shares`, ...) and replay's (`replay_add_line`,
`replay_register_asset`, `TaxBasis::check_journaled`,
`shares::check_shares_journaled`, ...), and every bound has its pair of
tests: the command refuses past it; replay of an old line past it is
accepted.
**Derived figures refuse, never panic.** Because replay accepts what
the commands now refuse, every figure the books derive is computed with
checked arithmetic: a sum or product exact arithmetic can't hold is
refused with "too large to compute exactly" (`TooLargeToCompute`), and
a date past chrono's last day with `DateOutOfRange`, at the point of
computation (a draft's debits, a document's total and balance, aging,
net pay, a payroll plan, an entry's money flow and its shares'
resolution, the fixed asset register, an amortization plan), never
wrapped, rounded early or panicked on, and the server stays up. Date
walks reach chrono's last day without stepping past it (an asset's
depreciation in December 262142, an interval ending at
`NaiveDate::MAX`). A replayed line whose own figures overflow (a
recurring template whose debits Decimal can't sum: no version could
write it, and the last one panicked replaying it) is refused as the
books open, never a panic.
**Enforced:** `ledger_core::calendar`; `Ledger::try_create_entry`,
`add_line`, `add_line_fx`, `validate_new_asset`, `dispose_asset`,
`schedules::validate_schedule`, `validate_recurring`,
`periods::validate_period_range`, `arap::validate_lines`,
`validate_due_date`, `payroll::check_payroll_ranges`, `payout_plan`,
`shares::check_shares`, `TaxBasis::check`, `reconciliation_report`; for
replay, `replay_add_line`, `replay_add_line_fx`,
`replay_register_asset`, `replay_correct_asset`,
`replay_dispose_asset`, `replay_set_asset_tax_basis`,
`replay_set_entry_parties`, `replay_register_recurring`,
`replay_register_schedule` (`validate_schedule_shape`),
`replay_create_period` (`validate_period_order`), `replay_invoice`,
`replay_bill`, `replay_invoice_payment`, `replay_bill_payment`,
`replay_payroll_run`, `validate_new_asset_journaled`,
`validate_recurring_journaled`, `check_shares_journaled`,
`TaxBasis::check_journaled`; derived: `Invoice::total`/`balance`,
`Bill::total`/`balance`, `AgingRow::total`, `ar_aging`, `ap_aging`,
`PayrollLine::net_pay`, `payroll_entry_plan`, `parties::entry_flow`,
`shares::share_target`, `shares::resolve`, `fixed_asset_register`,
`schedules::amortization_plan`, `month_end`, `depreciation_schedule`,
`analysis::intervals`, `form1099::vendor_part`, `k1::share_property`,
`ReceiptPolicy::entry_debits`; `ledger-store` `parse_date` (replay:
chrono's range), `parse_period`, `last_day_of_month`,
`record_statement`, `dispose_preview`, `run_recurring`; `ledger-mcp`
`parse_date`, `parse_period_mcp`, `last_day_of_month`.
**Checked:** `ledger-core` `tests/spec_bounds.rs` (each bound and its
replay pair), `calendar.rs`, the unit tests of `amortization_plan`,
`intervals`, `vendor_part` and `share_property`, `assets.rs`
`registration_rejects_date_overflow`, `invariants.rs`
`out_of_range_amount_is_rejected`; `ledger-store` `tests/store.rs`
`books_an_earlier_version_wrote_open_to_its_figures` (every book in
`tests/fixtures/old-books`, written by main 188c90ea, opens in the writer
and the viewer and replays to exactly the figures main computed) and
`every_figure_over_old_books_is_computed_or_refused`, `tests/bounds.rs`;
`ledger-mcp` `tests/hostile_args.rs` (every tool with generated hostile
arguments and every resource with generated URIs: the server never
stops, and the books reopen after; the inputs that once panicked,
pinned; every tool over every old book) and `server.rs`
`periods_are_a_year_and_a_month`, `dates_are_four_digit_years_and_real_days`;
`ledger-gui` `tests/old_books.rs`.