store: refusal property reaches every success path; two engine fixes
check / check (push) Canceled after 0s

The refusal property now starts from real books and adds compound
commands with valid arguments (book an entry, register an asset or
schedule, raise an invoice or bill, close a month to members), so every
command's success path is exercised. Compound commands checkpoint after
each step, so the law is checked against the one command refused.
STORE_CASES sets the case count (256 by default).

It found two bugs:

- run_recurring created drafts dated inside a closed period (B-12).
  generate_recurring now refuses them, atomically; the store leaves
  them ungenerated and books.run_recurring reports them in
  skipped_closed_period for a human to decide. Replay uses
  replay_generate_recurring so older journals still open. The store
  also journals before mutating the ledger here, as everywhere else.
- Disposing an asset for no proceeds (scrapping it) journaled a
  catch-up depreciation entry and a half-built draft with a 0.00 line,
  then refused. Zero lines are now left out (Store::disposal_lines,
  shared with the MCP preview), and the whole disposal is rehearsed on
  a clone before anything is written.

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 17:48:20 -06:00
1 parent 1212367138
commit 22d1a3d449
6 files changed
+633 -91

No files matched your search

+46 -1
View File
@@ -390,10 +390,39 @@ impl Ledger {
/// Generate one occurrence of a recurring entry as a *draft* (never
/// posted: the agent attaches the receipt and posts it). `date` must be
/// an occurrence not yet generated. Returns the draft's id.
///
/// ```text
/// GOAL: turn one due occurrence of a recurring entry into a draft.
/// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen.
/// SCENARIOS:
/// 1. IF the date falls in a closed period:
/// - SHOULD refuse (ClosedPeriod)
/// - NEVER create a draft there
/// BECAUSE a closed period takes no new entries, however they
/// are made (found by the store's refusal property, 2026-10-13).
/// 2. IF the date is not an ungenerated occurrence: SHOULD refuse.
/// 3. IF a line is refused: SHOULD leave the ledger as it was (no
/// half-built draft, no id spent).
/// 4. Otherwise: SHOULD create a draft with the template's lines and
/// mark the occurrence generated.
/// ```
pub fn generate_recurring(
&mut self,
id: RecurringId,
date: NaiveDate,
) -> Result<EntryId, LedgerError> {
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
self.replay_generate_recurring(id, date)
}
/// [`Self::generate_recurring`] without the closed-period check, for
/// replaying journals written before that check existed.
pub fn replay_generate_recurring(
&mut self,
id: RecurringId,
date: NaiveDate,
) -> Result<EntryId, LedgerError> {
let r = self
.recurring
@@ -407,7 +436,11 @@ impl Ledger {
let (description, lines) = (r.description.clone(), r.lines.clone());
let entry = self.create_entry(date, &description);
for (account, side, money) in lines {
self.add_line(entry, account, side, money)?;
if let Err(e) = self.add_line(entry, account, side, money) {
self.entries.remove(&entry);
self.next_entry_id = entry;
return Err(e);
}
}
self.recurring
.get_mut(&id)
@@ -417,6 +450,18 @@ impl Ledger {
Ok(entry)
}
/// Occurrences due through `through` that can't be generated because
/// their date falls in a closed period, as (recurring entry, date).
/// They need a human decision (book them in an open period by hand,
/// or not at all).
pub fn recurring_in_closed_periods(&self, through: NaiveDate) -> Vec<(RecurringId, NaiveDate)> {
self.recurring
.values()
.flat_map(|r| r.due(through).into_iter().map(move |d| (r.id, d)))
.filter(|(_, d)| self.closed_period_containing(*d).is_some())
.collect()
}
/// The id the next account added will receive.
///
/// Exposed for journaling stores that record the id before mutating.