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

Generated
+1
View File
@@ -3469,6 +3469,7 @@ dependencies = [
"chrono", "chrono",
"hex", "hex",
"ledger-core", "ledger-core",
"proptest",
"rusqlite", "rusqlite",
"rust_decimal", "rust_decimal",
"serde", "serde",
+10
View File
@@ -65,6 +65,16 @@ pub enum LedgerError {
#[error("entry {0} is not posted yet")] #[error("entry {0} is not posted yet")]
EntryNotPosted(u64), 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. /// The referenced journal entry does not exist.
#[error("entry {0} does not exist")] #[error("entry {0} does not exist")]
UnknownEntry(u64), UnknownEntry(u64),
+51 -3
View File
@@ -606,22 +606,70 @@ impl Ledger {
self.entries.get(&entry) 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 /// Post a reversing entry: same lines with sides flipped, linked back to
/// the original via `reversal_of`. The books never forget; they correct. /// the original via `reversal_of`. The books never forget; they correct.
/// Refused (see [`Self::check_reversal`]) before anything changes.
pub fn reverse_entry( pub fn reverse_entry(
&mut self, &mut self,
entry: EntryId, entry: EntryId,
date: NaiveDate, date: NaiveDate,
description: &str, description: &str,
) -> Result<EntryId, LedgerError> { ) -> Result<EntryId, LedgerError> {
self.check_reversal(entry, date)?;
let original = self let original = self
.entries .entries
.get(&entry) .get(&entry)
.ok_or(LedgerError::UnknownEntry(entry))? .ok_or(LedgerError::UnknownEntry(entry))?
.clone(); .clone();
if original.status() != EntryStatus::Posted {
return Err(LedgerError::EntryNotPosted(entry));
}
let id = self.create_entry(date, description); let id = self.create_entry(date, description);
for (idx, line) in original.lines().iter().enumerate() { for (idx, line) in original.lines().iter().enumerate() {
self.add_line(id, line.account, kernel::flip(line.side), line.amount)?; 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()); 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. // Reports: the accounting laws hold.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -19,6 +19,7 @@ hex = "0.4"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
proptest = "1"
rust_decimal = "1" rust_decimal = "1"
[lints] [lints]
+88 -3
View File
@@ -397,6 +397,7 @@ impl Store {
parent: Option<AccountId>, parent: Option<AccountId>,
placeholder: bool, placeholder: bool,
) -> Result<AccountId, StoreError> { ) -> Result<AccountId, StoreError> {
self.rehearse(|l| l.add_account(code, name, account_type, parent, placeholder))?;
// Pure pre-validation replicating ChartOfAccounts::add, so the // Pure pre-validation replicating ChartOfAccounts::add, so the
// journal write below cannot be followed by a failed mutation. // journal write below cannot be followed by a failed mutation.
let chart = self.ledger.chart(); let chart = self.ledger.chart();
@@ -439,6 +440,9 @@ impl Store {
date: chrono::NaiveDate, date: chrono::NaiveDate,
description: &str, description: &str,
) -> Result<EntryId, StoreError> { ) -> 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 id = self.ledger.next_entry_id();
let line = self.append_journal( let line = self.append_journal(
"entry.created", "entry.created",
@@ -481,6 +485,7 @@ impl Store {
/// Validate, journal, and post a draft entry. /// Validate, journal, and post a draft entry.
pub fn post_entry(&mut self, entry: EntryId) -> Result<(), StoreError> { pub fn post_entry(&mut self, entry: EntryId) -> Result<(), StoreError> {
self.rehearse(|l| l.post_entry(entry))?;
let snap = self let snap = self
.ledger .ledger
.get_entry(entry) .get_entry(entry)
@@ -518,9 +523,9 @@ impl Store {
.get_entry(entry) .get_entry(entry)
.ok_or(LedgerError::UnknownEntry(entry))? .ok_or(LedgerError::UnknownEntry(entry))?
.clone(); .clone();
if orig.status() != EntryStatus::Posted { // Every refusal before the journal: a journaled reversal the engine
return Err(LedgerError::EntryNotPosted(entry).into()); // 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 new_id = self.ledger.next_entry_id();
let flipped: Vec<Value> = orig.lines().iter().map(flip_line_json).collect(); let flipped: Vec<Value> = orig.lines().iter().map(flip_line_json).collect();
// Reversals journal a single entry.reversed line (no separate // Reversals journal a single entry.reversed line (no separate
@@ -548,6 +553,7 @@ impl Store {
/// Register a fixed asset. Journaled as `asset.registered`. /// Register a fixed asset. Journaled as `asset.registered`.
pub fn register_asset(&mut self, new: NewAsset) -> Result<AssetId, StoreError> { 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 // Pure pre-validation so the journal write cannot be followed by a
// failed mutation. // failed mutation.
self.ledger.validate_new_asset(&new)?; self.ledger.validate_new_asset(&new)?;
@@ -757,6 +763,7 @@ impl Store {
proceeds_account: AccountId, proceeds_account: AccountId,
gain_loss_account: AccountId, gain_loss_account: AccountId,
) -> Result<EntryId, StoreError> { ) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.dispose_asset(asset_id, date, proceeds))?;
let asset = self let asset = self
.ledger .ledger
.get_asset(asset_id) .get_asset(asset_id)
@@ -924,6 +931,7 @@ impl Store {
start: NaiveDate, start: NaiveDate,
end: NaiveDate, end: NaiveDate,
) -> Result<PeriodId, StoreError> { ) -> Result<PeriodId, StoreError> {
self.rehearse(|l| l.create_period(name, start, end))?;
// Pure pre-validation so the journal write cannot be followed by a // Pure pre-validation so the journal write cannot be followed by a
// failed mutation. // failed mutation.
ledger_core::periods::validate_period_range(start, end)?; ledger_core::periods::validate_period_range(start, end)?;
@@ -1059,6 +1067,20 @@ impl Store {
/// account path when missing. The ledger's internal ensure_account /// account path when missing. The ledger's internal ensure_account
/// then no-ops, which keeps replay deterministic: every account /// then no-ops, which keeps replay deterministic: every account
/// creation is an `account.added` event. /// 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( fn ensure_control_account(
&mut self, &mut self,
code: &str, code: &str,
@@ -1128,6 +1150,7 @@ impl Store {
/// Add a customer. Journaled as `customer.added`. /// Add a customer. Journaled as `customer.added`.
pub fn add_customer(&mut self, name: &str) -> Result<CustomerId, StoreError> { 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 engine's only failure mode is an empty name; replicate it so
// the journal write cannot be followed by a failed mutation. // the journal write cannot be followed by a failed mutation.
if name.trim().is_empty() { if name.trim().is_empty() {
@@ -1143,6 +1166,7 @@ impl Store {
/// Add a vendor. Journaled as `vendor.added`. /// Add a vendor. Journaled as `vendor.added`.
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, StoreError> { pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, StoreError> {
self.rehearse(|l| l.add_vendor(name))?;
if name.trim().is_empty() { if name.trim().is_empty() {
return Err(LedgerError::EmptyAssetDescription.into()); return Err(LedgerError::EmptyAssetDescription.into());
} }
@@ -1164,6 +1188,11 @@ impl Store {
due_date: NaiveDate, due_date: NaiveDate,
lines: Vec<DocumentLine>, lines: Vec<DocumentLine>,
) -> Result<InvoiceId, StoreError> { ) -> 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 // Pure pre-validation mirroring Ledger::create_invoice, so the
// journal writes below cannot be followed by a failed mutation. // journal writes below cannot be followed by a failed mutation.
// (The auto-posted entry balances by construction and its accounts // (The auto-posted entry balances by construction and its accounts
@@ -1223,6 +1252,11 @@ impl Store {
due_date: NaiveDate, due_date: NaiveDate,
lines: Vec<DocumentLine>, lines: Vec<DocumentLine>,
) -> Result<BillId, StoreError> { ) -> 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() { if self.ledger.get_vendor(vendor_id).is_none() {
return Err(LedgerError::UnknownVendor(vendor_id).into()); return Err(LedgerError::UnknownVendor(vendor_id).into());
} }
@@ -1274,6 +1308,10 @@ impl Store {
amount: Decimal, amount: Decimal,
cash_account: AccountId, cash_account: AccountId,
) -> Result<EntryId, StoreError> { ) -> 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 // Pure pre-validation mirroring Ledger::receive_payment, plus the
// account checks post_entry would perform: a failed post after the // account checks post_entry would perform: a failed post after the
// journal writes would leave the journal ahead of the ledger. // journal writes would leave the journal ahead of the ledger.
@@ -1332,6 +1370,10 @@ impl Store {
amount: Decimal, amount: Decimal,
cash_account: AccountId, cash_account: AccountId,
) -> Result<EntryId, StoreError> { ) -> 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 { if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPaymentAmount(amount).into()); return Err(LedgerError::InvalidPaymentAmount(amount).into());
} }
@@ -1512,6 +1554,12 @@ impl Store {
lines: Vec<PayrollLineInput>, lines: Vec<PayrollLineInput>,
cash_account: AccountId, cash_account: AccountId,
) -> Result<PayrollRunId, StoreError> { ) -> 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 // Pure pre-validation mirroring Ledger::run_payroll, so the journal
// writes below cannot be followed by a failed mutation. // writes below cannot be followed by a failed mutation.
self.ledger.validate_payroll(date, &lines, cash_account)?; self.ledger.validate_payroll(date, &lines, cash_account)?;
@@ -1589,6 +1637,7 @@ impl Store {
date: NaiveDate, date: NaiveDate,
cash_account: AccountId, cash_account: AccountId,
) -> Result<EntryId, StoreError> { ) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.remit_payroll_taxes(date, cash_account))?;
// Pure pre-validation mirroring Ledger::remit_payroll_taxes. // Pure pre-validation mirroring Ledger::remit_payroll_taxes.
if self.ledger.chart().get(cash_account).is_none() { if self.ledger.chart().get(cash_account).is_none() {
return Err(LedgerError::UnknownAccount(cash_account).into()); 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 /// Add a class (division) for line-level P&L tracking. Journaled as
/// `class.added`. /// `class.added`.
pub fn add_class(&mut self, name: &str) -> Result<ClassId, StoreError> { 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 engine's only failure mode is an empty name; replicate it so
// the journal write cannot be followed by a failed mutation. // the journal write cannot be followed by a failed mutation.
if name.trim().is_empty() { if name.trim().is_empty() {
@@ -1690,6 +1740,7 @@ impl Store {
line_index: usize, line_index: usize,
class_id: Option<ClassId>, class_id: Option<ClassId>,
) -> Result<(), StoreError> { ) -> 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. // Draft-only check via the ledger, before anything is journaled.
let entry = self let entry = self
.ledger .ledger
@@ -1802,6 +1853,7 @@ impl Store {
&mut self, &mut self,
new: ledger_core::NewSchedule, new: ledger_core::NewSchedule,
) -> Result<ledger_core::ScheduleId, StoreError> { ) -> Result<ledger_core::ScheduleId, StoreError> {
self.rehearse(|l| l.register_schedule(new.clone()))?;
ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?; ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?;
let id = self.ledger.next_schedule_id(); let id = self.ledger.next_schedule_id();
let line = self.append_journal( let line = self.append_journal(
@@ -1873,6 +1925,7 @@ impl Store {
&mut self, &mut self,
new: ledger_core::NewRecurring, new: ledger_core::NewRecurring,
) -> Result<ledger_core::RecurringId, StoreError> { ) -> Result<ledger_core::RecurringId, StoreError> {
self.rehearse(|l| l.register_recurring(new.clone()))?;
ledger_core::schedules::validate_recurring(&new, self.ledger.chart())?; ledger_core::schedules::validate_recurring(&new, self.ledger.chart())?;
let id = self.ledger.next_recurring_id(); let id = self.ledger.next_recurring_id();
let lines: Vec<Value> = new let lines: Vec<Value> = new
@@ -1938,6 +1991,7 @@ impl Store {
category: Option<&str>, category: Option<&str>,
deductible_percent: Option<Decimal>, deductible_percent: Option<Decimal>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.rehearse(|l| l.set_account_tax(account, category, deductible_percent))?;
self.ledger self.ledger
.check_account_tax(account, category, deductible_percent)?; .check_account_tax(account, category, deductible_percent)?;
let line = self.append_journal( let line = self.append_journal(
@@ -1962,6 +2016,7 @@ impl Store {
form: Option<ledger_core::Form1099>, form: Option<ledger_core::Form1099>,
tin_on_file: bool, tin_on_file: bool,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.rehearse(|l| l.set_vendor_1099(vendor, form, tin_on_file))?;
self.ledger self.ledger
.get_vendor(vendor) .get_vendor(vendor)
.ok_or(LedgerError::UnknownVendor(vendor))?; .ok_or(LedgerError::UnknownVendor(vendor))?;
@@ -1981,6 +2036,7 @@ impl Store {
entry: EntryId, entry: EntryId,
vendor: Option<VendorId>, vendor: Option<VendorId>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.rehearse(|l| l.set_entry_vendor(entry, vendor))?;
// Validate first, so nothing invalid is journaled. // Validate first, so nothing invalid is journaled.
self.ledger self.ledger
.get_entry(entry) .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 /// Replay one journal line into a ledger, asserting recorded ids. Used by
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine /// hydration, `verify`, and `rebuild`: the journal is the script, the engine
/// is the actor. /// 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( pub(crate) fn replay_line_into_ledger(
ledger: &mut Ledger, ledger: &mut Ledger,
line: &JournalLine, line: &JournalLine,
+245
View File
@@ -752,3 +752,248 @@ fn a_draft_before_a_posted_entry_verifies_and_reopens() {
let mut store = Store::open(&dir).unwrap(); let mut store = Store::open(&dir).unwrap();
assert_eq!(store.verify_and_heal().unwrap().healed, None); assert_eq!(store.verify_and_heal().unwrap().healed, None);
} }
/// Regression (found by the max-testing pass, 2026-10-13): reversing into a
/// closed period journaled the reversal *before* the engine refused it, so
/// the journal held an event replay could never apply and the books would
/// not reopen. Oracle (rank 1, spec R-3 + closed periods): a refused
/// command leaves no journal line, and the books always reopen.
#[test]
fn reversal_into_a_closed_period_is_refused_before_journaling() {
let dir = test_dir("reverse-closed");
let mut store = build_books(&dir);
let jan = store
.create_period(
"2026-01",
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
)
.unwrap();
store.close_period(jan).unwrap();
let posted = store.ledger().posted_entries().next().unwrap().id();
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
let err = store.reverse_entry(
posted,
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
"into a closed month",
);
assert!(err.is_err());
assert_eq!(
std::fs::read_to_string(dir.join("journal.jsonl")).unwrap(),
journal,
"a refused reversal must not reach the journal"
);
drop(store);
let reopened = Store::open(&dir).expect("the books still open");
reopened.verify().unwrap();
}
// ---------------------------------------------------------------------------
// Refusals never reach the journal (rank 2: a law over every command
// sequence). Found by the max-testing pass, 2026-10-13: a reversal into a
// closed period was journaled before the engine refused it, and the books
// would not reopen.
// ---------------------------------------------------------------------------
mod refusals {
use super::*;
use ledger_core::{DocumentLine, Form1099};
use proptest::prelude::*;
/// One store command, with arguments drawn to hit both the happy path
/// and every refusal: small id spaces so unknown, duplicate, posted,
/// closed, and already-reversed cases come up constantly.
#[derive(Clone, Debug)]
enum Op {
AddAccount(u8, u8, Option<u64>, bool),
CreateEntry(u32),
AddLine(u64, u64, bool, i64),
Post(u64),
Reverse(u64, u32),
CreatePeriod(u32, u32),
Close(u64),
AddCustomer(bool),
AddVendor(bool),
Invoice(u64, u32, u32, i64),
Bill(u64, u32, u32, i64),
ReceivePayment(u64, u32, i64, u64),
PayBill(u64, u32, i64, u64),
AddClass(u8),
SetLineClass(u64, usize, Option<u32>),
SetEntryVendor(u64, Option<u64>),
SetVendor1099(u64, bool),
SetAccountTax(u64, u8),
}
fn day(n: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 120))
}
/// Cents, biased toward the edges: zero, negative, tiny, huge.
fn arb_cents() -> impl Strategy<Value = i64> {
prop_oneof![
4 => 1i64..200_000,
1 => Just(0i64),
1 => -50_000i64..0,
1 => Just(i64::MAX / 2),
]
}
fn arb_op() -> impl Strategy<Value = Op> {
let id = 0u64..12;
prop_oneof![
(0u8..8, 0u8..5, prop::option::of(0u64..10), any::<bool>())
.prop_map(|(c, t, p, h)| Op::AddAccount(c, t, p, h)),
(0u32..120).prop_map(Op::CreateEntry),
(id.clone(), 0u64..10, any::<bool>(), arb_cents())
.prop_map(|(e, a, d, c)| Op::AddLine(e, a, d, c)),
id.clone().prop_map(Op::Post),
(id.clone(), 0u32..120).prop_map(|(e, d)| Op::Reverse(e, d)),
(0u32..120, 0u32..120).prop_map(|(a, b)| Op::CreatePeriod(a, b)),
(0u64..5).prop_map(Op::Close),
any::<bool>().prop_map(Op::AddCustomer),
any::<bool>().prop_map(Op::AddVendor),
(0u64..4, 0u32..120, 0u32..120, arb_cents())
.prop_map(|(c, d, due, a)| Op::Invoice(c, d, due, a)),
(0u64..4, 0u32..120, 0u32..120, arb_cents())
.prop_map(|(v, d, due, a)| Op::Bill(v, d, due, a)),
(0u64..4, 0u32..120, arb_cents(), 0u64..10)
.prop_map(|(i, d, a, c)| Op::ReceivePayment(i, d, a, c)),
(0u64..4, 0u32..120, arb_cents(), 0u64..10)
.prop_map(|(b, d, a, c)| Op::PayBill(b, d, a, c)),
(0u8..3).prop_map(Op::AddClass),
(id.clone(), 0usize..4, prop::option::of(0u32..4))
.prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)),
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)),
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)),
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)),
]
}
const CODES: [&str; 8] = [
"1000", "1200", "2000", "3000", "3999", "4000", "6000", "6100",
];
const TYPES: [AccountType; 5] = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
const TAX: [Option<&str>; 4] = [
None,
Some("advertising"),
Some("no-such-category"),
Some(""),
];
fn apply(store: &mut Store, op: &Op) -> Result<(), StoreError> {
let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD);
let line = |c: i64| {
vec![DocumentLine {
description: "work".into(),
amount: Decimal::new(c, 2),
}]
};
match *op {
Op::AddAccount(c, t, p, h) => store
.add_account(CODES[c as usize], "acct", TYPES[t as usize], p, h)
.map(drop),
Op::CreateEntry(d) => store.create_entry(day(d), "e").map(drop),
Op::AddLine(e, a, d, c) => {
let side = if d { Side::Debit } else { Side::Credit };
store.add_line(e, a, side, usd(c))
}
Op::Post(e) => store.post_entry(e),
Op::Reverse(e, d) => store.reverse_entry(e, day(d), "undo").map(drop),
Op::CreatePeriod(a, b) => store.create_period("p", day(a), day(b)).map(drop),
Op::Close(p) => store.close_period(p).map(drop),
Op::AddCustomer(blank) => store
.add_customer(if blank { " " } else { "Acme" })
.map(drop),
Op::AddVendor(blank) => store.add_vendor(if blank { "" } else { "Vend" }).map(drop),
Op::Invoice(c, d, due, a) => {
store.create_invoice(c, day(d), day(due), line(a)).map(drop)
}
Op::Bill(v, d, due, a) => store.create_bill(v, day(d), day(due), line(a)).map(drop),
Op::ReceivePayment(i, d, a, c) => store
.receive_payment(i, day(d), Decimal::new(a, 2), c)
.map(drop),
Op::PayBill(b, d, a, c) => store.pay_bill(b, day(d), Decimal::new(a, 2), c).map(drop),
Op::AddClass(n) => store.add_class(["A", "B", ""][n as usize]).map(drop),
Op::SetLineClass(e, i, c) => store.set_line_class(e, i, c),
Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v),
Op::SetVendor1099(v, t) => store.set_vendor_1099(v, Some(Form1099::Nec), t),
Op::SetAccountTax(a, k) => store.set_account_tax(a, TAX[k as usize], None),
}
}
/// The Lean model's `Inv` (verify/lean/Wanderbooks/Ledger.lean), checked
/// on the real store: every posted entry is acceptable, the books net
/// to zero, and no draft is dated in a closed period.
fn check_invariants(store: &Store) -> Result<(), TestCaseError> {
let l = store.ledger();
for e in l.posted_entries() {
prop_assert!(
e.validate(l.chart()).is_ok(),
"posted entry {} invalid",
e.id()
);
}
let tb = ledger_core::reports::trial_balance(l);
prop_assert_eq!(tb.total_debits, tb.total_credits);
for id in 1..l.next_entry_id() {
if let Some(e) = l.get_entry(id)
&& e.status() == ledger_core::EntryStatus::Draft
{
prop_assert!(
l.closed_period_containing(e.date()).is_none(),
"draft {} is dated {} inside a closed period",
id,
e.date()
);
}
}
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 1000,
failure_persistence: None,
..ProptestConfig::default()
})]
/// A refused command leaves the journal byte-identical, and after
/// any sequence the books reopen and verify.
#[test]
fn refused_commands_never_reach_the_journal(
ops in prop::collection::vec(arb_op(), 1..40),
seed in any::<u64>(),
) {
let dir = test_dir(&format!("refusals-{seed}"));
let mut store = Store::create(&dir, Currency::USD).unwrap();
let journal = dir.join(ledger_store::JOURNAL_FILE);
for op in &ops {
check_invariants(&store)?;
let before = std::fs::read_to_string(&journal).unwrap();
if let Err(e) = apply(&mut store, op) {
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(
after == before,
"refused {:?} ({}) but the journal grew by:\n{}",
op,
e,
after.strip_prefix(&before).unwrap_or(&after)
);
}
}
check_invariants(&store)?;
drop(store);
let reopened = Store::open(&dir);
prop_assert!(reopened.is_ok(), "books failed to reopen: {:?}", reopened.err());
reopened.unwrap().verify().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}
}