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

+88 -3
View File
@@ -397,6 +397,7 @@ impl Store {
parent: Option<AccountId>,
placeholder: bool,
) -> Result<AccountId, StoreError> {
self.rehearse(|l| l.add_account(code, name, account_type, parent, placeholder))?;
// Pure pre-validation replicating ChartOfAccounts::add, so the
// journal write below cannot be followed by a failed mutation.
let chart = self.ledger.chart();
@@ -439,6 +440,9 @@ impl Store {
date: chrono::NaiveDate,
description: &str,
) -> Result<EntryId, StoreError> {
// The checked constructor: no draft is ever dated in a closed
// period (the Lean invariant `Inv.draftsOpen`).
self.rehearse(|l| l.try_create_entry(date, description))?;
let id = self.ledger.next_entry_id();
let line = self.append_journal(
"entry.created",
@@ -481,6 +485,7 @@ impl Store {
/// Validate, journal, and post a draft entry.
pub fn post_entry(&mut self, entry: EntryId) -> Result<(), StoreError> {
self.rehearse(|l| l.post_entry(entry))?;
let snap = self
.ledger
.get_entry(entry)
@@ -518,9 +523,9 @@ impl Store {
.get_entry(entry)
.ok_or(LedgerError::UnknownEntry(entry))?
.clone();
if orig.status() != EntryStatus::Posted {
return Err(LedgerError::EntryNotPosted(entry).into());
}
// Every refusal before the journal: a journaled reversal the engine
// then refuses could never be replayed, and the books would not open.
self.ledger.check_reversal(entry, date)?;
let new_id = self.ledger.next_entry_id();
let flipped: Vec<Value> = orig.lines().iter().map(flip_line_json).collect();
// Reversals journal a single entry.reversed line (no separate
@@ -548,6 +553,7 @@ impl Store {
/// Register a fixed asset. Journaled as `asset.registered`.
pub fn register_asset(&mut self, new: NewAsset) -> Result<AssetId, StoreError> {
self.rehearse(|l| l.register_asset(new.clone()))?;
// Pure pre-validation so the journal write cannot be followed by a
// failed mutation.
self.ledger.validate_new_asset(&new)?;
@@ -757,6 +763,7 @@ impl Store {
proceeds_account: AccountId,
gain_loss_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.dispose_asset(asset_id, date, proceeds))?;
let asset = self
.ledger
.get_asset(asset_id)
@@ -924,6 +931,7 @@ impl Store {
start: NaiveDate,
end: NaiveDate,
) -> Result<PeriodId, StoreError> {
self.rehearse(|l| l.create_period(name, start, end))?;
// Pure pre-validation so the journal write cannot be followed by a
// failed mutation.
ledger_core::periods::validate_period_range(start, end)?;
@@ -1059,6 +1067,20 @@ impl Store {
/// account path when missing. The ledger's internal ensure_account
/// then no-ops, which keeps replay deterministic: every account
/// creation is an `account.added` event.
/// Rehearse a command on a scratch copy of the ledger before anything
/// is journaled, so every refusal the engine would make surfaces first.
/// A journaled command the engine then refused could never be replayed,
/// and the books would not reopen (found by the refusal property in
/// `tests/store.rs`, 2026-10-13). Hand-written pre-checks drift from
/// the engine; the engine itself cannot.
fn rehearse<T>(
&self,
op: impl FnOnce(&mut Ledger) -> Result<T, LedgerError>,
) -> Result<(), StoreError> {
op(&mut self.ledger.clone())?;
Ok(())
}
fn ensure_control_account(
&mut self,
code: &str,
@@ -1128,6 +1150,7 @@ impl Store {
/// Add a customer. Journaled as `customer.added`.
pub fn add_customer(&mut self, name: &str) -> Result<CustomerId, StoreError> {
self.rehearse(|l| l.add_customer(name))?;
// The engine's only failure mode is an empty name; replicate it so
// the journal write cannot be followed by a failed mutation.
if name.trim().is_empty() {
@@ -1143,6 +1166,7 @@ impl Store {
/// Add a vendor. Journaled as `vendor.added`.
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, StoreError> {
self.rehearse(|l| l.add_vendor(name))?;
if name.trim().is_empty() {
return Err(LedgerError::EmptyAssetDescription.into());
}
@@ -1164,6 +1188,11 @@ impl Store {
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<InvoiceId, StoreError> {
self.rehearse(|l| {
ensure_on(l, "1200", "Accounts Receivable", AccountType::Asset)?;
ensure_on(l, "4000", "Revenue", AccountType::Revenue)?;
l.create_invoice(customer_id, date, due_date, lines.clone())
})?;
// Pure pre-validation mirroring Ledger::create_invoice, so the
// journal writes below cannot be followed by a failed mutation.
// (The auto-posted entry balances by construction and its accounts
@@ -1223,6 +1252,11 @@ impl Store {
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<BillId, StoreError> {
self.rehearse(|l| {
ensure_on(l, "6999", "General Expenses", AccountType::Expense)?;
ensure_on(l, "2000", "Accounts Payable", AccountType::Liability)?;
l.create_bill(vendor_id, date, due_date, lines.clone())
})?;
if self.ledger.get_vendor(vendor_id).is_none() {
return Err(LedgerError::UnknownVendor(vendor_id).into());
}
@@ -1274,6 +1308,10 @@ impl Store {
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| {
ensure_on(l, "1200", "Accounts Receivable", AccountType::Asset)?;
l.receive_payment(invoice_id, date, amount, cash_account)
})?;
// Pure pre-validation mirroring Ledger::receive_payment, plus the
// account checks post_entry would perform: a failed post after the
// journal writes would leave the journal ahead of the ledger.
@@ -1332,6 +1370,10 @@ impl Store {
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| {
ensure_on(l, "2000", "Accounts Payable", AccountType::Liability)?;
l.pay_bill(bill_id, date, amount, cash_account)
})?;
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPaymentAmount(amount).into());
}
@@ -1512,6 +1554,12 @@ impl Store {
lines: Vec<PayrollLineInput>,
cash_account: AccountId,
) -> Result<PayrollRunId, StoreError> {
self.rehearse(|l| {
for (code, name, account_type) in PAYROLL_ACCOUNTS {
ensure_on(l, code, name, account_type)?;
}
l.run_payroll(date, lines.clone(), cash_account)
})?;
// Pure pre-validation mirroring Ledger::run_payroll, so the journal
// writes below cannot be followed by a failed mutation.
self.ledger.validate_payroll(date, &lines, cash_account)?;
@@ -1589,6 +1637,7 @@ impl Store {
date: NaiveDate,
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.remit_payroll_taxes(date, cash_account))?;
// Pure pre-validation mirroring Ledger::remit_payroll_taxes.
if self.ledger.chart().get(cash_account).is_none() {
return Err(LedgerError::UnknownAccount(cash_account).into());
@@ -1663,6 +1712,7 @@ impl Store {
/// Add a class (division) for line-level P&L tracking. Journaled as
/// `class.added`.
pub fn add_class(&mut self, name: &str) -> Result<ClassId, StoreError> {
self.rehearse(|l| l.add_class(name))?;
// The engine's only failure mode is an empty name; replicate it so
// the journal write cannot be followed by a failed mutation.
if name.trim().is_empty() {
@@ -1690,6 +1740,7 @@ impl Store {
line_index: usize,
class_id: Option<ClassId>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_line_class(entry_id, line_index, class_id))?;
// Draft-only check via the ledger, before anything is journaled.
let entry = self
.ledger
@@ -1802,6 +1853,7 @@ impl Store {
&mut self,
new: ledger_core::NewSchedule,
) -> Result<ledger_core::ScheduleId, StoreError> {
self.rehearse(|l| l.register_schedule(new.clone()))?;
ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?;
let id = self.ledger.next_schedule_id();
let line = self.append_journal(
@@ -1873,6 +1925,7 @@ impl Store {
&mut self,
new: ledger_core::NewRecurring,
) -> Result<ledger_core::RecurringId, StoreError> {
self.rehearse(|l| l.register_recurring(new.clone()))?;
ledger_core::schedules::validate_recurring(&new, self.ledger.chart())?;
let id = self.ledger.next_recurring_id();
let lines: Vec<Value> = new
@@ -1938,6 +1991,7 @@ impl Store {
category: Option<&str>,
deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_account_tax(account, category, deductible_percent))?;
self.ledger
.check_account_tax(account, category, deductible_percent)?;
let line = self.append_journal(
@@ -1962,6 +2016,7 @@ impl Store {
form: Option<ledger_core::Form1099>,
tin_on_file: bool,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_vendor_1099(vendor, form, tin_on_file))?;
self.ledger
.get_vendor(vendor)
.ok_or(LedgerError::UnknownVendor(vendor))?;
@@ -1981,6 +2036,7 @@ impl Store {
entry: EntryId,
vendor: Option<VendorId>,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_entry_vendor(entry, vendor))?;
// Validate first, so nothing invalid is journaled.
self.ledger
.get_entry(entry)
@@ -2823,6 +2879,35 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
/// Replay one journal line into a ledger, asserting recorded ids. Used by
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
/// is the actor.
/// [`Store::ensure_control_account`] on a bare ledger, for rehearsals.
fn ensure_on(
l: &mut Ledger,
code: &str,
name: &str,
account_type: AccountType,
) -> Result<AccountId, LedgerError> {
match l.chart().find_by_code(code) {
Some(a) => Ok(a.id),
None => l.add_account(code, name, account_type, None, false),
}
}
/// The payroll control accounts, in the order [`Store::run_payroll`]
/// creates them.
const PAYROLL_ACCOUNTS: [(&str, &str, AccountType); 7] = [
("6100", "Salary & Wage Expense", AccountType::Expense),
("6110", "Payroll Tax Expense", AccountType::Expense),
(
"2110",
"Federal Withholding Payable",
AccountType::Liability,
),
("2120", "State Withholding Payable", AccountType::Liability),
("2130", "SS Tax Payable", AccountType::Liability),
("2140", "Medicare Tax Payable", AccountType::Liability),
("2150", "Other Deductions Payable", AccountType::Liability),
];
pub(crate) fn replay_line_into_ledger(
ledger: &mut Ledger,
line: &JournalLine,