store: refusals leave the live books untouched; per-filing names; spec suite
check / check (push) Canceled after 0s

The refusal property gained the converse law (an accepted command
journals something; only batch runs may find nothing to do) and checks
the store's class and payroll listings. It found a schedule registered
with an out-of-range total whose first month was refused after its entry
was begun: the live ledger kept a draft the journal never had.

- Core: compound commands (post_schedule_month, reverse_entry,
  close_period_to, create_invoice, create_bill, receive_payment,
  pay_bill, run_payroll, remit_payroll_taxes) run inside
  Ledger::atomically, so a refusal partway leaves the ledger exactly as
  it was.
- Core: a schedule total no line could carry, or finer than a cent, and
  an asset cost beyond the line range, are refused at registration.
  Replay keeps older schedules (replay_register_schedule).
- Store: add_line, add_line_fx, mark_cleared, set_fx_rate,
  post_schedule_month and close_period_to changed the live ledger before
  journaling; they now run on a copy, journal what it produced, then
  swap it in, so a failed write leaves the live books as journaled.

New spec-blind suite tests/spec_store.rs (57 tests written from the
store's specs alone). Its two findings, adjudicated:
- A file filed twice under different names listed the first name and
  type for both (the projection kept them per blob). Schema v8 gives
  each attachment its own; older databases backfill and heal by verify.
- Withdrawn attachments stay in the store's listing, marked (the MCP
  listing hides them): the spec and doc said "omit" and are corrected.

Also removes journal::truncate_to_last_good_seq: unused, and it dropped
everything after the first bad complete line, which the books now treat
as corruption for a person, never repaired automatically.

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 19:24:34 -06:00
1 parent 8a49f0ff0e
commit 4d8d5daf37
11 files changed
+2075 -75

No files matched your search

+120 -1
View File
@@ -263,6 +263,17 @@ impl Ledger {
/// Register an amortization schedule. Returns its id.
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
schedules::validate_schedule(&new, &self.chart)?;
self.replay_register_schedule(new)
}
/// [`Self::register_schedule`] without the total's range and minor-unit
/// checks added on 2026-10-13, for replaying journals written before
/// them. Never for new work.
pub fn replay_register_schedule(
&mut self,
new: NewSchedule,
) -> Result<ScheduleId, LedgerError> {
schedules::validate_schedule_shape(&new, &self.chart)?;
let id = self.next_schedule_id;
self.next_schedule_id += 1;
self.schedules.insert(
@@ -339,6 +350,15 @@ impl Ledger {
&mut self,
id: ScheduleId,
month_end: NaiveDate,
) -> Result<EntryId, LedgerError> {
self.atomically(|l| l.post_schedule_month_steps(id, month_end))
}
/// The steps of [`Self::post_schedule_month`], run inside [`Self::atomically`].
fn post_schedule_month_steps(
&mut self,
id: ScheduleId,
month_end: NaiveDate,
) -> Result<EntryId, LedgerError> {
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
if let Some(p) = self.closed_period_containing(date) {
@@ -532,6 +552,18 @@ impl Ledger {
.collect()
}
/// Run a command made of several steps as one: if any step is
/// refused, the ledger is exactly as it was (no half-built draft, no
/// id spent). Found 2026-10-13: a schedule month whose amount was out
/// of range left a draft behind.
fn atomically<T>(
&mut self,
steps: impl FnOnce(&mut Self) -> Result<T, LedgerError>,
) -> Result<T, LedgerError> {
let before = self.clone();
steps(self).inspect_err(|_| *self = before)
}
/// The id the next account added will receive.
///
/// Exposed for journaling stores that record the id before mutating.
@@ -1062,6 +1094,16 @@ impl Ledger {
entry: EntryId,
date: NaiveDate,
description: &str,
) -> Result<EntryId, LedgerError> {
self.atomically(|l| l.reverse_entry_steps(entry, date, description))
}
/// The steps of [`Self::reverse_entry`], run inside [`Self::atomically`].
fn reverse_entry_steps(
&mut self,
entry: EntryId,
date: NaiveDate,
description: &str,
) -> Result<EntryId, LedgerError> {
self.check_reversal(entry, date)?;
let original = self
@@ -1108,6 +1150,11 @@ impl Ledger {
if new.cost.amount <= Decimal::ZERO {
return Err(LedgerError::InvalidAssetCost(new.cost.amount));
}
// Every depreciation and disposal line derives from the cost: one
// that no line could carry is refused here, not months later.
if new.cost.amount > kernel::MAX_LINE_AMOUNT {
return Err(LedgerError::AmountOutOfRange);
}
if new.cost.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch {
expected: self.base_currency.code().to_string(),
@@ -1379,6 +1426,15 @@ impl Ledger {
&mut self,
id: PeriodId,
to: &periods::CloseTo,
) -> Result<Option<EntryId>, LedgerError> {
self.atomically(|l| l.close_period_to_steps(id, to))
}
/// The steps of [`Self::close_period_to`], run inside [`Self::atomically`].
fn close_period_to_steps(
&mut self,
id: PeriodId,
to: &periods::CloseTo,
) -> Result<Option<EntryId>, LedgerError> {
self.preview_close_period_to(id, to)?;
let period = self.periods.get(&id).expect("previewed").clone();
@@ -1892,6 +1948,17 @@ impl Ledger {
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<InvoiceId, LedgerError> {
self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines))
}
/// The steps of [`Self::create_invoice`], run inside [`Self::atomically`].
fn create_invoice_steps(
&mut self,
customer_id: CustomerId,
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<InvoiceId, LedgerError> {
if !self.customers.contains_key(&customer_id) {
return Err(LedgerError::UnknownCustomer(customer_id));
@@ -1952,6 +2019,17 @@ impl Ledger {
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<BillId, LedgerError> {
self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines))
}
/// The steps of [`Self::create_bill`], run inside [`Self::atomically`].
fn create_bill_steps(
&mut self,
vendor_id: VendorId,
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<BillId, LedgerError> {
if !self.vendors.contains_key(&vendor_id) {
return Err(LedgerError::UnknownVendor(vendor_id));
@@ -2021,6 +2099,17 @@ impl Ledger {
date: NaiveDate,
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, LedgerError> {
self.atomically(|l| l.receive_payment_steps(invoice_id, date, amount, cash_account))
}
/// The steps of [`Self::receive_payment`], run inside [`Self::atomically`].
fn receive_payment_steps(
&mut self,
invoice_id: InvoiceId,
date: NaiveDate,
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, LedgerError> {
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPaymentAmount(amount));
@@ -2084,6 +2173,17 @@ impl Ledger {
date: NaiveDate,
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, LedgerError> {
self.atomically(|l| l.pay_bill_steps(bill_id, date, amount, cash_account))
}
/// The steps of [`Self::pay_bill`], run inside [`Self::atomically`].
fn pay_bill_steps(
&mut self,
bill_id: BillId,
date: NaiveDate,
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, LedgerError> {
if amount <= Decimal::ZERO {
return Err(LedgerError::InvalidPaymentAmount(amount));
@@ -2589,6 +2689,16 @@ impl Ledger {
date: NaiveDate,
lines: Vec<PayrollLine>,
cash_account: AccountId,
) -> Result<PayrollRunId, LedgerError> {
self.atomically(|l| l.run_payroll_steps(date, lines, cash_account))
}
/// The steps of [`Self::run_payroll`], run inside [`Self::atomically`].
fn run_payroll_steps(
&mut self,
date: NaiveDate,
lines: Vec<PayrollLine>,
cash_account: AccountId,
) -> Result<PayrollRunId, LedgerError> {
self.validate_payroll(date, &lines, cash_account)?;
let accounts = self.payroll_accounts();
@@ -2649,6 +2759,15 @@ impl Ledger {
&mut self,
date: NaiveDate,
cash_account: AccountId,
) -> Result<EntryId, LedgerError> {
self.atomically(|l| l.remit_payroll_taxes_steps(date, cash_account))
}
/// The steps of [`Self::remit_payroll_taxes`], run inside [`Self::atomically`].
fn remit_payroll_taxes_steps(
&mut self,
date: NaiveDate,
cash_account: AccountId,
) -> Result<EntryId, LedgerError> {
self.chart
.get(cash_account)
@@ -2808,7 +2927,7 @@ impl Ledger {
/// 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> {
pub(crate) fn check_minor_units(amount: Money) -> Result<(), LedgerError> {
match amount.currency.minor_units() {
Some(places) if amount.amount.normalize().scale() > places => {
Err(LedgerError::FinerThanMinorUnit {
+16
View File
@@ -112,6 +112,22 @@ pub fn amortization_plan(
/// Validate a new schedule against the chart.
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
validate_schedule_shape(new, chart)?;
// Every month's posting derives from the total: one no line could
// carry, or finer than a cent, is refused here, not at a month end
// (found by the store's refusal property, 2026-10-13).
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
return Err(LedgerError::AmountOutOfRange);
}
crate::ledger::check_minor_units(new.total)
}
/// [`validate_schedule`] without the total's range and minor-unit checks
/// added on 2026-10-13, for replaying journals written before them.
pub fn validate_schedule_shape(
new: &NewSchedule,
chart: &ChartOfAccounts,
) -> Result<(), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
if new.description.trim().is_empty() {
return bad("description is required".into());