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:
1 parent
27da4b63bf
commit
111a5af470
11 files changed
+2075
-75
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user