Files
wanderbooks/plan/review-2026-10-26/implementation_readiness_report.md
T

124 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Stage 9: Implementation readiness and codebase details (`plan/served.md` 766-2058)
Reviewers: `egm-implementation-readiness` (IR), `egm-codebase-details` (CD). 2026-10-26. Additional to Stages 5-8. Symbols, not line numbers. Sizes assume an entry-level Rust engineer new to this codebase; one engineer-day = one reviewed PR, `just check` green, 200-400 lines including tests and docs.
## Cross-cutting
### IR-CC-1 Blocker. The mechanical recipes every unit repeats are nowhere written down
**R-A New journal feature.** (1) `crates/ledger-store/src/format.rs`: `pub const NAME: &str = "name";` with a doc comment saying what an older version would misread; append to `KNOWN_FEATURES` (`Store::ensure_feature` has `debug_assert!(KNOWN_FEATURES.contains(..))`). (2) Unit test `name_is_its_own_feature` beside `document_standing_is_its_own_feature`: `declared_knowing` with every earlier feature gives `NewerJournal`, `require_feature` gives `FormatUpgradePending`, `upgrade().adds()` names it. (3) Every `Store` command writing the marked line calls `self.ensure_feature(NAME)` before `rehearse`/journaling; store test `<write>_waits_for_<name>` modelled on `repaying_after_a_reversal_waits_for_document_standing` (`tests/arap_standing.rs`): refused, journal unchanged, succeeds after `upgrade`, replay equals live. (4) `crates/ledger-cli/tests/format_upgrade.rs::books_p2_served_cut_over_whole`: one entry in the `refused` list and the same call after consent. (5) `handbook/format-upgrade.md` and rules.md B-27 list the feature. Feature names are allocated in the plan; no engineer invents one.
**R-B New event type.** (1) `format.rs` `EVENT_TYPES` (alphabetical; `tests/format.rs::every_event_the_store_replays_passes_the_gate` scans store.rs for every `"x.y" =>` arm and fails on a mismatch either way). (2) A payload reader in `ledger-store/src/meta.rs` in the style of `project_set_of`: missing key → `corrupt`, unknown enum value → `format::newer_content`. (3) A `replay_line` arm in store.rs calling a `Ledger::replay_*` method (`line.seq` is available). (4) An `apply_line_rows` arm (projection SQL); a new migration in `schema.rs` and `SCHEMA_VERSION` 15 → 16 only when a table or column changes; `tests/schema_files.rs`. (5) `projection_rows`/`compare_projections` list the new table; `Ledger::differs_from` gains the new state (the `("line_docs", ..)` pattern). (6) `fuzz/fuzz_targets/fuzz_store_replay.rs` `const TYPES` (`the_replay_fuzzer_writes_every_event_type` fails otherwise). (7) A `describe` arm in `ledger-gui/src/pages/journal.rs`. (8) rules.md and `docs/receipt-store.md` event table.
**R-C New MCP tool** (one commit; three tests fail on a partial state). (1) `types.rs` params/out structs with `JsonSchema` and doc comments (they are the schema descriptions). (2) Handler in `server.rs` or `tools/<group>.rs` with `#[tool(name, title, description, annotations(read_only_hint, destructive_hint, open_world_hint))]` (`annotations_complete`). (3) `scopes.rs::TABLE` row and its array length constant (compile error otherwise). (4) `docs/mcp-interface.md` entry in the shape `` `books.x { a, b? } → { c }` `` (`every_tool_is_documented_and_every_documented_tool_exists`). (5) Description ≤ 700 chars (`tool_descriptions_fit_700_characters` pins a count `OVER`; P5 drives it to 0), `tool_text_lint`. (6) Over-the-wire test in `crates/ledger-cli/tests/` through `common::Books::new(tag)` → `.wire()` → `.call(tool, json)` → `answer_of`. (7) `docs/parity.md` row.
**R-D New setting key.** (1) `ledger-core/src/settings.rs` `KEYS` entry `Key { name, toml, kind, sensitivity, doc }` (pattern `company.duns`); a TIN exemption is a `Kind` arm, not a key match (`no_tin` is applied by every text `Kind`; `Kind::Duns` escapes by having its own arm). (2) `setting_feature` arm in `ledger-store/src/format.rs` (`pub(crate)`; `Store::journal_settings` calls `ensure_features(setting_feature(..))`; the `"settings.set"` replay arm refuses an unknown key as `newer_content`). (3) `ledger-config` `toml_round_trip_zero_events`, `codec_props.rs`; `settings_units.rs` for the canonical form. (4) The settings.rs INVARIANTS block.
**R-E New handbook section.** `crates/ledger-mcp/handbook/<id>.md` plus a tuple in `handbook.rs::SECTIONS` (`include_str!`); placeholders like `{document_projects}` via `document_projects_rule(declared)` give different text before and after the owner's upgrade; the tests in handbook.rs check every section renders and ids are unique.
**R-F New Needs-attention item.** Until the attention engine moves, an item is written twice: `ledger-gui/src/attention.rs::find` (and its GOAL block's INVARIANT 2 list) and the server's `books.start` next steps in `tools/kept.rs::start_out`. Parity test `ledger-gui/tests/view_model.rs`; screenshot through `shot()` (`ledger-gui/tests/invoices.rs`).
**R-G** A `**For P12 (mutation targets):**` paragraph in `docs/testing.md` naming each new function's arms.
### IR-CC-2 Major. `docs/testing.md` has no section for S1 part B, S2 or S3 (last heading "P4b S1, part A"). P12's input is three units short. 0.5-day pre-step.
### IR-CC-3 Major. The journal-safety bar for a unit that writes new lines is not a checklist. "8.0.5 Before a journal-touching unit merges": (a) `ledger-store/tests/store.rs::books_written_under_older_rules_still_open` gains a hand-written line of the new shape via `JournalWriter`; (b) a new fixture under `tests/fixtures/old-books/` written by the version before, plus `expected.json`; (c) the `books_p2_served_cut_over_whole` refused entry; (d) the `_is_its_own_feature` test; (e) `tests/format.rs` arms; (f) a `figures.json` golden from the previous version where a figure could move (pattern `tests/fixtures/p4-arap-before`, `arap_standing.rs::write_the_p4_arap_fixture`); (g) "merge does not need the owner's go-ahead; the live books refuse the new write until he gives it".
## P4b S4 `documents_v2`
- **CD-A1/A2.** How a document is journaled today: `Store::create_invoice(customer_id, date, due_date, lines)`: `rehearse` → `approval_gate` → `arap::validate_lines`, `arap::validate_due_date`, `closed_period_containing` → `ensure_document_lines` (calls `ensure_feature(DOCUMENT_LINE_PROJECTS)` only if any line has `class_id`) → `ensure_control_account(&RECEIVABLES)`/`(&REVENUE)` (bills `GENERAL_EXPENSES`, `PAYABLES`) → posting from `arap::document_parts` → `journal_auto_lines` (`entry.created`, `entry.line_added`×n, `entry.posted`) → `append_journal("invoice.created", {id, customer_id, date, due_date, lines: [document_line_json], entry_id})` → `self.ledger.create_invoice(..)` → `apply_line_to_sqlite`. The feature pattern is `ensure_document_lines` + `document_line_json` (omits `None` keys: byte-identical old shape) + `parse_document_lines` (absent/null → None). The posting is built twice: in `Store::create_invoice` and in `Ledger::create_invoice_steps` (`ensure_control`, `add_tagged_line(.., DocTag{Origin})`, `add_document_parts`); `Ledger::replay_invoice`/`replay_bill` re-derive and compare (`ReplayMismatch`). Line accounts change: `arap::document_parts` key → `(Option<AccountId>, Option<ClassId>)`, `add_document_parts`, both `*_steps`, both `Store::create_*` posting vectors, both `replay_*`. `check_document_line_account` goes beside `Ledger::check_line_account`, reusing `control_role_of` and the `cash_accounts` setting.
- **CD-A3.** `arap::validate_due_date(date, due_date)` (calls `calendar::check`, refuses `DueBeforeDate`) is what `Terms::due_date` feeds; `arap::validate_lines` is where `quantity × unit_price == amount` goes.
- **CD-A4.** MCP today: `CreateDocumentParams { party_id, date, due_date (required), lines: Vec<DocumentLineParam{description, amount, class?}> }`, `CreateDocumentOut { id, total, entry_id, warnings, notes }`; `BooksServer::create_invoice` → `document_lines(&store, p.lines)` → `store.create_invoice(..)` → `created_uncategorized` → `document_notes`. `resolve_vendor(store, &str)` exists (id or name, ambiguity refused); add `resolve_customer` mirroring it. State the `due_date` fallback: `terms` arg → party `default_terms` → `invoicing.default_terms` → refuse.
- **CD-A5.** Preview pattern: `BooksServer::post_entry` with `confirmed: Option<bool>` returns the full `out` unchanged when not confirmed; `preview_lines_for(store, &[(AccountId, Side, Money)])` computes before/after balances; engine probe = `Store::rehearse` (`let mut probe = self.ledger.clone()`), so `Store::preview_invoice` runs `Ledger::create_invoice` on a clone. Settle `confirmed: bool` (run_schedules) vs `Option<bool>` (post_entry, reverse_entry, dispose_asset, reconcile).
- **CD-A6 Blocker.** "`issued_pdf` in `invoice.created`" cannot be done in the plan's event order: `Store::attach` allocates the id by `MAX(id)+1` and needs the entry to exist. Choose (b): no `issued_pdf` field; the filed copy is `receipt.attached` on the entry plus `attachment.linked` to `Subject::Invoice(id)` (`Store::link_attachment`, `attachments_for(Subject)` already read links); the sha is already in `receipt.attached`'s `blob_sha`. Render (`invoice_pdf(&InvoiceDoc)`) before `journal_auto_lines`. Reuse `Store::attach_receipt(Some(entry_id), "INV-0007.pdf", "application/pdf", &bytes, Some("Issued invoice"))`.
- **CD-A7.** Settings: `KEYS`, `Kind` arms; `invoicing.payment_methods` needs `Kind::PaymentMethods` whose arm validates the list and skips `no_tin` for `lines` of `ach`/`wire` methods only; each key an arm in `setting_feature`; `Settings::company().profile` (`Vec<(toml_key, value)>` of `company.*` text keys) is how `invoice_document` finds the address, so seller email/website are new `company.email`/`company.website` keys.
- **CD-A8.** `party.updated {id, change: PartyField}`; `PartyField` is `#[serde(tag="field", content="value")]` with `Name, DisplayName, Website, Address, Notes, Archived`; `Ledger::check_update_party` applies `parties::check_text` (TIN scan). The replay arm maps an unknown `field` to `JournalCorrupt`, not `newer_content`: S4 must fix that (precedent `meta::project_set_of`), add `PartyField::Email`, `PartyField::DefaultTerms`, gate with `ensure_feature(DOCUMENTS_V2)`, extend `UpdatePartyParams`, add a `describe` arm.
- **CD-A9.** `ledger_package::invoice_document(ledger, company, id, as_of, today) -> Result<InvoiceDoc, String>` builds `InvoiceDoc { number ("Invoice {id}"), issue_date, due_date, terms: None, seller: InvoiceParty, bill_to, items: Vec<InvoiceItem{description, amount}>, total, settled: Vec<(String, Decimal)>, balance, stamp: Option<InvoiceStamp>, stamp_line, note }`; `InvoiceStamp { Paid, PartiallyPaid, PastDue, Void, Draft }`; `templates/invoice.typ` prints seller, INVOICE + number, bill-to, dates, Terms row, lines (description, amount), Total, settled rows, Balance due, stamp, note; `paper: "us-letter"`. **New in S4:** quantity × unit price column, customer PO, payment methods + QR, memo, footer, seller email/website, the issued stamp-less copy, the `terms` string.
- **CD-A10.** QR: `labels.rs::qr_svg(tag) -> Result<String, PdfError>` and `templates/labels.typ`'s `image(bytes(l.qr), format: "svg", ..)`.
- **CD-A11.** S1 kept `amount_paid`, `owed`, `status` on `Invoice`/`Bill` as derived caches (`Ledger::refresh_doc`), never journaled; the full status is `arap::DocStatus { Open, Partial, Paid, WrittenOff, Void, Overpaid, Unattached }` via `arap::doc_status(origin_stands, origin_tagged, total, outstanding, written_off)`; `ControlCheck.attention: Vec<DocAttention>` lists Overpaid/Unattached. The plan's machine omits both.
- **CD-A12.** Handbook mechanism: `handbook.rs::SECTIONS`, `section(id, settings, currency, document_line_projects)`, `books://handbook/{id}`. S4 adds `invoices-and-bills.md` and a `{documents_v2}` placeholder.
- **CD-A13.** `create_bill` with a file: the upload path is `tools/files.rs` (`begin_upload`, `hand_over`) and the `upload_id`-to-bytes helper used by `books.attach_receipt`; category `receipts`, filed on the bill's entry, linked `Subject::Bill`.
- **IR-S4-1 step order** (each `just check` green; four PRs: 1-4, 5-6, 7-8, 9-11): 1 core `Terms` types + `Terms::describe()`, tests `terms_derive_due_dates`, `discount_bounds_and_late_reason`, Kani `terms_due_date_laws`; 2 optional fields on `Invoice`/`Bill`/`DocumentLine` with `#[serde(default, skip_serializing_if)]` as `class_id`, `PartySnapshot`, `check_document_line_account`, `document_parts` by `(account, class)`, q×u rule "compare as `Decimal`, no rounding; mismatch is `LedgerError::LineArithmetic`", tests `bill_lines_post_to_their_accounts`, `qty_times_price_must_equal_amount`, `invoice_line_to_deferred_revenue_posts_to_2400`; 3 numbers and duplicates (`next_invoice_number(settings)`, `(vendor, normalized vendor_ref)`), tests `numbers_run_and_voids_keep_theirs`, `duplicate_vendor_ref_refused`, `first_number_locked_after_first_numbered_invoice`; 4 settings (R-D) and party fields; 5 store (R-A, R-B): `DOCUMENTS_V2`, `ensure_feature` only when any new field is `Some`, `no_new_fields_journals_byte_for_byte`, `documents_v2_waits_for_the_upgrade`, projection columns (schema 16), `books_p2_served_cut_over_whole` entry; 6 issued PDF filed in the issuing call (`issued_pdf_is_filed_with_the_invoice_or_nothing_is`); 7 MCP `CreateInvoiceParams`, `customer` resolution, `party_id` alias until P5's doc rewrite, preview returning the number and a DRAFT `HandedFile`; `documents.rs::an_invoice_from_issue_to_paid` (S4 leg), `documents_v2_refused_until_consent`; 8 `create_bill` with `vendor_ref`, `received_on`, `terms`, `upload_id | existing_attachment_id` (refused when both absent under `documents_v2`), `duplicate_reason`; `duplicate_vendor_invoice_refused`; 9 PDF delta with golden sha in `ledger-import/tests/spec_import.rs` beside `pdf_is_deterministic_and_labels_are_data`, `invoice_pdf_prints_what_it_should`; 10 viewer (`shot("invoice-numbered")`, `view_model.rs`); 11 handbook (R-E), rules.md B-39, B-40, parity, For-P12. S6 may start after step 1 merges.
- **IR-S4-2.** Must stay green: `legacy_documents_replay_with_tags`, `legacy_reversal_now_counts`, `a_payment_journals_its_method_and_reference`, `several_payment_events_on_one_entry_tag_its_lines_in_order`, `the_list_shows_the_ledgers_figures`; `an_invoice_renders_deterministically` golden updated once with the recomputation in the commit; no bookspec example changes.
- **IR-S4-3/4.** Rollback: an older version derives the posting without `account` and hits `ReplayMismatch`, which is what the gate prevents (CC-3 a-e). No figure moves. External: D-39 go-ahead for live use only. Size 12-16 days.
## P4b S5 `invoice_sent`
- **IR-S5-1.** Depends on S1 only; if merged before S4, `to` is required and the reminder uses 14 days fixed; after S4 the defaults apply.
- **IR-S5-2.** Decisions to write: legacy invoices exempt from "never sent"; predicate `status ∈ {Open, Partial} ∧ sends.is_empty() ∧ today(company.timezone) > date + 2 days`; `mark_sent` on Void refused, on Paid allowed; bills get no sends; `Sent` gains `by: String`, `reference: Option<String>`, `SentVia::Other(String)`.
- **IR-S5-3 order.** 1 core `Sent`, `Invoice.sends`, `Ledger::mark_sent`; `a_send_changes_no_figure`, `mark_sent_on_void_refused`; 2 store (R-A, R-B) `INVOICE_SENT`, `invoice.sent`, projection `invoice_sends`, `differs_from`; `mark_sent_waits_for_invoice_sent`, `sends_replay_and_project`; `arap_props.rs` gains `MarkSent`; 3 MCP `books.mark_sent` (W); `documents.rs::mark_sent_records_who_and_how`; 4 attention (R-F); 5 handbook, docs, For-P12. Size 3-4 days.
## P4b S6 `settlements`
- **IR-S6-1 / CD-B2.** Exists: `Movement::Discount`, `DocState.discounted`, `kernel::running_bounds`/`bounds_kept` with Kani `bounds_check_matches_definition` and `bounds_kept_is_the_definition_on_books_in_bounds` (the plan lists the first as to-build). `Store::ensure_payment_features(doc, amount, details)` is the gate for payment events (adds `settlements`); `Store::receive_payment_with` uses `journal_auto_entry` (two lines); multi-document needs `journal_auto_lines`. `PaymentParams`, `PaymentDetails { instrument, reference }` (`MAX_REFERENCE_CHARS = 200`) are what `applications[]` extends. `AgingParams { as_of }`, `AgingRowOut` have no `detail`/`control`. Export seams: `hand_over_export`, `layout_statement`, `ledger_import::report::{format_amount, amount_parts}`, `pdf::statement_pdf`; CPA README assembled in `ledger-package/src/package.rs`.
- **IR-S6-2.** Replay for multi-document/discounted entries: legacy single-document events keep the positional rule (`several_payment_events_on_one_entry_tag_its_lines_in_order`); under `settlements` lines are named explicitly (Stage 8: `line`/`discount_line` indices on the existing events); unmatched lines listed by `control_check`; `a_settlement_names_its_lines_and_replays`.
- **IR-S6-3.** Kernel: `kernel::settle(applications, discounts, fee)` and `kernel::discount_max(total, percent_bp)`; Kani `settle_is_exact`, `discount_max_exact`; bridge properties in `tests/kernel_bridge.rs` (omitted by the plan).
- **IR-S6-4.** Roles (`purchase_discounts`, `sales_allowances`, `payment_processing_fees`) via the on-demand creation used by `receivables`/`payables`; `templates.rs::no_template_puts_another_account_at_a_control_code` stays green. Aging CSV columns `number, party, date, due_date, terms, total, paid, discounted, outstanding, bucket, days_overdue`; tie-out line `TOTAL,…,<Σ outstanding>,GL <account> <balance>,difference 0.00`. `books.bills_due` (R): rows `{bill, vendor, vendor_ref, due_date, outstanding, discount_by?, max_discount?}`, footer `by_date: [{date, amount}]` running sum.
- **IR-S6-5 order.** 1 kernel + Kani + bridge; 2 core `Ledger::settle`, tests `one_payment_settles_two_invoices`, `discount_bounds_and_late_reason`, `fee_splits_gross_fee_net`; 3 store `SETTLEMENTS` (R-A), line indices (R-B), `discount_reason` journaled, `settlement_waits_for_the_upgrade`, `arap_props.rs` ops, cut-over entry; 4 roles/templates; 5 MCP `applications[]`, `fee?`, `paid_in_full_marking?` (bool), `books.bills_due`, aging `detail`/`control`; over-the-wire `one_payment_settles_two_invoices`, `bill_on_terms_with_discount`, `card_paid_bill_is_not_1099_nec`, `equipment_bill_payment_is_investing_cash`, `bills_due_orders_by_due_and_discount`, `multi_document_payment_counts_once_for_1099`; 6 exports and CPA package; 7 viewer; 8 handbook, docs, For-P12, bookspec. Depends on S4 step 1. Size 8-10 days.
## P4b S7 `write_offs`
- **IR-S7-2.** Depends on S4 step 6 (attach inside a document command): `write_off_invoice {…, upload_id | existing_attachment_id}` required; `LedgerError::WriteOffOnCashBasis` when `books.accounting_basis = cash` (`Company.basis` from `Settings::company()`).
- **CD-B3.** Exists: `Movement::WriteOff/Adopted/RoleMove`, `DocStatus::WrittenOff/Unattached`, `arap::UnattachedLine`, `ControlCheck.unattached`, `Ledger::check_assign_role` (SCENARIO 6 `RoleAccountHasHandLines`), `Store::assign_role` journaling `role.assigned`, `Ledger::control_role_of`.
- **IR-S7-1/3, CD-B5.** If adoption/role moves were built: `differs_from` must compare `line_docs` directly; Lean `Documents.lean` (`TLine`, `gl`, `docSum`, `unattached`, `Step := document | reverse | assign | release`, `Reachable.control_is_documents`) has no bounds notion and no adopt constructor. (Stage 8 defers both tools; S7 = `write_off_invoice` + `bad_debts` role + B-41 + Lean `Step.writeOff`.)
- **IR-S7-4 order (cut).** 1 core `Ledger::write_off`; `half_paid_rest_written_off_is_written_off`, `write_off_refused_on_cash_basis`, `write_off_then_recovery`; 2 Lean `Step.writeOff`, `control_is_documents` re-proved; 3 store feature + event (R-A, R-B); 4 MCP `books.write_off_invoice` (destructive); `documents.rs::write_off_then_recovery`; 5 attention, viewer; 6 handbook step 4, B-41, bookspec `bad-debt-direct-write-off`. Size 4-6 days (8-10 with adoption and role moves).
## P4
- **CD-C1.** Drafts: `EntryStatus { Draft, Posted }`, `JournalEntry { id, date, description, lines, status, reversal_of }` with `pub(crate)` mutators; `Ledger::posted_entries()` iterates `order`; drafts only in the `entries` map. A draft state is a new field + `differs_from` + projection column + `entry.draft_state_set` arms.
- **CD-C2.** `Ledger::reverse_entry_steps`: `check_reversal` → `create_entry` → `replay_add_line` (flipped) + `set_line_class` + `tag_line` → `mark_reversal_of` → `post_entry` → copies `entry_vendors`, `entry_instruments`, `entry_parties`, `entry_no_party` → schedule `posted` set → `refresh_docs_of`. (Stage 8 keeps reversals immediate, so this stays.)
- **CD-C3.** The gate today: `ledger_config::ReceiptPolicy::receipt_required(&Ledger, &JournalEntry)`, `describe()`, `rule(currency)`; callers `BooksServer::post_entry` (`ReceiptRequiredError { code: "receipt_required", remedy_tool: "books.attach_receipt", split_group }`), `digest`, `dispose_asset`, `handbook::evidence_rule`, kept.rs `RulesOut.evidence`; GUI `Books::receipt_rule()` and `missing_receipt`. `Store::post_entry` checks only `approvals_missing`.
- **CD-C4.** `BooksServer::digest(DigestParams { since })` filters by entry date; `DigestEntry`, `totals_by_account`, `stale_rules`, `tail_repairs`, `unreconciled_accounts`, `uncategorized_accounts`, `control_problems`, `settings_problems`. `posted_seq`: add an `entry_id → seq` map filled in the `"entry.posted"` replay arm (precedent `Ledger::apply_settings(.., line.seq)`), no new event.
- **CD-C6.** Tools taking `confirmed` today: `post_entry`, `reverse_entry`, `run_schedules`, `dispose_asset`, `reconcile`, and the posting runs.
- **IR-P4 order (after Stage 8's cut).** 1 draft states, `list_drafts`, `discard_draft`, `set_draft_state`; `discard_unblocks_close`; Lean `Step.discard`; 2 gate unification: policy evaluation moves from `ledger-config` to `ledger-core` (`evidence::requirement(&Ledger, &JournalEntry, &Settings)`), `Store::post_entry` calls it, GUI deletes the string heuristic (`gui_and_server_gate_agree`); 3 digest `since_seq`, `as_of`, drafts by state (`digest_since_is_posting_time`); 4 `preview` digest at the envelope, required by every confirm tool (`stale_preview_refused`); 5 attention (R-F), handbook `drafts-and-reversals.md`, rules, For-P12. Feature `drafts_v2` in S7's upgrade batch; schema bump for `entries.draft_state`, `entries.posted_seq`. Size 8-10 days.
## P5
- **CD-D1.** `books.start` v1: `StartParams { focus }`, `start_out -> StartOut { book, head: HeadOut { seq, hash }, state, rules, settings_summary, conventions, open_items, projects, truncated, handbook_sections, format_upgrade, next_steps: Vec<NextStepOut { tool, args, why }>, isolation }`, `START_MAX` fit loop. v2 extends these.
- **CD-D2 Blocker.** Prompts: `opening.rs::capabilities()` enables tools and resources only; no `#[prompt_router]`, `list_prompts`, `get_prompt`, `list_resource_templates`. P5 adds them and the bridge's cold-start `opening_initialize` must advertise the same.
- **CD-D3.** `tool_descriptions_fit_700_characters` asserts `over == OVER`; P5 drives `OVER` to 0. `handbook::CONNECT_MAX = 1400`, `CONNECT_LEAD = 512`.
- **CD-D4.** `journal_history`: reuse the `describe` arms of `ledger-gui/src/pages/journal.rs` by moving them to `ledger-store` beside `meta.rs`; read with `journal::read_journal_and_format` + `Store::tail_repairs`.
- **CD-D5.** Source-only revision: refusal in `Ledger::check_convention`; `Source { decided_by, decided_on, citation, note }`; new event `convention.source_revised` + feature.
- **IR-P5-1 Blocker.** The result envelope touches every handler and every wire assertion: single owner, merged before or after every other MCP-touching branch (the EM writes which in 8.0.3).
- **IR-P5-2.** Agent evals: `crates/ledger-cli/tests/agent_evals/*.json` `[{"call": "books.start", "args": {}}, {"expect_next_steps_contains": "books.set_setting"}, …]`, one test `agent_evals_follow_only_next_steps`.
- **IR-P5-3 order.** 1 envelope; 2 guard registry enum with `remedy_tool`; 3 `books.start` v2; 4 prompts and resource templates with golden renders on the `p2-served` fixture; 5 `journal_history`, source-only revision; 6 descriptions ≤ 700, handbook complete; 7 `docs/mcp-interface.md` rewrite; 8 agent evals. Size 10-12 days.
## P6
- **CD-E1 Blocker.** "Identical bytes return the existing record" contradicts `Store::attach_receipt` INVARIANT 3 ("each filing MUST be its own attachment"); `attach` always allocates a new id even when `BlobStore::put` reports `deduplicated`. P6 must state the invariant change and that legacy duplicates stay.
- **CD-E2.** Consolidation sequence: `link_attachment` the first record to each subject, `unlink_attachment`/`unlink_receipt` the duplicate, then `withdraw_attachment(dup, "duplicate of #N")` (it refuses while linked: `AttachmentLinked`, `AlreadyLinked`).
- **CD-E3.** Correction to earlier stages: `ledger-gui/src/receipts.rs::text_of` is UTF-8 text only; PDF rendering is the private `pdf_first_page_png`/`render_pdf` (hayro 0.8, first 30 pages); the only PDF text extraction is a test helper in `ledger-import/tests/spec_import.rs` using `hayro_interpret` `GlyphRun` (dev-dependency); `tin_files::text_of` never reads a PDF. P6: promote the reader to `ledger_import::pdf_text(bytes)`, move the rasterizer to `ledger_import::page_png(bytes, page, scale)`, call both from `tin_files::text_of` and the viewer. No lopdf.
- **CD-E4/E5.** `GetReceiptParams { attachment_id, view: "meta" | "bytes" }`: `text`, `page_image` are new arms (`HandedFile` via `hand_over`). Categories validated in `BooksServer::file_document`; `Attachment.category` doc lists `receipts, formation, legal, tax, accounting, other` while descriptions already use `statement`, `import_source`; new categories (notices, plans, history; P8's `workpaper`) extend that one place.
- **IR-P6-1.** Backfill = `wanderbooks extract --book ID` over the admin socket, idempotent on `(attachment_id, extractor_version)`.
- **IR-P6-2 order.** 1 `ledger-import::extract::{pdf_text, page_png}` behind both callers; `extraction_deterministic`; `fuzz_extract_pdf`; 2 event `attachment.text_extracted` + feature `attachment_text`; 3 `get_receipt` meta+text, `page_image`; 4 dedup remainder (`find_attachment`, `consolidate_duplicates`, `attachment.withdrawn {duplicate_of}`, the "only evidence" refusal); 5 annotations and receipt numbers; 6 backfill command; 7 D-20 categories. Accept proxy: every attachment of the `p2-served` fixture gives `get_receipt` < 8 KB. Size 8-10 days.
## P8
- **CD-F1.** Exists: `Store::verify_and_heal() -> VerifyReport`, `verify()`, `verify_blobs()`, `Snapshot::verify_blobs()`; `BooksServer::verify` already appends `control_check(NaiveDate::MAX)`; access log `ledger_store::access::{AccessLog, read(book_dir) -> (Vec<Recorded>, Head)}`; `CashFlowStatement.reconciles`. Drop "vault public-key sha" (P7 superseded).
- **CD-F2.** "Closes in order" is an engine refusal already; member deltas `kernel::apportion_cumulative`; schedules `Schedule::due/next/remaining`; `FixedAssetRegister::unposted_depreciation()`; the audit calls these.
- **CD-F3.** Existing problem lists to convert: `settings_problems`, `control_problems`, `unreconciled_accounts`, `tax::uncategorized_accounts`, `rules::stale_rules`, `Backups::standing` + `needs_attention()`, `drill_due`; no shared `Finding` type: P8 defines one in `ledger-core`. `Subject::Period(u64)` for linking the workpaper; `Store::file_document(category, ..)` to file it.
- **IR-P8-1 Blocker.** A table `check | predicate (function) | finding code | fixture (how seeded) | severity`, one row per check; fixtures built in-test through `Store`; `audit_detects_each_seeded_defect` iterates it.
- **IR-P8-2 order.** 1 `ledger_core::audit::statements(&Ledger, through) -> Vec<Finding>`; 2 `Store::integrity_findings`; 3 server composition, `audit.run` event + feature `audits`, `workpaper` category; 4 workpaper JSON + Typst (`templates/workpaper.typ`, deterministic sha test); 5 attest tools with argument lists, one `policy.attestation_bar` setting, `export_cpa_package` `without_audit_reason`; 6 attention, handbook `cpa-handoff.md`, For-P12. Size 10-12 days.
## P9 / P10 / P11
- **CD-G1.** Dispatched commands (`ledger-cli/src/main.rs`): `gui, setup, server, host, mcp, serve, close, books, doctor, token, connect, passphrase, upload, download`; `ledger-host/src/commands.rs`: `serve, close, books (new_books, adopt, put_aside, restore/plan_restore/carry_out_restore, upgrade/upgrade_format, back_up_now), connect/connect_agent, token, passphrase, doctor, upload, download`. No `update`, `helper`, `tunnel`, `fetch`. P9's move = `commands::adopt` + `admin::Request::Adopt`/`AdoptPreview`.
- **CD-G2.** `connect_agent(common, agent, book)`: finds `claude`/`codex`, makes the token (`make_token`, `tokens::keeper(..).set(..)`), runs `<cli> mcp remove|add wanderbooks-<book> -- <this_executable()> mcp --book <id>`; writes nothing into a project folder (Codex global). The books home is new: `commands::books_home(book) -> PathBuf` + files, `connect_agent` unchanged.
- **CD-G3.** Viewer menu: `menu.rs` `Menu`/`Job::{Package, Worksheet(y)}`; `owner.rs` `trait Owner { close_books, new_books, back_up_now, plan_restore, restore, connect(Agent, book), upgrade_format(book, passphrase) }`, `Dialog::{NewBooks, Restore, Close, Upgrade}`, implemented over the admin socket (`ledger_mcp::admin::{Request, Response, call}`), not "the lease socket's owner intents" (plan 857 stale). The dock = new `Owner::start_agent(Agent, book) -> Child` + a panel in `app.rs`.
- **CD-G4.** Built already (P11 lists as to-build): `host.rs` INVARIANT 7 `run/update.lock` (`take_update_lock`), `Ending::Restart`, `restart()`, the version handshake refusal; `bridge.rs` `with_list_changed`, tests `the_agent_hears_of_changed_tools_only_after_a_restart`, `bridge_survives_server_restart`, `bridge_initializes_during_cold_start`. The updater = fetch, verify, stage, swap, then the existing restart.
- **CD-G5.** Backup/restore reuse: `ledger-store/src/backup.rs` (`Git`, `commit`, `push(Target)`, `verify`, `drill`, `prove_archive`), `ledger-mcp/src/backup.rs` (`Backups::{open, standing, drill_due, drill}`, `Upgraded`, `UpgradeBackup`), admin `Request::UpgradeFormat`, `BackUpNow`, `Restore`/`RestorePreview`; `Store::upgrade_format() -> Option<FormatUpgrade>`. Routes in `local.rs`: `/b/{book}/mcp`, `/u/{upload}`, `/a/{attachment}`, `/x/{export}`; `bearer(headers)` → `auth::Token`, `Scope { Read, Write, Post, Admin }`.
- **IR-P9-1.** P9a (no decision needed): `deploy/wanderbooks-server.service`, `deploy/README.md`, `self_host_adopt_same_head`, `.github/workflows/two-user.yml` running `agent_uid_cannot_read_tenant` (neither exists today), static bearer over a documented Tailscale Funnel; no seccomp/Landlock. 3-4 days. P9b after D-7. P9c not planned. External: a machine, tunnel, domain/TLS, a reviewer.
- **IR-P10-1.** Not an engineering unit: `docs/runbooks/re-audit.md` against existing tools plus a CI test `defect_reproductions_all_fixed` asserting the 24 named regression tests exist. 1 engineer-day; owner-run after P5, P6, P8.
- **IR-P11-1/2.** Non-code prerequisites listed separately (Apple Developer ID and notarization credentials as CI secrets; Windows signing identity; GitHub Releases on the mirror; the release hardware token; clean VMs). Order: 1 trial (2-3 days, output a change-log entry per format); 2 `wanderbooks update`, `rollback`, staged layout `<root>/updates/<version>/`, tested against a `file://` feed (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`); 3 Linux packages in CI containers (unsigned first; `linux_packages_install_and_upgrade`); 4 signing (minisign + OS; the two-key manifest per the owner's answer to Q-4); 5 macOS/Windows bundles; 6 dock as its own unit (`alacritty_terminal`, books home contents verbatim, `dock_runs_the_chosen_agent`, `books_home_holds_no_books_data`). Size 20-25 (1-5) + 8-10 (dock).
## P12 / P13
- **CD-H1.** Nine fuzz targets (`fuzz_config_parse, fuzz_core_serde, fuzz_entry_validate, fuzz_import_csv, fuzz_journal_line, fuzz_op_sequence, fuzz_settings_replay, fuzz_store_open, fuzz_store_replay`), scheduled.yml at 600 s each; `fuzz_op_sequence::Op` has no document op. Mutation recipes: `mutants` (core; excludes proofs.rs; `-j 4`), `mutants-store` (`STORE_CASES=64`, `-j 3`), `mutants-import`, `mutants-config`, `mutants-mcp` (`MCP_CASES=16`, with ledger-cli tests, `--no-default-features`); CI shards core 8 / store 4 / import 2 / config 1 / mcp 16, `timeout-minutes: 350`. No recipe for `ledger-host`, `ledger-package`, `ledger-vault`, `ledger-cli`: P12 adds them. `--jobserver-tasks` is not used (the recipes use `-j`). Pins: cargo-mutants 27.1.0, cargo-fuzz 0.13.2, kani-verifier 0.68.0, lean4 4.34.1.
- **IR-P12-1.** Parameters: 4 h per fuzz target (or the owner's number); the maxima are `STORE_CASES`, `WB_ORDERS_DEPTH=6`, proptest case counts named in justfile/docs/testing.md; build `fuzz_http_mcp_request`, `fuzz_upload_put` first (2 days); CC-2's sections first. Size 2-3 engineer-days of code plus machine time.
- **CD-I1 Blocker.** The in-repo seed is the starting point: `bookspec/README.md` (schema: `id, title, rule, status, sources, tags, appendix?`; `[setup]` fields; `[[events]]` kinds `lines | reverses | close | bill | invoice | run_depreciation | dispose_asset | run_schedules | register_schedule` plus `refused, payee, parties, instrument, older`; `[[changes]]`; `[[expect]]` with ~30 queries); 56 examples; loader `crates/ledger-core/tests/bookspec.rs` (`#[serde(deny_unknown_fields)]` structs, `every_bookspec_example_holds`, applies events to a real `Store` and `verify`s); `ledger-package/tests/package.rs::the_bookspec_iif_example_matches_the_engine`. The `Engine` trait is today the loader over `Store`.
- **CD-I2.** P4b's bookspec ids need loader extensions: `InvoiceSpec`/`BillSpec` lack `due_date`/`terms`; no `receive_payment`/`pay_bill`/`void_document`/`write_off` events; no `ar_aging`/`ap_aging`/`control_check`/`document` queries; the refusal vocabulary needs `UntaggedControlLine`.
- **IR-P13-1 order.** 1 move the loader into `bookspec/checker/` (own Cargo workspace) exposing `Setup`, `Event`, `Expect`; `ledger-core/tests/bookspec.rs` becomes the adapter by path dependency; 2 `pub trait Engine { fn load(&mut self, &Setup) -> Result<(), Refusal>; fn apply(&mut self, &Event) -> Result<(), Refusal>; fn query(&self, &Query) -> Result<Value, Refusal>; fn claims(&self) -> Claims; }`, `Refusal { rule: RuleId, reason }`; 3 rule ids `BS-<area>-<n>` mapped from B-1..B-41; 4 the spec skeleton with a done line ("every B-rule has an id, statement, source, one example; the five laws are Lean theorems in `bookspec/lean/`"); 5 runner (pass/fail/not claimed), GnuCash adapter later. In-tree until step 5. Size 15-20 days.
## Cross-cutting reuse to cite (CD-J)
`crates/ledger-cli/tests/common/mod.rs`: `Home` (`new`, `create`, `adopt`, `token`, `token_with`, `admin(Request)`), `Server::start`, `Wire` (`initialize`, `call`, `rpc`, `upload`, `begin_and_send`, `fetch`, `fetch_text`), `Books` (`new`, `with`, `placed`, `adopted`, `wire`, `session`, `settle`), `upload_args`, `answer_of`; parity precedent `projects_parity.rs`; older-rules fixtures `store.rs::books_written_under_older_rules_still_open`, `arap_standing.rs::legacy_documents_replay_with_tags` (exists; plan lists it as to-build); `BooksServer::as_of(Option<&str>)` the one date source; `BooksServer::hand_over(book, file_name, mime, bytes) -> HandedFile`; `parse_doc_ref("invoice:7")`, `doc_ref_text`, `doc_label`, `movement_key` (an "INV-0007" form is a new parser arm).
## Summary table
| Unit | Depends on | Non-code prerequisites | Size (eng-days) |
|---|---|---|---|
| Pre-steps (recipes, testing.md S1B/S2/S3, attention over PackageBooks, server.rs split) | — | — | 4-6 (+ the 700-line attention refactor) |
| S4 | S1-S3 | go-ahead for live use only | 12-16 (4 PRs) |
| S5 | S1 | same | 3-4 |
| S6 | S4 step 1 | same | 8-10 |
| S7 (cut) | S4 step 6 | same | 4-6 |
| P4 (cut) | S4-S7 MCP steps | shared go-ahead | 8-10 |
| P5 | P4; envelope single-owner | live-session check | 10-12 |
| P6 | — | — | 8-10 |
| P8 | P6 step 3, P4 | — | 10-12 |
| P9a / P9b | — / D-7 | machine, tunnel, TLS / reviewer | 3-4 / later |
| P10 | P5, P6, P8 | owner-run | 1 |
| P11a / P11b dock | — | certificates, keys, VMs | 20-25 / 8-10 |
| P12 | everything | desktop | 2-3 + runs |
| P13 | — | — | 15-20 |