fix: Windows reads of lock files, update paths cfg(unix), merge main again (P11c)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

spec_vault swept every file under a book, lock files included; on Windows
a held LockFileEx lock is mandatory and the read failed. The locks are
already dedicated files (never data), so the test skips *.lock. Merged
main (P13 step 6, the macOS server.log assert, P11a's AppImage identity):
host.rs keeps main's five-field identity on Unix and size/last-write on
Windows; the update poll and set_program_version are Unix-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 17:03:30 -06:00
commit 7749afc557
234 files changed
+13028 -18411

No files matched your search

+51 -7
View File
@@ -31,9 +31,9 @@ the specification in `bookspec/` (which never depends on the engine).
| `ledger-core` | the semantics engine: money, chart, journal, `Ledger` commands, reports, periods, assets, schedules, parties, A/R and A/P, payroll, projects, settings and conventions, the kernel and its Kani harnesses | nothing |
| `ledger-vault` | the TIN vault: XChaCha20-Poly1305 records under the book's key | nothing |
| `ledger-config` | the `wanderbooks.toml` codec (adoption in, `books.export_settings` out) and the receipt policy until P4 moves it into the core | `ledger-core` |
| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) |
| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `describe.rs` (the journal in words, for `books.journal_history`), `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) |
| `ledger-reader` | the one reader: a PDF's text and pages, an XLSX's cells as exact decimal strings, plain text (P6; a plain library, in-process, D-61); `ledger-import` re-exports it as `extract` | none (`hayro`, `zip`, `quick-xml`) |
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` |
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels, the audit workpaper) out | `ledger-core`, `ledger-reader` |
| `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` |
| `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above |
| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a) | `ledger-store`, `ledger-mcp`, `ledger-config` |
@@ -59,13 +59,26 @@ closure. The reader now lives in the small library crate `ledger-reader`;
re-exports it as `extract`), the store no longer builds the typesetter,
and `ledger-import` still never depends on the store.
Audits (P8, rules.md B-45) are split by what each layer can see. The pure
checks over a ledger are `ledger_core::audit` (`statements`, with the facts
a ledger cannot know handed in by the caller: the receipts filed, the
statement of cash flows the role settings give, the settings problems, the
undecided 1099 vendors); what only the store can see (the chain, the
database against a replay, the stored files, the access log, duplicate
files) and the audit and attestation events are `ledger_store::audit` (a
child module of `store`, because it reads the store's private state); the
workpaper's PDF is `ledger_import::workpaper` with its Typst template in
`ledger-import`'s `templates/` beside the other documents; the backups'
standing is read by the server, which alone knows the backups
(`BackupStanding`), and the tools are `ledger-mcp`'s `tools/audit.rs`.
`ledger-core` itself (`crates/ledger-core/src/`): `money`, `account`,
`journal`, `ledger` (the command surface and replay), `kernel` (every
money arithmetic, generic over `Amount`) and `proofs` (its Kani
harnesses), `reports`, `cashflow`, `analysis`, `periods`, `assets`,
`schedules`, `recurring`, `payouts`, `payroll`, `form1099`, `tax`,
`classes`, `parties`, `arap`, `settings`, `conventions`, `calendar`,
`shares`, `tax_capital`, `rules`, `error`; its tests in `tests/`
`shares`, `tax_capital`, `rules`, `audit` (the audit programme's pure checks, P8), `error`; its tests in `tests/`
(`invariants.rs`, `properties.rs`, `spec_*.rs`, `kernel_bridge.rs`,
`model.rs`, `bookspec.rs`, `golden/`).
@@ -365,9 +378,21 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by
without the agent noticing. The format gate stays the server's (3.9):
`rollback` refuses while a book declares a format or feature the kept
version does not know;
- an AppImage is the exception to the restart: the host runs from the old
image's mount, whose path does not change, so it keeps running the old
version until the books are next closed and opened (docs/packaging.md).
- an AppImage runs the program from a mount that goes away with the image,
so the host learns the image's own path from `APPIMAGE` (the one variable
`start.rs` adds to its scrubbed environment, only when it names an
absolute file; the host reads it about itself and never tells an agent),
runs and restarts from that file, and tells a replaced program by its
inode, size and last write, like any other;
- updates are also **staged** (step 4): while the books are open the host
looks at the feed at most once a day (`update_check` knob; the look is
noted in `updates/checked`), and only downloads and verifies a newer
release into `<root>/updates/staged/`. It never swaps. The staged program
is applied by an opener just before it starts a host (no host runs, so no
call is cut off), after a second check against the key, or by
`wanderbooks update`, the owner's say-so, which reuses the staged copy. A
local release is named in `<root>/updates/feed` (a `file://` address, then
the key file), the same rule as `--trusted-key`.
`deploy/packaging/` holds the Linux packaging: `packager.toml`
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD),
@@ -375,4 +400,23 @@ the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD` (the AUR
package, which repacks the release's tarball), `build-linux.sh`,
`repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a
throwaway-keyed feed on this machine) and `test-linux-packages.sh`.
`.github/workflows/release.yml` builds them on a version tag.
`.github/workflows/release.yml` builds them on a version tag. The macOS
bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an
unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5).
## The result envelope and the guards (`ledger-mcp`, P5)
Every tool result is given the same fields in one place: `call_tool` runs the
tool, then `next_steps::apply` adds `next_steps`, `guards`, `warnings` and
(on a tool that is not read-only) `head`, in the structured content and in
the text block alike, never overwriting a field the tool wrote. Next steps
come from the tool's own result (`next_steps::tool_steps`: the confirm after
a preview, what a post, a draft, a statement or a close leads to) and, after
a change to an entry or an invoice, from the viewer's own Needs-attention
items about it (`ledger_package::find_with`). `guards.rs` is the registry of
named refusals, each with the tool that remedies it; a refusal whose text
carries a guard gets it as `structuredContent.guards`. The prompts
(`tools/prompts.rs`, text in `prompts/*.md`) and the resource templates are
served by the same `ServerHandler`; `books.start` is loaded up front by
clients that defer tools (`_meta`). `ledger-store`'s `describe.rs` is the
one place that turns a journal line into a sentence for an agent.
+228 -31
View File
@@ -12,12 +12,16 @@ locked to one assistant.
| Crate | Role | Spec |
|---|---|---|
| `ledger-core` | the engine: money, entries, the laws; every rule the tools enforce | `docs/spec/rules.md`, `docs/verification.md` |
| `ledger-package` | the read-only views shared with the viewer: reports, aging, the CPA package, Needs attention (the one list `books.start` and the viewer both draw) | `docs/architecture.md` |
| `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.)
(P5 rewrote the overview sections for the surface as it is: the result
envelope, `books.start` v2, the prompts, the resource templates and the
journal in words, below. The per-tool sections are held to the code by a
test: `every_tool_is_documented_and_every_documented_tool_exists`.)
Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting
logic: it translates tool calls into `Ledger` commands and formats results.
@@ -106,15 +110,37 @@ Conventions, decisions, open items and approvals live in the books too
when; `books.start` lists them, and the handbook's
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 },
- `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? }],
truncated?, handbook_sections, format_upgrade?: {
guard, format, adds, how }, next_steps: [{ tool, args?, why }],
isolation }`. The connect text names it as the first call.
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"}`,
@@ -128,8 +154,9 @@ settings-and-conventions section says how to use them.
`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 step (`books.handbook {section: "contract"}`, when the chart
is empty or `focus` is `onboarding`); `books.open_items` (an item due
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
@@ -138,11 +165,11 @@ settings-and-conventions section says how to use them.
server's own damage check and are not repeated); then the focus's step
(`close`:
`books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then
always `books.handbook {section: "contract"}` and `books.digest`.
`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. Not built yet (P5):
`book.id`, `book.url`, `head.last_handoff`, the `agent` input, and
loading it up front under tool search. Also `books://start`.
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
@@ -173,6 +200,16 @@ as it is made.
`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
@@ -190,8 +227,14 @@ as it is made.
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.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`,
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
@@ -350,8 +393,8 @@ 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.
Tool descriptions are at most 700 characters (`tool_descriptions_fit_700_characters`
allows none longer; the long text lives in the handbook).
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
that changes anything also has a `destructiveHint`, true for these 31:
@@ -443,9 +486,44 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
`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`
`books.purge_tin`, `books.consolidate_duplicates`,
`books.discard_draft` and `books.audit`. (`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 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). A tool that can change the books always has `head` and
`next_steps`; a read has `next_steps` only when something follows it, so
a resource and its tool say the same (`books://settings` is
`books.settings`). `guards` is on a refusal; a success that has no
warnings has no `warnings` key. A tool that can change the books always answers with all four;
a read has `next_steps` only when something follows it and no `guards`
or `warnings` of the envelope's, so a resource and its tool say the same
(`books://settings` is `books.settings`). 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).
@@ -1958,7 +2036,17 @@ posting.
## Resources (read-only)
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
`list_resources` advertises, `read_resource` renders.
`list_resources` advertises, `list_resource_templates` advertises the
templates below (P5), `read_resource` renders.
- Templates: `books://entries/{id}` (as `books.get_entry`),
`books://attachments/{id}` (as below), `books://journal/{seq}` (that line
in words: `books.journal_history`'s answer for it),
`books://reports/{kind}{?start,end,as_of}` (`kind` is `trial-balance`,
`balance-sheet` or `profit-and-loss`; `as_of` for the first two, `start`
and `end` for the third; omitted: today, or the fiscal year to date) and
`books://digest{?since,since_seq,as_of}` (`since_seq` wins over `since`;
bare: this month so far).
- `books://start`, `books://settings`, `books://conventions`
- `books://chart`
@@ -2118,8 +2206,8 @@ snapshots; see plan 8.2 S4.
receivable line tagged with the invoice and the receivable side one line
per (account, project) part.
- `customer` is the customer's id, its party's id or its name (a name
two customers answer to is refused); `party_id` is the old spelling of
the same thing (a customer id), kept until P5.
two customers answer to is refused); it is required (the old `party_id`
spelling is gone, P5).
- The due date: `due_date` as given (and it overrides `terms`); else
`terms` ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on receipt",
"Due 2026-11-15", rules.md B-39); else the customer's
@@ -2170,8 +2258,8 @@ snapshots; see plan 8.2 S4.
sha256 }, warnings?, notes? }`.
Confirmed it posts Dr each line's account / Cr `2000` Accounts Payable,
the payable line tagged with the bill and one debit line per (account,
project) part; `vendor` is a vendor id, its party's id or its name, and
`party_id` the old spelling (a vendor id).
project) part; `vendor` is a vendor id, its party's id or its name
(required; the old `party_id` spelling is gone, P5).
- `vendor_ref` is the vendor's own invoice number: required in books
that have declared `documents_v2` (B-40). The same vendor and number
twice is refused unless `duplicate_reason` says why, which is
@@ -2194,8 +2282,8 @@ snapshots; see plan 8.2 S4.
- `warnings` names an expense account it created with no tax category;
`notes` one per finished or abandoned project a line names (none for
on-spec: a cost of on-spec work is an expense like any other).
- `books.receive_payment { date, deposit_to | cash_account, instrument?,
reference?, invoice | document_id, amount? | applications: [{ invoice,
- `books.receive_payment { date, deposit_to, instrument?,
reference?, invoice, amount? | applications: [{ invoice,
amount, discount?, discount_reason? }], fee?: { amount, account?,
vendor? }, paid_in_full_marking?, confirmed?, preview? }` → `{ preview?,
entry_id, documents: [{ ref, number?, outstanding, status }], deposited,
@@ -2217,11 +2305,11 @@ snapshots; see plan 8.2 S4.
repeat the single document's figures. An overpayment is refused with the
excess and the remedy (a plain entry to 2300 Customer Deposits).
`paid_in_full_marking: true` adds a UCC §3-311 warning and records
nothing else. `document_id` and `cash_account` are the older names of
`invoice`/`bill` and `deposit_to`/`paid_from`. The books record
nothing else. (The older names `document_id` and `cash_account` are
gone, P5.) The books record
payments; they never collect them (D-45).
- `books.pay_bill { date, paid_from | cash_account, instrument?,
reference?, bill | document_id, amount? | applications: [{ bill, amount,
- `books.pay_bill { date, paid_from, instrument?,
reference?, bill, amount? | applications: [{ bill, amount,
discount?, discount_reason? }], confirmed?, preview? }` → as `receive_payment`, per bill and
one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer
account the cash, Cr purchase discounts the discounts. A discount on a
@@ -2466,7 +2554,7 @@ until the owner upgrades them.
## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
- `books.export_cpa_package { as_of?, without_audit_reason?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins?,
allow_open_drafts?: { reason } }` → `{ file,
tins_included, tin_gaps, warnings?, note }`: the zip,
@@ -2604,3 +2692,112 @@ state and discard tools wait for the books' format upgrade).
drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems:
[...] }` for the report's end date, and take `strict: true` to be refused,
naming what is open, instead of warned.
## Tools — the journal in words (P5)
- `books.journal_history { after_seq?, types?, subject?, limit? }` → `{
lines: [{ seq, ts, type, summary, subject?, entry_id? }], more, head_seq,
tail_repairs: [{ at, bytes }] }`. The journal in order, a sentence for
each line (`summary`: what it did, to what, the short names a person
typed; never a setting's value, never a full TIN), the line's own
`subject` (`entry:12`, `invoice:3`, `bill:3`, `account:10`, `party:5`,
`asset:2`) and the entry it belongs to. `types` takes an exact type
(`entry.posted`) or a group ending in a dot (`entry.`); `subject` keeps
the lines about one thing (a line is about the subject its type names and
every one its payload refers to); `limit` is 50 by default, 200 at most;
`more` says to ask again with the last `seq` as `after_seq`. Read-only,
`books:read`. It is how a figure is traced to what produced it and the
only way to read the journal; `tail_repairs` lists torn tails set aside
when the books opened (when, how many bytes). The sentences come from
`ledger-store`'s `describe.rs`.
## Prompts (P5)
The server advertises the prompts capability (also in the bridge's cold
`initialize`). `prompts/list` and `prompts/get` need a token that may read.
Each prompt's text is a file in `ledger-mcp/prompts/`, with the company's
name and the arguments filled in; it names only tools that exist (a test
checks). An argument is one token (letters, digits, `-`, `_`, `.`, `:`; at
most 40 characters) of the shape the prompt asks for, so an argument can
never rewrite the text; an unknown prompt, a missing, extra or malformed
argument is refused.
| Prompt | Arguments | Drives |
|---|---|---|
| `onboard` | none | the company's facts, the chart, the policies, the opening balances, the trial-balance check |
| `reconcile` | `account` (a code), `month` (YYYY-MM) | the statement, `books.record_statement`, `books.mark_cleared`, `books.reconcile` |
| `month_end_close` | `month` (YYYY-MM) | the company's own close steps, reconciliations, drafts, schedules, checks, `books.close_period` |
| `cpa_handoff` | `year` (YYYY) | `books.verify`, the periods, 1099 work, `books.export_cpa_package` |
| `annual_compliance` | `year` (YYYY) | the year's filings, 1099s, assets, open items |
| `audit` | `scope` (`month`, `annual`, `cpa_handoff` or `full`) | `books.verify`, `books.control_check`, the digest, evidence against entries (P8 fills in its own tools) |
`migrate`, `process_inbox` and `receipts_from_email` are not built (D-46,
D-28, D-17).
## Tools — audit and attestations (P8)
An audit is the books read as an auditor reads them, through a day, in one
run: the same books give the same findings and the same workpaper. An
attestation is a claim by a checker that it looked at something. The audit
finds; the attestations stand behind it; the CPA package waits for both
(rules.md B-45; decision D-9; the journal feature `audits`: the audit run
and the attestations wait for the books' format upgrade, refused with
`format_upgrade_pending` until then).
- `books.audit { scope, through?, confirmed?, preview? }` → `{ status:
"preview" | "audited" | "already_run", preview, head, checks: [{ code,
severity, predicate }], run?: { id, through, through_seq, scope,
program_version, tb_hash, findings_sha256, workpaper_attachment,
workpaper_pdf, at_current_head }, findings: [{ severity, code, subject,
message }], summary }`. `scope` is `month`, `annual`, `cpa_handoff` or
`full`; `through` defaults to today. Without `confirmed` it lists the
checks and changes nothing; with `confirmed` and the `preview` digest it
runs them, files the workpaper as two documents (category `workpaper`:
JSON, and a PDF whose bytes depend only on the findings) and journals
`audit.run`. The checks are the statements (trial balance, the
accounting equation, the profit and loss against equity, the cash flow's
footing), the A/R and A/P control accounts, closes, schedules and
depreciation, the asset register, each posted entry against the receipt
policy, open drafts, tax categories, 1099 decisions and the settings;
then the journal's chain, the database against a replay, the stored
files, the access log, duplicate files and the backups. The same books
(the figure head), day, scope and programme return the run that exists
(`already_run`) and file and journal nothing. "The same books" means no
entry, document, close, settings or role change since: an audit, an
attestation or a filed receipt does not change them. Needs books:write to
preview, books:post to run.
- `books.workpaper { id }` → `{ run, workpaper, pdf? }`: the workpaper as
filed (what was checked, through which day and state, every finding) and
its PDF handed over (`pdf` is a download command; kept 15 minutes).
Read-only.
- `books.attest_receipt { attachment_id, entry_id, amount, date, method?,
note? }` → `{ attestation, bar, why_not_counted? }`. The checker's
reading of the receipt's total and date, compared by the books with the
posted entry's amounts (its debits, or any one line) and date; a
difference is recorded with `disagrees: true` and never counts. The
receipt must be filed on or linked to the entry.
- `books.review_entry { entry_id, method?, note? }` → the same shape: a
posted entry reviewed whole.
- `books.attest_period { period, method?, note? }` → the same shape.
`period` is an id or a name from `books.list_periods`. Refused unless an
audit through the period's end stands at the books' current head; the
attestation names that run.
- `books.attestations { through?, kind? }` → `{ bar, attestations: [{ seq,
kind, by, at, attachment_id?, entry_id?, period_id?, audit_run_id?,
amount_checked?, date_checked?, disagrees, method, note?, counted }],
counted }`. `kind` is `receipt`, `entry` or `period`. `counted` is read
against `policy.attestation_bar` as it stands when asked and never
stored: a bar change changes what counts, earlier claims too.
`method` is `{ model?, reasoning_effort?, adversarial?, verifier_agents? }`
and is the client's own claim: the books record it and cannot verify it.
`by` is the token's name. The bar (`policy.attestation_bar`, default
`effort=high verifiers=2 adversarial=true`) holds every attestation to the
effort, and a period attested on a year-level audit (`annual`,
`cpa_handoff`, `full`) to the adversarial check and the verifier count too.
`books.export_cpa_package` gains `without_audit_reason?`: on books that
declared `audits`, with `policy.cpa_package_requires_audit` at `refuse` (the
default once the books hold an audit run; before one it warns), the package is refused (`audit_required`) unless an audit with
scope `cpa_handoff` or `full` stands at the current head with a period
attested on it at the bar; a reason overrides the refusal and is journaled
as a decision. Under `warn` the package is made and its `warnings` say so.
+50 -16
View File
@@ -16,8 +16,8 @@ not a tool, so there is nothing for a recipe to call.
| `.rpm` | `cargo-generate-rpm` (table in `crates/ledger-cli/Cargo.toml`) | same paths, marker `rpm`; 36 MB | dnf repository (`repo-indexes.sh dnf`) | unsigned until step 5 (`--signing-key` takes the PGP key then) |
| AUR `PKGBUILD` | `cargo packager` makes the tarball (`pacman` format) and a PKGBUILD for it; ours is `deploy/packaging/aur/PKGBUILD`, which repacks the release's tarball (package `wanderbooks-bin`) | same paths, marker `aur`; built with `makepkg` | pacman (the AUR keeps old PKGBUILDs) | n/a (pacman trusts the AUR's checksums) |
| AppImage | `cargo packager` (`appimage`); 44 MB | one file the owner puts anywhere and runs; needs no admin password | `wanderbooks update` (`$APPIMAGE` is the file replaced) against the manifest; minisign | minisign by the release key (step 5) |
| `.dmg` | `cargo packager` (`dmg`) | (trial) needs a Mac | `wanderbooks update` | Developer ID and notarization (step 5) |
| NSIS `.exe` | `cargo packager` (`nsis`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) |
| `.dmg` | `cargo packager` (`dmg`), `deploy/packaging/build-macos.sh`; **built in CI, unsigned; the trial on a Mac is the owner's** | `Wanderbooks.app` dragged to Applications; no admin password (the trial will say) | `wanderbooks update` (it replaces the program inside the `.app`: see the note below) | **unsigned**; Developer ID and notarization are step 5 |
| NSIS `.exe` | `cargo packager` (`nsis`) | **not built**: native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) |
`just package-linux` builds the first four (`deploy/packaging/build-linux.sh`,
about a minute after the release build, itself 8 to 12 minutes on a
@@ -64,21 +64,18 @@ What the trial found, each of which is built around now:
manifest's `signature` is that `.sig` as written, and `update` reads the
public key in either form. Checked end to end by hand (below), and by
`a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it`.
7. **The AppImage updates itself but the books it holds do not restart
onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run
from a shell (`Wanderbooks.AppImage update`, or the one that was running)
proves the swap and the rollback: after `update`, the AppImage on disk
is the new file, the old one is kept, and `rollback` restores it. But the
host that is open runs from the old image's mount
7. **The AppImage updates itself, and (since step 4) the books it holds
restart onto the new image.** `update` replaces `$APPIMAGE`. The host
that is open used to run from the old image's mount
(`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so
`host.rs` never sees a replaced file and the open books keep the old
version until they are closed and opened again (`wanderbooks close`).
Fixing it means the host learning the image's path (its environment is
scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`,
or the path would come from the mount table) and starting its successor
from it: a decision about what the host may trust, left to the EM (the
unit's report names it). Package installs and a hand-placed executable
restart as designed (`bridge_survives_update`).
it never saw a replaced file. The host now reads `APPIMAGE` (it is in
the allow-list of `start.rs`'s scrubbed environment, only when it names
an absolute file; a path the host reads about itself and never tells an
agent), runs and restarts from that file, and tells a replaced program
by its inode, size and last write. Tested without FUSE by
`a_replaced_appimage_restarts_the_books` (the programs run from one
copy while `APPIMAGE` names another, as a mount does); not run against
a real image mount in this wave.
8. **Onboarding with no terminal does not exist yet.** The unit's Done-when
(1) says an AppImage "opens, onboards and posts an entry with no
terminal". The AppImage opens (a bare launch is the viewer), but the viewer
@@ -118,6 +115,43 @@ package manager. Not tested: Ubuntu, openSUSE, a package-manager
*downgrade* (the repositories keep every release by construction), and the
`.rpm` and `.deb` under their real signatures (step 5).
## The macOS bundle (step 6, built in CI, unsigned)
`deploy/packaging/packager-macos.toml` (cargo-packager: the `dmg` format,
which makes the `.app` first; the icon is the 512 px PNG, made into an
`.icns` by the packager; `minimum-system-version` 11.0), `Info.plist`
(merged into the generated one: one viewer at a time, no discrete GPU),
`build-macos.sh` (the same shape as the Linux script, in portable shell
because macOS has BSD `sed`) and `just package-macos`. The `macos` job of
`.github/workflows/release.yml` runs on `macos-latest` (Apple silicon: the
manifest key is `macos-aarch64`; an Intel build is not made), uploads the
`.dmg` as a run artifact (named `macos-dmg-unsigned`) and, on a tag,
attaches it to the release. **It is not signed or notarized** (step 5: the
owner's Developer ID and Q-4), so Gatekeeper refuses it on a Mac that
downloaded it; the owner opens it with right-click, Open. What was checked
here, on Linux: the config parses under cargo-packager 0.11.8's strict
schema (unknown keys are errors) and the script's `sed` is the portable
subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs
the CI run (Actions, release, Run workflow, this branch) and then a Mac.
Open items for the trial on a Mac: whether `wanderbooks update` replacing
the program inside a signed `.app` breaks its signature (it will: step 5
must decide, probably by updating a whole `.app`); the translocated-app
check (`translocated_app_refuses_to_connect`) is not built.
## Updates staged by the host (step 4)
The host looks at the feed at most once a day while the books are open
(`update_check`), and downloads and verifies a newer release into
`<root>/updates/staged/` with a `meta.json` (version, sha256, signature,
notes). It never swaps. The next opener that starts a host applies it
first; `wanderbooks update` applies it too and reuses the staged copy;
`update --check` says "already downloaded". A local release is named by
`<root>/updates/feed` (a `file://` address, then the key file). Without a
compiled-in release key and without that file, the look does nothing but
log why. Not built: the in-app "restart now to update" choice and the
viewer's notice, which need an admin-socket intent and a place in the
viewer (see the unit's As-built block).
## What `update` and `rollback` need from a release
`manifest.json` at `releases/latest/download/` (D-26):
+24
View File
@@ -223,3 +223,27 @@ backdated entries included: defect 6), `zero_net_accounts`,
`drafts_by_state` and the reconciliation split; reports gained
`as_of_status` and `strict`; the CPA package is refused while a draft
dated in it is open (4.8).
### P5: the agent surface (2026-10-29)
No semantic feature moved, so no status changed and the floor stands. P5
changes how an agent is told what to do, not how a figure is computed: the
result envelope on every tool result (`next_steps`, `guards`, `warnings`,
`head`), `books.start` v2, the prompts, the resource templates,
`books.journal_history` (the journal in words) and source corrections
(`source_revisions`: `convention.source_revised`, `decision.source_revised`,
`open_item.source_revised`). One new row, `partial` until a hand-verified
golden covers it:
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|---|---|---|---|
| Audit trail in words (`books.journal_history`, the `describe` sentences) | QBO "Audit log", Xero "History and notes" | partial | tests `journal_history_traces_what_the_books_did`, `every_event_type_has_a_sentence`; the viewer keeps its own sentences (the shared describer is the server's) |
### P8: audit and attestations (2026-10-29)
Three new rows, none above changed status, and the floor stands.
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|---|---|---|---|
| Audit run: one programme of checks over the statements, the control accounts, closes, schedules, assets, evidence, drafts, tax categories, 1099 decisions, settings and the store's integrity (`books.audit`, `books.workpaper`) | An auditor's workpaper; QBO "Audit log"/"Review" and Xero's "Reports review" are a cross-check only | partial | rules.md B-45; `ledger_core::audit`, `ledger_store::audit`; tests `audit_detects_each_seeded_defect` (every code seeded), `audit_is_deterministic`, `workpaper_pdf_is_deterministic`; the fixture `p8_before` was written by the version before; `partial` until a hand-verified golden covers a workpaper |
| Attestations of receipts, entries and periods, weighed against `policy.attestation_bar` when asked (`books.attest_receipt`, `books.review_entry`, `books.attest_period`, `books.attestations`) | An accountant's sign-off on a month | partial | `below_bar_not_counted`, `amount_mismatch_never_counts`, `stale_head_period_attestation_refused`; the method is the client's own claim, and the books say so |
| The CPA package waits for a counted audit (`policy.cpa_package_requires_audit`) | A close checklist before handoff | partial | `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`; behind the feature `audits` |
+29 -2
View File
@@ -64,7 +64,16 @@ apply is safe — on startup the applier replays every journal line with
`attachment.annotated`, `entry.settles` (P6), `entry.draft_state_set`
(a draft building or awaiting its evidence: a note on the draft, no
figure) and `entry.draft_discarded` (a draft thrown away, with why; the
draft's earlier lines stay in the journal) (P4, feature `drafts_v2`)
draft's earlier lines stay in the journal) (P4, feature `drafts_v2`),
and `convention.source_revised {id, source, reason}`,
`decision.source_revised {id, source: {decided_by: [..], decided_on,
citation?}, reason}` and `open_item.source_revised {id, source, reason}`
(P5, feature `source_revisions`: who decided a convention, a decision or
an open item, when and on what, corrected without touching its words;
replay sets the source and nothing else, and the projection updates the
row's existing source columns, so no schema changed)
`audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`
(P8, feature `audits`: claims about the books, no figure)
(plus the A/R, A/P, asset,
period, payroll, and class events listed on `JournalLine`).
- `payload`: the full domain facts (account fields, entry with lines, blob
@@ -167,6 +176,20 @@ a backup is then proven where it lands, by a drill (docs/mcp-interface.md,
read off a file (the closed field list; a later annotation replaces the
earlier record), and `entry.settles {entry_id, settles[]}` says which
entries a payment pays back (the only source of "reimbursed").
- **Audits and attestations are records, not figures** (P8, rules.md B-45,
feature `audits`): `audit.run {id, through, through_seq, scope,
program_version, tb_hash, findings_sha256, workpaper_attachment?,
workpaper_pdf?}` says a programme of checks was run through a day at the
books' figure head `through_seq` (the last line that bears on a figure;
audits, attestations and filings of evidence do not move it), and which
two filed documents (category `workpaper`) hold its findings;
`receipt.attested {attachment_id, entry_id, by, at, method,
amount_checked, date_checked, disagrees, note?}`, `entry.reviewed
{entry_id, by, at, method, note?}` and `period.attested {period_id, by,
at, method, audit_run_id}` are a named client's claim that it checked.
`method` ({model?, reasoning_effort?, adversarial?, verifier_agents?}) is
that client's own word. Whether a claim counts is never journaled: it is
derived against `policy.attestation_bar` when asked.
- The store is policy-free: it records attachments faithfully. Whether a
posting *requires* a receipt is decided by the MCP layer's receipt policy
(`docs/mcp-interface.md`), which reads the `attachments` table.
@@ -192,7 +215,11 @@ Suspected corruption is fixed by rebuild, never by forensics.
as above; `extractions(attachment_id, extractor, version, text_sha256,
pages, status, seq)` and `annotations(attachment_id, receipt_number,
payee, amount, date, instrument, category, source, reimbursable_to,
note, seq)` (P6; amounts TEXT, never REAL); `events(seq, type, payload,
note, seq)` (P6; amounts TEXT, never REAL); `audits(id, through,
through_seq, scope, program_version, tb_hash, findings_sha256,
workpaper_attachment, workpaper_pdf, seq)` and `attestations(seq, kind,
attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, method,
amount_checked, date_checked, disagrees, note)` (P8, schema 20); `events(seq, type, payload,
created_at)` — the applier's output,
a queryable mirror of the journal stream and the feed the future GUI
tails. (Poll `WHERE seq > ?`; later SSE over the HTTP transport. This is
+189
View File
@@ -0,0 +1,189 @@
# The re-audit, through the MCP alone
The owner's acceptance of the whole programme (plan/served.md, P10). An
agent audits a company's books using only the books tools, the books' own
filed documents and the viewer's Export menu. Nothing here reads the data
root, a database file or the journal file. The engineering mirror of this
runbook is `crates/ledger-cli/tests/defects.rs` (every tool named below
exists; every defect has a named regression test); the run itself is the
owner's, on the owner's books.
Money is a decimal string everywhere. Every tool named is a tool in
`books.start`'s list; if one is missing, stop: the runbook is stale, not
the books.
## Before you start
- Two roles. The **auditor** runs `books.audit`, reads, and proposes fixes.
The **verifiers** (at least two agents, not the auditor, not whoever made
the entry) attest. The human confirms every correction.
- The bar is `policy.attestation_bar` (default `effort=high verifiers=2
adversarial=true`, D-9). Read it with `books.settings`. A verifier's
`method` is its own claim, said truthfully: `{ model, reasoning_effort,
adversarial, verifier_agents }`. A reading that differs from the entry is
recorded with `disagrees: true` and never counts; never bend a reading to
fit.
- Context discipline. A receipt is read as text first
(`books.get_receipt`, the default `meta` view is under 8 KiB, `view:
"text"` gives the whole text), as one page picture (`view: "page_image"`,
`page`, `max_px`) only when the text is empty or looks wrong, and never as
`bytes`. Record each verifier's own token count for receipts; the exit
bar is under 100K tokens per verifier.
- Start with `books.start` (rules, conventions, open items, its `next_steps`)
and `books.handbook` section `attestations-and-audit`. The `audit` prompt
(`scope`: `month`, `annual`, `cpa_handoff` or `full`) is the same
procedure for a client that offers prompts.
- Audits and attestations need the books' `audits` feature (the owner's
format upgrade). Without it the tools refuse with `format_upgrade_pending`:
do the upgrade first, as its own step, with the owner.
## The two cases
- **T1, desktop only.** Claude Code and Codex on the owner's machine, through
`wanderbooks connect`. Separation between the agent and the data root is
advisory. The evidence that it held is the access log
(`books.access_log`, `books:admin`) and the agents' transcripts, which never
name the data root or a path into it.
- **T2, a self-host machine running** (D-30, D-31; `deploy/README.md`). The
same steps, plus claude.ai through the tunnel once P9b exists. Separation
is enforced by the machine's account split. Until P9b, T2 is T1 with the
server on its own machine.
The steps are identical; only the evidence in step 8 differs.
## Step 1. The first audit and its fixes
1. `books.audit { scope: "full", through: <the last month end> }` without
`confirmed`: it lists the checks and changes nothing. Show the human.
Then run it with `confirmed: true` and the `preview` digest. Do the same
for `{ scope: "cpa_handoff", through: <the fiscal year end> }`.
2. `books.workpaper { id }` for each run: the findings and the PDF as filed.
3. Fix every finding through journaled corrections, never an edit:
- a wrong posted entry: `books.reverse_entry { entry_id, date,
description }`, previewed, then `confirmed: true`; then the right entry
through `books.draft_entry`, `books.add_line`, `books.post_entry`
(preview, then `confirmed`);
- a reversal with no receipt of its own (an old one, `legacy_reversal_exempt`):
its evidence is its original's (D-12, D-47). Link the original's receipt
to the reversal with `books.link_attachment { attachment_id, subject }`;
`books.list_receipts { entry_id }` finds the receipt;
- a duplicate file: `books.consolidate_duplicates` (preview, then
`confirmed`); `books.find_attachment` to look one up by hash or number;
- a receipt's facts: `books.annotate_attachment`;
- an open draft: post it, or `books.discard_draft { reason }`;
- an undecided 1099 vendor: `books.set_vendor_1099`;
- a missing tax category: `books.set_tax_category`;
- a judgment the books should remember: `books.record_decision`.
Nothing is fixed without the human's confirmation.
4. Audit again at the new head. An audit is idempotent on the same books
(`already_run`), so a new run means the books changed. Repeat until the
`full` and `cpa_handoff` runs have no error findings; keep the final two
run ids. Each journaled fix can be traced with `books.journal_history
{ subject }`.
## Step 2. Every receipt attested
For every receipt on a posted entry (`books.list_receipts`, entry by entry,
or `books.search_entries` then `books.get_entry`):
1. Verifier A reads `books.get_receipt` (text; a page image only if needed)
and calls `books.attest_receipt { attachment_id, entry_id, amount, date,
method, note? }` with the total and the date as read off the receipt.
2. Verifier B does the same independently, without seeing A's reading.
3. `books.attestations { kind: "receipt" }` shows each claim with
`counted`. A claim below the bar, or with `disagrees: true`, does not
count; the disagreement is a finding: reopen step 1 for that entry.
## Step 3. Every entry reviewed, every period attested
1. `books.review_entry { entry_id, method }` for every posted entry, by a
verifier that did not make it.
2. `books.attest_period { period, method }` for each month since the
company's formation (`books.list_periods`), each against a standing
audit at the current head (`scope: "month"` or `"full"`), then once for
the fiscal year against the `cpa_handoff` run. A period attestation is
refused when the books changed since the audit: audit again, then attest.
3. `books.attestations` with no filter: every row has `counted: true` and
none has `disagrees`.
## Step 4. The defect regressions
The 2026-10-16 audit found 20 defects; P0 found two more (21, 22) and P3
two (23, 24). Each has a named regression test in the workspace;
`crates/ledger-cli/tests/defects.rs` holds the list `(defect, test, file)`
and proves each name exists. Show them green, from a checkout of the build
under audit:
```
cargo test -p ledger-cli --test defects
```
then each test named in that list, by name, for example
`cargo test -p ledger-core --test spec_cash_flow
defect1_two_member_llc_first_year_regression`. Two defects have no code
fix to test: defect 2 (reversals without their own evidence) is closed by
D-12 and D-47 (its test shows the rule now) and defect 20 (the migration
scripts) was retired at the cutover (no tool exists; the test checks none
came back). Record the test run's output in the sign-off decision (step 7).
## Step 5. The Sheet comparison
The old Google Sheet and the books were frozen as of 2026-10-11 and
compared a final time before the cutover (P2, `verify-sheet`); that run is
filed as a document. There is no comparison tool and this
runbook builds none.
1. `books.find_attachment { text: "verify-sheet" }` finds the filed run
(its extracted text names the tool); `books.get_receipt { attachment_id }`
reads it (text view; its `links` and `sha256` identify it). Cite it by
attachment id and sha256. If it is not found, stop: the comparison was
not filed at the cutover and the owner decides what stands in for it.
2. `books.record_decision { subject: "Sheet comparison as of 2026-10-11",
decision_md, decided_by: [<the owner>], decided_on, evidence_attachment:
<that workpaper's id> }` stating that the frozen Sheet and the books
agreed then, and nothing newer is claimed.
## Step 6. The CPA package from both doors
1. The agent's door: `books.export_cpa_package { as_of: <the year end>,
include_tins: true }` (TINs are included by default, D-29). Without a
counted audit it is refused (`audit_required`); never use
`without_audit_reason` for the re-audit, the point is that it is not
needed. Save the zip with the download command in its `file`.
2. The viewer's door: `wanderbooks gui <book>`, the Export menu, the CPA
package as of the same day. It lands in the Downloads folder.
3. Compare the two zips file by file: byte-identical except for timestamps
(the README's generated-at line and the zip entries' times). A difference
in any figure is a defect: stop and report it.
## Step 7. Verify, back up, sign off
1. `books.verify { blobs: true }`: `status: "ok"` (not `healed`: a healed
result means a torn tail was set aside; note it and look at
`books.journal_history`'s `tail_repairs`).
2. `books.backup`: every target not `stale`, `last_error` empty, and
`last_drill_at` set with no `drill_error`. If not, `books.back_up_now`
(an admin action) and read it again. The drill is the restore test; a
backup that was never restored is not a backup.
3. The owner's sign-off: `books.record_decision { subject: "Re-audit
sign-off", decision_md, decided_by: [<the owner>], decided_on }` naming
the final `cpa_handoff` and `full` run ids, the journal head
(`books.verify`'s `head`), the defects run, and the CPA package's sha256.
## Step 8. The exit evidence
The re-audit passes only when all of these are true. The owner checks them;
the agent presents the proof for each.
| Exit bar | Proof |
|---|---|
| Zero failing workpaper checks | the final `full` and `cpa_handoff` workpapers (`books.workpaper`) |
| Cash flow difference 0.00 | `books.cash_flow` for the year; no `cash_flow_unfooted` finding |
| Every receipt and posted entry has a counted attestation, none disagrees | `books.attestations` (step 3.3) |
| Every action under the right principal | `books.access_log`: the auditor's token and each verifier's token appear on their own calls; no action by an admin token that an agent token did |
| No transcript names the data root | grep each agent's transcript for the root path before sign-off (T1); T2: the tenant root is not readable by the agent's account |
| No result carried a full TIN to a client that should not see one | `books.access_log` shows `include_tins` only on `books.export_cpa_package` and `books.get_w9`/`books.vendor_1099` calls by the owner's agent; the agents' exports may carry them (D-29), nothing else |
| Receipt reading stayed under 100K tokens per verifier | each verifier's own token count for its receipt calls (the harness's count, written into the attestation `note`) |
| The sign-off is journaled | `books.decisions` shows it |
When all are true the books are re-audited through the MCP alone.
+100 -2
View File
@@ -13,7 +13,7 @@ Foundation").
Spec blocks in the code cite these as `SOURCE: rules.md B-n`. Every rule
also has a bookspec identifier, `**bookspec:** BS-<area>-<n>`: the table
from B-1 to B-45 is `bookspec/spec/rules-index.md` (P13, D-34).
from B-1 to B-45 is `spec/rules-index.md` in the bookspec repository, https://git.swaits.com/swaits/bookspec (P13, D-34).
---
@@ -1761,7 +1761,15 @@ saying what it duplicates, B-44). P4's is `drafts_v2` (an
`entry.draft_state_set` or `entry.draft_discarded`, both new event types:
an older version would refuse them, or, reading around them, keep a
discarded draft that holds a period open and call a waiting draft one
being built, B-43). A command asks for
being built, B-43). P5's is `source_revisions` (a
`convention.source_revised`, `decision.source_revised` or
`open_item.source_revised`, new event types: an older version would
refuse them, or show the old source as the record's authority). A command asks for
being built, B-43). P8's is `audits` (an `audit.run`, `receipt.attested`,
`entry.reviewed` or `period.attested`, all new event types, and the
settings `policy.attestation_bar` and `policy.cpa_package_requires_audit`:
an older version would refuse the types and the keys, or drop the claims a
handoff rests on, B-45). A command asks for
the feature it needs (`Store::ensure_feature`) before its first journaled
line, and only when the line it is about to write uses it, so a write
refused for waiting on the upgrade journals nothing, never an entry
@@ -3582,3 +3590,93 @@ withdraw_attachment_duplicate}` and `Store::attach`'s dedup; feature
`page_image_is_a_png_of_the_page`,
`consolidation_never_removes_an_entrys_only_evidence`); fuzz targets
`fuzz_extract_pdf` and `fuzz_extract_xlsx`.
## B-45 An audit is deterministic over the books; an attestation is a claim by a named client
**bookspec:** `BS-ledger-13`
**Status:** CONVENTION (the SOURCE block below; Pub. 583 and Rev. Proc. 98-25
for the audit trail); CHOICE D-9 (owner-adopted recommendation,
2026-10-16: the attestation bar, and the CPA package waits for a counted
audit unless given a reason).
```text
GOAL: the books can be audited by a programme that gives the same
findings for the same books, and a claim that someone checked rests
on the books as they were when the audit ran, says how it was made,
and is weighed against the owner's bar when asked.
SOURCE: IRS Pub. 583 (records that support the entries); Rev. Proc. 98-25
§5.01 (the relationship between the records and the books must be
demonstrable: an audit trail); CHOICE D-9 (the bar: reasoning
effort at least high; for a year, adversarial verification by at
least two independent agents; no model allow-list; the CPA package
refuses without a counted audit unless given a reason); CHOICE
D-53 (an audit is a run, not a job); CONVENTION (the figure head,
below; a client's method is its own claim).
INVARIANTS:
1. An audit is a pure function of the journal's figures: its findings
(`ledger_core::audit::statements`, the store's integrity checks) are
a list in a fixed order, and the same books, day, scope and
programme give the same findings, the same trial balance hash and
the same workpaper bytes. A check that cannot be made is itself a
finding, never a silent pass.
2. The figure head is the seq of the last journal line that bears on a
figure. Audit runs, attestations, filings of evidence (receipts,
documents, statements, reconciliations, extraction, annotations,
links), decisions and open items, and the format declaration do not
move it; an entry, a draft, a document, a close, a schedule, an
asset, a settings change or a role change does. A run is idempotent
on (through, head, scope, programme): asking again returns it and
journals nothing.
3. A period is attested only on a run at the current head whose day is
on or after the period's end.
4. An attestation is a claim by the token that made it (`by`); its
method (model, reasoning effort, adversarial, verifier agents) is
the client's own, recorded and never verified. A receipt attestation
whose amount or date differs from the entry's is recorded with
`disagrees` and never counts.
5. `counted` is derived against `policy.attestation_bar` as it stands
when asked and never stored: a bar change changes what counts, past
claims too. Every attestation needs the reasoning effort; one on a
year-level run (annual, cpa_handoff, full) needs the adversarial
check and the verifier count too.
6. The CPA package, on books that declared `audits` and under
`policy.cpa_package_requires_audit = refuse` (unset: refuse once the
books hold an audit run, warn before), needs a run of scope
cpa_handoff or full at the current head with a period attested on it
that counts; otherwise it is refused, unless a reason is given,
which is journaled as a decision.
7. Nothing here moves a figure. Every refusal leaves the journal
unchanged; every write waits for the feature `audits`.
SCENARIOS:
1. The same books audited twice through one day: one run, one
workpaper; the second call files and journals nothing.
2. An entry posted after the audit: the period cannot be attested until
the audit is run again.
3. A receipt read as 41.00 against a 40.00 entry: recorded, `disagrees`,
not counted, however hard the checker says it thought.
4. The bar lowered afterwards: earlier attestations that now meet it
count.
5. A CPA package with no counted audit: refused (`audit_required`);
with a reason, made, and the reason is a decision in the journal.
6. Books that never declared `audits`: the package is as it was; the
audit run and the attestations are refused with
`format_upgrade_pending`.
```
**Enforced:** `ledger_core::audit` (`statements`, `Finding`, `CODES`,
`AttestationBar::counts`, `Scope`, `Method`), `ledger_store::audit`
(`Store::figure_head`, `integrity_findings`, `record_audit_run`,
`attest_receipt`, `review_entry`, `attest_period`, `run_for_period`,
`attestations`, `PROGRAM_VERSION`), `ledger_import::workpaper_pdf`,
ledger-mcp `tools/audit.rs` and the gate in `books.export_cpa_package`,
journal feature `audits`.
**Checked:** `ledger-core` `tests/audit.rs`
(`audit_detects_each_seeded_defect`, `below_bar_not_counted`,
`amount_mismatch_never_counts`); `ledger-store` `tests/audit.rs`
(`audit_writes_wait_for_audits`, `a_run_is_idempotent_on_the_figure_head`,
`stale_head_period_attestation_refused`, `integrity_detects_each_seeded_defect`,
`books_the_version_before_p8_wrote_wait_for_the_upgrade`); `ledger-import`
`workpaper_pdf_is_deterministic`; `ledger-cli` `tests/audit.rs`
(`audit_is_deterministic`, `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`,
`audits_wait_for_the_upgrade`).
+242
View File
@@ -3769,6 +3769,25 @@ it (the same program with a tail, so its hash differs). All in
file now on disk (not a deleted one). The host's own restart laws stay
`open_while_held.rs`'s (`a_restart_is_not_a_close`).
Step 3's gap and step 4 (same file):
- `a_replaced_appimage_restarts_the_books`: the programs run from one copy
(a mount's) while `APPIMAGE` names another (the image); `update`
replaces only the image; the host restarts onto it (its `/proc/PID/exe`
is the image), the mount's copy is untouched, the session writes on.
- `the_host_stages_a_daily_update_and_the_next_start_applies_it`: with a
local feed named in `updates/feed`, the host stages 9.0.0 by itself;
the program, the host and the session are unchanged meanwhile; after
`close`, the next opener applies it (the old program is kept, the staged
folder gone) and the books are as they were.
- `a_daily_update_that_is_not_ours_is_not_staged`: a signature by another
key, then a wrong sha256, each looked at and neither staged.
- `update_installs_the_copy_the_host_staged_without_downloading_again`: the
release's file is deleted; `update` installs from the staged copy.
- `a_staged_update_that_was_tampered_with_is_dropped_not_applied`: a staged
file edited together with its recorded checksum fails the signature; the
next start drops it and runs the old program.
Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`,
`a_package_marker_beside_the_program_makes_it_package_managed`,
`a_manifest_address_is_resolved_beside_the_manifest`,
@@ -3803,3 +3822,226 @@ covers the feature one; a case with a higher format number is a gap to
add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy`
(`failed_update_rolls_back`), `install_kind` (the marker test), and
`parse_version`'s rejections.
## P5: the agent surface (2026-10-29)
Every tool result carries the envelope (`next_steps`, `guards`, `warnings`,
`head`), `books.start` is v2, the prompts and resource templates are
served, `books.journal_history` reads the journal in words, and a
convention, decision or open item can have its source corrected. Journal
feature `source_revisions`; events `convention.source_revised`,
`decision.source_revised`, `open_item.source_revised`; no schema change
(the projection updates the rows' existing source columns).
- **`ledger-mcp` `src/server_tests.rs`:**
`every_mutating_result_has_head_and_next_steps` (every tool of the
router is given the envelope; the non-read-only ones have `head` equal to
the journal's and a step each with `why` and `required`; the text block
says what the structure says),
`the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`,
`books_start_is_always_loaded`, `tool_descriptions_fit_700_characters`
(the allowance list is gone: every description is at most 700
characters). `guards.rs`: `every_guard_names_an_existing_tool`,
`a_refusal_becomes_its_guard`. `next_steps.rs`:
`a_preview_gets_its_confirm_step`, `every_step_a_tool_names_exists`.
`tools/prompts.rs`: `the_prompts_name_only_tools_that_exist`,
`an_argument_is_one_token_of_the_right_shape`.
- **`ledger-store`:** `tests/source_revisions.rs`:
`a_source_only_revision_is_journaled` (the three events, words and
revision unchanged, live equals replay equals a rebuilt database),
`a_source_revision_must_change_only_the_source` (other words, the same
source, no reason, unknown record: refused, journal unchanged),
`source_revisions_wait_for_the_upgrade` (refused before the go-ahead with
`format_upgrade_pending`, journaling nothing). `format.rs`:
`source_revisions_is_its_own_feature`. `describe.rs`:
`every_event_type_has_a_sentence`, `settings_show_their_keys_never_their_values`,
`a_full_tin_in_a_description_is_masked`,
`history_filters_in_order_and_says_when_more_remain`.
- **8.0.6:** `books_written_under_older_rules_still_open` hand-writes the
three events; the fixture `old-books/p5_before` (writer
`old-books-writer/src/bin/p5_before.rs`, run against b97234ac, the
revision before P5: a convention revised once, two decisions, two open
items, a draft) opens to that version's figures, so no figure moved;
`books_p2_served_cut_over_whole` refuses `books.raise_item` with `revises`
before consent and takes it after.
- **`ledger-cli` `tests/agent_surface.rs`:**
`journal_history_traces_what_the_books_did`,
`a_source_only_revision_over_the_wire`, `the_prompts_list_and_render`
(each prompt rendered equals its file with the blanks filled; an
argument that is not one token of the right shape, a missing or extra
one, and a struck prompt are refused),
`the_resource_templates_are_advertised_and_read`,
`books_start_v2_is_whole_and_fits` (at most 8 KiB on the P2 fixture; the
agent's self-report in the access log only),
`a_new_book_is_told_to_onboard`,
`a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy`.
- **`ledger-cli` `tests/agent_evals.rs`** (transcripts in `tests/agent_evals/`):
`agent_evals_follow_only_next_steps` plays six scripted transcripts (no
model): onboarding to a first post, a receipt-backed post, a draft that
waits for its receipt and posts when it arrives, a reversal, a
reconciliation, a month-end close. After `books.start` a transcript may
call only a tool (or fetch only a prompt) the previous result's
`next_steps` named, with the arguments the step gave and the ones the
agent decides. `setup` calls (the chart, the books an agent finds) are
not the agent's moves.
- **The sweep:** the `party_id`, `document_id` and `cash_account` aliases of
`books.create_invoice`, `books.create_bill`, `books.receive_payment` and
`books.pay_bill` are gone (S4, S6 said "until P5"); the over-the-wire
tests that used them name `customer`/`vendor`, `invoice`/`bill` and
`deposit_to`/`paid_from`.
**For P12 (mutation targets):** `next_steps::apply`'s three branches (an
error, a result without an object, the normal path) and the `mutating`
test that gives `head`; `tool_steps`'s confirm branch (`!confirmed` and a
`preview`), the `receipt_missing` pair before it, and each tool's arm;
`attention_steps`'s subject match (entry against invoice) and its
`Problem` severity for `required`; `subject_of`'s precedence;
`guards::from_refusal`'s structured and worded branches and
`Guard::remedy_tool`'s table; `prompts::checked`'s token test and each
`Check` shape (month length and calendar, year length, the word list);
`prompt_get`'s extra-argument and missing-argument refusals;
`describe::subjects`'s per-prefix id choice, `describe`'s `settings.set`
key list and TIN mask, `history`'s `>` on `after_seq`, its `limit` cut and
`more`; `check_convention_source`'s three equalities (title/body/structure,
source, kind), `check_decision_source` and `check_open_item_source`'s
equal-source refusals and the empty-reason refusals; the three
`apply_*_source` arms (a mutant that also bumps `revision` or `seq` shows
in `a_source_only_revision_is_journaled`); `books.start`'s fit order and
`reconciliation` split (`reconciled_through.is_some()`).
## P8: audit and attestations (2026-10-29)
An audit run is a deterministic programme of checks over the books through
a day, filed as a JSON and a PDF workpaper; attestations are claims by a
named client that it checked a receipt, an entry or a period, weighed
against `policy.attestation_bar` when asked. Journal feature `audits`;
events `audit.run`, `receipt.attested`, `entry.reviewed`, `period.attested`
(no figure moves); schema 20 (taken as the next after the last, renumbered
at merge): the `audits` and `attestations` tables; settings
`policy.attestation_bar` (a new `Kind::AttestationBar`) and
`policy.cpa_package_requires_audit`. No kernel change, no Kani harness, no
Lean theorem: the audit calls kernel functions and adds none, and the laws
it checks are already theorems.
- **`ledger-core` `tests/audit.rs`:** `audit_detects_each_seeded_defect`
(the code table `audit::CODES` has a row per check and the test iterates
it: a seeding fixture for each, exactly that code found, its severity the
row's; the checks a correct engine never lets happen are seeded through
the predicate each check is built on, `check_trial_balance`,
`check_equation`, `check_articulation`, `check_cash_flow`,
`check_control`, `check_split`, `check_register`),
`a_whole_book_gives_no_failure_or_warning`,
`findings_come_in_one_order`, `below_bar_not_counted` (the bar's effort,
the year-level adversarial check and verifier count, a lowered bar, the
parse), `amount_mismatch_never_counts`.
- **`ledger-store` `tests/audit.rs`:** `audit_writes_wait_for_audits`
(R-A: every write refused with `FormatUpgradePending`, nothing
journaled, taken after `upgrade_format`, replay equal to live),
`a_run_is_idempotent_on_the_figure_head` (a filing and a run do not move
the head, an entry does, asking again journals nothing),
`amount_mismatch_never_counts` (a wrong amount and a wrong date are
recorded as disagreeing; a receipt not on the entry, a draft and an
unknown entry are refused), `stale_head_period_attestation_refused`
(no audit, an audit that stops short of the period, a standing one, an
entry since), `integrity_detects_each_seeded_defect` (every code of
`ledger_store::audit::CODES`: a missing and a damaged stored file, a
missing extraction text, a duplicate file written by an older version,
a damaged access log, a database that differs from a replay, a damaged
journal, a stale backup, a failed drill),
`books_the_version_before_p8_wrote_wait_for_the_upgrade` (8.0.6 item 2).
`tests/store.rs::books_written_under_older_rules_still_open` hand-writes
the four events (8.0.6 item 1); `tests/format.rs` and
`format.rs::audits_is_its_own_feature` (8.0.6 item 4). The four event
names are now this version's, so the tests that need an event type no
version has written use `ledger.not_yet_written` instead of
`period.attested` (`tests/format.rs`, `ledger-gui/tests/newer_journal.rs`).
- **8.0.6:** the fixture `old-books/p8_before` (writer
`old-books-writer/src/bin/p8_before.rs`, run against b97234ac) holds
receipted entries, a 40.00 purchase with no receipt, a small one, a
reversal, an invoice part paid, an open draft and a closed month;
`books_an_earlier_version_wrote_open_to_its_figures` opens it to that
version's figures, so no figure moved and no golden changed;
`books_p2_served_cut_over_whole` refuses `books.audit` and
`books.review_entry` before consent and takes them after.
- **`ledger-import` `workpaper.rs`:** `workpaper_pdf_is_deterministic`,
`a_different_finding_is_a_different_document`.
- **`ledger-cli` `tests/audit.rs`:** `audit_is_deterministic` (preview,
run, the same run and findings again with nothing journaled, the
workpaper and its PDF hash equal twice, a new entry makes a new audit),
`attestations_count_against_the_bar_when_asked` (a disagreeing reading,
a medium effort, no claim, a lowered bar read retroactively),
`a_period_is_attested_on_a_standing_audit`,
`cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`
(refused, a reason journaled as a decision, an audit alone not enough, a
year attested below the whole bar not enough, the whole bar, an entry
since, `warn`), `audits_wait_for_the_upgrade` (the fixture over the
wire; the package is not held up by an audit it cannot ask for).
- **`ledger-cli` `tests/attention_parity.rs`:**
`audit_items_are_on_both_lists_once_audits_are_declared`.
**For P12 (mutation targets):** `audit::statements`' order of checks and
`sort` (severity, then code, subject, message); each `check_*` predicate's
comparison (`check_trial_balance`'s `balanced`, `check_articulation`'s
`!=`, `check_split`'s `> unit`, `check_register`'s two conditions,
`check_cash_flow`'s three arms, `check_control`'s `difference`, unattached
and `Overpaid` arms); `evidence`'s three arms (Required, the reversal
exemption and the policy test that lists it, the plain exemption) and its
receipt-set lookup; `closed_net`'s sign for revenue and expense;
`AttestationBar::counts` (each of the four conditions, `>=`, the `full`
branch, the `disagrees` early return) and `parse`; `Scope::full_bar` and
`serves_handoff`; `Store::figure_head` (the `NOT_FIGURES` list: a type
wrongly in it lets a stale audit stand, a type wrongly out of it makes an
audit stale at its own filing), `find_audit_run`'s four equalities,
`record_audit_run`'s idempotence return, `attest_receipt`'s amount rule
(the debit total or any one line) and date equality, `run_for_period`'s
`through_seq == head` and `through >= period.end`;
`integrity_findings`'s eight arms; `backup_findings`' three conditions; the
export gate (`cpa_audit_standing`'s four conditions and its feature test,
the `refuse`/`warn` split, the reason's trim); `audit_items` in Needs
attention (the declared test, the attested set, the last-ended fiscal
year, the three year conditions).
## P10: the re-audit runbook's mirror
`docs/runbooks/re-audit.md` is the owner's acceptance, run on his books
through the MCP alone. What CI can check of it is in
`crates/ledger-cli/tests/defects.rs`:
- `every_defect_reproduction_is_a_named_test`: a table of `(defect, test
name, file)` for the 2026-10-16 audit's defects 1 to 19, P0's 21 and 22
and P3's 23 and 24 (23 rows); each named function exists in its file, is
a `#[test]` (or inside `proptest!`) and is not `#[ignore]`d. Defect 2 is
closed by D-12 and D-47, so its row is the rule as it now stands
(`a_reversal_needs_no_receipt_of_its_own`).
- `defect_20_no_migration_tool_exists`: defect 20 (the migration scripts)
was retired at the cutover; no tool of that kind is in the scopes table.
- `the_re_audit_runbook_names_only_what_exists`: every `books.<name>` in
the runbook is in `crates/ledger-mcp/src/scopes.rs`, the commands it
names are dispatched by `main.rs`, and its eight steps are present.
The mirror proves the tests exist, not that they pass; the runbook's step 4
runs them by name. No CI job was added: the test runs in the existing
`check` job's `cargo test --workspace`. The run itself (a counted
attestation on every receipt and entry, the two CPA packages equal, the
sign-off) is the owner's and has no CI equivalent.
**For P12 (mutation targets):** the `DEFECTS` table's file/name lookup (a
`starts_with` on `fn <name>(`; the `#[test]`/`#[ignore]` window of eight
lines above it) and the scopes-line scan in `defect_20_*`. A mutant that
widens the window or drops the `ignore` check survives only if no ignored
or untested name is in the table; the table is the oracle.
## P13 step 6: the specification in its own repository (2026-10-29)
The specification (text, examples, checker, runner, Lean, GnuCash adapter)
lives at https://git.swaits.com/swaits/bookspec (CC BY 4.0 for `spec/` and `examples/`, MIT OR Apache-2.0 for the
code) and is pinned by commit in `crates/ledger-core/Cargo.toml` and
`crates/ledger-package/Cargo.toml`. `just check` runs the examples exactly as
before: `cargo test --workspace` includes `ledger-core` `tests/bookspec.rs`
(`every_bookspec_example_holds`, `the_runner_reports_every_claimed_rule_green`),
which reads them through `bookspec_checker::examples()`, and `ledger-package`
`tests/package.rs` reads the IIF example the same way. Two checks that need
this repository's files live in `tests/bookspec.rs`: `every_vendored_pin_is_verbatim`
(each vendored pin in the spec is verbatim in `docs/spec/sources`) and
`rules_md_carries_every_id`. The examples named `bookspec/examples/...` in the
older sections above are files of that repository. The checker's, the
runner's and the derivation tests run in its own CI.