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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:17:13 -06:00
1 parent 54f632dfea
commit e2c421be35
12 files changed
+365 -100

No files matched your search

+14 -2
View File
@@ -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::<Vec<_>>();
@@ -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")?;
+113 -70
View File
@@ -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::<u64>(),
) {
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