124 lines
36 KiB
Markdown
124 lines
36 KiB
Markdown
# 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 |
|