From 0dcf865ff80e656398e926d2754d7327f69d367f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 18:17:13 -0600 Subject: [PATCH] core: no entry posts a fraction of a cent; verify compares whole ledgers CI's run of the store refusal property found a payroll run that posted $65.27794: the employer tax was passed in unrounded and nothing refused it, though rules.md B-3 already says a ledger in one money holds no fractions of its smallest unit. Sums of it then differed in scale between journal replay and the SQLite read-back. - Ledger::post_entry refuses any line finer than its currency's ISO 4217 minor unit (FinerThanMinorUnit), whatever made the line: payroll, closes, disposals. add_line refuses it up front. Older journals replay as booked through replay_post_entry. Spec tests for the kernel's ten-place limit now run on the replay path. - The property's body is now run_ops, so failing sequences become named regression tests (regression_payroll_with_a_fraction_of_a_cent). The generator rounds payroll tax to the cent, as callers must. - Ledger derives PartialEq and gains differs_from; Store::verify compares the live ledger with the journal replay in full (drafts, documents, schedules, periods, settings), not just balances, and names the parts that differ. The property checks the ledger its commands built against that replay and against the reopened books. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/account.rs | 4 +- crates/ledger-core/src/arap.rs | 10 +- crates/ledger-core/src/error.rs | 14 ++ crates/ledger-core/src/journal.rs | 4 +- crates/ledger-core/src/ledger.rs | 105 +++++++++++++- crates/ledger-core/src/periods.rs | 2 +- crates/ledger-core/tests/invariants.rs | 59 +++++++- crates/ledger-core/tests/spec_ledger.rs | 42 +++++- crates/ledger-store/src/store.rs | 16 ++- crates/ledger-store/tests/store.rs | 183 +++++++++++++++--------- docs/spec/rules.md | 12 +- docs/testing.md | 14 +- 12 files changed, 365 insertions(+), 100 deletions(-) diff --git a/crates/ledger-core/src/account.rs b/crates/ledger-core/src/account.rs index 1ece5ea..780aa62 100644 --- a/crates/ledger-core/src/account.rs +++ b/crates/ledger-core/src/account.rs @@ -57,7 +57,7 @@ impl AccountType { } /// A single account in the chart of accounts. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Account { /// Opaque handle. pub id: AccountId, @@ -84,7 +84,7 @@ pub struct Account { } /// The registry of accounts. Owns id allocation for accounts it holds. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct ChartOfAccounts { accounts: HashMap, by_code: HashMap, diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index 4635a76..421602c 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -27,7 +27,7 @@ pub type InvoiceId = u64; pub type BillId = u64; /// A customer: someone we invoice. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Customer { /// Opaque handle. pub id: CustomerId, @@ -36,7 +36,7 @@ pub struct Customer { } /// A vendor: someone who bills us. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Vendor { /// Opaque handle. pub id: VendorId, @@ -64,7 +64,7 @@ pub enum DocumentStatus { } /// One line on an invoice or bill. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct DocumentLine { /// Description of the goods/services. pub description: String, @@ -73,7 +73,7 @@ pub struct DocumentLine { } /// A sales invoice: we billed a customer. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Invoice { /// Opaque handle. pub id: InvoiceId, @@ -106,7 +106,7 @@ impl Invoice { } /// A vendor bill: we owe a vendor. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Bill { /// Opaque handle. pub id: BillId, diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 5481119..0810c37 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -35,6 +35,20 @@ pub enum LedgerError { #[error("journal line amount is out of range (at most 1000000000000000, ten decimal places)")] AmountOutOfRange, + /// An amount finer than its currency's smallest unit (ISO 4217 minor + /// units): no one can pay a fraction of a cent (rules.md B-3). + #[error( + "amount {amount} {currency} has more than {places} decimal places; round it to the {currency} minor unit first" + )] + FinerThanMinorUnit { + /// The amount as given. + amount: rust_decimal::Decimal, + /// Its currency code. + currency: String, + /// The decimal places the currency allows. + places: u32, + }, + /// The referenced account does not exist in the chart of accounts. #[error("unknown account id {0}")] UnknownAccount(u64), diff --git a/crates/ledger-core/src/journal.rs b/crates/ledger-core/src/journal.rs index 957bdd3..4e26972 100644 --- a/crates/ledger-core/src/journal.rs +++ b/crates/ledger-core/src/journal.rs @@ -24,7 +24,7 @@ pub enum EntryStatus { } /// One debit or credit line of a journal entry. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct JournalLine { /// The account this line posts to. pub account: AccountId, @@ -51,7 +51,7 @@ pub struct JournalLine { } /// A balanced set of journal lines with a date and a description. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct JournalEntry { id: EntryId, date: NaiveDate, diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 246aab7..44f240b 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -166,7 +166,7 @@ pub struct ReconciliationReport { } /// The books. Single-currency (see R-5); owns id allocation. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Ledger { base_currency: Currency, chart: ChartOfAccounts, @@ -462,6 +462,76 @@ impl Ledger { .collect() } + /// The parts of the ledger that differ from `other`, by field name + /// (empty when equal). For diagnosing a store whose journal replay and + /// projection disagree. + pub fn differs_from(&self, other: &Ledger) -> Vec<&'static str> { + [ + ("base_currency", self.base_currency == other.base_currency), + ("chart", self.chart == other.chart), + ("entries", self.entries == other.entries), + ("order", self.order == other.order), + ("assets", self.assets == other.assets), + ("periods", self.periods == other.periods), + ("customers", self.customers == other.customers), + ("vendors", self.vendors == other.vendors), + ("invoices", self.invoices == other.invoices), + ("bills", self.bills == other.bills), + ("cleared", self.cleared == other.cleared), + ("fx_rates", self.fx_rates == other.fx_rates), + ("payroll_runs", self.payroll_runs == other.payroll_runs), + ("classes", self.classes == other.classes), + ( + "next_account_id", + self.next_account_id == other.next_account_id, + ), + ("next_entry_id", self.next_entry_id == other.next_entry_id), + ("next_asset_id", self.next_asset_id == other.next_asset_id), + ( + "next_period_id", + self.next_period_id == other.next_period_id, + ), + ( + "next_customer_id", + self.next_customer_id == other.next_customer_id, + ), + ( + "next_vendor_id", + self.next_vendor_id == other.next_vendor_id, + ), + ( + "next_invoice_id", + self.next_invoice_id == other.next_invoice_id, + ), + ("next_bill_id", self.next_bill_id == other.next_bill_id), + ( + "next_payroll_run_id", + self.next_payroll_run_id == other.next_payroll_run_id, + ), + ("next_class_id", self.next_class_id == other.next_class_id), + ("schedules", self.schedules == other.schedules), + ( + "next_schedule_id", + self.next_schedule_id == other.next_schedule_id, + ), + ("recurring", self.recurring == other.recurring), + ( + "next_recurring_id", + self.next_recurring_id == other.next_recurring_id, + ), + ("entry_vendors", self.entry_vendors == other.entry_vendors), + ( + "invoice_payments", + self.invoice_payments == other.invoice_payments, + ), + ("bill_payments", self.bill_payments == other.bill_payments), + ] + .into_iter() + .filter(|(_, same)| !same) + .map(|(name, _)| name) + .collect() + } + /// The id the next account added will receive. /// /// Exposed for journaling stores that record the id before mutating. @@ -722,6 +792,7 @@ impl Ledger { amount: Money, ) -> Result<(), LedgerError> { self.check_line_account(account)?; + check_minor_units(amount)?; self.replay_add_line(entry, account, side, amount) } @@ -886,8 +957,25 @@ impl Ledger { /// 4. IF it doesn't balance, has fewer than two lines, a zero, negative, /// or out-of-range amount, or an unknown or placeholder account: /// SHOULD refuse with that reason. + /// 5. IF a line is finer than its currency's minor unit (a fraction + /// of a cent): SHOULD refuse (FinerThanMinorUnit) + /// BECAUSE a ledger in one money holds no fractions of its + /// smallest unit (B-3); found when a payroll run posted $65.27794 + /// (the store's refusal property in CI, 2026-10-13). /// ``` pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { + if let Some(e) = self.entries.get(&entry) { + for line in e.lines() { + check_minor_units(line.amount)?; + } + } + self.replay_post_entry(entry) + } + + /// [`Self::post_entry`] without the minor-unit check added on + /// 2026-10-13, for replaying journals written before it. Never for new + /// work. + pub fn replay_post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { let snapshot = self .entries .get(&entry) @@ -2667,3 +2755,18 @@ impl Ledger { Ok(()) } } + +/// Refuse an amount finer than its currency's minor unit (rules.md B-3). +/// A currency with no defined minor unit (ISO 4217 "N.A.") is not checked. +fn check_minor_units(amount: Money) -> Result<(), LedgerError> { + match amount.currency.minor_units() { + Some(places) if amount.amount.normalize().scale() > places => { + Err(LedgerError::FinerThanMinorUnit { + amount: amount.amount, + currency: amount.currency.code().to_string(), + places, + }) + } + _ => Ok(()), + } +} diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs index d9449b8..7863e07 100644 --- a/crates/ledger-core/src/periods.rs +++ b/crates/ledger-core/src/periods.rs @@ -120,7 +120,7 @@ pub fn allocate_last_absorbs( pub const RETAINED_EARNINGS_CODE: &str = "3999"; /// A named accounting period: a month, quarter, or year. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Period { /// Opaque handle. pub id: PeriodId, diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index a93e1ad..40177da 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -210,17 +210,68 @@ fn out_of_range_amount_is_rejected() { ledger.post_entry(id).unwrap_err(), LedgerError::AmountOutOfRange ); - // Too precise. + // Too precise: refused as soon as it is added (finer than a cent)... let id = ledger.create_entry(date(21), "Too precise"); let tiny = Money::new("0.00000000001".parse().unwrap(), Currency::USD); - ledger.add_line(id, c.cash, Side::Debit, tiny).unwrap(); - ledger.add_line(id, c.equity, Side::Credit, tiny).unwrap(); + assert!(matches!( + ledger.add_line(id, c.cash, Side::Debit, tiny), + Err(LedgerError::FinerThanMinorUnit { places: 2, .. }) + )); + // ...and out of range even for books replayed from before that rule. + ledger + .replay_add_line(id, c.cash, Side::Debit, tiny) + .unwrap(); + ledger + .replay_add_line(id, c.equity, Side::Credit, tiny) + .unwrap(); assert_eq!( - ledger.post_entry(id).unwrap_err(), + ledger.replay_post_entry(id).unwrap_err(), LedgerError::AmountOutOfRange ); } +/// B-3 (DECISION, Stephen 2026-10-13: a ledger in one money holds no +/// fractions of its smallest unit). Regression: a payroll run posted +/// $65.27794 (found by the store's refusal property in CI, 2026-10-13). +#[test] +fn no_entry_posts_a_fraction_of_the_minor_unit() { + let c = fixture_company(); + let mut ledger = c.ledger; + let usd = |s: &str| Money::new(s.parse().unwrap(), Currency::USD); + // Whole cents, with or without trailing zeros, are fine. + for ok in ["65.27", "65.270", "65"] { + let id = ledger.create_entry(date(21), "cents"); + ledger.add_line(id, c.cash, Side::Debit, usd(ok)).unwrap(); + ledger + .add_line(id, c.equity, Side::Credit, usd(ok)) + .unwrap(); + ledger.post_entry(id).unwrap(); + } + // A fraction of a cent is refused when added... + let id = ledger.create_entry(date(21), "sub-cent"); + assert!(matches!( + ledger.add_line(id, c.cash, Side::Debit, usd("65.27794")), + Err(LedgerError::FinerThanMinorUnit { places: 2, .. }) + )); + // ...and when posted, whatever path put the line on the draft. + ledger + .replay_add_line(id, c.cash, Side::Debit, usd("65.275")) + .unwrap(); + ledger + .replay_add_line(id, c.equity, Side::Credit, usd("65.275")) + .unwrap(); + assert!(matches!( + ledger.post_entry(id), + Err(LedgerError::FinerThanMinorUnit { .. }) + )); + assert_eq!( + ledger.get_entry(id).unwrap().status(), + ledger_core::EntryStatus::Draft + ); + // Books from before the rule still replay as booked. + ledger.replay_post_entry(id).unwrap(); +} + #[test] fn fx_overflow_is_an_error() { let c = fixture_company(); diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs index 7d0ff0e..9106952 100644 --- a/crates/ledger-core/tests/spec_ledger.rs +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -123,6 +123,23 @@ fn try_post( l.post_entry(id).err() } +/// [`try_post`] through the replay path, which books written before the +/// minor-unit rule (B-3, 2026-10-13) still take: for the kernel's own +/// limits below a cent. +fn try_replay_post( + l: &mut Ledger, + date: NaiveDate, + lines: &[(AccountId, Side, Money)], +) -> Option { + let id = l.try_create_entry(date, "attempt").unwrap(); + for (a, s, m) in lines { + if let Err(e) = l.replay_add_line(id, *a, *s, *m) { + return Some(e); + } + } + l.replay_post_entry(id).err() +} + /// Everything a refusal could disturb, as plain comparable data. #[derive(Clone, Debug, PartialEq)] struct Observed { @@ -687,8 +704,9 @@ fn an_unbalanced_entry_is_refused_with_its_totals() { credits: dec("99.99"), }) ); - // One ten-billionth is still a difference. - let r = try_post( + // One ten-billionth is still a difference (on replayed books; new + // work refuses anything finer than a cent first). + let r = try_replay_post( &mut b.l, d(2026, 3, 1), &[ @@ -771,13 +789,29 @@ fn line_range_is_one_quadrillion_and_ten_places() { ) }; assert_eq!(ok(&mut b, "1000000000000000"), None); - assert_eq!(ok(&mut b, "0.0000000001"), None); assert_eq!( ok(&mut b, "1000000000000000.01"), Some(LedgerError::AmountOutOfRange) ); + // Below a cent, new work is refused first (B-3); replayed books meet + // the kernel's ten-place limit. + assert!(matches!( + ok(&mut b, "0.0000000001"), + Some(LedgerError::FinerThanMinorUnit { .. }) + )); + let replayed = |b: &mut Books, amt: &str| { + try_replay_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd(amt)), + (b.cash, Side::Credit, usd(amt)), + ], + ) + }; + assert_eq!(replayed(&mut b, "0.0000000001"), None); assert_eq!( - ok(&mut b, "0.00000000001"), + replayed(&mut b, "0.00000000001"), Some(LedgerError::AmountOutOfRange) ); assert_eq!(b.l.posted_entries().count(), 2); diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 0dbc048..d4a7ac9 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2358,6 +2358,18 @@ impl Store { "sqlite projection diverged from journal".into(), )); } + // The whole ledger, not only its balances: drafts, documents, + // schedules, periods, settings. A replay bug in any of them shows + // here even when every balance agrees. (The SQLite read-back below + // is partial by design; the projection is checked table by table + // at the end.) + let parts = replayed.differs_from(&self.ledger); + if !parts.is_empty() { + return Err(StoreError::VerifyMismatch(format!( + "live ledger diverged from journal replay in: {}", + parts.join(", ") + ))); + } // The accounting snapshot ignores the asset register, so compare it // explicitly: journal replay, live ledger, and projection must agree. let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::>(); @@ -2600,7 +2612,7 @@ impl Store { ledger.set_line_class(got, idx, Some(cid as u32))?; } } - ledger.post_entry(got)?; + ledger.replay_post_entry(got)?; } } } @@ -3075,7 +3087,7 @@ pub(crate) fn replay_line_into_ledger( )?; } } - ledger.post_entry(id)?; + ledger.replay_post_entry(id)?; } "entry.reversed" => { let want = get_u64(p, "id")?; diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index bab36c2..fb6c680 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1192,7 +1192,8 @@ mod refusals { state_withholding: Decimal::ZERO, ss_employee: Decimal::ZERO, medicare_employee: Decimal::ZERO, - ss_employer: Decimal::new(g, 2) * Decimal::new(62, 3), + // 6.2%, rounded to the cent as a caller must. + ss_employer: (Decimal::new(g, 2) * Decimal::new(62, 3)).round_dp(2), medicare_employer: Decimal::ZERO, other_deductions: Decimal::ZERO, }], @@ -1380,77 +1381,119 @@ mod refusals { ops in prop::collection::vec(arb_op(), 1..40), seed in any::(), ) { - let dir = test_dir(&format!("refusals-{seed}")); - let mut store = Store::create(&dir, Currency::USD).unwrap(); - // Start from realistic books, so most commands act on real - // things and refusals come from bad arguments (the first - // version started empty and almost nothing ever posted). - // Ids 1..=9: cash, equipment, accumulated depreciation, two - // members' capital, a liability, sales, rent, depreciation. - for (code, name, t) in [ - ("1000", "Cash", AccountType::Asset), - ("1500", "Equipment", AccountType::Asset), - ("1510", "Accumulated depreciation", AccountType::Asset), - ("3000", "Capital A", AccountType::Equity), - ("3001", "Capital B", AccountType::Equity), - ("2500", "Loan", AccountType::Liability), - ("4100", "Sales", AccountType::Revenue), - ("6000", "Rent", AccountType::Expense), - ("6200", "Depreciation", AccountType::Expense), - ] { - store.add_account(code, name, t, None, false).unwrap(); - } - for (n, (dr, cr, cents)) in [(1u64, 4u64, 1_000_000i64), (8, 1, 120_000), (1, 7, 250_000)] - .into_iter() - .enumerate() - { - let e = store.create_entry(day(n as u32 * 10), "setup").unwrap(); - let m = Money::new(Decimal::new(cents, 2), Currency::USD); - store.add_line(e, dr, Side::Debit, m).unwrap(); - store.add_line(e, cr, Side::Credit, m).unwrap(); - store.post_entry(e).unwrap(); - } - let journal = dir.join(ledger_store::JOURNAL_FILE); - for op in &ops { - check_invariants(&store)?; - let mut before = std::fs::read_to_string(&journal).unwrap(); - let result = apply(&mut store, op, &mut || { - before = std::fs::read_to_string(&journal).unwrap(); - }); - if std::env::var("STORE_STATS").is_ok() { - let kind = format!("{op:?}"); - let kind = kind.split('(').next().unwrap_or(""); - match &result { - Ok(()) => eprintln!("OK {kind}"), - Err(e) => eprintln!("REFUSED {kind} {e}"), - } - } - if let Err(e) = result { - 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)?; - // Reopening gives back exactly these books (rank 3: the live - // ledger against journal replay). - let before = ledger_core::reports::snapshot(store.ledger()).unwrap(); - let attachments = store.list_attachments(None).unwrap().len(); - drop(store); - let reopened = Store::open(&dir); - prop_assert!(reopened.is_ok(), "books failed to reopen: {:?}", reopened.err()); - let reopened = reopened.unwrap(); - reopened.verify().unwrap(); - prop_assert_eq!(ledger_core::reports::snapshot(reopened.ledger()).unwrap(), before); - prop_assert_eq!(reopened.list_attachments(None).unwrap().len(), attachments); - let _ = std::fs::remove_dir_all(&dir); + run_ops(&ops, seed)?; } } + + /// Run one sequence of ops against fresh books and check every law. + fn run_ops(ops: &[Op], seed: u64) -> Result<(), TestCaseError> { + let dir = test_dir(&format!("refusals-{seed}")); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + // Start from realistic books, so most commands act on real + // things and refusals come from bad arguments (the first + // version started empty and almost nothing ever posted). + // Ids 1..=9: cash, equipment, accumulated depreciation, two + // members' capital, a liability, sales, rent, depreciation. + for (code, name, t) in [ + ("1000", "Cash", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("1510", "Accumulated depreciation", AccountType::Asset), + ("3000", "Capital A", AccountType::Equity), + ("3001", "Capital B", AccountType::Equity), + ("2500", "Loan", AccountType::Liability), + ("4100", "Sales", AccountType::Revenue), + ("6000", "Rent", AccountType::Expense), + ("6200", "Depreciation", AccountType::Expense), + ] { + store.add_account(code, name, t, None, false).unwrap(); + } + for (n, (dr, cr, cents)) in [(1u64, 4u64, 1_000_000i64), (8, 1, 120_000), (1, 7, 250_000)] + .into_iter() + .enumerate() + { + let e = store.create_entry(day(n as u32 * 10), "setup").unwrap(); + let m = Money::new(Decimal::new(cents, 2), Currency::USD); + store.add_line(e, dr, Side::Debit, m).unwrap(); + store.add_line(e, cr, Side::Credit, m).unwrap(); + store.post_entry(e).unwrap(); + } + let journal = dir.join(ledger_store::JOURNAL_FILE); + for op in ops { + check_invariants(&store)?; + let mut before = std::fs::read_to_string(&journal).unwrap(); + let result = apply(&mut store, op, &mut || { + before = std::fs::read_to_string(&journal).unwrap(); + }); + if std::env::var("STORE_STATS").is_ok() { + let kind = format!("{op:?}"); + let kind = kind.split('(').next().unwrap_or(""); + match &result { + Ok(()) => eprintln!("OK {kind}"), + Err(e) => eprintln!("REFUSED {kind} {e}"), + } + } + if let Err(e) = result { + 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)?; + // Reopening gives back exactly these books (rank 3: the live + // ledger against journal replay). + // The ledger the commands built against its own journal's + // replay, every part of it (rank 3). + let verified = store.verify(); + prop_assert!(verified.is_ok(), "live vs. replay: {:?}", verified.err()); + let before = ledger_core::reports::snapshot(store.ledger()).unwrap(); + let live = store.ledger().clone(); + let attachments = store.list_attachments(None).unwrap().len(); + drop(store); + let reopened = Store::open(&dir); + prop_assert!( + reopened.is_ok(), + "books failed to reopen: {:?}", + reopened.err() + ); + let reopened = reopened.unwrap(); + reopened.verify().unwrap(); + prop_assert_eq!( + ledger_core::reports::snapshot(reopened.ledger()).unwrap(), + before + ); + let parts = live.differs_from(reopened.ledger()); + prop_assert!(parts.is_empty(), "reopened books differ in: {:?}", parts); + prop_assert_eq!(reopened.list_attachments(None).unwrap().len(), attachments); + let _ = std::fs::remove_dir_all(&dir); + Ok(()) + } + + /// Regression (CI, 2026-10-13): a payroll run with unrounded employer + /// tax posted $65.27794, and sums of it differed in scale between the + /// journal replay and the SQLite read-back. Posting now refuses + /// fractions of a cent (B-3; `no_entry_posts_a_fraction_of_the_minor_unit`); + /// this replays the sequence with the tax rounded, as callers must. + #[test] + fn regression_payroll_with_a_fraction_of_a_cent() { + run_ops( + &[ + Op::Book(0, 1, 1, 1), + Op::Book(0, 1, 1, 1), + Op::CreateEntry(0), + Op::RunPayroll(0, 105_287, 1, 1), + Op::GoodClose(2), + Op::Pair(6, 8, 1, 1), + Op::Post(6), + ], + 0, + ) + .unwrap(); + } } /// Contract (2026-10-13): rules added to the engine never lock anyone out diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 6b6bbdb..568cb40 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -66,10 +66,14 @@ match a bank statement: DECISION (Stephen, 2026-10-13), after the GnuCash cross-check found wanderbooks kept $108.34567 where GnuCash booked $108.35. Lines booked before then replay as booked. -**Enforced:** `Ledger::add_line` (base currency only), -`Ledger::add_line_fx` (base amount = foreign amount × rate, rounded to -the minor unit; original kept). **Checked:** -`a_foreign_line_is_booked_in_whole_base_units`; the GnuCash oracle. +**Enforced:** `Ledger::add_line` (base currency only, no fraction of the +minor unit), `Ledger::add_line_fx` (base amount = foreign amount × rate, +rounded to the minor unit; original kept), `Ledger::post_entry` (every +line, whatever made it: payroll, closes, disposals; `FinerThanMinorUnit`, +since 2026-10-13 after a payroll run posted $65.27794). Journals from +before replay as booked (`replay_post_entry`). **Checked:** +`a_foreign_line_is_booked_in_whole_base_units`, +`no_entry_posts_a_fraction_of_the_minor_unit`; the GnuCash oracle. **Checked:** `foreign_currency_line_is_rejected`, `fx_overflow_is_an_error`. ## B-4 Five elements, closed list diff --git a/docs/testing.md b/docs/testing.md index acffc3f..1d1846d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,7 +15,8 @@ the store's journal safety; the other crates come next (gap list). | Compatibility contracts | journals written under older rules must open (1) | `ledger-store/tests/store.rs` (`books_written_under_older_rules_still_open`) | `just check` | <1s | every change | | Properties | laws over all inputs (2) | `properties.rs`, `spec_*.rs`, `kernel_bridge.rs` | `just check` | seconds | every change | | Metamorphic | scaling, reversal, ordering invariance (2) | `spec_reports.rs` | `just check` | seconds | every change | -| Refusal law (store) | a refused command never reaches the journal (2) | `store.rs` `refused_commands_never_reach_the_journal` | `just check` | ~15s | every change | +| Refusal law (store) | a refused command never reaches the journal; the books reopen to the same reports (2, 3) | `store.rs` `refused_commands_never_reach_the_journal` | `just check` (`STORE_CASES=5000` deeper) | ~20s | every change | +| Fault injection (store) | a full disk or a cut-off append leaves whole, openable books (1) | `ledger-store/tests/faults.rs`, `store.rs` (`power_loss_at_any_byte_of_the_last_append_is_recovered`) | `just check` | ~5s | every change | | Model-based stateful | the Lean state machine, transcribed (3) | `tests/model.rs` | `just check` | ~3s | every change | | Differential: Decimal vs i64 | the kernel at two types (3) | `kernel_bridge.rs` | `just check` | <1s | every change | | Differential: GnuCash | another implementation, adjudicated (3) | `tests/oracle_gnucash.rs`, `oracle/gnucash/` | `just oracle` | ~2 min | weekly | @@ -23,7 +24,7 @@ the store's journal safety; the other crates come next (gap list). | Fuzzing | crash, hang, broken books (4) | `fuzz/fuzz_targets/`, corpora in `fuzz/corpus/` | `just fuzz ` | 10 min each | weekly | | Miri | undefined behavior in our use of dependencies (4) | the core's unit, bridge, invariant tests | `just miri` | ~2 min | on demand | | Golden | `tests/golden/fixture_company.json` (5) | `invariants.rs` | `just check` | <1s | every change | -| Benchmarks | a budget (6) | `benches/books.rs` | `just bench` | ~1 min | none (no budget yet) | +| Benchmarks | a baseline (6) | `benches/books.rs` | `just bench` | ~1 min | none (no budget, by decision) | | Mutation | can the tests fail? | `ledger-core` | `just mutants` | ~12 min | weekly | CI: `.github/workflows/check.yml` runs `just check` on every push; @@ -39,7 +40,10 @@ Layer 1). The van depreciation golden was replaced on 2026-10-13 by a hand computation (`van_schedule_is_the_hand_computed_one`) after every row was checked against it. -## Benchmarks (baseline 2026-10-13, no budget yet) +## Benchmarks (baseline 2026-10-13) + +No budget, by decision (Stephen, 2026-10-13: the books do little work +and are fast enough). The numbers are a baseline to spot a regression. 40-account chart, two-line entries across a year, on the development laptop: @@ -83,7 +87,7 @@ say so in their doc comments. | Technique | Status | Trigger | |---|---|---| -| Fault injection | SKIP for the core (no I/O, clock, or network) | the first I/O or clock in `ledger-core`; the store is next in scope (torn writes, full disk) | +| Fault injection | SKIP for the core (no I/O, clock, or network); DONE for the store's journal (full disk, cut-off append) | the first I/O or clock in `ledger-core` | | Integration | SKIP for the core (no collaborators) | the core gains one; covered at the store and MCP layers | | End-to-end | SKIP for the core (a library) | MCP stdio tests cover the journeys | | Security properties | SKIP for the core (no roles, no secrets) | the first permission concept, or a secret-bearing field | @@ -91,4 +95,4 @@ say so in their doc comments. | Lean: apportion's sum law for any n | OPEN | Kani covers n <= 3, properties n <= 5; needs a permutation lemma | | ASC paragraphs not yet read | OPEN | ASC 360-10-35-4, 340-10, 230-10-45-10, 606-10-55-37B, 210: needs a FASB basic-view login | | GnuCash's own close | OPEN | the oracle books closes as ordinary entries; comparing GnuCash's Close Book needs its Scheme API | -| Store, import, MCP, config, GUI crates | NEXT | in that order, the same playbook | +| Store: differential replay vs. projection, concurrency (writer + snapshot reader), benchmarks at 10k, mutation | NEXT | then import, MCP, config, GUI, the same playbook |