Store: say "newer wanderbooks", not "corrupt", for books from a newer version
An unknown event type on a whole, chained line, or a journal.format_set
declaring a newer journal format, now raises StoreError::NewerJournal
(update wanderbooks) instead of JournalCorrupt. Corruption (bad hash,
seq, or JSON) is still corruption. The gate runs in the store, the
Snapshot and the GUI, after each line's parse, seq and hash checks, and
never skips a line it can't read. Adds the journal.format_set event
(format 1 today; nothing writes it yet) and a registry of every event
type this version reads, tested against the replay and projection arms.
Review fixes (P0 review, 2026-10-16):
- A line's own hash is checked only by the next line's prev, and the
gate used to refuse a line before reading the next: a flipped bit in a
middle line's type (or a format_set edited from 1 to 2) was reported
as "newer, the books are not damaged". Now the reader settles every
refusal against the next line (format::settle): a next line that
doesn't chain makes it corrupt; one that chains makes it newer and
verified; with no next line the message says "newer or damaged; update,
then run verify", never "not damaged" (NewerJournal.verified).
- A newer format number is judged newer before its features are parsed:
a newer format may shape them differently.
- Snapshot treats a blank complete line as corrupt, as the writer does.
- docs/spec/rules.md B-27, which the code cites, now exists.
Round-2 review fixes (2026-10-16):
- The envelope {seq, ts, type, payload, prev} and its hash (SHA-256 of
the canonical JSON) are frozen across formats (rules.md B-27); new
fields go in payload. settle reads only the next line's prev, leniently,
so a newer format's next line (a float, a string seq, keys of its own)
is never called corrupt: no readable prev means unverified newer.
- A key beside the envelope's was silently dropped (and the chain then
broke as "corrupt"): it is now NewerJournal, hashed over the raw
canonical JSON so the next line still checks it; JournalLine::parse
refuses such a line.
- "the books are not damaged" overclaimed: a verified refusal says line N
is intact and this version can't check the lines after it.
- The writer read the journal with BufRead::lines (invalid UTF-8 was an
I/O error); it now splits bytes on newlines as the Snapshot does, so a
non-UTF-8 line is JournalCorrupt in both.
- Store::open truncated a torn tail before the gate ran, altering newer
books (perhaps mid-append) before refusing them. The gate now runs over
the complete lines first; the tail is set aside (and journaled) only
when they pass.
- Mutation testing (docs/testing.md, P0): the viewer and the writer say
the same words about the same damaged line (the newline is no part of
it); read_journal_and_format returns every line and the format in
force; the journal page tells a format declaration.
Plan: plan/served.md P0 and 5.1.
Review round 3 (S6, S7). Store::open replays every complete line in
memory (the first line's currency, then the ledger) before a cut-off
last line is set aside, so a complete line the books can't apply
refuses the open with the journal byte for byte as it was and nothing
set aside (B-27 SCENARIO 3; test
a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was);
catch_up now only projects. The viewer
applies the writer's first-line rule (created_currency, shared) instead
of guessing USD: any other first line, or a store.created without a
sound base_currency, is corrupt to both (test
the_first_line_is_store_created_for_the_viewer_and_the_writer). fuzz_store_replay varies the
first line (14,696 runs clean).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
4f8047689f
commit
9f9f00ed9a
16 files changed
+2029
-77
No files matched your search
@@ -16,7 +16,8 @@
|
||||
|
||||
use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||
use crate::format::JournalFormat;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Reconciliation, Statement, Store, Subject,
|
||||
apply_line_to_sqlite, compare_projections, projection_rows, query_attachment_links,
|
||||
@@ -71,6 +72,8 @@ pub struct Snapshot {
|
||||
offset: u64,
|
||||
/// Hash of the last line read: what the next line must chain onto.
|
||||
head: String,
|
||||
/// The journal format in force (the format gate's record).
|
||||
format: JournalFormat,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
@@ -91,6 +94,7 @@ impl Snapshot {
|
||||
lines: Vec::new(),
|
||||
offset: 0,
|
||||
head: GENESIS_PREV.to_string(),
|
||||
format: JournalFormat::default(),
|
||||
};
|
||||
snap.read_new()?;
|
||||
Ok(snap)
|
||||
@@ -114,7 +118,16 @@ impl Snapshot {
|
||||
/// 1. IF nothing was appended: SHOULD return 0 and change nothing.
|
||||
/// 2. IF the file shrank or no longer chains (a restore, a repaired
|
||||
/// tail): SHOULD reload from scratch.
|
||||
/// 3. IF a complete new line fails its hash or parse: SHOULD refuse.
|
||||
/// 3. IF a complete new line fails its hash or parse (a blank line
|
||||
/// included, as the writer reads it): SHOULD refuse.
|
||||
/// 4. IF a complete new line is whole and chained but one this
|
||||
/// version can't read (an unknown type, a newer format, a key
|
||||
/// beside the envelope's): SHOULD refuse with NewerJournal
|
||||
/// (verified or not as format::settle finds it: a next line whose
|
||||
/// prev doesn't match makes it corrupt), applying
|
||||
/// none of the new lines (as the writer refuses the books whole),
|
||||
/// and refuse the same way on every refresh until wanderbooks is
|
||||
/// updated; what was read before stays.
|
||||
/// ```
|
||||
pub fn refresh(&mut self) -> Result<usize, StoreError> {
|
||||
let len = std::fs::metadata(self.data_dir.join(JOURNAL_FILE))?.len();
|
||||
@@ -145,40 +158,63 @@ impl Snapshot {
|
||||
return Ok(0);
|
||||
};
|
||||
let complete = &buf[..=last_newline];
|
||||
let mut added = 0;
|
||||
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
|
||||
let line: JournalLine = JournalLine::parse(raw).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!(
|
||||
"seq {}: unparseable line: {e}",
|
||||
self.lines.len() + 1
|
||||
))
|
||||
// First check every new line, as the writer does when it opens the
|
||||
// books: parse, seq, chain, then the format gate, stopping at the
|
||||
// first failure. A batch holding a line this version can't read is
|
||||
// refused whole, so the viewer and the writer give the same answer
|
||||
// for the same journal.
|
||||
let mut format = self.format.clone();
|
||||
let mut head = self.head.clone();
|
||||
let mut checked: Vec<(JournalLine, String, u64)> = Vec::new();
|
||||
let mut raws = complete
|
||||
.split_inclusive(|&b| b == b'\n')
|
||||
.map(|raw| (raw.len() as u64, &raw[..raw.len() - 1]))
|
||||
.peekable();
|
||||
while let Some((len, raw)) = raws.next() {
|
||||
let seq = self.lines.len() + checked.len() + 1;
|
||||
// A blank complete line is no line at all: corrupt, as the
|
||||
// writer finds it (it never writes one).
|
||||
let read = crate::journal::read_line(raw).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!("seq {seq}: unparseable line: {e}"))
|
||||
})?;
|
||||
let want_seq = self.lines.len() as u64 + 1;
|
||||
if line.seq != want_seq {
|
||||
let line = &read.line;
|
||||
if line.seq != seq as u64 {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"seq gap: expected {want_seq}, found {}",
|
||||
"seq gap: expected {seq}, found {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if line.prev != self.head {
|
||||
if line.prev != head {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"hash chain broken at seq {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if let Err(refused) = crate::format::gate(&read, &mut |l| format.admit(l)) {
|
||||
// Its own hash is checked by the next line's prev, when
|
||||
// there is one (format::settle).
|
||||
let next = raws.peek().map(|(_, raw)| *raw);
|
||||
return Err(crate::format::settle(refused, &read.hash, next));
|
||||
}
|
||||
head = read.hash;
|
||||
checked.push((read.line, head.clone(), len));
|
||||
}
|
||||
// Then apply them. The offset advances line by line, so if replay
|
||||
// refuses one, the snapshot holds exactly the lines before it.
|
||||
let added = checked.len();
|
||||
for (line, hash, len) in checked {
|
||||
if line.seq == 1 {
|
||||
let code = line.payload["base_currency"].as_str().unwrap_or("USD");
|
||||
self.base_currency = Currency::new(code)
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad base_currency {code}")))?;
|
||||
// The writer's rule: the first line is a sound store.created.
|
||||
self.base_currency = crate::store::created_currency(&line)?;
|
||||
self.ledger = Ledger::new(self.base_currency);
|
||||
}
|
||||
self.format.admit(&line)?;
|
||||
replay_line_into_ledger(&mut self.ledger, &line, self.base_currency)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
self.head = sha256_hex(line.to_canonical().as_bytes());
|
||||
self.head = hash;
|
||||
self.lines.push(line);
|
||||
added += 1;
|
||||
self.offset += len;
|
||||
}
|
||||
self.offset += complete.len() as u64;
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
@@ -192,6 +228,12 @@ impl Snapshot {
|
||||
self.base_currency
|
||||
}
|
||||
|
||||
/// The journal format in force: format 1 unless the books declared
|
||||
/// another with `journal.format_set`.
|
||||
pub fn journal_format(&self) -> &JournalFormat {
|
||||
&self.format
|
||||
}
|
||||
|
||||
/// The data directory.
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
|
||||
Reference in new issue
Block a user