Max-testing pass, step 2 (rank 1 regressions + a rank 2 law). Two ways
to make the books unopenable, both found by test, both fixed:
1. Reversing into a closed period: the store journaled `entry.reversed`,
then the engine refused it at post. Replay could never apply that
line, so Store::open failed ("period 1 is closed"). Regression:
reversal_into_a_closed_period_is_refused_before_journaling.
2. An oversized invoice (or any command whose hand-written store
pre-checks lagged the engine, here the new line range) journaled,
then failed in the engine. Found by the new property
refused_commands_never_reach_the_journal within 4s.
The fix is structural, not another hand-written check: every
journal-first store command now rehearses on a scratch clone of the
ledger (`Store::rehearse`), so whatever the engine refuses is refused
before the journal, now and after any future engine change. The
create_entry rehearsal uses the checked constructor: no draft is ever
dated in a closed period (the Lean invariant Inv.draftsOpen, which the
store had bypassed).
The property (1,000 random command sequences, heavy on refusals)
asserts, after every command: a refusal leaves the journal
byte-identical; every posted entry validates; the trial balance
balances; no draft sits in a closed period; and the books reopen and
verify at the end. Shown to have teeth: removing the create_entry
rehearsal makes it fail and shrink to open-period, close, draft.
Engine (spec confirmed by Stephen, 2026-10-13), Ledger::check_reversal,
a pure pre-check that reverse_entry and the store share:
- an entry is reversed at most once (EntryAlreadyReversed); reversing
the reversal re-instates it;
- a closing entry is never reversed (ClosingEntryIrreversible);
- a refused reversal leaves no trace (it used to leave a draft and
burn an id).
Specs go in doc comments as a ```text GOAL/INVARIANTS/SCENARIOS block.
New dev dependency: proptest for ledger-store (already the workspace's
property library).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
The money arithmetic every invariant rests on now lives in one kernel,
ledger-core/src/kernel.rs: signing a line, totals, net, per-account
movements, reversal, normal balances, trial-balance columns, closing
lines, and the rounding plug for member splits. It is generic over a
checked `Amount` trait. The engine runs it on Decimal (validate,
movements, reverse_entry, account_balance, close_period_to, allocate,
trial_balance): there is one copy of the arithmetic, and it is the one
under proof.
Three layers, docs/verification.md maps every law to its proofs:
- Kani (src/proofs.rs): 17 harnesses prove the kernel at i64 for every
input up to 4 lines over 3 accounts. An entry is accepted exactly
when it is two or more positive, in-range lines whose debits equal
credits; accepted entries net to zero; grouping by account loses
nothing; a reversal mirrors and cancels its original; trial-balance
columns balance iff the books net to zero; closing lines zero their
accounts and a close balances however the net is split; the plug
makes parts sum to the whole. No panics, no overflow. kani::cover!
shows the proofs are not vacuous.
- Lean 4 (verify/lean): 24 headline theorems for books of any size. The
ledger is a state machine (add account, draft, post, reverse, open,
close), and in every reachable state the books net to zero, the trial
balance balances (as of any date), and the accounting equation holds.
Posting only appends ("posted is forever"), closed periods are
frozen, a close zeroes revenue and expenses and credits exactly the net
income, and period statements add up. Audit.lean shows standard axioms
only, no sorry.
- Bridge (tests/kernel_bridge.rs): the Decimal and i64 kernels agree.
What the proofs found (each one missed by every test, fuzzer, and
mutant):
- The engine accepted negative line amounts; only MCP refused them. Now
NegativeAmountLine: direction is the side's job.
- Summing lines near Decimal::MAX panicked. Lines are now capped at one
quadrillion and ten decimal places (AmountOutOfRange), which keeps
every realistic sum exact, and the kernel's sums are checked.
- FX conversion (foreign_amount * fx_rate) could panic the same way; it
is now a checked multiply.
- Kani itself: 0.68 mis-models symbolic-length copy_from_slice
(model-checking/kani#4904) and reported a reversal that doesn't net to
zero. The counterexample, replayed natively, passes, and a 15-line
standalone crate reproduces the bug. Harnesses avoid bulk copies, and
a should_panic canary will flag the upstream fix.
`just prove` runs it all (Kani memory- and time-capped, fails on an
unsatisfiable cover or a non-standard Lean axiom). AGENTS.md now makes
formal coverage part of every core change.
Also, docs: GnuCash is *an* oracle, never *the* oracle (Stephen). The
spec is accounting principles, proved. Golden files are recomputed from
principles. External implementations (GnuCash, the old Sheet, QBO/Xero)
are cross-checks whose disagreements get adjudicated, never obeyed.
docs/oracle.md gains "Who is right"; AGENTS, README, architecture,
roadmap, parity, and STATUS are reworded to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
Zero warnings from `cargo clippy --workspace --all-targets`, which is now
a hard rule rather than "clean except pre-existing":
- ChartOfAccounts::add takes the Account it registers instead of eight
loose parameters (too_many_arguments); Ledger::add_account builds it.
Same validation, same errors.
- close_period tool: collapse the nested if-let into a let chain.
- iif.rs docs: the IIF layout example shows tabs as `\t` instead of
literal tab characters (tabs_in_doc_comments).
- Tests: drop an unused binding; assert! instead of assert_eq! against a
literal bool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu