store: refusals leave the live books untouched; per-filing names; spec suite

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 27da4b63bf
commit 111a5af470
11 files changed
+2075 -75

No files matched your search

-39
View File
@@ -290,42 +290,3 @@ pub fn repair_torn_tail(path: &Path) -> Result<Option<TornTail>, StoreError> {
saved_as,
}))
}
/// Truncate a journal file to its last good line. For recovering *copies*
/// with a torn tail (e.g. a backup taken mid-write): the projection never
/// saw the torn line, so dropping it is safe. Returns the last good seq.
/// The live journal's torn tail is handled on open by [`repair_torn_tail`].
pub fn truncate_to_last_good_seq(path: &Path) -> Result<u64, StoreError> {
let raw = std::fs::read(path)?;
let mut good_len = 0usize;
let mut last_good_seq = 0u64;
let mut expected_prev = GENESIS_PREV.to_string();
// Walk raw bytes: a line is bytes up to and including b'\n'. Trailing
// bytes without a newline are a torn tail and are dropped.
let mut start = 0usize;
for (i, &b) in raw.iter().enumerate() {
if b != b'\n' {
continue;
}
let line_bytes = &raw[start..i];
start = i + 1;
let line_str = std::str::from_utf8(line_bytes).map_err(|_| {
StoreError::JournalCorrupt(format!("seq {}: invalid utf-8", last_good_seq + 1))
})?;
if line_str.trim().is_empty() {
break; // blank line: our writer never emits these
}
match JournalLine::parse(line_str.as_bytes()) {
Ok(line) if line.seq == last_good_seq + 1 && line.prev == expected_prev => {
expected_prev = sha256_hex(line.to_canonical().as_bytes());
last_good_seq = line.seq;
good_len = i + 1; // include the newline
}
_ => break,
}
}
let file = OpenOptions::new().write(true).open(path)?;
file.set_len(good_len as u64)?;
file.sync_all()?;
Ok(last_good_seq)
}
+13 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 7;
pub const SCHEMA_VERSION: u32 = 8;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -195,5 +195,17 @@ CREATE TABLE IF NOT EXISTS entry_vendors(
entry_id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL
);
"#,
r#"
-- v8: each filing keeps its own file name and type. Before, they came from
-- the blob, so filing the same bytes again under another name listed the
-- first name (found by the spec-blind store suite, 2026-10-13). Existing
-- rows take the blob's; a database whose filings differed then fails
-- verify's table check and is rebuilt from the journal.
ALTER TABLE attachments ADD COLUMN file_name TEXT;
ALTER TABLE attachments ADD COLUMN mime_type TEXT;
UPDATE attachments SET
file_name = (SELECT original_name FROM blobs WHERE sha256 = attachments.blob_sha),
mime_type = (SELECT mime_type FROM blobs WHERE sha256 = attachments.blob_sha);
"#,
];
+38 -25
View File
@@ -602,7 +602,8 @@ impl Store {
side: Side,
amount: Money,
) -> Result<(), StoreError> {
self.ledger.add_line(entry, account, side, amount)?;
let mut next = self.ledger.clone();
next.add_line(entry, account, side, amount)?;
let line = self.append_journal(
"entry.line_added",
json!({
@@ -616,6 +617,7 @@ impl Store {
"currency": amount.currency.code(),
}),
)?;
self.ledger = next;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
@@ -1128,20 +1130,20 @@ impl Store {
id: PeriodId,
to: &CloseTo,
) -> Result<Option<EntryId>, StoreError> {
// Pure pre-validation: a doomed close journals nothing, not even
// the Retained Earnings account creation below.
self.ledger.preview_close_period_to(id, to)?;
// Rehearsed whole: a doomed close journals nothing, not even the
// Retained Earnings account creation below.
self.rehearse(|l| l.close_period_to(id, to))?;
if matches!(to, CloseTo::RetainedEarnings { .. }) {
// Retained Earnings must exist before the close so its creation
// (if needed) is journaled as account.added; the close's own
// ensure then no-ops and replay stays deterministic.
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
}
let closing_entry = self.ledger.close_period_to(id, to)?;
let mut next = self.ledger.clone();
let closing_entry = next.close_period_to(id, to)?;
let entry_detail = match closing_entry {
Some(eid) => {
let e = self
.ledger
let e = next
.get_entry(eid)
.expect("close just posted the closing entry");
Some(json!({
@@ -1190,6 +1192,7 @@ impl Store {
"entry": entry_detail,
}),
)?;
self.ledger = next;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(closing_entry)
}
@@ -1206,7 +1209,8 @@ impl Store {
line_index: usize,
cleared: bool,
) -> Result<(), StoreError> {
self.ledger.mark_cleared(entry_id, line_index, cleared)?;
let mut next = self.ledger.clone();
next.mark_cleared(entry_id, line_index, cleared)?;
let line = self.append_journal(
"line.cleared",
json!({
@@ -1215,6 +1219,7 @@ impl Store {
"cleared": cleared,
}),
)?;
self.ledger = next;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
@@ -1587,7 +1592,8 @@ impl Store {
date: NaiveDate,
rate: Decimal,
) -> Result<(), StoreError> {
self.ledger.set_fx_rate(currency, date, rate)?;
let mut next = self.ledger.clone();
next.set_fx_rate(currency, date, rate)?;
let line = self.append_journal(
"fx.rate_set",
json!({
@@ -1596,6 +1602,7 @@ impl Store {
"rate": rate.to_string(),
}),
)?;
self.ledger = next;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
@@ -1613,7 +1620,8 @@ impl Store {
foreign_amount: Decimal,
fx_rate: Decimal,
) -> Result<(), StoreError> {
self.ledger.add_line_fx(
let mut next = self.ledger.clone();
next.add_line_fx(
entry_id,
account,
side,
@@ -1623,8 +1631,7 @@ impl Store {
)?;
// Read back the stored base amount rather than recomputing it: the
// journal must carry exactly what the ledger holds.
let stored = self
.ledger
let stored = next
.get_entry(entry_id)
.expect("entry exists after add_line_fx")
.lines()
@@ -1647,6 +1654,7 @@ impl Store {
"fx_rate": fx_rate.to_string(),
}),
)?;
self.ledger = next;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
@@ -2032,20 +2040,21 @@ impl Store {
id: ledger_core::ScheduleId,
month_end: NaiveDate,
) -> Result<EntryId, StoreError> {
// Pure pre-validation, so the mutation below cannot fail.
// On a copy first: the posting can still be refused after the
// preview (an amount out of range, found by the refusal property,
// 2026-10-13), and a refusal must leave the live books untouched.
let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?;
if let Some(p) = self.ledger.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(p, date.to_string()).into());
}
let entry = self.ledger.post_schedule_month(id, month_end)?;
let mut next = self.ledger.clone();
let entry = next.post_schedule_month(id, month_end)?;
let line = self.append_journal(
"schedule.posted",
json!({
"schedule_id": id,
"month": date.to_string(),
"entry": entry_detail_json(&self.ledger, entry),
"entry": entry_detail_json(&next, entry),
}),
)?;
self.ledger = next;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(entry)
}
@@ -2302,8 +2311,8 @@ impl Store {
Ok(entry_id)
}
/// Withdraw a receipt or document, recording why: it drops out of
/// listings and can never be linked again. Nothing is deleted: the bytes
/// Withdraw a receipt or document, recording why: it is marked withdrawn
/// (active listings omit it) and can never be linked again. Nothing is deleted: the bytes
/// stay in the blob store and the journal keeps the filing and the
/// withdrawal. A linked receipt must be unlinked first.
///
@@ -2315,8 +2324,9 @@ impl Store {
/// 1. IF the attachment is unknown or already withdrawn: SHOULD refuse.
/// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked).
/// 3. IF the reason is blank: SHOULD refuse.
/// 4. Otherwise: SHOULD journal `attachment.withdrawn`; listings omit
/// it; its bytes still verify and read.
/// 4. Otherwise: SHOULD journal `attachment.withdrawn`; the store's
/// listing marks it withdrawn with the reason (the MCP listing
/// hides it unless asked); its bytes still verify and read.
/// ```
pub fn withdraw_attachment(
&mut self,
@@ -3252,7 +3262,7 @@ pub(crate) fn replay_line_into_ledger(
}
"schedule.registered" => {
let want = get_u64(p, "id")?;
let got = ledger.register_schedule(ledger_core::NewSchedule {
let got = ledger.replay_register_schedule(ledger_core::NewSchedule {
description: get_str(p, "description")?.to_string(),
kind: serde_json::from_value(p["kind"].clone())?,
total: parse_money(p, "total")?,
@@ -3840,7 +3850,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
conn.execute(
"INSERT INTO attachments(id, blob_sha, entry_id, category, description, created_at) VALUES (?,?,?,?,?,?)",
"INSERT INTO attachments(id, blob_sha, entry_id, category, description, created_at, file_name, mime_type) VALUES (?,?,?,?,?,?,?,?)",
params![
get_u64(p, "attachment_id")? as i64,
get_str(p, "blob_sha")?,
@@ -3848,6 +3858,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
get_str(p, "category")?,
p.get("description").and_then(|v| v.as_str()),
line.ts,
get_str(p, "file_name")?,
get_str(p, "mime_type")?,
],
)?;
}
@@ -4252,7 +4264,8 @@ pub(crate) fn query_attachments(
entry_id: Option<EntryId>,
) -> Result<Vec<Attachment>, StoreError> {
let mut sql = String::from(
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
"SELECT a.id, a.blob_sha, a.entry_id, a.category, \
COALESCE(a.file_name, b.original_name), COALESCE(a.mime_type, b.mime_type), \
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
FROM attachments a \
JOIN blobs b ON b.sha256 = a.blob_sha",
File diff suppressed because it is too large. Load diff
+12
View File
@@ -1351,6 +1351,8 @@ mod refusals {
}
let tb = ledger_core::reports::trial_balance(l);
prop_assert_eq!(tb.total_debits, tb.total_credits);
prop_assert_eq!(store.list_classes().len(), l.list_classes().len());
prop_assert_eq!(store.list_payroll_runs().len(), l.list_payroll_runs().len());
for id in 1..l.next_entry_id() {
if let Some(e) = l.get_entry(id)
&& e.status() == ledger_core::EntryStatus::Draft
@@ -1432,6 +1434,16 @@ mod refusals {
Err(e) => eprintln!("REFUSED {kind} {e}"),
}
}
// The converse law: an accepted command is on the record. Only
// a batch run may find nothing to do.
if result.is_ok() && !matches!(op, Op::RunDepreciation(..) | Op::RunRecurring(..)) {
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(
after.len() > before.len(),
"accepted {:?} but journaled nothing",
op
);
}
if let Err(e) = result {
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(