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

36 KiB
Raw Blame History

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 verifys); 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