P3: the books' format moves only with the owner's go-ahead (D-39)

Books an update could write a newer format for open on their old format
and are asked at every open; anything needing a new feature is refused
until the owner agrees with the passphrase, after a verified backup that
includes the keys. New books start on the current format. Each projection
schema has its own database file, so an older version still opens the
books until the owner agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent b0a9fd8ad4
commit 3a9aa92315
62 files changed
+5309 -628

No files matched your search

+48 -15
View File
@@ -129,8 +129,13 @@ settings-and-conventions section says how to use them.
state ("needs_onboarding" or "active"), settings_source, rules: {
evidence, parties, approval_rules }, settings_summary: [{ key, value,
set_by, seq }], conventions: [{ id, kind, title, summary }], open_items:
[{ id, title, due }], truncated, handbook_sections, next_steps: [{ tool,
args?, why }], isolation }`. The connect text names it as the first call.
[{ id, title, due }], truncated, handbook_sections, format_upgrade?: {
guard, format, adds, how }, next_steps: [{ tool, args?, why }],
isolation }`. The connect text names it as the first call.
`format_upgrade` is there only for books waiting for a format upgrade
(below); its next step comes first, before anything else, at every
start until it is done: `books.handbook {section: "format-upgrade"}`,
"Tell the human first: …".
`isolation` is `same_user (advisory)` (D-32): the books are hidden from
agents in the owner's own account, not locked against them. Lists cut
to fit say so in `truncated` ("conventions: 4 more (books.conventions)").
@@ -138,6 +143,25 @@ settings-and-conventions section says how to use them.
company's close steps, `audit` `books.verify`. Not built yet (P5):
`book.id`, `book.url`, `head.last_handoff`, the `agent` input, and
loading it up front under tool search. Also `books://start`.
**The format upgrade** (plan 3.9, D-39, rules.md B-27). Books an older
wanderbooks wrote are in an older journal format: they are served on it,
and every tool that needs nothing new works as before. A tool whose
change needs the newer format (on main: settings, conventions, decisions,
open items and approvals need journal format 2; later formats add
features) is refused before anything is journaled, its error beginning
`format_upgrade_pending:` and saying that only the owner can give the
go-ahead, with the owner passphrase, in the app or with `wanderbooks
books upgrade ID` at a terminal, and that no tool can. No tool consents:
none is offered, and the server takes the go-ahead only on its admin
socket, from the host's lease intent `consent_format_upgrade` (the app's
dialog, `books upgrade`), with the passphrase it checks. Before the
upgrade the server commits the books to their own backup repository and
proves the copy holds them exactly (journal, stored files, vault, keys);
then one `journal.format_set` declares the whole format, and the same
session's next call can make the change. Books this version creates are
in its format from their second line, and an adoption's copy moves to it
as it is made.
- `books.conventions { kind?, include_retired? }` → every convention with
its full text, structure, revision and source (`books://conventions`).
`books.set_convention { id, kind, title, body_md, structured?,
@@ -746,11 +770,14 @@ through the policy.
## Tools — integrity
- `books.verify { blobs? }` → `{ status: "ok" | "healed", events, head,
healed?, blobs_verified?, checked }`. Verifies the journal's hash chain,
healed?, blobs_verified?, database, other_databases?, checked }`
(`database`: the projection file verified, one for each schema,
`wanderbooks.v{N}.db`; `other_databases`: other schemas' files beside it,
left for the versions that use them). Verifies the journal's hash chain,
replays it against the live ledger, and compares every table of the
SQLite database row for row against a fresh projection of the journal.
A drifted database is rebuilt from the journal (the old file kept as
`wanderbooks.mismatch.<time>`). With `blobs` (default true), re-hashes
`wanderbooks.v{N}.mismatch.<time>`). With `blobs` (default true), re-hashes
every stored file. The server runs the same check, and heals the same
way, every time it starts.
@@ -906,8 +933,9 @@ first entry's).
year (and its entries) whose payments the change reaches in a closed
period or after that year's 1099s were due (January 31 of the next
year): tell the human, a filed 1099 may need correcting. Journaled as
`vendor.1099_set` (a W-9 flag alone as `vendor.w9_flag_set`), after a
`journal.format_set` declaring the `vendor_1099_decisions` feature once.
`vendor.1099_set` (a W-9 flag alone as `vendor.w9_flag_set`), under the
journal feature `vendor_1099_decisions` (books an older version wrote
refuse it, `format_upgrade_pending`, until their format upgrade: B-27).
- `books.add_instrument { kind, label, last4?, owner_party?, account }` →
`{ id, kind, card_or_network, label, last4?, owner_party?, account_id,
account, warnings }`. `kind`: `bank` (the company's checks, ACH, wires,
@@ -1120,10 +1148,12 @@ the name; `roles` include vendor and customer when it has those records.
party itself on: the card it issued, the loan it made, what the members
advanced (each member). `books.update_party { creditor_of: [codes] }`
replaces the list (`[]` clears it), journaled as `party.creditor_set {id,
account_id, on}`, one line per account added or dropped (the journal's
feature `party_creditors` is declared before the first); refused,
journaling nothing, for an account that isn't a liability, is a
placeholder, or is payables or a payroll withholding (owed to many).
account_id, on}`, one line per account added or dropped, under the
journal feature `party_creditors` (books an older version wrote refuse
the whole change, `format_upgrade_pending`, until their format upgrade:
B-27); refused, journaling nothing, for an account that isn't a
liability, is a placeholder, or is payables or a payroll withholding
(owed to many).
**Each party's part of an entry** (`books.get_entry`, the
`books.post_entry` preview, `books.set_entry_party`: `parties: [{ party_id,
@@ -1569,9 +1599,10 @@ posting.
one that isn't one line of at most 200 characters (kept trimmed); a
`reason` is one to 2,000 characters with no full TIN. Journaled as
`account.renamed { account_id, code, name, previous, reason }`, keyed
by id (a code can change; an id can't); the first rename declares the
journal feature `account_renamed` (rules.md B-27), so an earlier
version stops at the declaration. `notes` always says that QuickBooks
by id (a code can change; an id can't), under the journal feature
`account_renamed` (rules.md B-27; books an older version wrote refuse
it, `format_upgrade_pending`, until their format upgrade), so an
earlier version stops at the declaration. `notes` always says that QuickBooks
matches accounts by name (tell a CPA who imported an earlier IIF), says
when sub-accounts' full names change with it, when the new name moves
the account into or out of the cash accounts (`books.cash_accounts`
@@ -1600,8 +1631,10 @@ posting.
- `policy.require_tax_category` (`books.set_setting`): `warn` (unset) or
`refuse` (rules.md B-32). The accounts invoices, bills and payroll
create (Revenue 4000, General Expenses 6999, 6100, 6110) are never
refused; their results' `warnings` name them. The first change naming
the key declares the journal feature `require_tax_category`.
refused; their results' `warnings` name them. A change naming the key
needs the journal feature `require_tax_category`: books an older
version wrote refuse it (`format_upgrade_pending`) until the owner's
format upgrade (rules.md B-27).
## Tools — import
+6 -2
View File
@@ -133,8 +133,12 @@ the MCP server's reads, and the future GUI all hit SQLite — and SQLite is
**disposable by design**: `rebuild` drops it and replays the journal.
Suspected corruption is fixed by rebuild, never by forensics.
- **Location.** `$WANDERBOOKS_DATA/wanderbooks.db`, falling back to
`~/.local/share/wanderbooks/wanderbooks.db`. The journal lives next to it
- **Location.** `$WANDERBOOKS_DATA/wanderbooks.v{N}.db`, falling back to
`~/.local/share/wanderbooks/wanderbooks.v{N}.db`, N the schema: each
schema keeps a file of its own, and a version never migrates, writes or
deletes another schema's (`ledger_store::db_file_for`; the legacy
`wanderbooks.db` is used only while it holds this version's schema;
rules.md B-27). The journal lives next to it
at `$WANDERBOOKS_DATA/journal.jsonl`; blobs under
`$WANDERBOOKS_DATA/blobs/`. WAL mode on, `synchronous=FULL`,
`integrity_check` on startup.
+106 -35
View File
@@ -331,7 +331,7 @@ earnings the same running way, D-38, 2026-10-20)
`Ledger::members_regime`, `Regime::allocate`,
`periods::allocate_cumulative`, `kernel::apportion_cumulative`,
`periods::allocate`, `kernel::apportion`, `members_equity`,
`Store::close_period_to` (the feature declaration), the store's
`Store::close_period_to` (the feature it needs, B-27), the store's
`close_to_from_journal`. **Checked:** Kani `apportion_is_exact`,
`apportion_is_fair`, `apportion_cumulative_is_exact`,
`cumulative_close_never_drifts_a_unit_at_halves`, `_at_tenths`,
@@ -860,7 +860,7 @@ B-16 cites them (P3 depreciation review R7).
`a_reversal_dated_before_the_entry_it_reverses_is_refused`,
`a_schedule_month_reversed_after_it_ended_is_left_for_a_human`,
`a_month_that_rounds_to_nothing_is_never_a_posting`,
`a_schedule_month_posted_again_declares_month_reposting_and_replays`,
`a_schedule_month_posted_again_waits_for_month_reposting_and_replays`,
`daily_plans_pin_month_end_clamping`,
`prop_daily_plan_sums_exactly_and_lands_on_month_ends`,
`prop_monthly_even_plan_is_unchanged`; `ledger-mcp`
@@ -1366,7 +1366,9 @@ unit tests in `tin_files.rs`.
**Status:** DECISION (the owner, 2026-10-16; `plan/served.md` 5.1 and P0);
DECISION (P0 review, 2026-10-16: a line's own hash is checked before the
line is called newer; the envelope and its hash are frozen across
formats)
formats); DECISION (the owner, 2026-10-20, D-39, and `plan/served.md`
3.9: older books move to a newer format only with the owner's go-ahead,
asked at every open until given)
Every journal line has a type, and a version of wanderbooks reads a fixed
set of them (`ledger_store::format::EVENT_TYPES`). A newer version may
@@ -1410,19 +1412,14 @@ lines before it pass, so an older version never alters newer books.
A line an earlier version can't read is announced by a journal feature
(`ledger_store::format::KNOWN_FEATURES`): a new event type, or a field or
value on an event type an earlier version knows but would misread. The
writer declares it once, in the `journal.format_set` just before the
first line that needs it, with the format in force (at least 2) and the
features already in force (`Store::ensure_feature`): an earlier reader
stops at the declaration, and books that never use the feature stay
readable by it. A tool journaling several commands as one declares the
feature before the first of them, never between them (a refused format
upgrade would leave the tool's work half done): `books.set_w9` declares
`vendor_1099_decisions` before the W-9 when the vendor's W-9 flag follows
it. P3's: `members_cumulative` (B-13: a members close counted from its
count's first close, `"rounding": "cumulative_since"`), `account_renamed`
(before the first `account.renamed`, B-31), `require_tax_category`
(before the first `settings.set` naming `policy.require_tax_category`,
B-32), `party_creditors` (`party.creditor_set`: defect 24, B-35),
books declare it in a `journal.format_set` before the first line that
needs it, so an earlier reader stops at the declaration; this version
declares every feature it knows at once (below), never one at a time on
the way. P3's: `members_cumulative` (B-13: a members close counted from
its count's first close, `"rounding": "cumulative_since"`),
`account_renamed` (an `account.renamed`, B-31), `require_tax_category` (a
`settings.set` naming `policy.require_tax_category`, B-32),
`party_creditors` (`party.creditor_set`: defect 24, B-35),
`payment_instruments` (`instrument.added`, `entry.instrument_set`:
defect 12, B-19), `vendor_1099_decisions` (`vendor.1099_set` with who
decided, a `not_reportable` decision, its reason or the date it takes
@@ -1432,21 +1429,75 @@ effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19),
B-16) and `month_reposting` (a schedule month or a depreciation period
posted again after a reversal un-posted it; an older version would refuse
the second `schedule.posted`, and its projection ignore the second
`asset.depreciation_posted`, B-15). A value
in a line of a known type that only a newer version writes (a 1099
decision or reason kind, a payment method's kind) is newer, never
corrupt. A command decides whether it needs a feature, and declares it,
before its first journaled line, so a feature refused (a format upgrade
awaiting the owner's consent) journals nothing, never an entry without
the line that says what it is
(`a_refused_journal_feature_journals_nothing`). A later piece asks the
owner's go-ahead before the first such declaration (plan D-39).
`asset.depreciation_posted`, B-15). A command asks for the feature it
needs (`Store::ensure_feature`) before its first journaled line, and only
when the line it is about to write uses it, so a write refused for
waiting on the upgrade journals nothing, never an entry without the line
that says what it is (`a_write_waiting_for_the_upgrade_journals_nothing`),
and a write that needs nothing new is never refused. A tool journaling
several commands as one asks before the first of them, never between
them (a refusal there would leave the tool's work half done):
`books.set_w9` asks for `vendor_1099_decisions` before the W-9 when the
vendor's W-9 flag follows it. A value in a line of a known type that only
a newer version writes (a 1099 decision or reason kind, a payment
method's kind) is newer, never corrupt.
**The other way round: the format upgrade.** This version's format is
the newest it reads with every feature it knows (`format::current`). Books
it creates declare it in their second line, so they wait for nothing.
Books an older version wrote declare less (format 1, written before the
gate, declares nothing): they *wait for a format upgrade*, which is a fact
of their journal alone (`JournalFormat::upgrade`). They open and replay as
they are, byte for byte; everything that needs nothing new is written as
ever; a write that would need the newer format or a feature they haven't
declared is refused before anything is journaled
(`StoreError::FormatUpgradePending`, guard `format_upgrade_pending`), never
declared on the way. Only the owner's go-ahead moves them, with the owner
passphrase (the server checks it, Argon2id): in the app, which asks at
every open until it is given, or with `wanderbooks books upgrade ID` at a
terminal, whose commands that open the books also ask; never an agent, and
no tool. Before the upgrade the server commits the books to their own
backup repository and proves the copy holds them exactly as they are
(journal, stored files, vault and keys, byte for byte), all under the
books' lock; a backup that can't be made or proven upgrades nothing. The
upgrade is then one `journal.format_set` declaring the whole format, so a
book's format moves exactly once per version; books already in it are left
alone. An adoption's copy is new books of this version and moves to its
format as it is made (the original keeps its own). The upgrade never
weakens the gate: books declaring a feature this version doesn't know are
newer, as above.
**Each projection schema in a file of its own** (the lead's decision,
2026-10-21, for 3.9's "before consent, the way back is always there"). The
books' SQLite projection is derived from the journal, and its schema moves
with the version (`schema::SCHEMA_VERSION`), but an older version refuses a
database of a newer schema (`SchemaTooNew`). So each schema keeps its own
file, `wanderbooks.v{N}.db` (`store::db_file_for`): a version opens or
creates only its own; its own missing, it is built from the journal; the
legacy `wanderbooks.db` (every version through P2, schema 12) is used only
while it already holds this version's schema, so books needing no
migration aren't rebuilt for nothing; a file of any other schema, older or
newer, is never migrated, written, refused or deleted, and stays byte for
byte for the version that wrote it (going back finds it as it left it, or
builds its own). A file named for this version's schema that holds another
is damaged, kept aside and rebuilt, as a corrupt one is. `books.verify`
(`database`, `other_databases`) and `wanderbooks doctor` say which file is
in use and which others are there; none is deleted for being stale.
**Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`,
`gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`,
`gate`, `settle`, `declaration`, `current`, `JournalFormat::upgrade`,
`require_format`, `require_feature`), `journal::ENVELOPE_KEYS`,
`JournalLine::parse`, `journal::read_journal_and_format`,
`Snapshot::read_new`, `Store::open`, `Store::declare_journal_format`,
`StoreError::NewerJournal` (its `verified` flag picks the message).
`Snapshot::read_new`, `Store::open`, `Store::create`, `store::db_file_for`,
`store::database_in_use` (and `Snapshot::check_disk`, which checks the file
it names), `Store::database_file`, `Store::other_databases`,
`Store::declare_journal_format`, `Store::upgrade_format`,
`Store::ensure_feature`, `Root::adopt`, `StoreError::NewerJournal` (its
`verified` flag picks the message), `StoreError::FormatUpgradePending`,
`ledger_store::backup::prove_archive`, ledger-mcp `Backups::upgrade_format`
and `local.rs` (the passphrase), ledger-host `host.rs` (the lease intent,
handed to the server) and `commands.rs` (`books upgrade`, asking at open),
ledger-gui `owner.rs` (the notice and the dialog).
**Checked:** `ledger-store` `tests/format.rs` (`old_reader_gets_newer_journal`,
`a_damaged_line_that_looks_newer_is_corrupt_when_the_next_line_checks_it`,
`an_unreadable_last_line_is_reported_as_newer_or_damaged`,
@@ -1460,7 +1511,23 @@ owner's go-ahead before the first such declaration (plan D-39).
`damaged_journals_are_still_corrupt`) and the unit tests in `format.rs`;
`ledger-gui` `tests/newer_journal.rs`; `ledger-mcp` `tests/startup.rs`;
the `fuzz_store_replay` target (the writer and the viewer agree on newer
versus bad).
versus bad). The upgrade: `ledger-store` `tests/format_upgrade.rs` (new
books in this version's format, a change waiting for the upgrade, every
old-books fixture upgrading to its figures, a newer feature still
refused), the unit tests in `format.rs` and `backup.rs`
(`the_archive_proves_to_hold_the_book_as_it_is_keys_and_all`),
`tests/settings.rs` (`a_change_needs_journal_format_2`), `tests/root.rs`
(adoption), `tests/schema_files.rs` (a P2-shaped books dir, its
`wanderbooks.db` used at its schema and left byte for byte past it; an
older schema's file left byte for byte and whole while this version's is
built and kept up; a newer schema's file left alone, not refused; a damaged
file of this version's schema rebuilt; new books' file named for their
schema); `ledger-mcp` `local.rs` (`format_upgrade_waits_for_consent`,
`no_upgrade_without_a_backup`,
`no_upgrade_unless_the_backup_just_made_is_proven`); `ledger-host`
`lease.rs` (`a_consent_never_shows_its_passphrase`); `ledger-cli`
`tests/format_upgrade.rs`
(from end to end); `ledger-gui` `tests/format_upgrade.rs`.
## B-28 What new commands take: four-digit years, amounts a line can carry
@@ -1508,7 +1575,11 @@ command's (`Ledger::add_line`, `register_asset`, `TaxBasis::check`,
`replay_register_asset`, `TaxBasis::check_journaled`,
`shares::check_shares_journaled`, ...), and every bound has its pair of
tests: the command refuses past it; replay of an old line past it is
accepted.
accepted. What an earlier version left beside its journal is kept for it
the same way: its projection database, in its own schema's file, is never
migrated or written by a later version (B-27, "each projection schema in a
file of its own"), so the earlier version, gone back to, opens its books
as it left them.
**Derived figures refuse, never panic.** Because replay accepts what
the commands now refuse, every figure the books derive is computed with
@@ -1759,8 +1830,8 @@ by name, so a CPA who imported an earlier IIF is told.
`check_rename_reason`,
`replay_rename_account` (only that the account exists),
`ChartOfAccounts::set_name`, `Ledger::add_control_account`;
`Store::rename_account` (journal feature `account_renamed`, declared
before the first rename, B-27); `books.rename_account` (scope
`Store::rename_account` (journal feature `account_renamed`, which the
books declare before their first rename, B-27); `books.rename_account` (scope
`books:post`, like renumbering). **Checked:** `ledger-core`
`tests/spec_ledger.rs` (`rename_account_*`), `tests/model.rs`
(`Cmd::Rename`); `ledger-store` `tests/store.rs`
@@ -1823,8 +1894,8 @@ included, and an account's header flag never changes).
`check_tax_category_kept` (commands only, never `set_account_tax`, which
replay calls); `settings::TaxCategoryPolicy`, `Settings::require_tax_category`;
`Store::add_account_with_tax`, `add_account`, `set_account_tax`
(journal feature `require_tax_category`, declared before the first
`settings.set` naming the key); `ledger-mcp` `books.add_account`,
(journal feature `require_tax_category`, which the books declare before
their first `settings.set` naming the key, B-27); `ledger-mcp` `books.add_account`,
`books.import_chart_csv`, `books.digest`; `ledger-gui` Needs attention
(Info under warn, Todo under refuse). **Checked:** `ledger-core`
`tests/tax.rs`; `ledger-store` `tests/store.rs`
@@ -2214,7 +2285,7 @@ not the books' accounting method (`books.accounting_basis`).
`shares::{target, split, resolve}`, `parties::{flow, money_accounts,
card_accounts, MoneyAccounts::for_parties, activity_with, Activity::totals,
Basis}`, `Ledger::set_party_creditor`, `Store::set_party_creditor` (the
`party_creditors` journal feature, declared before its first line);
`party_creditors` journal feature, asked for before its first line);
`ledger-mcp` `books.get_entry` (`resolved`, `direction`, `cash`,
`in_kind`), `books.party` and `books.parties` (`basis`), `books.update_party`
(`creditor_of`); the viewer's party pages and entry page.
+113 -9
View File
@@ -284,7 +284,7 @@ since `asset_long_life` alone takes three minutes a run):
| Law | Test |
|---|---|
| Replay is pure: after any sequence of settings, convention, decision, open-item, template and approval commands, valid or refused, the live store, the store reopened from its journal and the viewer's snapshot hold the same settings and conventions, and the store verifies (database row for row) | `ledger-store` `tests/settings.rs` `settings_replay_is_pure` (proptest, 512 cases per check; 2,000 by hand, release, passed) |
| The first such change declares journal format 2, once; a change to what is set journals nothing; refused changes journal nothing | `the_first_change_declares_format_2`, `refused_changes_journal_nothing` |
| Settings need journal format 2: books this version made declared it as they were created, so a change journals only itself; a change to what is set journals nothing; books in format 1 refuse a change (nothing journaled) until their format upgrade, then make it after the one declaration; refused changes journal nothing | `a_change_needs_journal_format_2`, `refused_changes_journal_nothing` |
| Approval rules block nothing until enforced; then an entry a rule covers posts only once its approver approved it as it stands | `approval_rules_off_by_default`; `ledger-mcp` `spec_kept.rs` `approval_rules_through_the_server` |
| A template drafts exactly, odd cents by largest remainder, its slots named by each draft, all or nothing | `templates_draft_exactly_or_not_at_all`, `template_slots_take_the_account_each_draft_names`, `templates_draft_through_the_server`, core `splits_are_exact` |
| Books written under P1 open to the same record forever | `books_written_under_p1_open_to_their_record` (fixture `old-books/p1_record`, also in every old-books test of the store, the viewer and the server) |
@@ -1100,7 +1100,7 @@ no mutation run (D-33): the targets are listed for P12 below.
`books_written_under_p3_keep_their_names` (fixture `old-books/p3_rename`,
written once by `write_p3_rename_fixture`, `--ignored`; its figures are
also in `books_an_earlier_version_wrote_open_to_its_figures`).
- `ledger-store` `tests/format.rs`: `p3_features_are_declared_once_before_the_first_line_that_needs_them`,
- `ledger-store` `tests/format.rs`: `p3_features_wait_for_the_upgrade_and_are_declared_once`,
`an_unknown_feature_before_a_rename_is_newer_at_the_declaration`.
**Properties:** `prop_period_defaults_resolve` (`ledger-core`, 5,000
@@ -1260,7 +1260,7 @@ no mutation run here; what P12 must cover is at the end.
| Properties: a reversal's target, parts and flow are the original's negated; parts sum to the target, each with its sign; a sole party's money part is the flow as before P3; tin_last4 is every source present | `a_reversal_is_the_exact_opposite`, `a_sole_partys_money_part_is_the_flow_as_before`, `tin_last4_is_every_source_present` (`PROPTEST_CASES`, default 256; 5,000 run clean) | B-35, B-9 |
| Decimal and i64 agree on party_target and reverse_target | `kernel_bridge.rs` `party_target_agrees` | the bridge |
| Kani: the direction is the sign, the target is odd, reverse_target an involution | `proofs.rs` `party_target_direction_is_its_sign`, `party_target_is_odd`, `reverse_target_is_an_involution` | the kernel spec |
| The journal: party.creditor_set after its feature, declared once; refusals journal nothing; books without creditors never change; corrupt lines refused | `ledger-store/tests/party_creditors.rs` | the format gate |
| The journal: party.creditor_set one line each, under the feature the books declared when created; in books an older version wrote, refused (FormatUpgradePending, nothing journaled) until the format upgrade; refusals journal nothing; books without creditors never change, in either format; corrupt lines refused | `ledger-store/tests/party_creditors.rs` | the format gate |
| Over the MCP: get_entry's direction, cash, in_kind; party totals by basis, echoed, others refused; basis all equals members' equity; creditor_of round trips and a card fee isn't a payment; the post_entry preview's parts are what it posts | `ledger-mcp/tests/spec_party_totals.rs` | the tool docs |
| Defect 11 over the MCP: a member's TIN last four in the directory, a W-9's, none, after a purge | `ledger-mcp/tests/spec_vault.rs` `defect11_the_directory_shows_a_members_tin_last4` | B-26 |
| The viewer shows the tools' numbers, labelled cash basis, with the in-kind line; its what-for reads a reversal as `parties::flow` does | `ledger-gui/tests/parties.rs` `the_party_page_shows_the_basis_and_in_kind`, `a_reversed_member_card_payment_is_nothing_on_the_issuers_page` | `activity_with`, `flow` |
@@ -1312,8 +1312,8 @@ mutation run (D-33); the list P12 must cover is at the end.
| The bucket table, all 16 cases; splitting and netting exact | `payment_bucket_is_the_table_in_all_16_cases`, `buckets_net_money_moved_between_the_companys_sources`, Kani `payment_bucket_is_the_table`, `payment_split_is_exact`, `net_buckets_keeps_the_total`, bridge `payment_buckets_agree` |
| Properties | `no_instruments_no_financing_is_the_old_report` (the regression: with cash named and no methods or financing, the old figure exactly), `buckets_partition_each_payment`, `a_reversal_nets_every_bucket_to_zero`, `the_worksheet_ties_to_the_ledger` (paid + excluded + undetermined + not reportable + receipts for something else = every payment line in the year, dated decisions, sales and receipts included; the payment lists sum to their columns) |
| Payment methods: refusals, the reversal chain | `a_payment_method_is_refused_unless_sound`, `a_payment_method_tags_the_whole_reversal_chain`, unit tests in `instruments.rs` |
| W-9 flag kept apart from the decision (brief 1.5, R8) | `set_vendor_1099_keeps_the_w9_flag_when_omitted`, `set_w9_keeps_the_1099_decision_and_its_source`, `set_w9_declares_the_w9_flags_feature_before_its_first_line` (`spec_1099.rs`), `marking_a_w9_keeps_who_decided_the_1099` (`ledger-store` `tests/form1099.rs`) |
| Journal: features declared once before their first line; replay and projection exact; refusals and no-ops journal nothing; newer values newer; old books as they always meant | `ledger-store` `tests/form1099.rs` (`each_feature_is_declared_once_before_its_first_line`, `decisions_and_methods_replay_and_project`, `refusals_and_no_ops_journal_nothing`, `an_unknown_1099_value_is_newer_not_corrupt`, `books_written_before_decisions_read_as_they_always_meant`), unit tests in `ledger-store` `form1099.rs`, the op model (`refused_commands_never_reach_the_journal` with `SetVendor1099`, `SetW9Flag`, `AddInstrument`, `SetEntryInstrument`; an accepted command journals or changes nothing), `books_an_earlier_version_wrote_open_to_its_figures` (fixture `p2_vendor_1099`) |
| W-9 flag kept apart from the decision (brief 1.5, R8) | `set_vendor_1099_keeps_the_w9_flag_when_omitted`, `set_w9_keeps_the_1099_decision_and_its_source`, `set_w9_asks_for_the_w9_flags_feature_before_its_first_line` (`spec_1099.rs`: refused whole in P2's books until their upgrade), `marking_a_w9_keeps_who_decided_the_1099` (`ledger-store` `tests/form1099.rs`) |
| Journal: lines written only under their features (refused, nothing journaled, in books an older version wrote until the format upgrade; never declared on the way); replay and projection exact; refusals and no-ops journal nothing; newer values newer; old books as they always meant | `ledger-store` `tests/form1099.rs` (`each_feature_waits_for_the_upgrade_and_is_never_declared_on_the_way`, `decisions_and_methods_replay_and_project`, `refusals_and_no_ops_journal_nothing`, `an_unknown_1099_value_is_newer_not_corrupt`, `books_written_before_decisions_read_as_they_always_meant`), unit tests in `ledger-store` `form1099.rs`, the op model (`refused_commands_never_reach_the_journal` with `SetVendor1099`, `SetW9Flag`, `AddInstrument`, `SetEntryInstrument`; an accepted command journals or changes nothing), `books_an_earlier_version_wrote_open_to_its_figures` (fixture `p2_vendor_1099`) |
| Records change no entry (verification 13a) | `decision_and_method_records_change_no_entry` |
| The quieter untagged note (R7) | `the_untagged_note_counts_only_money_out` (`ledger-package`) |
| One builder: the package CSV is the worksheet CSV for the package's accounts; README wording | `ledger-package` `tests/package.rs` (the zip's CSV equals `vendor_1099_csv` for the request's accounts, else the settings', through as_of: `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of`; the README line pinned); over the MCP, the package's CSV is `books.vendor_1099`'s for the same `cash_accounts`, `financing_accounts` and `through` (`ledger-mcp` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`) |
@@ -1357,7 +1357,7 @@ must cover is at the end.
| Party totals and the 1099 worksheet never contradict: paid less received is the worksheet's paid + excluded_card + undetermined + not_reportable plus its other receipts, for a vendor named alone, whatever the methods, refunds and reversals | property `party_totals_are_the_1099s_buckets` (`ledger-core` `tests/form1099.rs`; `PROPTEST_CASES`, default 256; 2,000 run clean; dropping a bucket from the sum fails at once) | B-35 item 9, by hand |
| The same, one year by hand: a member's check, a member's card, a member's payment of unknown method, a company card charge and a company check to one contractor, then the member reimbursed | `bookspec/examples/1099-and-party-totals-for-a-members-payment.toml` | B-35 item 9; Treas. Reg. §1.6041-1(e)(1), (a)(1)(iv) |
| The CPA package's 1099 worksheet is `books.vendor_1099`'s CSV for the package's accounts (the request's, else the settings', R18) through its as_of, byte for byte; without `through` the files differ; a `through` outside the year and an unknown account are refused | `ledger-mcp` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`; `ledger-package` `tests/package.rs` `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of` | B-33; mcp-interface.md |
| A feature is declared before a command's first line: `books.update_party` with a field and the first creditor journals the declaration, then the field, then the creditor | `ledger-mcp` `tests/spec_party_totals.rs` `update_party_declares_the_creditor_feature_before_its_first_line` | B-27 |
| A feature is asked for before a command's first line: `books.update_party` with a field and the first creditor, in P2's books waiting for their format upgrade, is refused whole (nothing journaled, never the field without the creditor); once upgraded, as in books this version made, it journals the field, then the creditor, declaring nothing | `ledger-mcp` `tests/spec_party_totals.rs` `update_party_asks_for_the_creditor_feature_before_its_first_line` | B-27 |
| One bookspec loader for every example, the parties' and the 1099s' with them: `setup.parties`, `setup.members` (`distributions_account`), `setup.payees` (a party's vendor record), `setup.status_changes`, `setup.instruments`, events' `parties` and `instrument`, and the queries `vendor_1099`, `k1`, `party_part`, `party_totals`, `party_tin_last4`; refused: a party or payment method not in the setup, a reversal naming parties, party totals without a basis, a 1099 line leaving a figure out, a wrong direction or status | `ledger-core` `tests/bookspec.rs` (`every_bookspec_example_holds`, `a_party_not_in_the_setup_is_refused`, `a_party_parts_direction_is_checked`, `a_reversal_naming_parties_is_refused`, `party_totals_without_a_basis_are_refused`, `a_vendor_1099_line_leaving_a_figure_out_is_refused`, `a_vendor_1099_status_is_checked`, `a_payment_method_not_in_the_setup_is_refused`) | bookspec/README.md |
**For P12 (mutation targets, none run, D-33):** `parties::card_accounts`
@@ -1385,7 +1385,7 @@ a bookspec example (`bookspec/examples/`, P13, D-34).
| A disposal inside a month already run takes back the days after it (R2: excess 500.00, book value 6,500.00) | `defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days` (store and `ledger-mcp` `spec_assets.rs`) |
| A month skipped is caught up in the month the run is made; a later run for it posts nothing (settled in design, P3) | core `a_skipped_month_is_caught_up_in_the_month_run`, store `a_skipped_month_is_caught_up_in_the_month_run`, MCP `a_skipped_month_is_caught_up_and_said` |
| A reversed run is un-posted and runs again (feature month_reposting); re-instating a run whose month ran again is refused; a disposal, and a run its disposal took as final, can't be reversed; a reversed run before a disposal leaves nothing behind (R3) | store `a_reversed_depreciation_month_can_be_run_again`, `a_disposal_entry_cannot_be_reversed`, `a_reversed_run_before_a_disposal_leaves_nothing_behind`; core `a_reversed_schedule_month_is_due_again` |
| What the books carry is read entry by entry, each in its own month: a run reversed after its month ended isn't run twice (its month posts nothing; the reversal's month catches it up), for depreciation; a schedule month reversed after it ended is left for a human, listed with why (P3 review) | store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`; GUI `a_run_reversed_after_its_month_leaves_the_reversals_month_due`; core `schedules.rs` `a_schedule_month_reversed_after_it_ended_is_left_for_a_human`; store `format.rs` `a_schedule_month_posted_again_declares_month_reposting_and_replays` |
| What the books carry is read entry by entry, each in its own month: a run reversed after its month ended isn't run twice (its month posts nothing; the reversal's month catches it up), for depreciation; a schedule month reversed after it ended is left for a human, listed with why (P3 review) | store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`; GUI `a_run_reversed_after_its_month_leaves_the_reversals_month_due`; core `schedules.rs` `a_schedule_month_reversed_after_it_ended_is_left_for_a_human`; store `format.rs` `a_schedule_month_posted_again_waits_for_month_reposting_and_replays` |
| Re-instating a run (reversing its reversal) never carries the books past the schedule, nor past cost less salvage (P3 review) | store `reinstating_a_run_a_later_run_caught_up_is_refused` |
| What the books carry is each run and each reversal in its own month, alternately adding and taking away (P3 review) | core `spec_assets_payroll.rs` `depreciation_effects_read_each_entry_in_its_month` |
| A schedule month that rounds to nothing is never a posting: left out of a daily plan, never due in an equal-months one (P3 review; no test held it before) | core `schedules.rs` `a_month_that_rounds_to_nothing_is_never_a_posting` |
@@ -1399,7 +1399,7 @@ a bookspec example (`bookspec/examples/`, P13, D-34).
| An equal-months schedule starts on the 1st (D-37); a daily one's last day is in the calendar | `an_equal_months_schedule_starts_on_the_first`, `spec_bounds.rs` `a_daily_schedule_whose_last_day_passes_9999_is_refused` |
| chrono's month-end clamping is pinned for daily plans (Jan 29, 30, 31; Feb 29) | `schedules.rs` unit `daily_plans_pin_month_end_clamping` |
| A new asset's cost and salvage are in the minor unit (replay takes finer ones, computed); a life ending 9999-12-31 is taken, a day later refused | `spec_bounds.rs` `an_asset_cost_finer_than_a_cent_is_refused`, `a_life_ending_on_9999_12_31_is_accepted`, `a_life_ending_after_it_is_refused` |
| The first daily schedule declares feature schedule_convention; an older reader stops there; monthly_even declares nothing; an unknown convention is newer, never corrupt | `ledger-store` `tests/format.rs` `the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there`, `a_monthly_even_schedule_declares_no_feature`, `an_unknown_schedule_convention_is_newer_not_corrupt`; `format.rs` unit `an_older_reader_stops_at_p3s_features` |
| A daily schedule needs feature schedule_convention (books an older version wrote wait for the upgrade); an older reader stops at its declaration; monthly_even needs none; an unknown convention is newer, never corrupt | `ledger-store` `tests/format.rs` `a_daily_schedule_waits_for_its_feature_and_an_older_reader_stops_there`, `a_monthly_even_schedule_declares_no_feature`, `an_unknown_schedule_convention_is_newer_not_corrupt`; `format.rs` unit `an_older_reader_stops_at_p3s_features` |
| Accumulated depreciation never falls, never exceeds cost − salvage, is 0 before service and the whole from the last day (sampled days: the first and final months, two between, a dozen month ends; R9) | property `prop_depreciation_is_monotone_and_bounded` (500 cases) |
| No posted month moves but a half-cent tie's cent (old Decimal arithmetic as the oracle) | property `prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties` (500 cases) |
| A daily plan sums exactly, lands on month ends, each amount > 0; from the 1st, `months` postings within a cent of total/months | property `prop_daily_plan_sums_exactly_and_lands_on_month_ends` (2,000 cases) |
@@ -1410,7 +1410,7 @@ a bookspec example (`bookspec/examples/`, P13, D-34).
| A depreciation run's or schedule month's reversal can't be dated before the entry it reverses (P3 review round 2: a run of March reversed on Jan 31, then a disposal on Feb 15, ended February with a 1,000.00 debit in accumulated depreciation) | store `a_reversal_dated_before_the_entry_it_reverses_is_refused` |
| A disposal waits only until what the runs carry at its month's end is what they carry now, so a late change undone after its month closed lets it through (P3 review round 2; round 1 refused it for ever) | store `a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed` |
| Depreciation still due when the disposal's month has a run is charged in the disposal entry, never as a second run of the month (P3 review round 2; no store test held it) | store `a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry` |
| A command that needs a journal feature decides and declares it before its first journaled line: a refused feature journals nothing (the format-upgrade gate) | store `format.rs` `a_refused_journal_feature_journals_nothing` |
| A command that needs a journal feature asks for it before its first journaled line: in books waiting for the format upgrade it is refused and journals nothing, and after the upgrade it is taken | store `format.rs` `a_write_waiting_for_the_upgrade_journals_nothing` |
| What a viewer's one pass says a run of each month posts is run_amount's for that month | property `prop_run_amounts_are_each_months_run_amount` (500 cases); Kani `catch_ups_is_every_suffixs_catch_up` |
| The kernel on i64 and i32 (Kani's) is the kernel on i128 and Decimal (the engine's) | `kernel_bridge.rs` `prorate_agrees_at_i64_and_i128`, `prorated_through_agrees_at_i32_and_i128`, `catch_up_and_true_up_agree` |
| The charge never falls and is continuous at month ends, over every 1-to-3-month span from every day of a common and a leap year | `kernel.rs` unit `the_charge_never_falls_and_is_continuous_over_real_calendars` (exhaustive) |
@@ -1450,6 +1450,110 @@ projection upsert, `period_month`; `format.rs` `KNOWN_FEATURES`,
and `schedule`'s disposed months. Equivalent mutants foreseen:
none (`r >= step - r` against `>` is killed by `half_a_unit_rounds_up`).
## P3: the format upgrade (2026-10-20)
D-39 and plan/served.md 3.9 (`format_upgrade_pending`): books an older
wanderbooks wrote wait for the owner's go-ahead before they move to this
version's journal format (rules.md B-27). Mutation testing waits for P12
(D-33); the targets are listed at the end.
- `ledger-store` `format.rs` unit tests: this version's format is the
newest with every known feature; books wait exactly when behind (a
synthetic version with features covers what main can't yet: features
missing one at a time, all added at once, a format ahead never
"behind"); a write needs what the books declared, and the refusal
names the guard, says nothing changed, and that only the owner can
agree; a reader that doesn't know a declared feature still refuses it
as newer.
- `ledger-store` `tests/format_upgrade.rs`: new books declare the format
as they are created; older books open without a byte changing, a change
needing format 2 (settings, a convention, an open item) is refused
journaling nothing while a change needing nothing new is made, the
upgrade is one line with the whole format, again nothing, the figures
unchanged; every old-books fixture (30) upgrades, verifies as it did and
replays to its expected.json in the writer, reopened, and the viewer; a
declared newer feature is still refused as newer. `tests/settings.rs`
(`a_change_needs_journal_format_2`, the lazy declaration it replaces),
`tests/root.rs` (an adoption's copy moves to this version's format; the
source keeps its own), `tests/format.rs` (its books are format 1, as
before the gate).
- `ledger-store` `tests/schema_files.rs` (each projection schema in a file
of its own, the lead's decision of 2026-10-21): a P2-shaped books dir
(`tests/fixtures/p2-books`, its `wanderbooks.db` at schema 12, written by
`write_the_p2_fixture` while this code's schema was P2's) used as it is
at its schema and left byte for byte, and whole, past it; a legacy file
one schema older than this version's, on every version, left byte for
byte and whole for its schema while this version's is built from the
journal and kept up, the report and the viewer naming the one in use; a
newer schema's file, under its own name or the legacy one, left alone,
never refused; this version's own damaged (not a database, or holding
another schema) kept aside and rebuilt, a damaged legacy one left; new
books' file named for its schema. Checked by breaking it (the legacy
file always taken, the schema check dropped): three tests fail. The
store's other tests use `db_file()`, this version's file.
- `ledger-store` `backup.rs`: `the_archive_proves_to_hold_the_book_as_it_is_keys_and_all`
(a changed or extra key, a changed vault record, books that moved on, a
journal changed under the recorded head: each refused; the scratch
folder always removed).
- `ledger-mcp` `local.rs`: `format_upgrade_waits_for_consent` (listed as
waiting; no passphrase set, a wrong one or none refused before anything
is done, no backup, no line, still served; with it, the backup is the
archive's latest commit, holding the journal as it was and the key; one
line, the whole format; on the backup record; again, nothing; new books
wait for nothing, and asking for their upgrade makes not even a backup
repository), `no_upgrade_without_a_backup` (a backup refused: nothing
upgraded, still waiting, recorded) and
`no_upgrade_unless_the_backup_just_made_is_proven` (an archive whose
head names another branch takes the commit where no clone finds it:
what is proven isn't what was made, nothing upgraded, both recorded;
put right, the latest commit, which already holds the books, is proven
and the upgrade made). Each checked by breaking the code it guards (the
proof skipped, the made-is-proven check dropped, the early return for
books that wait for nothing removed): the test fails, then passes once
restored.
- `ledger-cli` `tests/format_upgrade.rs` (the plan's
`format_upgrade_waits_for_consent`, from end to end through the one
executable): a command not at a terminal says the books wait; `books
list` and `doctor` say so; `books.start` names it and puts the handbook's
format-upgrade section first; `books.set_setting` refused with the
guard, journaling nothing, while adding an account works; no tool's name
could consent, and no call moved the format; `books upgrade` refused off
a terminal; the lease intent with a wrong passphrase refused; at a
terminal an empty passphrase declines, and `books list` asks at each of
two opens, while one whose output goes to a file only notes it on the
terminal and asks nothing, its output its own; with the passphrase, the
backup (journal as it was, the key)
then one line, the agent's same session then sets the setting and
verifies; asked again, nothing.
- `ledger-gui` `tests/format_upgrade.rs`: the dialog at open and at the
next open, "Not now" leaving the standing notice (whose button, and the
Books menu, ask again), the viewer writing nothing; nothing sent without
a passphrase, the one typed handed to the owner as typed, a refusal said
in the dialog, which stays, the right one closing it; books in this
version's format and older books dirs never asked; an upgrade whose
worker dies without an answer is said in the dialog, which can then be
closed (never left at work, holding the window). Screenshots looked
at (`SHOT_DIR=... cargo test -p ledger-gui --test format_upgrade
screenshots`): the dialog over the overview, at work, refused, the
notice, the menu item, done.
- Unit tests in `admin.rs` (the passphrase never in Debug; the request's
shape), `ledger-host` `lease.rs` (`a_consent_never_shows_its_passphrase`:
the lease intent's Debug, and the line's, name no passphrase), and
`owner_menu.rs`'s fake owner takes the new call.
**For P12:** `ledger-store` `format.rs` (`current`, `upgrade_to`, `adds`,
`require_format`, `require_feature`), `store.rs` (`create_in_format`,
`ensure_format`, `ensure_feature`, `upgrade_format`), `root.rs`
(adoption's upgrade and its verify), `backup.rs` (`prove_archive`,
`held_files`); `ledger-mcp` `backup.rs` (`upgrade_format`,
`UpgradeFailed::parts`), `local.rs` (`upgrade_format`, the List's
`format_upgrade`, `waiting`), `server.rs` (`with_store_mut`),
`tools/kept.rs` (the first next step, `format_upgrade`), `admin.rs`;
`ledger-host` `host.rs` (`consent`, `consented`), `commands.rs`
(`consent_now`, `upgrade_format`, `offer_upgrades`, `offer_upgrade`,
`upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`,
`ASKED_UPGRADES`). None run.
## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work