blockworx/store/replay.rs
1//! Replaying `log.jsonl` into a [`Repo`], verifying D12 as it goes.
2//!
3//! Two passes, and the order is the point. The chain is verified first,
4//! over parsed records alone: a rewritten record is detected by its
5//! *successor's* `parent` link, so checking links before folding is what
6//! lets the second pass say something the first cannot — a state stamp
7//! that disagrees under an intact chain is not a tampered file, it is the
8//! app's fold having changed since the record was written.
9//!
10//! The stamps are [`Verify::Sampled`] at load and [`Verify::Full`] under
11//! `blockworx verify`, per D12's reserved escalation: recomputing every
12//! stamp re-serializes the whole document once per record, which cost
13//! 16.6 s of a cold open on a 301-record, 2500-block container
14//! (`TUNING.md`, Finding 7).
15//!
16//! The second pass also rebuilds the undo journal (F6). Each record's
17//! `kind` crosses into the headless spelling here and is handed to
18//! `Repo::replay_one`, which runs the live session's journalling policy —
19//! so the depth a document reopens with is the depth it closed with, and
20//! one fold per record still serves both the stamp check and the journal.
21//!
22//! The second pass is also where a record's artwork is read back out of
23//! the container ([`super::assets`]) — after the chain, which covers the
24//! record as the file holds it, and before the stamp, which then witnesses
25//! the payload independently. See [`replay`].
26//!
27//! Target-independent: the browser replays the same text, and reaches its
28//! payloads through the same [`AssetSource`].
29
30use blockworx_doc::{document::FoldError, repo::Repo, rev::Rev};
31
32use super::assets::{AssetFault, AssetSource};
33use super::notes::Notes;
34use super::record::{Digest, Entry, LogRecord};
35use super::tags::Tags;
36
37/// How many records one verified state stamp stands for under
38/// [`Verify::Sampled`]. Every record is still folded and every chain link
39/// still checked; what this spaces out is `Document::content_hash`, whose
40/// cost is the whole document rather than the record.
41pub const STAMP_SAMPLE: usize = 32;
42
43/// How much of D12 a replay checks. The chain is always checked in full —
44/// it is a hash over one line. The state stamps are what this chooses
45/// between, because each one costs a re-serialization of the entire
46/// document.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum Verify {
49 /// The head's stamp and one in every [`STAMP_SAMPLE`]. The head is
50 /// never skipped: it is the document the session opens on, and fold
51 /// drift that reaches the present reaches it.
52 Sampled,
53 /// Every stamp — the fsck, and what the editor's opens no longer do.
54 Full,
55}
56
57impl Verify {
58 fn checks_stamp(self, at: usize, head: usize) -> bool {
59 match self {
60 Verify::Full => true,
61 Verify::Sampled => at == head || at.is_multiple_of(STAMP_SAMPLE),
62 }
63 }
64}
65
66/// Where in the file something went wrong — enough to render a span-style
67/// report without re-reading it.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub struct Located {
70 /// 1-based.
71 pub line: usize,
72 /// 1-based, in bytes from the start of the line.
73 pub column: usize,
74 /// Byte offset of the line's first byte from the start of the file.
75 pub offset: usize,
76 /// The line's length in bytes, its newline excluded.
77 pub len: usize,
78}
79
80impl std::fmt::Display for Located {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 write!(f, "line {}, column {}", self.line, self.column)
83 }
84}
85
86/// What a record failed on.
87#[derive(Debug)]
88pub enum Fault {
89 Malformed(serde_json::Error),
90 /// D12: the record does not link to the one before it, so the file has
91 /// been rewritten, reordered, or spliced at this point.
92 Chained {
93 expected: Digest,
94 found: Digest,
95 },
96 /// The record claims a position the fold did not assign it.
97 Misnumbered {
98 expected: Rev,
99 found: Rev,
100 },
101 /// The fold refuses a record it once accepted.
102 Refused(FoldError),
103 /// The record names artwork in `assets/` that is not there, or is not
104 /// what its hash says. The chain still checks out — the log is the
105 /// log it always was; what is missing is beside it.
106 Artwork(AssetFault),
107 /// The chain is intact and the record folds, but to a different
108 /// document than the one that wrote it: this is a fold regression in
109 /// the *app*, not damage to the file.
110 Drift {
111 expected: Digest,
112 found: Digest,
113 blame: Blame,
114 },
115 /// D18: a tag record stamps the head it was appended under, and this
116 /// one does not.
117 Mistagged {
118 expected: Digest,
119 found: Digest,
120 },
121}
122
123/// Which record a stamp mismatch may be blamed on. Under
124/// [`Verify::Sampled`] the stamps between two samples were never
125/// recomputed, so the sample that fails is the first *evidence* of the
126/// drift rather than its origin, and the report says so.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum Blame {
129 /// Every stamp before this one verified: the drift is this record.
130 ThisRecord,
131 /// The drift is at or before this record, and after `verified` — the
132 /// last rev whose stamp was recomputed.
133 AtOrBefore { verified: Rev },
134}
135
136impl std::fmt::Display for Fault {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 Fault::Malformed(error) => write!(f, "the record does not parse: {error}"),
140 Fault::Chained { expected, found } => write!(
141 f,
142 "the record links to {found}, but the record before it hashes to {expected} — \
143 the log has been rewritten here",
144 ),
145 Fault::Misnumbered { expected, found } => {
146 write!(
147 f,
148 "the record claims rev {found}, but folds at {expected}",
149 found = found.get(),
150 expected = expected.get()
151 )
152 }
153 Fault::Refused(refusal) => write!(f, "the fold refuses the record: {refusal}"),
154 Fault::Artwork(fault) => write!(f, "{fault}"),
155 Fault::Drift {
156 expected,
157 found,
158 blame,
159 } => write!(
160 f,
161 "the record folds to {found} but was written at {expected}; the chain is intact, \
162 so this build's fold no longer reproduces what wrote the log{blame}",
163 ),
164 Fault::Mistagged { expected, found } => write!(
165 f,
166 "the tag stamps {expected}, but the log it was appended to stands at {found} — \
167 it was written against a history this is not",
168 ),
169 }
170 }
171}
172
173impl std::fmt::Display for Blame {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 match self {
176 Blame::ThisRecord => Ok(()),
177 Blame::AtOrBefore { verified } => write!(
178 f,
179 " — the drift is at or before this record, and after rev {verified}, whose stamp \
180 was the last one recomputed; `blockworx verify` checks every stamp and names it \
181 exactly",
182 verified = verified.get(),
183 ),
184 }
185 }
186}
187
188/// A fault and where it is. Kept apart from [`Broken`] so a container can
189/// hold on to the report as its read-only reason without also holding the
190/// repo it came with.
191#[derive(Debug)]
192pub struct BreakReport {
193 pub at: Located,
194 pub fault: Fault,
195}
196
197impl std::fmt::Display for BreakReport {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 write!(f, "{}: {}", self.at, self.fault)
200 }
201}
202
203/// A final record the writer never finished. Appends are one `write_all`
204/// of the line and its newline followed by an fsync, so a log that does
205/// not end in a newline ends in a write that was never made durable: the
206/// record is dropped and the file truncated back to the last whole line.
207#[derive(Clone, Copy, Debug)]
208pub struct DroppedTail {
209 pub at: Located,
210 /// Where the whole log ends.
211 pub good_bytes: u64,
212}
213
214/// Whether the log ended where it should have. Not a bool: a dropped tail
215/// is something the user is told about.
216#[derive(Clone, Copy, Debug)]
217pub enum Tail {
218 Whole,
219 Dropped(DroppedTail),
220}
221
222/// A log that verified end to end.
223pub struct Replayed {
224 pub repo: Repo,
225 /// The digest the next appended record must name as its parent.
226 pub head: Digest,
227 pub tail: Tail,
228 /// One per commit in `repo`'s log, in the same order — the audit
229 /// columns the fold itself has no use for.
230 pub entries: Vec<Entry>,
231 /// What the log's tag records name its revs (D18). Never in `repo`.
232 pub tags: Tags,
233 /// What the log's note records say, keyed by the rev each occupies
234 /// (D17). Unlike the tags, these *are* in `repo` — as the empty
235 /// commits they were written as.
236 pub notes: Notes,
237 /// How many records were read, tags included — which `entries` is not,
238 /// since a tag consumes no rev.
239 pub verified: usize,
240}
241
242/// A log that did not. The prefix that *did* verify is still here: a
243/// damaged log has a real past, and the reader is owed a look at it —
244/// which is also why it is returned boxed, since it carries a whole
245/// document and the success path should not widen to hold one.
246pub struct Broken {
247 pub repo: Repo,
248 pub head: Digest,
249 pub report: BreakReport,
250 /// As [`Replayed::entries`], for the verified prefix.
251 pub entries: Vec<Entry>,
252 /// As [`Replayed::tags`], for the verified prefix.
253 pub tags: Tags,
254 /// As [`Replayed::notes`], for the verified prefix.
255 pub notes: Notes,
256 /// As [`Replayed::verified`], for the verified prefix.
257 pub verified: usize,
258}
259
260/// Fold `log` record by record, checking every link and the stamps
261/// `depth` asks for. Artwork the records only reference is read back out
262/// of `assets`.
263///
264/// The three checks nest, and the nesting is what makes each one mean
265/// something. The chain covers the record *as the file holds it* — a
266/// hollowed asset op included — so it is settled by the first pass
267/// before a payload is read. Hydration then rebuilds the ops, verifying each
268/// payload against the hash the record addresses it by. The state stamp
269/// comes last, over the document that fold produced, and was written
270/// against a document folded from the same whole ops — so it is an
271/// independent second witness that the bytes beside the log are the bytes
272/// that wrote it.
273///
274/// # Errors
275/// [`Broken`], carrying the verified prefix as a repo, when an interior
276/// record does not parse, does not link, names artwork that is not there,
277/// does not fold, or does not reproduce the document it was written at.
278pub fn replay(log: &str, depth: Verify, assets: &dyn AssetSource) -> Result<Replayed, Box<Broken>> {
279 let scan = scan(log);
280 let last = scan.records.len().saturating_sub(1);
281 let mut repo = Repo::default();
282 let mut head = Digest::genesis();
283 let mut stamped = Rev::ZERO;
284
285 for (taken, verified) in scan.records.iter().enumerate() {
286 let record = &verified.record;
287 // D18: a tag is about history rather than part of it. It folds
288 // nothing — which is what exempts it from the sequence check — and
289 // claims only that the log stood where it says when it was
290 // appended, checked on the same schedule as its neighbours' stamps.
291 let Some(journal) = record.kind.journals_as() else {
292 if depth.checks_stamp(taken, last) {
293 let state: Digest = repo.document().content_hash().into();
294 if state != record.state {
295 let fault = Fault::Mistagged {
296 expected: record.state,
297 found: state,
298 };
299 return Err(broken(
300 &scan,
301 assets,
302 taken,
303 head,
304 report(verified.at, fault),
305 ));
306 }
307 }
308 head = verified.digest;
309 continue;
310 };
311 let commit = match record.commit(assets) {
312 Ok(commit) => commit,
313 Err(fault) => {
314 let report = report(verified.at, Fault::Artwork(fault));
315 return Err(broken(&scan, assets, taken, head, report));
316 }
317 };
318 let document = match repo.replay_one(commit, journal) {
319 Ok(document) => document,
320 Err(refusal) => {
321 let report = report(verified.at, Fault::Refused(refusal));
322 return Err(broken(&scan, assets, taken, head, report));
323 }
324 };
325 if document.rev() != record.rev {
326 let fault = Fault::Misnumbered {
327 expected: document.rev(),
328 found: record.rev,
329 };
330 return Err(broken(
331 &scan,
332 assets,
333 taken,
334 head,
335 report(verified.at, fault),
336 ));
337 }
338 if depth.checks_stamp(taken, last) {
339 let state: Digest = document.content_hash().into();
340 if state != record.state {
341 let fault = Fault::Drift {
342 expected: record.state,
343 found: state,
344 blame: blame(stamped, record.rev),
345 };
346 return Err(broken(
347 &scan,
348 assets,
349 taken,
350 head,
351 report(verified.at, fault),
352 ));
353 }
354 stamped = record.rev;
355 }
356 head = verified.digest;
357 }
358
359 let entries = entries(&scan.records);
360 let (tags, notes) = projections(&scan.records);
361 let verified = scan.records.len();
362 match scan.end {
363 End::Whole => Ok(Replayed {
364 repo,
365 head,
366 tail: Tail::Whole,
367 entries,
368 tags,
369 notes,
370 verified,
371 }),
372 End::Truncated(dropped) => Ok(Replayed {
373 repo,
374 head,
375 tail: Tail::Dropped(dropped),
376 entries,
377 tags,
378 notes,
379 verified,
380 }),
381 End::Broken(report) => Err(Box::new(Broken {
382 repo,
383 head,
384 report,
385 entries,
386 tags,
387 notes,
388 verified,
389 })),
390 }
391}
392
393/// The audit columns, one per *commit* — a tag consumes no rev, so it
394/// contributes no entry and the pairing with the repo's log holds.
395fn entries(verified: &[Verified]) -> Vec<Entry> {
396 verified
397 .iter()
398 .filter(|v| v.record.kind.journals_as().is_some())
399 .map(|v| Entry::from(&v.record))
400 .collect()
401}
402
403/// The two projections the fold does not keep: what the tag records name
404/// (D18) and what the note records say (D17). Built from the records
405/// rather than beside the fold, so a whole log and a verified prefix are
406/// projected by the same pass.
407fn projections(verified: &[Verified]) -> (Tags, Notes) {
408 let mut tags = Tags::default();
409 let mut notes = Notes::default();
410 for record in verified.iter().map(|v| &v.record) {
411 if record.kind.journals_as().is_none() {
412 tags.set(record.rev, &record.label);
413 }
414 notes.take(record);
415 }
416 (tags, notes)
417}
418
419fn blame(stamped: Rev, at: Rev) -> Blame {
420 if stamped.next() == at {
421 Blame::ThisRecord
422 } else {
423 Blame::AtOrBefore { verified: stamped }
424 }
425}
426
427/// The break at record `taken`, whose repo is the prefix *before* it —
428/// re-replayed, because the growing one has already taken the offending
429/// record (it folded legally; what it failed was its own stamp). Only the
430/// failure path pays for that, and the records being re-folded have each
431/// folded once already — which is also why the refusal arms cannot be
432/// reached and simply stop.
433fn broken(
434 scan: &Scan,
435 assets: &dyn AssetSource,
436 taken: usize,
437 head: Digest,
438 report: BreakReport,
439) -> Box<Broken> {
440 let mut repo = Repo::default();
441 for verified in &scan.records[..taken] {
442 let Some(journal) = verified.record.kind.journals_as() else {
443 continue;
444 };
445 let Ok(commit) = verified.record.commit(assets) else {
446 break;
447 };
448 if repo.replay_one(commit, journal).is_err() {
449 break;
450 }
451 }
452 let (tags, notes) = projections(&scan.records[..taken]);
453 Box::new(Broken {
454 repo,
455 head,
456 report,
457 entries: entries(&scan.records[..taken]),
458 tags,
459 notes,
460 verified: taken,
461 })
462}
463
464fn report(at: Located, fault: Fault) -> BreakReport {
465 BreakReport { at, fault }
466}
467
468struct Verified {
469 record: LogRecord,
470 at: Located,
471 digest: Digest,
472}
473
474/// How the record sequence stopped.
475enum End {
476 Whole,
477 Truncated(DroppedTail),
478 Broken(BreakReport),
479}
480
481struct Scan {
482 records: Vec<Verified>,
483 end: End,
484}
485
486/// Pass one: parse and link. Stops at the first record that does not
487/// belong, keeping everything before it.
488fn scan(log: &str) -> Scan {
489 let mut records: Vec<Verified> = Vec::new();
490 let mut parent = Digest::genesis();
491 let mut offset = 0;
492
493 for (number, chunk) in log.split_inclusive('\n').enumerate() {
494 let line = chunk.strip_suffix('\n');
495 let text = line.unwrap_or(chunk);
496 let at = Located {
497 line: number + 1,
498 column: 1,
499 offset,
500 len: text.len(),
501 };
502 if line.is_none() {
503 return Scan {
504 records,
505 end: End::Truncated(DroppedTail {
506 at,
507 good_bytes: offset as u64,
508 }),
509 };
510 }
511 offset += chunk.len();
512
513 let record: LogRecord = match serde_json::from_str(text) {
514 Ok(record) => record,
515 Err(error) => {
516 let at = Located {
517 column: error.column().max(1),
518 ..at
519 };
520 return Scan {
521 records,
522 end: End::Broken(BreakReport {
523 at,
524 fault: Fault::Malformed(error),
525 }),
526 };
527 }
528 };
529 if record.parent != parent {
530 let fault = Fault::Chained {
531 expected: parent,
532 found: record.parent,
533 };
534 return Scan {
535 records,
536 end: End::Broken(BreakReport { at, fault }),
537 };
538 }
539 parent = record.digest_as_written(text);
540 records.push(Verified {
541 record,
542 at,
543 digest: parent,
544 });
545 }
546
547 Scan {
548 records,
549 end: End::Whole,
550 }
551}