Skip to main content

nautilus_event_store/verifier/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Off-trader verifier that proves a run file's integrity before the trader opens it.
17//!
18//! See `README.md` "Storage backend" and "Determinism contract" sections for the SPEC
19//! posture: redb 4.x does not framewise-checksum data pages, so a `zero-tail` corruption
20//! opens cleanly and panics on first read. The verifier therefore exercises every entry
21//! and every stored index pair, accumulating findings so a single run produces one
22//! actionable report rather than failing fast on the first hit. The supervisor runs the
23//! verifier in an isolated process so a bad file aborts the verifier, not trading.
24//!
25//! Scope:
26//!
27//! - Walk every `seq` over `[1, high_watermark]` and recompute [`crate::EntryHash`].
28//! - Detect gaps in the seq sequence (the SPEC's gap-detection idempotency primitive).
29//! - Validate that every `client_order_id` and `venue_order_id` stored target seq still
30//!   resolves to a clean entry; full payload-derived rebuild is deferred until the
31//!   wrapper-type encoders land.
32//! - Validate manifest invariants: `high_watermark` matches the durable last seq, the
33//!   recorded `start_ts_init` and `end_ts_init` bracket the entry stream, and a sealed
34//!   manifest's status is a terminal state.
35//!
36//! The library API stays narrow: a single [`Verifier`] type that owns a backend, a
37//! [`VerifyReport`] structured for downstream operator tooling, and a [`VerifyError`]
38//! reserved for failures that prevent the verifier from producing any report at all.
39
40use std::{collections::BTreeSet, fmt::Debug, path::Path};
41
42use crate::{
43    backend::{EventStore, IndexKind, RedbBackend},
44    entry::EventStoreEntry,
45    error::EventStoreError,
46    manifest::{RunId, RunManifest, RunStatus},
47};
48
49/// Verifier over a single open run.
50///
51/// Constructed either by passing an already-open backend ([`Verifier::new`]) or by
52/// opening a sealed redb file directly ([`Verifier::open_redb`] or
53/// [`Verifier::open_redb_file`]). The verifier never mutates the backend; it walks the
54/// entry table and the secondary indices, then emits a typed [`VerifyReport`].
55pub struct Verifier {
56    backend: Box<dyn EventStore>,
57}
58
59impl Debug for Verifier {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct(stringify!(Verifier)).finish_non_exhaustive()
62    }
63}
64
65impl Verifier {
66    /// Wraps an already-open backend for read-only verification.
67    #[must_use]
68    pub fn new(backend: Box<dyn EventStore>) -> Self {
69        Self { backend }
70    }
71
72    /// Opens a sealed redb run file at `<base_dir>/<instance_id>/<run_id>.redb` and
73    /// wraps it for verification.
74    ///
75    /// Mirrors [`crate::backend::RedbBackend::open_sealed`]: only sealed files are
76    /// accepted, since opening a still-`Running` file would race with a live writer
77    /// and break the off-trader-process posture.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`VerifyError::Backend`] when the underlying backend rejects the open
82    /// (file missing, run still `Running`, header corruption).
83    pub fn open_redb(
84        base_dir: impl AsRef<Path>,
85        instance_id: &str,
86        run_id: &str,
87    ) -> Result<Self, VerifyError> {
88        let backend =
89            RedbBackend::open_sealed(base_dir.as_ref().to_path_buf(), instance_id, run_id)?;
90        Ok(Self {
91            backend: Box::new(backend),
92        })
93    }
94
95    /// Opens a sealed redb run file directly by path and wraps it for verification.
96    ///
97    /// The backend uses a read-only database handle for this path. The verifier
98    /// reports findings, but it never seals or quarantines the file; a supervisor or
99    /// operator process decides that policy from the returned [`VerifyReport`].
100    ///
101    /// # Errors
102    ///
103    /// Returns [`VerifyError::Backend`] when the underlying backend rejects the open
104    /// (file missing, run still `Running`, header corruption).
105    pub fn open_redb_file(path: impl AsRef<Path>) -> Result<Self, VerifyError> {
106        let backend = RedbBackend::open_sealed_file(path.as_ref().to_path_buf())?;
107        Ok(Self {
108            backend: Box::new(backend),
109        })
110    }
111
112    /// Returns a reference to the wrapped backend.
113    #[must_use]
114    pub fn backend(&self) -> &dyn EventStore {
115        self.backend.as_ref()
116    }
117
118    /// Performs a full integrity scan of the open run and returns the typed report.
119    ///
120    /// `verify` reads the manifest, walks every `seq` in `[1, high_watermark]`,
121    /// cross-checks the stored client- and venue-order-id indices, and validates manifest
122    /// invariants. Hash mismatches, gaps, index drift, and manifest mismatches surface as
123    /// [`VerifyFinding`]s on the returned report; only failures that prevent the verifier
124    /// from producing a report at all surface as [`VerifyError`].
125    ///
126    /// # Errors
127    ///
128    /// Returns [`VerifyError::Backend`] when the backend refuses a read-side
129    /// operation (no run open, disk pressure, manifest decode failure).
130    pub fn verify(&self) -> Result<VerifyReport, VerifyError> {
131        let manifest = self.backend.manifest()?;
132        let high_watermark = self.backend.high_watermark()?;
133
134        let mut findings = Vec::new();
135        let scan = self.scan_entries(high_watermark, &mut findings)?;
136
137        self.cross_check_indices(&scan, &mut findings)?;
138        check_snapshot_anchor(self.backend.as_ref(), high_watermark, &mut findings)?;
139        validate_manifest(&manifest, high_watermark, &scan, &mut findings);
140
141        Ok(VerifyReport {
142            run_id: manifest.run_id.clone(),
143            status: manifest.status,
144            high_watermark,
145            entries_scanned: scan.scanned,
146            findings,
147        })
148    }
149
150    fn scan_entries(
151        &self,
152        high_watermark: u64,
153        findings: &mut Vec<VerifyFinding>,
154    ) -> Result<EntryScan, VerifyError> {
155        let mut scanned: u64 = 0;
156        let mut min_ts: Option<u64> = None;
157        let mut max_ts: Option<u64> = None;
158        let mut clean_seqs: BTreeSet<u64> = BTreeSet::new();
159        let mut corrupted_seqs: BTreeSet<u64> = BTreeSet::new();
160        let mut gap_cursor: Option<u64> = None;
161
162        for seq in 1..=high_watermark {
163            match self.backend.scan_seq(seq) {
164                Ok(Some(entry)) => {
165                    flush_pending_gap(seq, &mut gap_cursor, findings);
166
167                    // The recomputed hash check inside scan_seq covers the entry
168                    // contents, but the entry's embedded seq is one of those
169                    // contents: a row whose value is moved or duplicated under a
170                    // different table key still hashes correctly. Cross-check the
171                    // table key against the embedded seq so the verifier catches
172                    // that class of corruption rather than reporting a clean run.
173                    if entry.seq != seq {
174                        findings.push(VerifyFinding::SeqMismatch {
175                            table_key: seq,
176                            embedded_seq: entry.seq,
177                        });
178                        corrupted_seqs.insert(seq);
179                        scanned += 1;
180                        continue;
181                    }
182                    record_entry(&entry, &mut min_ts, &mut max_ts);
183                    clean_seqs.insert(seq);
184                    scanned += 1;
185                }
186                Ok(None) | Err(EventStoreError::Gap { .. }) => {
187                    extend_pending_gap(seq, &mut gap_cursor);
188                }
189                Err(scan_err) => {
190                    let finding = match scan_err {
191                        EventStoreError::HashMismatch { seq: bad } => {
192                            VerifyFinding::HashMismatch { seq: bad }
193                        }
194                        EventStoreError::SeqMismatch {
195                            table_key,
196                            embedded_seq,
197                        } => VerifyFinding::SeqMismatch {
198                            table_key,
199                            embedded_seq,
200                        },
201                        EventStoreError::Corrupted(reason) => {
202                            VerifyFinding::Undecodable { seq, reason }
203                        }
204                        other => return Err(VerifyError::Backend(other)),
205                    };
206                    flush_pending_gap(seq, &mut gap_cursor, findings);
207                    findings.push(finding);
208                    corrupted_seqs.insert(seq);
209                    scanned += 1;
210                }
211            }
212        }
213
214        flush_pending_gap(high_watermark + 1, &mut gap_cursor, findings);
215
216        Ok(EntryScan {
217            scanned,
218            min_ts,
219            max_ts,
220            clean_seqs,
221            corrupted_seqs,
222        })
223    }
224
225    fn cross_check_indices(
226        &self,
227        scan: &EntryScan,
228        findings: &mut Vec<VerifyFinding>,
229    ) -> Result<(), VerifyError> {
230        for kind in [IndexKind::ClientOrderId, IndexKind::VenueOrderId] {
231            for (key, stored_seq) in self.backend.iter_index_keys(kind)? {
232                let drift = classify_target(stored_seq, scan);
233                if let Some(drift) = drift {
234                    findings.push(VerifyFinding::IndexDrift { kind, key, drift });
235                }
236            }
237        }
238
239        Ok(())
240    }
241}
242
243#[derive(Debug)]
244struct EntryScan {
245    scanned: u64,
246    min_ts: Option<u64>,
247    max_ts: Option<u64>,
248    clean_seqs: BTreeSet<u64>,
249    corrupted_seqs: BTreeSet<u64>,
250}
251
252fn record_entry(entry: &EventStoreEntry, min_ts: &mut Option<u64>, max_ts: &mut Option<u64>) {
253    let ts = entry.ts_init.as_u64();
254    *min_ts = Some(min_ts.map_or(ts, |cur| cur.min(ts)));
255    *max_ts = Some(max_ts.map_or(ts, |cur| cur.max(ts)));
256}
257
258fn extend_pending_gap(seq: u64, gap_cursor: &mut Option<u64>) {
259    if gap_cursor.is_none() {
260        *gap_cursor = Some(seq);
261    }
262}
263
264fn flush_pending_gap(
265    next_seq: u64,
266    gap_cursor: &mut Option<u64>,
267    findings: &mut Vec<VerifyFinding>,
268) {
269    if let Some(start) = gap_cursor.take() {
270        findings.push(VerifyFinding::Gap {
271            range: GapRange {
272                from: start,
273                to: next_seq - 1,
274            },
275        });
276    }
277}
278
279fn classify_target(stored_seq: u64, scan: &EntryScan) -> Option<IndexDrift> {
280    if scan.clean_seqs.contains(&stored_seq) {
281        None
282    } else if scan.corrupted_seqs.contains(&stored_seq) {
283        Some(IndexDrift::TargetCorrupted { stored_seq })
284    } else {
285        Some(IndexDrift::DanglingTarget { stored_seq })
286    }
287}
288
289// The restore path reads the snapshot anchor before tail replay, so an anchor that
290// fails to decode or points past the durable watermark must not verify clean. Other
291// read failures (disk pressure, storage errors) propagate: suppressing them would
292// pass a run whose restore would fail reading the same anchor.
293fn check_snapshot_anchor(
294    backend: &dyn EventStore,
295    high_watermark: u64,
296    findings: &mut Vec<VerifyFinding>,
297) -> Result<(), VerifyError> {
298    match backend.latest_snapshot_anchor() {
299        Ok(Some(anchor)) if anchor.high_watermark > high_watermark => {
300            findings.push(VerifyFinding::SnapshotAnchorInvalid {
301                reason: format!(
302                    "snapshot anchor high_watermark {} exceeds durable high_watermark {high_watermark}",
303                    anchor.high_watermark,
304                ),
305            });
306        }
307        Ok(_) => {}
308        Err(EventStoreError::Corrupted(msg)) => {
309            findings.push(VerifyFinding::SnapshotAnchorInvalid {
310                reason: format!("snapshot anchor unreadable: {msg}"),
311            });
312        }
313        Err(other) => return Err(VerifyError::Backend(other)),
314    }
315    Ok(())
316}
317
318fn validate_manifest(
319    manifest: &RunManifest,
320    high_watermark: u64,
321    scan: &EntryScan,
322    findings: &mut Vec<VerifyFinding>,
323) {
324    if manifest.high_watermark != high_watermark {
325        findings.push(VerifyFinding::ManifestMismatch {
326            kind: ManifestField::HighWatermark,
327            reason: format!(
328                "manifest high_watermark {} disagrees with durable high_watermark {high_watermark}",
329                manifest.high_watermark,
330            ),
331        });
332    }
333
334    if let Some(min_ts) = scan.min_ts
335        && manifest.start_ts_init.as_u64() > min_ts
336    {
337        findings.push(VerifyFinding::ManifestMismatch {
338            kind: ManifestField::StartTsInit,
339            reason: format!(
340                "manifest start_ts_init {} sits above earliest entry ts_init {min_ts}",
341                manifest.start_ts_init.as_u64(),
342            ),
343        });
344    }
345
346    if manifest.is_sealed() {
347        match (manifest.end_ts_init.map(|t| t.as_u64()), scan.max_ts) {
348            (Some(stored), Some(observed)) if stored != observed => {
349                findings.push(VerifyFinding::ManifestMismatch {
350                    kind: ManifestField::EndTsInit,
351                    reason: format!(
352                        "manifest end_ts_init {stored} disagrees with last observed ts_init {observed}",
353                    ),
354                });
355            }
356            (None, Some(observed)) => findings.push(VerifyFinding::ManifestMismatch {
357                kind: ManifestField::EndTsInit,
358                reason: format!(
359                    "sealed manifest is missing end_ts_init while entries up to ts_init {observed} exist",
360                ),
361            }),
362            (Some(stored), None) => findings.push(VerifyFinding::ManifestMismatch {
363                kind: ManifestField::EndTsInit,
364                reason: format!(
365                    "sealed manifest carries end_ts_init {stored} despite empty entry table",
366                ),
367            }),
368            _ => {}
369        }
370    }
371}
372
373/// The structured report produced by [`Verifier::verify`].
374///
375/// Operators key on [`VerifyReport::is_clean`] for the binary verdict and walk
376/// [`VerifyReport::findings`] for the actionable items. The verifier never
377/// quarantines on its own: that is the supervisor's call given the report.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct VerifyReport {
380    /// The id of the verified run, copied from the manifest.
381    pub run_id: RunId,
382    /// The lifecycle status the run carried at verification time.
383    pub status: RunStatus,
384    /// The durable high-watermark the verifier walked up to.
385    pub high_watermark: u64,
386    /// The number of `seq` slots the verifier successfully read (clean or hash-mismatched).
387    pub entries_scanned: u64,
388    /// Every integrity finding the verifier accumulated.
389    pub findings: Vec<VerifyFinding>,
390}
391
392impl VerifyReport {
393    /// Returns `true` when the verifier accumulated no findings.
394    #[must_use]
395    pub fn is_clean(&self) -> bool {
396        self.findings.is_empty()
397    }
398}
399
400/// One actionable integrity finding from a verifier run.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub enum VerifyFinding {
403    /// The recomputed canonical hash of `seq` did not match the stored value.
404    HashMismatch {
405        /// The sequence number whose hash diverged.
406        seq: u64,
407    },
408    /// One or more contiguous `seq` slots inside the high-watermark are missing.
409    Gap {
410        /// The inclusive range of missing seqs.
411        range: GapRange,
412    },
413    /// The entry stored at table key `table_key` carries an `entry.seq` that
414    /// disagrees with the key.
415    ///
416    /// The canonical hash hashes `entry.seq` rather than the table key, so a row
417    /// whose bytes were moved or duplicated under a different key still passes the
418    /// hash check. The verifier surfaces the divergence so that class of
419    /// corruption never reads as clean.
420    SeqMismatch {
421        /// The redb table key (the slot the verifier was reading).
422        table_key: u64,
423        /// The seq embedded inside the decoded entry value.
424        embedded_seq: u64,
425    },
426    /// The row stored at `seq` failed to decode into an entry.
427    ///
428    /// Recorded per slot so one bad row cannot mask every other finding.
429    Undecodable {
430        /// The sequence number whose stored bytes failed to decode.
431        seq: u64,
432        /// Operator-readable explanation of the decode failure.
433        reason: String,
434    },
435    /// A stored sidecar index entry diverges from the projection rebuilt from the
436    /// entry table.
437    IndexDrift {
438        /// Which sidecar index the finding applies to.
439        kind: IndexKind,
440        /// The stringified key inside that index.
441        key: String,
442        /// The kind of drift observed.
443        drift: IndexDrift,
444    },
445    /// A manifest field disagrees with the entry table or violates a sealed-state
446    /// invariant.
447    ManifestMismatch {
448        /// Which manifest field the finding applies to.
449        kind: ManifestField,
450        /// Operator-readable explanation of the mismatch.
451        reason: String,
452    },
453    /// The recorded snapshot anchor cannot support a restore: it fails to decode or
454    /// points past the durable high-watermark.
455    SnapshotAnchorInvalid {
456        /// Operator-readable explanation of the failure.
457        reason: String,
458    },
459}
460
461/// An inclusive `[from, to]` range of missing seqs.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
463pub struct GapRange {
464    /// First missing seq.
465    pub from: u64,
466    /// Last missing seq.
467    pub to: u64,
468}
469
470/// The kind of drift observed for a sidecar index key.
471///
472/// Today the verifier only reports target reachability for the `client_order_id` and
473/// `venue_order_id` indices because the rebuild is not yet payload-aware. Variants for
474/// rebuild-vs-stored mismatches (missing from stored, divergent seq, unknown key) will
475/// land when wrapper-type encoders provide a payload-derived projection.
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
477pub enum IndexDrift {
478    /// The stored index points at a seq that does not exist inside the high-watermark.
479    DanglingTarget {
480        /// The seq the stored index recorded.
481        stored_seq: u64,
482    },
483    /// The stored index points at a seq whose entry failed the hash check.
484    TargetCorrupted {
485        /// The seq the stored index recorded.
486        stored_seq: u64,
487    },
488}
489
490/// A manifest field flagged by [`VerifyFinding::ManifestMismatch`].
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
492pub enum ManifestField {
493    /// `manifest.high_watermark` does not match the durable last seq.
494    HighWatermark,
495    /// `manifest.start_ts_init` sits above the earliest observed `ts_init`.
496    StartTsInit,
497    /// `manifest.end_ts_init` does not bracket the entry stream as expected for a
498    /// sealed run.
499    EndTsInit,
500}
501
502/// Errors that prevent the verifier from producing a report at all.
503///
504/// Findings on a successful report cover the operator's actionable surface; this
505/// type captures the verifier's own failure modes (no run open, disk pressure on a
506/// read, manifest header damage that prevents loading the manifest).
507#[derive(Debug, thiserror::Error)]
508pub enum VerifyError {
509    /// A backend operation refused service before the verifier could produce a report.
510    #[error("backend access failed: {0}")]
511    Backend(#[from] EventStoreError),
512}
513
514#[cfg(test)]
515mod tests {
516    use bytes::Bytes;
517    use indexmap::IndexMap;
518    use nautilus_core::UnixNanos;
519    use rstest::{fixture, rstest};
520    use ustr::Ustr;
521
522    use super::*;
523    use crate::{
524        backend::{AppendEntry, IndexKey, MemoryBackend, ScanDirection},
525        compute_entry_hash,
526        entry::Topic,
527        headers::Headers,
528        manifest::{RegisteredComponents, RunManifest, RunStatus},
529    };
530
531    fn manifest(run_id: &str) -> RunManifest {
532        RunManifest {
533            run_id: run_id.to_string(),
534            parent_run_id: None,
535            instance_id: "trader-001".to_string(),
536            binary_hash: "deadbeef".to_string(),
537            schema_version: 1,
538            crate_versions: "feedface".to_string(),
539            feature_flags: Vec::new(),
540            adapter_versions: IndexMap::new(),
541            config_hash: "cafebabe".to_string(),
542            registered_components: RegisteredComponents::default(),
543            seed: None,
544            start_ts_init: UnixNanos::from(0),
545            end_ts_init: None,
546            high_watermark: 0,
547            status: RunStatus::Running,
548        }
549    }
550
551    fn build_entry(seq: u64, headers: Headers, ts_init: u64) -> EventStoreEntry {
552        let topic: Topic = "exec.command.SubmitOrder".into();
553        let payload_type = Ustr::from("SubmitOrder");
554        let payload = Bytes::from_static(b"\x01\x02\x03\x04");
555        let ts_publish = UnixNanos::from(ts_init + 1);
556        let ts_init = UnixNanos::from(ts_init);
557        let hash = compute_entry_hash(
558            seq,
559            ts_init,
560            ts_publish,
561            topic.as_ref(),
562            payload_type.as_str(),
563            &payload,
564            &headers,
565        );
566
567        EventStoreEntry::new(
568            hash,
569            seq,
570            headers,
571            topic,
572            payload_type,
573            payload,
574            ts_init,
575            ts_publish,
576        )
577    }
578
579    fn append_with(seq: u64, ts_init: u64, index_keys: Vec<IndexKey>) -> AppendEntry {
580        AppendEntry::new(build_entry(seq, Headers::empty(), ts_init), index_keys)
581    }
582
583    /// Test-only wrapper that delegates every call to an inner backend except the
584    /// manifest, which it returns verbatim, and (optionally) the high-watermark.
585    /// Lets unit tests drive manifest-mismatch and trailing-gap findings the
586    /// public `MemoryBackend` API would normalize away on seal.
587    struct ManifestOverrideBackend {
588        inner: MemoryBackend,
589        manifest_override: RunManifest,
590        high_watermark_override: Option<u64>,
591    }
592
593    impl ManifestOverrideBackend {
594        fn new(inner: MemoryBackend, manifest_override: RunManifest) -> Self {
595            Self {
596                inner,
597                manifest_override,
598                high_watermark_override: None,
599            }
600        }
601
602        fn with_high_watermark(mut self, hwm: u64) -> Self {
603            self.high_watermark_override = Some(hwm);
604            self
605        }
606    }
607
608    impl EventStore for ManifestOverrideBackend {
609        fn open_run(&mut self, m: RunManifest) -> Result<(), EventStoreError> {
610            self.inner.open_run(m)
611        }
612
613        fn append_batch(&mut self, entries: &[AppendEntry]) -> Result<u64, EventStoreError> {
614            self.inner.append_batch(entries)
615        }
616
617        fn scan_range(
618            &self,
619            from: u64,
620            to: u64,
621            direction: ScanDirection,
622        ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
623            self.inner.scan_range(from, to, direction)
624        }
625
626        fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
627            self.inner.scan_seq(seq)
628        }
629
630        fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
631            self.inner.lookup(kind, key)
632        }
633
634        fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
635            self.inner.iter_index_keys(kind)
636        }
637
638        fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
639            self.inner.seal(status)
640        }
641
642        fn manifest(&self) -> Result<RunManifest, EventStoreError> {
643            Ok(self.manifest_override.clone())
644        }
645
646        fn high_watermark(&self) -> Result<u64, EventStoreError> {
647            if let Some(hwm) = self.high_watermark_override {
648                return Ok(hwm);
649            }
650            self.inner.high_watermark()
651        }
652    }
653
654    #[fixture]
655    fn open_backend() -> MemoryBackend {
656        let mut backend = MemoryBackend::new();
657        backend
658            .open_run(manifest("1700000000-aaaa1111"))
659            .expect("open run");
660        backend
661    }
662
663    fn verifier_for(backend: MemoryBackend) -> Verifier {
664        Verifier::new(Box::new(backend))
665    }
666
667    #[rstest]
668    fn clean_run_reports_no_findings(mut open_backend: MemoryBackend) {
669        open_backend
670            .append_batch(&[
671                append_with(1, 10, Vec::new()),
672                append_with(2, 11, Vec::new()),
673                append_with(3, 12, Vec::new()),
674            ])
675            .expect("append");
676        open_backend.seal(RunStatus::Ended).expect("seal");
677
678        let report = verifier_for(open_backend).verify().expect("verify");
679
680        // Lock the canonical clean case to zero findings exactly: any spurious
681        // additional finding must fail this test rather than slip past is_clean()
682        // matchers in the more targeted suites below.
683        assert!(report.is_clean(), "findings was: {:?}", report.findings);
684        assert_eq!(report.findings.len(), 0);
685        assert_eq!(report.high_watermark, 3);
686        assert_eq!(report.entries_scanned, 3);
687        assert_eq!(report.status, RunStatus::Ended);
688    }
689
690    #[rstest]
691    fn empty_run_reports_no_findings(mut open_backend: MemoryBackend) {
692        open_backend.seal(RunStatus::Ended).expect("seal");
693
694        let report = verifier_for(open_backend).verify().expect("verify");
695
696        assert!(report.is_clean(), "findings was: {:?}", report.findings);
697        assert_eq!(report.entries_scanned, 0);
698    }
699
700    #[rstest]
701    fn hash_mismatch_surfaces_per_seq(mut open_backend: MemoryBackend) {
702        open_backend
703            .append_batch(&[append_with(1, 10, Vec::new())])
704            .expect("append");
705        let mut tampered = build_entry(2, Headers::empty(), 11);
706        tampered.payload = Bytes::from_static(b"\xFF");
707        open_backend
708            .append_batch(&[AppendEntry::without_indices(tampered)])
709            .expect("append");
710        open_backend
711            .append_batch(&[append_with(3, 12, Vec::new())])
712            .expect("append");
713
714        let report = verifier_for(open_backend).verify().expect("verify");
715
716        assert!(
717            report
718                .findings
719                .iter()
720                .any(|f| matches!(f, VerifyFinding::HashMismatch { seq: 2 })),
721            "findings was: {:?}",
722            report.findings,
723        );
724        assert_eq!(report.entries_scanned, 3);
725        assert_eq!(report.high_watermark, 3);
726    }
727
728    #[rstest]
729    fn multiple_hash_mismatches_all_surface(mut open_backend: MemoryBackend) {
730        // Confirms the verifier walks past hash mismatches instead of bailing on the
731        // first hit: seq=2 and seq=4 are both tampered, and both must appear in the
732        // report.
733        for seq in 1..=4u64 {
734            let mut entry = build_entry(seq, Headers::empty(), 10 + seq);
735            if seq == 2 || seq == 4 {
736                entry.payload = Bytes::from_static(b"\xFF");
737            }
738            open_backend
739                .append_batch(&[AppendEntry::without_indices(entry)])
740                .expect("append");
741        }
742
743        let report = verifier_for(open_backend).verify().expect("verify");
744
745        let mismatch_seqs: Vec<u64> = report
746            .findings
747            .iter()
748            .filter_map(|f| match f {
749                VerifyFinding::HashMismatch { seq } => Some(*seq),
750                _ => None,
751            })
752            .collect();
753        assert_eq!(mismatch_seqs, vec![2, 4]);
754    }
755
756    #[rstest]
757    fn client_order_id_index_clean_when_target_resolves(mut open_backend: MemoryBackend) {
758        open_backend
759            .append_batch(&[AppendEntry::new(
760                build_entry(1, Headers::empty(), 10),
761                vec![IndexKey::new(IndexKind::ClientOrderId, "O-1".to_string())],
762            )])
763            .expect("append");
764        open_backend.seal(RunStatus::Ended).expect("seal");
765
766        let report = verifier_for(open_backend).verify().expect("verify");
767
768        assert!(report.is_clean(), "findings was: {:?}", report.findings);
769    }
770
771    #[rstest]
772    #[case::client_order_id(IndexKind::ClientOrderId)]
773    #[case::venue_order_id(IndexKind::VenueOrderId)]
774    fn entity_index_target_corrupted_drift(
775        mut open_backend: MemoryBackend,
776        #[case] kind: IndexKind,
777    ) {
778        // Stored entity-index entry points at seq=1 whose stored hash no longer
779        // matches the recomputed hash. The verifier must surface TargetCorrupted
780        // for both ClientOrderId and VenueOrderId so a drop of either kind from
781        // the cross-check loop fails this test.
782        let mut tampered = build_entry(1, Headers::empty(), 10);
783        tampered.payload = Bytes::from_static(b"\xFF");
784        open_backend
785            .append_batch(&[AppendEntry::new(
786                tampered,
787                vec![IndexKey::new(kind, "K-1".to_string())],
788            )])
789            .expect("append");
790
791        let report = verifier_for(open_backend).verify().expect("verify");
792
793        assert!(
794            report.findings.iter().any(|f| matches!(
795                f,
796                VerifyFinding::IndexDrift {
797                    kind: drift_kind,
798                    drift: IndexDrift::TargetCorrupted { stored_seq: 1 },
799                    ..
800                } if *drift_kind == kind
801            )),
802            "findings was: {:?}",
803            report.findings,
804        );
805    }
806
807    fn find_manifest_mismatch(findings: &[VerifyFinding], target: ManifestField) -> &str {
808        findings
809            .iter()
810            .find_map(|f| match f {
811                VerifyFinding::ManifestMismatch { kind, reason } if *kind == target => {
812                    Some(reason.as_str())
813                }
814                _ => None,
815            })
816            .unwrap_or_else(|| {
817                panic!("expected ManifestMismatch({target:?}), findings was: {findings:?}")
818            })
819    }
820
821    #[rstest]
822    fn manifest_high_watermark_drift() {
823        // Real durable hwm is 1, but the manifest reports 99. Verifier must surface
824        // a HighWatermark mismatch whose reason carries both values so a swap of
825        // observed and stored sides would fail this test.
826        let mut inner = MemoryBackend::new();
827        inner.open_run(manifest("run-hwm")).expect("open run");
828        inner
829            .append_batch(&[append_with(1, 10, Vec::new())])
830            .expect("append");
831        inner.seal(RunStatus::Ended).expect("seal");
832
833        let mut stale = inner.manifest().expect("manifest");
834        stale.high_watermark = 99;
835        let backend = ManifestOverrideBackend::new(inner, stale);
836
837        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
838        let reason = find_manifest_mismatch(&report.findings, ManifestField::HighWatermark);
839
840        assert!(reason.contains("99"), "reason was: {reason}");
841        assert!(reason.contains('1'), "reason was: {reason}");
842    }
843
844    #[rstest]
845    fn manifest_end_ts_init_drift_when_sealed() {
846        // Real durable max ts_init is 25, but the sealed manifest's end_ts_init
847        // claims 99. The reason must surface both values; without that assertion,
848        // a min/max swap inside record_entry would still pass.
849        let mut inner = MemoryBackend::new();
850        inner.open_run(manifest("run-end-ts")).expect("open run");
851        inner
852            .append_batch(&[
853                append_with(1, 10, Vec::new()),
854                append_with(2, 25, Vec::new()),
855            ])
856            .expect("append");
857        inner.seal(RunStatus::Ended).expect("seal");
858
859        let mut drifted = inner.manifest().expect("manifest");
860        drifted.end_ts_init = Some(UnixNanos::from(99));
861        let backend = ManifestOverrideBackend::new(inner, drifted);
862
863        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
864        let reason = find_manifest_mismatch(&report.findings, ManifestField::EndTsInit);
865
866        assert!(reason.contains("99"), "reason was: {reason}");
867        assert!(reason.contains("25"), "reason was: {reason}");
868    }
869
870    #[rstest]
871    fn manifest_end_ts_init_missing_when_sealed_with_entries() {
872        // Sealed manifest forgot to record end_ts_init while the entry stream is
873        // non-empty: validate_manifest's (None, Some) arm must fire and the
874        // reason must carry the observed last ts_init.
875        let mut inner = MemoryBackend::new();
876        inner
877            .open_run(manifest("run-end-ts-missing"))
878            .expect("open run");
879        inner
880            .append_batch(&[append_with(1, 42, Vec::new())])
881            .expect("append");
882        inner.seal(RunStatus::Ended).expect("seal");
883
884        let mut drifted = inner.manifest().expect("manifest");
885        drifted.end_ts_init = None;
886        let backend = ManifestOverrideBackend::new(inner, drifted);
887
888        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
889        let reason = find_manifest_mismatch(&report.findings, ManifestField::EndTsInit);
890
891        assert!(reason.contains("missing"), "reason was: {reason}");
892        assert!(reason.contains("42"), "reason was: {reason}");
893    }
894
895    #[rstest]
896    fn manifest_end_ts_init_set_on_sealed_empty_run() {
897        // Sealed manifest carries end_ts_init even though the entry table is
898        // empty: validate_manifest's (Some, None) arm must fire and the reason
899        // must carry the spurious stored value.
900        let mut inner = MemoryBackend::new();
901        inner
902            .open_run(manifest("run-end-ts-empty"))
903            .expect("open run");
904        inner.seal(RunStatus::Ended).expect("seal");
905
906        let mut drifted = inner.manifest().expect("manifest");
907        drifted.end_ts_init = Some(UnixNanos::from(77));
908        let backend = ManifestOverrideBackend::new(inner, drifted);
909
910        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
911        let reason = find_manifest_mismatch(&report.findings, ManifestField::EndTsInit);
912
913        assert!(reason.contains("77"), "reason was: {reason}");
914        assert!(reason.contains("empty"), "reason was: {reason}");
915    }
916
917    #[rstest]
918    fn manifest_start_ts_init_drift() {
919        // Earliest entry ts_init is 10, but the manifest's start_ts_init is 50.
920        // Reason must carry both values so a flipped comparison or wrong-side
921        // formatting fails the test.
922        let mut inner = MemoryBackend::new();
923        inner.open_run(manifest("run-start-ts")).expect("open run");
924        inner
925            .append_batch(&[
926                append_with(1, 10, Vec::new()),
927                append_with(2, 25, Vec::new()),
928            ])
929            .expect("append");
930        inner.seal(RunStatus::Ended).expect("seal");
931
932        let mut drifted = inner.manifest().expect("manifest");
933        drifted.start_ts_init = UnixNanos::from(50);
934        let backend = ManifestOverrideBackend::new(inner, drifted);
935
936        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
937        let reason = find_manifest_mismatch(&report.findings, ManifestField::StartTsInit);
938
939        assert!(reason.contains("50"), "reason was: {reason}");
940        assert!(reason.contains("10"), "reason was: {reason}");
941    }
942
943    #[rstest]
944    fn trailing_gap_surfaces_when_last_seqs_missing() {
945        // Inner backend holds seqs 1..=3, but both the manifest and the advertised
946        // high-watermark claim 5. The verifier must walk to seq=5, find seqs 4-5
947        // missing, and emit a single trailing GapRange{4,5}. Removing the
948        // `flush_pending_gap(high_watermark + 1, ...)` call after the loop would
949        // drop this finding entirely.
950        let mut inner = MemoryBackend::new();
951        inner
952            .open_run(manifest("run-trailing-gap"))
953            .expect("open run");
954        inner
955            .append_batch(&[
956                append_with(1, 10, Vec::new()),
957                append_with(2, 11, Vec::new()),
958                append_with(3, 12, Vec::new()),
959            ])
960            .expect("append");
961        inner.seal(RunStatus::Ended).expect("seal");
962
963        let mut drifted = inner.manifest().expect("manifest");
964        drifted.high_watermark = 5;
965        // Advertise hwm=5 on both sides so the HighWatermark mismatch path stays
966        // quiet and the test pins only the trailing-gap behavior.
967        let backend = ManifestOverrideBackend::new(inner, drifted).with_high_watermark(5);
968
969        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
970
971        let gaps: Vec<GapRange> = report
972            .findings
973            .iter()
974            .filter_map(|f| match f {
975                VerifyFinding::Gap { range } => Some(*range),
976                _ => None,
977            })
978            .collect();
979        assert_eq!(gaps, vec![GapRange { from: 4, to: 5 }]);
980        assert_eq!(report.entries_scanned, 3);
981        assert_eq!(report.high_watermark, 5);
982    }
983
984    /// Test backend that rewrites a single `scan_seq` result so the value's
985    /// embedded seq disagrees with the requested table key. Lets the unit suite
986    /// exercise the redb-only "row moved under wrong key" corruption class
987    /// without setting up a real on-disk file.
988    struct SeqRewriteBackend {
989        inner: MemoryBackend,
990        target_key: u64,
991        substitute: EventStoreEntry,
992    }
993
994    impl EventStore for SeqRewriteBackend {
995        fn open_run(&mut self, m: RunManifest) -> Result<(), EventStoreError> {
996            self.inner.open_run(m)
997        }
998        fn append_batch(&mut self, e: &[AppendEntry]) -> Result<u64, EventStoreError> {
999            self.inner.append_batch(e)
1000        }
1001        fn scan_range(
1002            &self,
1003            from: u64,
1004            to: u64,
1005            direction: ScanDirection,
1006        ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
1007            self.inner.scan_range(from, to, direction)
1008        }
1009        fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
1010            if seq == self.target_key {
1011                return Ok(Some(self.substitute.clone()));
1012            }
1013            self.inner.scan_seq(seq)
1014        }
1015        fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
1016            self.inner.lookup(kind, key)
1017        }
1018        fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
1019            self.inner.iter_index_keys(kind)
1020        }
1021        fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
1022            self.inner.seal(status)
1023        }
1024        fn manifest(&self) -> Result<RunManifest, EventStoreError> {
1025            self.inner.manifest()
1026        }
1027        fn high_watermark(&self) -> Result<u64, EventStoreError> {
1028            self.inner.high_watermark()
1029        }
1030    }
1031
1032    /// Test backend that fails `scan_seq` for one slot with a decode-style
1033    /// `Corrupted` error, exercising the accumulate-don't-abort contract.
1034    struct UndecodableBackend {
1035        inner: MemoryBackend,
1036        target_key: u64,
1037    }
1038
1039    impl EventStore for UndecodableBackend {
1040        fn open_run(&mut self, m: RunManifest) -> Result<(), EventStoreError> {
1041            self.inner.open_run(m)
1042        }
1043        fn append_batch(&mut self, e: &[AppendEntry]) -> Result<u64, EventStoreError> {
1044            self.inner.append_batch(e)
1045        }
1046        fn scan_range(
1047            &self,
1048            from: u64,
1049            to: u64,
1050            direction: ScanDirection,
1051        ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
1052            self.inner.scan_range(from, to, direction)
1053        }
1054        fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
1055            if seq == self.target_key {
1056                return Err(EventStoreError::Corrupted(format!(
1057                    "decode entry seq={seq}: bad length prefix",
1058                )));
1059            }
1060            self.inner.scan_seq(seq)
1061        }
1062        fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
1063            self.inner.lookup(kind, key)
1064        }
1065        fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
1066            self.inner.iter_index_keys(kind)
1067        }
1068        fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
1069            self.inner.seal(status)
1070        }
1071        fn manifest(&self) -> Result<RunManifest, EventStoreError> {
1072            self.inner.manifest()
1073        }
1074        fn high_watermark(&self) -> Result<u64, EventStoreError> {
1075            self.inner.high_watermark()
1076        }
1077    }
1078
1079    #[rstest]
1080    fn undecodable_row_is_recorded_and_scan_continues() {
1081        // One row fails to decode at seq 2; the walk must continue so later entries
1082        // still count and indices pointing at the bad row classify as TargetCorrupted.
1083        let mut inner = MemoryBackend::new();
1084        inner
1085            .open_run(manifest("run-undecodable"))
1086            .expect("open run");
1087        inner
1088            .append_batch(&[
1089                append_with(1, 10, Vec::new()),
1090                AppendEntry::new(
1091                    build_entry(2, Headers::empty(), 11),
1092                    vec![IndexKey::new(IndexKind::ClientOrderId, "O-1".to_string())],
1093                ),
1094                append_with(3, 12, Vec::new()),
1095            ])
1096            .expect("append");
1097        inner.seal(RunStatus::Ended).expect("seal");
1098
1099        let backend = UndecodableBackend {
1100            inner,
1101            target_key: 2,
1102        };
1103
1104        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
1105
1106        assert!(!report.is_clean());
1107        assert_eq!(report.entries_scanned, 3);
1108        assert_eq!(
1109            report.findings.len(),
1110            2,
1111            "findings was: {:?}",
1112            report.findings,
1113        );
1114        assert!(
1115            report
1116                .findings
1117                .iter()
1118                .any(|f| matches!(f, VerifyFinding::Undecodable { seq: 2, .. },)),
1119            "findings was: {:?}",
1120            report.findings,
1121        );
1122        assert!(
1123            report.findings.iter().any(|f| matches!(
1124                f,
1125                VerifyFinding::IndexDrift {
1126                    drift: IndexDrift::TargetCorrupted { stored_seq: 2 },
1127                    ..
1128                },
1129            )),
1130            "findings was: {:?}",
1131            report.findings,
1132        );
1133    }
1134
1135    #[rstest]
1136    fn seq_mismatch_surfaces_when_row_value_disagrees_with_key() {
1137        // Row at table_key=2 holds the bytes of an entry whose embedded seq is 99.
1138        // The hash recomputes correctly (because the hash covers entry.seq=99),
1139        // so scan_seq returns Ok(Some(entry)) without raising HashMismatch. The
1140        // verifier must catch the key/embedded-seq divergence rather than mark
1141        // the slot clean.
1142        let mut inner = MemoryBackend::new();
1143        inner
1144            .open_run(manifest("run-seq-mismatch"))
1145            .expect("open run");
1146        inner
1147            .append_batch(&[
1148                append_with(1, 10, Vec::new()),
1149                append_with(2, 11, Vec::new()),
1150                append_with(3, 12, Vec::new()),
1151            ])
1152            .expect("append");
1153        inner.seal(RunStatus::Ended).expect("seal");
1154
1155        let substitute = build_entry(99, Headers::empty(), 11);
1156        let backend = SeqRewriteBackend {
1157            inner,
1158            target_key: 2,
1159            substitute,
1160        };
1161
1162        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
1163
1164        assert!(
1165            report.findings.iter().any(|f| matches!(
1166                f,
1167                VerifyFinding::SeqMismatch {
1168                    table_key: 2,
1169                    embedded_seq: 99,
1170                }
1171            )),
1172            "findings was: {:?}",
1173            report.findings,
1174        );
1175    }
1176
1177    #[rstest]
1178    fn seq_mismatch_marks_target_corrupted_for_dependent_indices() {
1179        // Same row corruption as above, but the stored client_order_id index
1180        // points at the rewritten slot. The slot must be classified as corrupted
1181        // so the index drift surfaces TargetCorrupted rather than silently
1182        // accepting the lookup.
1183        let mut inner = MemoryBackend::new();
1184        inner
1185            .open_run(manifest("run-seq-mismatch-idx"))
1186            .expect("open run");
1187        inner
1188            .append_batch(&[
1189                append_with(1, 10, Vec::new()),
1190                AppendEntry::new(
1191                    build_entry(2, Headers::empty(), 11),
1192                    vec![IndexKey::new(IndexKind::ClientOrderId, "O-1".to_string())],
1193                ),
1194            ])
1195            .expect("append");
1196        inner.seal(RunStatus::Ended).expect("seal");
1197
1198        let substitute = build_entry(99, Headers::empty(), 11);
1199        let backend = SeqRewriteBackend {
1200            inner,
1201            target_key: 2,
1202            substitute,
1203        };
1204
1205        let report = Verifier::new(Box::new(backend)).verify().expect("verify");
1206
1207        assert!(
1208            report.findings.iter().any(|f| matches!(
1209                f,
1210                VerifyFinding::IndexDrift {
1211                    kind: IndexKind::ClientOrderId,
1212                    drift: IndexDrift::TargetCorrupted { stored_seq: 2 },
1213                    ..
1214                }
1215            )),
1216            "findings was: {:?}",
1217            report.findings,
1218        );
1219    }
1220
1221    #[rstest]
1222    fn verify_propagates_no_run_open_as_error() {
1223        let backend = MemoryBackend::new();
1224        let verifier = Verifier::new(Box::new(backend));
1225
1226        let err = verifier.verify().expect_err("must fail");
1227
1228        match err {
1229            VerifyError::Backend(EventStoreError::Backend(msg)) => {
1230                assert!(msg.contains("no run open"), "msg was: {msg}");
1231            }
1232            VerifyError::Backend(other) => {
1233                panic!("expected Backend(no run open), was {other:?}")
1234            }
1235        }
1236    }
1237}