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)?;
+82
View File
@@ -354,6 +354,88 @@ fn reversal_nets_to_zero_and_links_back() {
assert!(balance_sheet(&ledger).unwrap().balanced());
}
/// A posted mistake, for the reversal rules below.
fn posted_mistake(ledger: &mut Ledger, c: &Company) -> ledger_core::EntryId {
let id = ledger.create_entry(date(21), "Mistake");
ledger.add_line(id, c.rent, Side::Debit, usd(500)).unwrap();
ledger.add_line(id, c.cash, Side::Credit, usd(500)).unwrap();
ledger.post_entry(id).unwrap();
id
}
/// Spec (Stephen, 2026-10-13): an entry is reversed at most once. A second
/// reversal would undo the original twice.
#[test]
fn an_entry_is_reversed_at_most_once() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
let before = ledger.next_entry_id();
assert_eq!(
ledger.reverse_entry(id, date(23), "Again").unwrap_err(),
LedgerError::EntryAlreadyReversed(id, rev)
);
// Refused means untouched: no entry allocated, no draft left behind.
assert_eq!(ledger.next_entry_id(), before);
assert_eq!(ledger.account_movement(c.rent), Decimal::from(800));
}
/// Spec: reversing the reversal re-instates the original, and is allowed
/// (once, like any other entry).
#[test]
fn a_reversal_can_itself_be_reversed_once() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
let back = ledger.reverse_entry(rev, date(23), "Re-instate").unwrap();
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
assert_eq!(
ledger.reverse_entry(rev, date(24), "Again").unwrap_err(),
LedgerError::EntryAlreadyReversed(rev, back)
);
}
/// Spec (Stephen, 2026-10-13): a closing entry is never reversed. Undoing
/// it would leave the period marked closed with its earnings unswept.
#[test]
fn a_closing_entry_is_never_reversed() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
let closing = ledger.close_period(jan).unwrap().unwrap();
let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
let before = ledger.next_entry_id();
assert_eq!(
ledger
.reverse_entry(closing, feb, "Undo close")
.unwrap_err(),
LedgerError::ClosingEntryIrreversible(closing)
);
assert_eq!(ledger.next_entry_id(), before);
}
/// Regression (max-testing pass, 2026-10-13): a reversal dated in a closed
/// period was refused only at post time, after allocating an id and
/// leaving a draft behind. Refused means untouched.
#[test]
fn a_reversal_into_a_closed_period_leaves_no_trace() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let id = posted_mistake(&mut ledger, &c);
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
// Nothing is open in January now except our posted mistake; close it.
ledger.close_period(jan).unwrap();
let before = ledger.next_entry_id();
assert!(matches!(
ledger.reverse_entry(id, date(25), "Into the closed month"),
Err(LedgerError::ClosedPeriod(..))
));
assert_eq!(ledger.next_entry_id(), before);
assert!(ledger.check_reversal(id, date(25)).is_err());
}
// ---------------------------------------------------------------------------
// Reports: the accounting laws hold.
// ---------------------------------------------------------------------------