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

+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.