batpak 0.9.0

Event sourcing with causal graphs and caller-defined gates. Sync API, no async runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
//! Untrusted-footer recovery via the SIDX entry table as a self-authenticating
//! manifest — the round-7 "cake-and-eat-it" resolution.
//!
//! For an UNTRUSTED footer boundary (CRC-failed SDX3, legacy un-CRC'd SDX2, or a
//! forged trailer) the trailer `string_table_offset` is unauthenticated and must
//! never bound recovery. Plain CRC-valid-frame recovery (see
//! [`super::crc_valid_frames_end`]) recovers the prefix and fails closed on
//! mid-stream corruption, but it CANNOT distinguish a torn/corrupt LAST committed
//! frame (followed by the footer) from "intact frames + footer" — so it silently
//! drops a committed event, ignoring a caller's `FailClosed` posture. This module
//! closes that gap by corroborating the CRC-independent SIDX entry table against
//! the independently CRC-verified recovered frames:
//!
//! 1. parse the entry table WITHOUT requiring the footer CRC (every entry is an
//!    UNTRUSTED HYPOTHESIS);
//! 2. recover the CRC-valid prefix and build the recovered-frame map `R`;
//! 3. corroborate each entry against `R` by (offset, length, content event_hash);
//! 4. decide: a corroborated manifest attesting to a committed frame missing from
//!    the recovered stream FAILS CLOSED (real data loss, any policy); a
//!    corroborated manifest over intact frames RECOVERS; an
//!    uncorroborated/unparseable manifest HONORS the caller's tail posture — the
//!    default permissive posture recovers the CRC-valid prefix (never a false
//!    fail-closed that would brick a benign corrupt-footer store), while the
//!    strict `FailClosed` posture refuses a NON-EMPTY prefix as an unprovable
//!    tail (truncation of a further committed frame cannot be ruled out under an
//!    untrusted footer with no manifest signal).
//!
//! Load-bearing assumptions (validated against the writer):
//! - A corroborated entry anchors the WHOLE table to this segment: a forger
//!   cannot match a real frame's content-addressed blake3 `event_hash`, so once
//!   one entry corroborates, `entry_count` and the append-ordered entries are
//!   trustworthy enough to assert "frame N should exist."
//! - SIDX entries cover ONLY committed frames; batch BEGIN/COMMIT markers are real
//!   frames but NOT SIDX entries, and entries are recorded post-COMMIT. So we
//!   match entries↔frames by (offset, length, event_hash), never assume
//!   contiguity, and only assert a missing-frame for a committed (SIDX-recorded)
//!   frame — leaving the scan loops' BatchRecoveryState discard logic untouched.

use super::{crc_valid_frame_exists_after, frame_decode, sidx, try_decode_frame_at, StoreError};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::io::{Read, Seek, SeekFrom};

/// Minimal view of a frame's serialized `FramePayload` used during untrusted
/// recovery to extract the content-addressed `event_hash` for corroboration.
///
/// We deserialize ONLY the `event.hash_chain` field (everything else is
/// `IgnoredAny`) so the corroboration walk stays cheap. The `event_hash` here is
/// the blake3 of the event content (`event.hash_chain.event_hash`) — the SAME
/// value the writer records into each [`sidx::SidxEntry`]. A forger cannot match
/// it for a real frame, which is what makes a corroborated entry trustworthy
/// despite the failed footer CRC.
#[derive(Deserialize)]
struct CorroborationFramePayload {
    event: CorroborationEvent,
}

#[derive(Deserialize)]
struct CorroborationEvent {
    #[serde(rename = "header")]
    _header: serde::de::IgnoredAny,
    #[serde(rename = "payload")]
    _payload: serde::de::IgnoredAny,
    hash_chain: Option<crate::event::HashChain>,
}

/// One recovered, CRC-verified frame keyed by its byte offset: its on-disk
/// `frame_length` and its content-addressed `event_hash` (blake3 of the event
/// content). This is the trusted side of corroboration — these bytes decoded
/// cleanly under their own per-frame CRC, so the `event_hash` here is genuine.
#[derive(Clone, Copy, Debug)]
pub(crate) struct RecoveredFrame {
    pub(crate) frame_length: u32,
    pub(crate) event_hash: Option<[u8; 32]>,
}

/// The map `R` from offset → recovered frame, built during the CRC-valid walk.
pub(crate) type RecoveredFrameMap = BTreeMap<u64, RecoveredFrame>;

/// The outcome of the untrusted-footer recovery decision.
///
/// `RecoverPrefix(end)` recovers the CRC-valid frame region `[frames_start ..
/// end)`; `RecoverPrefixWithTruncationEvidence` recovers the same prefix but
/// records footer-cross-checked truncation evidence for the caller. The three
/// fail-closed reasons surface the SAME [`StoreError::CorruptSegment`] refusal
/// but carry DISTINCT detail strings (see [`resolve_untrusted_frames_end`]):
/// - `FailClosedCorroboratedLoss` — a CORROBORATED manifest attests to a
///   committed frame the recovered stream is missing (proven data loss);
/// - `FailClosedUnprovableTail` — the strict tail posture refusing a non-empty
///   recovered prefix under an untrusted footer with no corroborating manifest
///   (fail-closed on ABSENCE of a corroborating signal);
/// - `FailClosedEvidenceOfTruncation` — the strict tail posture refusing a
///   prefix that ends strictly BEFORE the untrusted footer's own claimed frame
///   end (fail-closed on POSITIVE-if-untrusted truncation evidence).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum UntrustedRecovery {
    /// Recover the CRC-valid prefix that ends at this offset.
    RecoverPrefix(u64),
    /// A CORROBORATED manifest attests to a committed frame the recovered stream
    /// is missing — proven data loss — so fail closed REGARDLESS of tail policy.
    FailClosedCorroboratedLoss,
    /// STRICT (`FailClosed`) posture refusing an UNPROVABLE tail: a non-empty
    /// CRC-valid prefix was recovered beneath an untrusted footer with NO
    /// corroborating manifest entry, so a torn/truncated further committed frame
    /// cannot be ruled out. Only returned when the caller passed
    /// `fallback_fail_closed` (opted into the strict tail posture); the default
    /// permissive posture recovers the prefix instead.
    FailClosedUnprovableTail,
    /// Permissive (`RecoverTornTail`) recover of the CRC-valid prefix ending at
    /// `end`, WITH positive truncation evidence: the untrusted footer's own claimed
    /// frame end `footer_claimed_end` lies strictly PAST `end`, so a torn/corrupt
    /// region sits between the recovered frames and the footer. The default posture
    /// recovers the prefix while recording the evidence for the caller.
    RecoverPrefixWithTruncationEvidence { end: u64, footer_claimed_end: u64 },
    /// STRICT posture refusing a tail with POSITIVE truncation evidence: the
    /// CRC-valid prefix ends strictly BEFORE the untrusted footer's claimed frame
    /// end, so a torn/corrupt region sits between them. Distinct from
    /// `FailClosedUnprovableTail`, which refuses a clean prefix-ends-at-footer tail
    /// on mere ABSENCE of a corroborating manifest. Fail-closed-on-SUSPICION: the
    /// footer is untrusted, so a forged `footer_claimed_end` could trip it.
    FailClosedEvidenceOfTruncation { footer_claimed_end: u64 },
}

/// Positive, footer-cross-checked evidence that a committed frame was torn between
/// the recovered CRC-valid prefix and the (untrusted) footer. Recorded on the
/// permissive recover path; the strict path fails closed instead (see
/// [`UntrustedRecovery::FailClosedEvidenceOfTruncation`]).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct TruncationEvidence {
    /// The CRC-valid recovered-prefix end `P` (where the frame walk stopped).
    pub(crate) recovered_prefix_end: u64,
    /// The untrusted footer's OWN claimed frame-region end, strictly past `P`.
    pub(crate) footer_claimed_frames_end: u64,
}

/// The resolved frame-region end for an untrusted footer, plus any truncation
/// evidence recorded on the permissive recover path (`None` on the strict path,
/// which fails closed on such evidence rather than recovering).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ResolvedFramesEnd {
    pub(crate) frames_end: u64,
    pub(crate) truncation_evidence: Option<TruncationEvidence>,
}

/// Walk the CRC-valid frames from `frames_start`, building the recovered-frame
/// map `R` AND applying the same mid-stream-corruption fail-closed rule as
/// [`crc_valid_frames_end`]. Returns `(stop_offset, R)` where `stop_offset` (P)
/// is the first non-decodable position (the recovered prefix end) and `R` maps
/// each recovered frame's offset to its `(frame_length, event_hash)`.
///
/// This is the trusted, CRC-verified half of the "cake-and-eat-it" untrusted
/// recovery: every entry in `R` comes from a frame whose own CRC passed, so its
/// `event_hash` is genuine and can corroborate (or refute) an untrusted SIDX
/// entry. Mid-stream corruption (a CRC-valid frame after P) still fails closed
/// here exactly as `crc_valid_frames_end` does — the manifest path is layered ON
/// TOP of that guard, never replacing it.
///
/// # Errors
/// Returns [`StoreError::Io`] on seek/read failure, or
/// [`StoreError::CorruptSegment`] on mid-stream corruption (same contract as
/// [`crc_valid_frames_end`]).
pub(super) fn crc_valid_frames_end_with_map<R: Read + Seek>(
    source: &mut R,
    frames_start: u64,
    file_len: u64,
    segment_id: u64,
) -> Result<(u64, RecoveredFrameMap), StoreError> {
    let mut cursor = frames_start;
    let mut recovered: RecoveredFrameMap = BTreeMap::new();

    loop {
        if cursor >= file_len {
            return Ok((file_len, recovered));
        }
        match try_decode_frame_at(source, cursor, file_len)? {
            Some(frame_size) => {
                // Re-read the frame bytes to extract its content `event_hash`. The
                // frame already CRC-validated in try_decode_frame_at, so this only
                // deserializes the (small) header/hash_chain prefix. A frame whose
                // hash_chain is absent or whose payload cannot be deserialized still
                // counts as a recovered frame (length known) but carries no
                // event_hash, so it can never corroborate an entry — a conservative,
                // safe default.
                let event_hash = read_frame_event_hash(source, cursor, frame_size);
                let frame_length = u32::try_from(frame_size).ok();
                if let Some(frame_length) = frame_length {
                    recovered.insert(
                        cursor,
                        RecoveredFrame {
                            frame_length,
                            event_hash,
                        },
                    );
                }
                cursor = match cursor.checked_add(frame_size) {
                    Some(next) => next,
                    None => return Ok((cursor, recovered)),
                };
            }
            None => {
                let resync_from = match cursor.checked_add(1) {
                    Some(next) => next,
                    None => return Ok((cursor, recovered)),
                };
                if crc_valid_frame_exists_after(source, resync_from, file_len)? {
                    return Err(StoreError::corrupt_segment_with_detail(
                        segment_id,
                        format!(
                            "mid-stream corruption: frame at offset {cursor} is non-decodable but a \
                             CRC-valid frame follows before EOF (file_len {file_len}); refusing to \
                             silently truncate to the prefix during untrusted-footer recovery"
                        ),
                    ));
                }
                return Ok((cursor, recovered));
            }
        }
    }
}

/// Extract the content `event_hash` from the CRC-valid frame of size `frame_size`
/// that begins at `at`. Returns `None` if the frame cannot be re-read or its
/// `FramePayload`/`hash_chain` cannot be deserialized — a frame with no usable
/// hash simply cannot corroborate an entry. Never errors: a corroboration miss is
/// always safe (it degrades to fall-back), so any failure here is `None`.
fn read_frame_event_hash<R: Read + Seek>(
    source: &mut R,
    at: u64,
    frame_size: u64,
) -> Option<[u8; 32]> {
    let total = usize::try_from(frame_size).ok()?;
    // This guard rejects frames too small to hold the 8-byte [len][crc] header as
    // a CHEAP pre-check: it short-circuits BEFORE the seek+read below. The
    // `< 8` -> `== 8` mutant is return-value equivalent (every `total <= 8` still
    // yields `None`: a sub-header buffer fails `frame_decode`, and a bare 8-byte
    // header decodes to an empty payload that won't deserialize) — but it is NOT
    // unobservable: for an undersized frame the mutant falls through and CONSUMES
    // the source. `undersized_frame_rejected_without_consuming_the_source` pins
    // the no-I/O short-circuit, killing the `== 8` mutant.
    if total < 8 {
        return None;
    }
    if source.seek(SeekFrom::Start(at)).is_err() {
        return None;
    }
    let mut frame = vec![0u8; total];
    if source.read_exact(&mut frame).is_err() {
        return None;
    }
    // frame_decode strips the 8-byte [len][crc] header and re-verifies the CRC.
    let (msgpack, _consumed) = frame_decode(&frame).ok()?;
    let payload: CorroborationFramePayload = crate::encoding::from_bytes(msgpack).ok()?;
    payload.event.hash_chain.map(|chain| chain.event_hash)
}

/// Corroborate untrusted SIDX entries against the CRC-verified recovered frames
/// `R`, then decide whether to recover the prefix or fail closed.
///
/// CONTRACT (load-bearing assumptions, validated against the writer):
///
/// 1. A corroborated entry ANCHORS the entire entry table to THIS segment. An
///    entry is corroborated iff there is a recovered frame at `entry.frame_offset`
///    whose `frame_length` AND content `event_hash` (blake3) match the entry. The
///    `event_hash` is content-addressed and CRC-guarded by default: the value
///    matched here is the one STORED in the frame (re-read under the per-frame
///    CRC), not one re-derived from the payload on this path, so a forger cannot
///    ACCIDENTALLY fabricate an entry matching a real frame's blake3. A deliberate
///    rewrite of both the payload and its stored hash is caught only by the full
///    recompute ([`ChainVerification::Recompute`](crate::store::ChainVerification)
///    / [`Store::verify_chain`](crate::store::Store::verify_chain)), never by CRC
///    alone. So once >= 1 entry corroborates,
///    the append-ordered entries and `entry_count` are trustworthy enough to assert
///    "a committed frame at offset X should exist."
///
/// 2. SIDX entries cover ONLY committed frames. Batch BEGIN/COMMIT markers are real
///    frames but are NOT SIDX entries, and entries are recorded post-COMMIT. So we
///    match entries↔frames by (offset, length, event_hash), NEVER assume frame
///    contiguity, and only assert a missing-frame for a committed (SIDX-recorded)
///    frame. This does not touch the cross-segment batch discard logic in the scan
///    loops — it only chooses where the CRC-valid frame region ends.
///
/// DECISION (only ever invoked on the UNTRUSTED footer path):
///
/// - (a) FAIL CLOSED iff at least one entry corroborates (the manifest is
///   anchored to this segment) AND some anchored entry references a committed
///   frame at an offset at or after P (the recovery stop) that is NOT in `R`. The
///   manifest attests to a trailing committed frame the stream is missing — the
///   torn-last-frame-under-corrupt-footer case. Honored REGARDLESS of tail
///   policy: a corroborated manifest proving missing committed data is real data
///   loss.
/// - (b) RECOVER the prefix iff at least one entry corroborates and every entry
///   the anchored manifest references either maps to a recovered frame or lies
///   strictly before P (manifest agrees the recovered region is complete).
/// - (c) HONOR THE TAIL POSTURE iff ZERO entries corroborate (unparseable table
///   or no trustworthy signal). There is no manifest PROOF a committed frame is
///   missing, so the outcome is the caller's `fallback_fail_closed` posture:
///   - permissive (default `RecoverTornTail` → `false`): recover the CRC-valid
///     prefix. Never a false fail-closed — a benign corrupt-footer store whose
///     frames are intact must still open (this is what keeps the DEFAULT path
///     unchanged).
///   - strict (`FailClosed` → `true`) with a NON-EMPTY recovered prefix: refuse
///     as `FailClosedUnprovableTail`. Under an untrusted (unauthenticated) footer
///     with no corroborating manifest, a torn/truncated further committed frame
///     cannot be ruled out, so a store that opted into the strict posture must not
///     silently accept an unprovable tail. An EMPTY prefix has no data to lose and
///     always recovers (nothing), so it never fails closed.
///
/// The round-7 corroborated-loss trigger (case a) is independent of policy and
/// fires regardless.
pub(crate) fn corroborate_untrusted_entries(
    entries: &[sidx::SidxEntry],
    recovered: &RecoveredFrameMap,
    recovery_stop: u64,
    footer_claimed_frames_end: Option<u64>,
    fallback_fail_closed: bool,
) -> UntrustedRecovery {
    // An entry is CORROBORATED when a recovered frame sits at its offset with a
    // matching length AND a matching content event_hash. The event_hash match is
    // the anchor: content-addressed and CRC-guarded here (a full blake3 recompute
    // happens only under ChainVerification::Recompute / Store::verify_chain).
    let is_corroborated = |entry: &sidx::SidxEntry| -> bool {
        match recovered.get(&entry.frame_offset) {
            Some(frame) => {
                frame.frame_length == entry.frame_length
                    && frame.event_hash == Some(entry.event_hash)
            }
            None => false,
        }
    };

    let any_corroborated = entries.iter().any(is_corroborated);
    if !any_corroborated {
        // (c) No corroborating manifest signal. Cross-check the untrusted footer's
        // OWN claimed frame end against where the CRC-valid walk actually stopped: a
        // footer claiming frames PAST `recovery_stop` means a torn/corrupt region
        // sits between the recovered frames and the footer (positive, if untrusted,
        // truncation evidence). An EMPTY recovered prefix has nothing to lose and
        // recovers regardless of posture (unchanged).
        let torn_gap = footer_claimed_frames_end.filter(|&claimed| recovery_stop < claimed);
        if recovered.is_empty() {
            return UntrustedRecovery::RecoverPrefix(recovery_stop);
        }
        if fallback_fail_closed {
            return match torn_gap {
                Some(claimed) => UntrustedRecovery::FailClosedEvidenceOfTruncation {
                    footer_claimed_end: claimed,
                },
                None => UntrustedRecovery::FailClosedUnprovableTail,
            };
        }
        return match torn_gap {
            Some(claimed) => UntrustedRecovery::RecoverPrefixWithTruncationEvidence {
                end: recovery_stop,
                footer_claimed_end: claimed,
            },
            None => UntrustedRecovery::RecoverPrefix(recovery_stop),
        };
    }

    // (a) The manifest is anchored. Any entry that names a COMMITTED frame at or
    // past the recovery stop P which is NOT present in R proves the recovered
    // stream dropped a committed frame (torn last frame under a corrupt footer).
    // Match by (offset, length, event_hash); contiguity is never assumed.
    for entry in entries {
        if entry.frame_offset >= recovery_stop {
            // The manifest claims a committed frame at/after P. It is present in R
            // only if a recovered frame at that offset matches length + content
            // hash. (R never holds offsets >= P — the walk stopped at P — so this is
            // always "missing", but we keep the explicit corroboration check so the
            // intent is self-documenting and robust to future map changes.)
            let present = recovered.get(&entry.frame_offset).is_some_and(|frame| {
                frame.frame_length == entry.frame_length
                    && frame.event_hash == Some(entry.event_hash)
            });
            if !present {
                return UntrustedRecovery::FailClosedCorroboratedLoss;
            }
        }
    }

    // (b) Every committed frame the anchored manifest names is either present in R
    // or strictly before P. The manifest agrees the recovered region is complete.
    UntrustedRecovery::RecoverPrefix(recovery_stop)
}

/// Resolve the frame-region end for an UNTRUSTED footer boundary using the SIDX
/// entry table as a self-authenticating manifest (the "cake-and-eat-it" path).
///
/// This is the single entry point the three scan/compaction sites call instead of
/// bare [`crc_valid_frames_end`]. It:
///   1. walks the CRC-valid frames (recovering the prefix + building `R`, and
///      still failing closed on mid-stream corruption — unchanged round-5/6
///      behavior);
///   2. parses the untrusted entry table (zero entries on any parse failure);
///   3. corroborates entries against `R` and decides (case a/b/c above).
///
/// `fallback_fail_closed` is the caller's [`scan::FrameScanTailPolicy`] reduced to
/// a bool (FailClosed → true), which case (c) now HONORS: a strict caller refuses
/// a non-empty recovered prefix under an untrusted footer with no corroboration
/// (an unprovable tail), while the default permissive caller recovers the prefix.
/// It is passed as a bool to keep this module decoupled from the scan layer.
///
/// # Errors
/// Returns [`StoreError::Io`] on read failure, or [`StoreError::CorruptSegment`]
/// for mid-stream corruption (from the walk), a corroborated missing committed
/// frame (case a), or a strict refusal of an unprovable non-empty tail (case c).
pub(crate) fn resolve_untrusted_frames_end<R: Read + Seek>(
    source: &mut R,
    frames_start: u64,
    file_len: u64,
    segment_id: u64,
    footer_claimed_frames_end: Option<u64>,
    fallback_fail_closed: bool,
) -> Result<ResolvedFramesEnd, StoreError> {
    // Step 2/4c: parse the untrusted entry table FIRST (it seeks to EOF). Zero
    // entries on any parse failure → pure fall-back.
    let entries = sidx::read_entries_unauthenticated(source, segment_id)?;

    // Step 2/4b: recover the CRC-valid prefix + build R. This is the mid-stream
    // corruption guard; it errors before we ever consult the manifest.
    let (recovery_stop, recovered) =
        crc_valid_frames_end_with_map(source, frames_start, file_len, segment_id)?;

    // An untrusted footer's claimed frame end is plausible truncation evidence only
    // if it leaves room for the footer trailer after it. A forged/out-of-bounds
    // claim (> file_len - TRAILER_LEN — e.g. == file_len) is garbage, not a torn
    // frame region, so it degrades to the absence-only unprovable-tail decision
    // instead of a FALSE evidence-of-truncation. TRAILER_LEN mirrors the 16-byte
    // SIDX trailer read by `detect_sidx_boundary`.
    const TRAILER_LEN: u64 = 16;
    let bounded_footer_claim = footer_claimed_frames_end
        .filter(|&claimed| claimed <= file_len.saturating_sub(TRAILER_LEN));

    // Step 3/4: corroborate + decide.
    match corroborate_untrusted_entries(
        &entries,
        &recovered,
        recovery_stop,
        bounded_footer_claim,
        fallback_fail_closed,
    ) {
        UntrustedRecovery::RecoverPrefix(end) => Ok(ResolvedFramesEnd {
            frames_end: end,
            truncation_evidence: None,
        }),
        UntrustedRecovery::RecoverPrefixWithTruncationEvidence { end, footer_claimed_end } => {
            Ok(ResolvedFramesEnd {
                frames_end: end,
                truncation_evidence: Some(TruncationEvidence {
                    recovered_prefix_end: end,
                    footer_claimed_frames_end: footer_claimed_end,
                }),
            })
        }
        UntrustedRecovery::FailClosedCorroboratedLoss => {
            Err(StoreError::corrupt_segment_with_detail(
                segment_id,
                format!(
                    "untrusted-footer recovery: a corroborated SIDX manifest entry attests to a \
                     committed frame at/after the recovered prefix end {recovery_stop} that is \
                     missing from the CRC-valid frame stream (file_len {file_len}); a torn/corrupt \
                     last committed frame under a corrupt footer would silently drop a committed \
                     event — refusing to recover"
                ),
            ))
        }
        UntrustedRecovery::FailClosedUnprovableTail => {
            Err(StoreError::corrupt_segment_with_detail(
                segment_id,
                format!(
                    "untrusted-footer recovery: strict FailClosed posture refuses an unprovable \
                     tail — a non-empty CRC-valid prefix ending at {recovery_stop} was recovered \
                     beneath an untrusted footer (file_len {file_len}) with NO corroborating SIDX \
                     manifest entry, so a torn/truncated further committed frame cannot be ruled \
                     out; the default RecoverTornTail posture would instead recover this prefix"
                ),
            ))
        }
        UntrustedRecovery::FailClosedEvidenceOfTruncation { footer_claimed_end } => {
            Err(StoreError::corrupt_segment_with_detail(
                segment_id,
                format!(
                    "untrusted-footer recovery: strict FailClosed posture refuses a tail with POSITIVE \
                     truncation evidence — CRC-valid frames end at {recovery_stop} but the untrusted \
                     footer claims frames extend to {footer_claimed_end} (file_len {file_len}); the \
                     {gap}-byte region between is neither CRC-valid frames nor footer, so a committed \
                     frame was torn/dropped; the default RecoverTornTail posture would recover the \
                     prefix and record the evidence",
                    gap = footer_claimed_end - recovery_stop
                ),
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::read_frame_event_hash;
    use std::io::{Cursor, Seek, SeekFrom};

    #[test]
    fn undersized_frame_rejected_without_consuming_the_source() {
        // A frame smaller than the 8-byte [len][crc] header can never decode, so
        // the `< 8` guard rejects it as a cheap pre-check — without seeking or
        // reading. We park the cursor at a known offset; the guard must leave it
        // there. The `< 8` -> `== 8` mutant falls through for a 4-byte frame,
        // seeks to `at`, and reads the buffer — moving the cursor (while still
        // returning None). Pinning the parked position kills that mutant.
        let mut source = Cursor::new(vec![0u8; 64]);
        source
            .seek(SeekFrom::Start(41))
            .expect("park the cursor before the call");
        let hash = read_frame_event_hash(&mut source, 0, 4);
        assert_eq!(hash, None, "an undersized frame yields no event hash");
        assert_eq!(
            source.position(),
            41,
            "the size guard must reject an undersized frame without consuming the source"
        );
    }

    #[test]
    fn exactly_header_sized_frame_is_read_not_pre_rejected() {
        // The cheap pre-check rejects ONLY frames strictly smaller than the
        // 8-byte [len][crc] header. A frame of EXACTLY 8 bytes passes the
        // guard and reaches the I/O path: it decodes to an empty payload
        // (len = 0, crc32("") = 0) whose deserialize fails, so the hash is
        // still None — but the source HAS been consumed (seek to `at`, then
        // an 8-byte read leaves the cursor at 8). The `< 8` -> `<= 8` mutant
        // short-circuits at total == 8 and leaves the cursor parked at 41;
        // pinning the post-read position convicts it.
        let mut source = Cursor::new(vec![0u8; 64]);
        source
            .seek(SeekFrom::Start(41))
            .expect("park the cursor before the call");
        let hash = read_frame_event_hash(&mut source, 0, 8);
        assert_eq!(hash, None, "a bare 8-byte header carries no event hash");
        assert_eq!(
            source.position(),
            8,
            "an exactly-header-sized frame must be seeked-to and read, not pre-rejected"
        );
    }
}