fix(store,core): a refused command never reaches the journal; reversal rules

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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 11:40:50 -06:00
1 parent 3609bf065e
commit a09c1fb218
7 files changed
+478 -6

No files matched your search

+10
View File
@@ -65,6 +65,16 @@ pub enum LedgerError {
#[error("entry {0} is not posted yet")]
EntryNotPosted(u64),
/// An entry is reversed at most once: a second reversal would undo it
/// twice. (Reversing the reversal re-instates it.)
#[error("entry {0} is already reversed by entry {1}")]
EntryAlreadyReversed(u64, u64),
/// A period's closing entry is never reversed: that would leave the
/// period closed with its earnings unswept.
#[error("entry {0} closes a period and cannot be reversed")]
ClosingEntryIrreversible(u64),
/// The referenced journal entry does not exist.
#[error("entry {0} does not exist")]
UnknownEntry(u64),
+51 -3
View File
@@ -606,22 +606,70 @@ impl Ledger {
self.entries.get(&entry)
}
/// Pure pre-check for [`Self::reverse_entry`]: every way a reversal can
/// be refused, checked without touching the books, so journaling stores
/// can validate before they journal.
///
/// ```text
/// GOAL: decide whether `entry` may be reversed on `date`.
/// INVARIANTS:
/// 1. MUST NOT mutate anything.
/// 2. NEVER accepts a reversal that `reverse_entry` would then refuse.
/// SCENARIOS:
/// 1. IF the entry is unknown or still a draft:
/// - SHOULD refuse (`UnknownEntry`, `EntryNotPosted`)
/// BECAUSE only posted entries are in the books to correct.
/// 2. IF the entry was already reversed:
/// - SHOULD refuse with `EntryAlreadyReversed(entry, by)`
/// - NEVER undo it a second time
/// BECAUSE a second reversal would undo the original twice
/// (Stephen, 2026-10-13). Reversing the reversal re-instates it.
/// 3. IF the entry closes a period:
/// - SHOULD refuse with `ClosingEntryIrreversible`
/// BECAUSE the period would stay closed with its earnings
/// unswept (Stephen, 2026-10-13).
/// 4. IF `date` falls in a closed period:
/// - SHOULD refuse with `ClosedPeriod`
/// BECAUSE closed periods take no new postings.
/// ```
pub fn check_reversal(&self, entry: EntryId, date: NaiveDate) -> Result<(), LedgerError> {
let original = self
.entries
.get(&entry)
.ok_or(LedgerError::UnknownEntry(entry))?;
if original.status() != EntryStatus::Posted {
return Err(LedgerError::EntryNotPosted(entry));
}
if let Some(by) = self
.posted_entries()
.find(|e| e.reversal_of() == Some(entry))
{
return Err(LedgerError::EntryAlreadyReversed(entry, by.id()));
}
if self.closing_entry_ids().contains(&entry) {
return Err(LedgerError::ClosingEntryIrreversible(entry));
}
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
Ok(())
}
/// Post a reversing entry: same lines with sides flipped, linked back to
/// the original via `reversal_of`. The books never forget; they correct.
/// Refused (see [`Self::check_reversal`]) before anything changes.
pub fn reverse_entry(
&mut self,
entry: EntryId,
date: NaiveDate,
description: &str,
) -> Result<EntryId, LedgerError> {
self.check_reversal(entry, date)?;
let original = self
.entries
.get(&entry)
.ok_or(LedgerError::UnknownEntry(entry))?
.clone();
if original.status() != EntryStatus::Posted {
return Err(LedgerError::EntryNotPosted(entry));
}
let id = self.create_entry(date, description);
for (idx, line) in original.lines().iter().enumerate() {
self.add_line(id, line.account, kernel::flip(line.side), line.amount)?;