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 |