Skip to main content

prikk_store/
block_state.rs

1//! Format-2 Block shape and authoritative clean-state derivation.
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4
5use prikk_error::{PrikkError, Result};
6use prikk_object::{BlockKind, BlockPayload, MerkleRoot, ObjectId, ObjectType};
7
8use crate::lifecycle_cache::replay::{
9    LifecycleReplayError, TextCache, apply_candidate_patches, apply_one_block_with_text_cache,
10};
11use crate::node_lifecycle::NodeLifecycleState;
12use crate::object_store::ObjectReader;
13use crate::state_root::{compute_state_root, entries_from_state};
14
15/// Validate the format-2 Block kind and parent cardinality contract.
16pub fn validate_block_v2_shape(payload: &BlockPayload) -> Result<()> {
17    match (payload.kind, payload.parent_block_ids.as_slice()) {
18        (BlockKind::Root, []) | (BlockKind::Normal, [_]) => validate_non_merge_shape(payload),
19        (BlockKind::Merge, [_, _]) => validate_merge_shape(payload),
20        (BlockKind::Root, _) => Err(PrikkError::Integrity(
21            "format-2 Root Block must have zero parents".to_string(),
22        )),
23        (BlockKind::Normal, _) => Err(PrikkError::Integrity(
24            "format-2 Normal Block must have exactly one parent".to_string(),
25        )),
26        (BlockKind::Merge, _) => Err(PrikkError::Integrity(
27            "format-2 Merge Block must have exactly two parents".to_string(),
28        )),
29        (BlockKind::Repair | BlockKind::Import, _) => Err(PrikkError::Integrity(
30            "format-2 Block kind is not authorized".to_string(),
31        )),
32    }
33}
34
35/// `Root`/`Normal` blocks carry neither DC-75 field — only a `Merge` block has two parents to
36/// disambiguate or a proven baseline to record.
37fn validate_non_merge_shape(payload: &BlockPayload) -> Result<()> {
38    if payload.mainline_parent_id.is_some() || payload.merge_baseline_block_id.is_some() {
39        return Err(PrikkError::Integrity(format!(
40            "format-2 {:?} Block must not carry a mainline parent or merge baseline",
41            payload.kind
42        )));
43    }
44    Ok(())
45}
46
47/// A `Merge` block additionally names, and is bound to, its mainline parent (DC-75): one of its two
48/// `parent_block_ids`, designating which side state derivation and replay follow. Every other kind
49/// carries neither field — `parent_block_ids`' own cardinality already says everything a `Root` or
50/// `Normal` block needs.
51fn validate_merge_shape(payload: &BlockPayload) -> Result<()> {
52    let Some(mainline) = payload.mainline_parent_id else {
53        return Err(PrikkError::Integrity(
54            "format-2 Merge Block must name a mainline parent".to_string(),
55        ));
56    };
57    if !payload.parent_block_ids.contains(&mainline) {
58        return Err(PrikkError::Integrity(
59            "format-2 Merge Block mainline parent must be one of its own parents".to_string(),
60        ));
61    }
62    if payload.merge_baseline_block_id.is_none() {
63        return Err(PrikkError::Integrity(
64            "format-2 Merge Block must record the baseline confluence was proven against"
65                .to_string(),
66        ));
67    }
68    Ok(())
69}
70
71/// The parent state derivation and replay follow for an already shape-validated payload: mainline
72/// only for a `Merge` block (DC-75), the sole parent otherwise. Callers must have already run
73/// [`validate_block_v2_shape`] on `payload` — this trusts `mainline_parent_id` is `Some` and names a
74/// real parent for `Merge`, exactly as that validation requires.
75fn state_derivation_parent(payload: &BlockPayload) -> Option<ObjectId> {
76    if payload.kind == BlockKind::Merge {
77        payload.mainline_parent_id
78    } else {
79        payload.parent_block_ids.first().copied()
80    }
81}
82
83/// DC-92: a per-process-invocation memo of already-verified block states, shared across every
84/// [`derive_next_state_root`]/[`verify_block_v2_state`] call within one `verify`, `seal`, or `merge`
85/// invocation. **Never persisted, never read across process invocations** — constructed empty by
86/// its caller and dropped when that invocation ends. This is why it does not engage NFR-PERF-04 or
87/// DC-64's trust-ladder ruling: there is no file, no cross-run state, nothing for either to govern.
88///
89/// **The load-bearing invariant.** An entry is inserted for block X only once X has passed *every*
90/// check the unmemoized path performs for it: shape validation ([`validate_block_v2_shape`]), schema
91/// version (`schema_version == 2`), and replay-and-compare against X's own recorded
92/// `state_merkle_root`. A memo entry means "X passed everything verification currently checks,"
93/// never "X's state happened to match." [`validate_v2_lineage`] enforces the shape/schema half
94/// before an entry can even be produced; [`verify_v2_lineage_roots`] and [`verify_block_v2_state`]
95/// enforce the replay-and-compare half before inserting. Neither ever writes a memo entry for a
96/// block whose own check failed or was skipped.
97///
98/// **Carries a `TextCache` alongside each state, not just the state itself.** A `TextFile` node's
99/// content identity after an `EditText` is not necessarily a stored blob (DC-65's invariant); a
100/// single continuous replay materializes it into a `TextCache` as it walks the lineage, and a later
101/// `EditText` against the same node depends on finding it there. Splitting a from-genesis replay
102/// into separately-memoized steps without carrying that cache forward reproduces exactly the gap
103/// `crate::lifecycle_cache::incremental`'s own one-block step already found and falls back to full
104/// replay for (see its module doc) — this was caught here by the existing test suite, not
105/// anticipated in the original design, and fixed by carrying the cache rather than by falling back,
106/// since blocks memoized here really are visited in lineage order and a real cache exists to carry.
107#[derive(Debug, Default)]
108pub(crate) struct LineageStateMemo {
109    verified: BTreeMap<ObjectId, (NodeLifecycleState, TextCache)>,
110}
111
112impl LineageStateMemo {
113    pub(crate) fn new() -> Self {
114        Self::default()
115    }
116
117    /// Number of entries currently live. Read by [`verify_blocks_topological`] to track its own
118    /// peak-concurrency diagnostic, and by the frontier-boundedness test that checks it.
119    pub(crate) fn len(&self) -> usize {
120        self.verified.len()
121    }
122
123    /// Drop `block_id`'s entry once [`verify_blocks_topological`] has determined no remaining block
124    /// still needs it (DC-92 §4.2) — the mechanism that turns O(N) live entries into O(frontier).
125    /// Not exposed outside this module: callers elsewhere in `verify`/`seal`/`merge` never share a
126    /// memo across more than the one derivation they asked for, so they have no reason to evict.
127    fn evict(&mut self, block_id: &ObjectId) {
128        self.verified.remove(block_id);
129    }
130}
131
132/// Derive the state root for a proposed format-2 Block from its parent and ordered Patches.
133///
134/// Convenience entry point for callers that only need one derivation and do not track a
135/// [`LineageStateMemo`] of their own — every current caller except `verify`'s own per-object loop.
136/// Constructs a fresh, call-scoped memo and delegates to [`derive_next_state_root_with_memo`]: this
137/// still gets the full benefit of DC-92's per-call fix (O(i²) → O(i) in lineage depth), just without
138/// sharing work across a *later*, separate call — which is exactly what a single `seal` or `merge`
139/// invocation is.
140pub fn derive_next_state_root(
141    reader: &impl ObjectReader,
142    parent: Option<ObjectId>,
143    patch_ids: &[ObjectId],
144) -> Result<MerkleRoot> {
145    derive_next_state_root_with_memo(reader, parent, patch_ids, &mut LineageStateMemo::new())
146}
147
148/// Same guarantee as [`derive_next_state_root`], but threading a caller-supplied
149/// [`LineageStateMemo`] through the whole derivation (DC-92). `verify`'s outer per-object loop is
150/// the one caller that needs this directly: constructing one memo before that loop and passing it to
151/// every block's [`verify_block_v2_state`] call is what takes `verify`'s total cost from O(N²) (one
152/// O(i) derivation per block, summed) to O(N) (every block's own state derived at most once for the
153/// whole invocation, however many later blocks' lineages reference it).
154pub(crate) fn derive_next_state_root_with_memo(
155    reader: &impl ObjectReader,
156    parent: Option<ObjectId>,
157    patch_ids: &[ObjectId],
158    memo: &mut LineageStateMemo,
159) -> Result<MerkleRoot> {
160    let (mut state, mut text_cache) = resolved_parent_state(reader, parent, memo)?;
161    apply_candidate_patches(reader, &mut state, &mut text_cache, patch_ids)?;
162    compute_state_root(&entries_from_state(&state)?)
163}
164
165/// Failure of [`derive_next_state_root_for_candidate`], split at exactly the point RFC 115 Stage 4
166/// needs classified: whether *the parent's own already-sealed lineage* failed to resolve (always an
167/// integrity failure -- this repository's own history is broken), or whether *applying the
168/// candidate patches themselves* failed (needs further classification by the caller, since an
169/// accepted-but-unsealed patch failing to apply to a receiver's own tip is an ordinary divergence,
170/// not corruption -- ordinary [`derive_next_state_root`] cannot tell these apart because
171/// `From<LifecycleReplayError> for PrikkError` flattens the variant away before a caller ever sees
172/// it).
173#[derive(Debug)]
174pub(crate) enum CandidateStateDerivationError {
175    /// The parent Block's own lineage did not resolve. This repository's own sealed history is
176    /// broken; the candidate patches were never reached.
177    Lineage(PrikkError),
178    /// Applying `patch_ids` onto the parent's resolved state failed. The caller must classify this
179    /// variant -- see RFC 115 Stage 4 handoff §4's ruled table.
180    Patch(LifecycleReplayError),
181}
182
183/// Same derivation as [`derive_next_state_root`], but for a caller that must distinguish *why* it
184/// failed rather than receive one flattened [`PrikkError`] (RFC 115 Stage 4 handoff §4). The only
185/// caller today is the seal-from-accepted path: the first place prikk applies patches that were not
186/// authored against the state they are being applied to, where conflating "this repository's own
187/// history is broken" with "these two histories merely diverged" would be a serious diagnostic
188/// defect. Every other caller of state derivation replays already-sealed history, where a patch
189/// failing to apply always does mean corruption -- this function changes nothing about that; it only
190/// stops discarding the distinction for the one caller that needs it.
191pub(crate) fn derive_next_state_root_for_candidate(
192    reader: &impl ObjectReader,
193    parent: Option<ObjectId>,
194    patch_ids: &[ObjectId],
195) -> std::result::Result<MerkleRoot, CandidateStateDerivationError> {
196    let (mut state, mut text_cache) =
197        resolved_parent_state(reader, parent, &mut LineageStateMemo::new())
198            .map_err(CandidateStateDerivationError::Lineage)?;
199    apply_candidate_patches(reader, &mut state, &mut text_cache, patch_ids)
200        .map_err(CandidateStateDerivationError::Patch)?;
201    compute_state_root(&entries_from_state(&state).map_err(CandidateStateDerivationError::Lineage)?)
202        .map_err(CandidateStateDerivationError::Lineage)
203}
204
205/// Shared by [`derive_next_state_root_with_memo`] and [`verify_block_v2_state`]: resolve `parent`'s
206/// state and carried `TextCache` (DC-92), verifying and memoizing anything not already known-good
207/// for this invocation. `None` (genesis parent) returns empty state and an empty cache, matching
208/// what a from-genesis replay starts from today.
209fn resolved_parent_state(
210    reader: &impl ObjectReader,
211    parent: Option<ObjectId>,
212    memo: &mut LineageStateMemo,
213) -> Result<(NodeLifecycleState, TextCache)> {
214    match parent {
215        Some(parent_id) => {
216            let lineage = validate_v2_lineage(reader, parent_id, memo)?;
217            verify_v2_lineage_roots(reader, &lineage, memo)?;
218            memo.verified.get(&parent_id).cloned().ok_or_else(|| {
219                PrikkError::Integrity(format!(
220                    "format-2 parent Block {parent_id} was not verified before state derivation"
221                ))
222            })
223        }
224        None => Ok((NodeLifecycleState::new(), TextCache::new())),
225    }
226}
227
228/// Recompute and compare one persisted format-2 Block's state root, threading a shared
229/// [`LineageStateMemo`] (DC-92) so a caller checking many blocks — `verify`'s outer per-object loop
230/// — never re-derives a block's state twice across the whole run. On success, this block's own
231/// verified state is inserted into `memo`, so a *later* block whose lineage passes through this one
232/// reuses it instead of re-deriving. Never inserted before success — see [`LineageStateMemo`]'s own
233/// doc for why that ordering is the entire point.
234pub(crate) fn verify_block_v2_state(
235    reader: &impl ObjectReader,
236    block_id: ObjectId,
237    payload: &BlockPayload,
238    memo: &mut LineageStateMemo,
239) -> Result<()> {
240    validate_block_v2_shape(payload)?;
241    let parent = state_derivation_parent(payload);
242    let (mut state, mut text_cache) = resolved_parent_state(reader, parent, memo)?;
243    apply_candidate_patches(reader, &mut state, &mut text_cache, &payload.patch_ids)?;
244    let computed = compute_state_root(&entries_from_state(&state)?)?;
245    if computed != payload.state_merkle_root {
246        return Err(PrikkError::Integrity(format!(
247            "format-2 Block {block_id} state root does not match authoritative replay"
248        )));
249    }
250    memo.verified.insert(block_id, (state, text_cache));
251    Ok(())
252}
253
254/// Outcome of attempting to verify one `CurrentV6` Block's state root during
255/// [`verify_blocks_topological`]'s whole-batch pass (DC-95 Stage 2 Level 2). Distinct from
256/// `verify::StageOutcome`/`StageStatus` (Level 1): there is no operator-requested halt at block
257/// granularity, so there is no `Halted` analogue — a block's non-evaluation is always because its
258/// own state-derivation parent did not itself evaluate, never because an unrelated walk stopped.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum BlockStateStatus {
261    /// The block's state root was independently re-derived and matches its recorded value.
262    Verified,
263    /// The block's own state-root check failed.
264    Failed {
265        /// The error the check raised.
266        message: String,
267    },
268    /// This block's state-derivation parent did not itself evaluate (`Failed` or `NotEvaluated`), so
269    /// this block's own state is undefined by construction and [`verify_block_v2_state`] was never
270    /// attempted for it — attempting anyway would mean either trusting an unsound parent or
271    /// re-deriving from genesis per descendant, defeating DC-92's whole memoization point.
272    /// `blocked_by` names this block's *immediate* state-derivation parent, not the root cause
273    /// (implementation review v1 §4 / Level 2 handoff §7 Q2: each record asserts only what it
274    /// knows — a reader follows the chain one hop at a time, exactly as `StageStatus::NotEvaluated`
275    /// requires at the stage level).
276    NotEvaluated {
277        /// This block's own state-derivation parent.
278        blocked_by: ObjectId,
279    },
280}
281
282/// One block's resolved outcome from [`verify_blocks_topological`].
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct BlockStateOutcome {
285    /// The block this outcome is for.
286    pub block_id: ObjectId,
287    /// How that block's state-root check resolved.
288    pub status: BlockStateStatus,
289}
290
291/// Result of [`verify_blocks_topological`]: one outcome per input block, plus its pre-existing
292/// diagnostic. Always exactly `blocks.len()` outcomes — no block may be silently absent, the same
293/// invariant Level 1's `StageOutcome` carries at the stage level.
294#[derive(Debug, Clone)]
295pub(crate) struct TopologicalVerification {
296    /// One outcome per block in `blocks`, in the order each was resolved (topological order, not
297    /// input order).
298    pub(crate) outcomes: Vec<BlockStateOutcome>,
299    /// Peak number of entries [`LineageStateMemo`] held live at any point during this call —
300    /// diagnostic only, unchanged by Level 2, read by the frontier-boundedness test and ignored by
301    /// every production caller (unread outside `#[cfg(test)]`, hence the attribute below).
302    #[allow(dead_code)]
303    pub(crate) peak_memo_entries: usize,
304}
305
306/// Verify every format-2 Block in `blocks` — `verify`'s own outer loop's batch, collected in
307/// ObjectId scan order by its Phase A pass — in **state-dependency order** rather than that scan
308/// order (DC-92 §4.2). `state_derivation_parent` reduces every block, including `Merge` (mainline
309/// parent only), to a single state-derivation parent, so the dependency structure here is always a
310/// tree/forest, never a general multi-parent DAG — the same simplification
311/// [`validate_v2_lineage`]'s own single-parent walk already relies on.
312///
313/// **Why this bounds memory, not just avoids re-deriving.** `verify_block_v2_state` is called on a
314/// block only once its state-derivation parent is already memoized (or it has none — a root). The
315/// instant every block that depends on a given memo entry has consumed it, that entry is evicted —
316/// so at most a handful of entries are ever live at once: exactly the "frontier" of the traversal,
317/// not the total block count. For a strict linear history the frontier is a small constant (two
318/// entries momentarily coexist right as a new tip is verified, before its now-fully-consumed parent
319/// is evicted) regardless of how deep the history is; for `B` concurrently open, never-merged
320/// branches, it is `O(B)`. This is the mechanism the implementation review's §4 measurement asked
321/// for: turning `LineageStateMemo` from something that grows with every block `verify` ever checks
322/// into something that only ever holds what the *traversal in progress* still needs.
323///
324/// Uses Kahn's algorithm — in-degree map, children map, FIFO queue — the same shape already
325/// established in this codebase by [`crate::merge_evidence::topological_order`] for a related but
326/// distinct purpose (ordering candidate blocks for merge evidence, over full `parent_block_ids`
327/// rather than the single state-derivation parent used here).
328///
329/// A block whose `state_derivation_parent` is not itself present in `blocks` (a format-1 ancestor at
330/// a format transition boundary, or a missing/wrong-schema parent under corruption) is treated as
331/// immediately ready — nothing in *this* batch to wait for — and `verify_block_v2_state`'s own
332/// internal lineage walk still runs for it exactly as before, so a genuine defect there is still
333/// caught with the same error it always was; this function adds no new trust in that path, only
334/// reordering the batch that *is* self-contained.
335///
336/// **DC-95 Stage 2 Level 2: item-contained.** A block whose own check fails no longer aborts the
337/// whole pass — it is recorded [`BlockStateStatus::Failed`] and the walk continues. Every block
338/// whose state-derivation parent resolved to anything but [`BlockStateStatus::Verified`] is recorded
339/// [`BlockStateStatus::NotEvaluated`] *without* attempting `verify_block_v2_state` at all: its state
340/// is undefined by construction (§ above), so nothing is gained by attempting it and failing a second,
341/// less informative way. The topological order Kahn's algorithm already establishes guarantees a
342/// block's parent (if in-batch) is always resolved before the block itself, so looking up the
343/// parent's already-recorded status is always safe. The batch-level cycle detection below remains a
344/// genuine whole-pass failure — a cycle violates the tree/forest structure every other guarantee in
345/// this function assumes, the same footing as a directory-shape violation one level up in `verify`'s
346/// own pipeline (DC-95 Stage 2 Level 2 Step 0 §1.1's structural/semantic split) — and this check is
347/// provably unreachable in practice regardless (round 6's ruling, kept for defense).
348pub(crate) fn verify_blocks_topological(
349    reader: &impl ObjectReader,
350    blocks: &[(ObjectId, BlockPayload)],
351    memo: &mut LineageStateMemo,
352) -> Result<TopologicalVerification> {
353    let by_id: BTreeMap<ObjectId, &BlockPayload> =
354        blocks.iter().map(|(id, payload)| (*id, payload)).collect();
355
356    let mut children: BTreeMap<ObjectId, Vec<ObjectId>> = BTreeMap::new();
357    let mut pending_parent: BTreeMap<ObjectId, bool> = BTreeMap::new();
358    for (id, payload) in blocks {
359        let has_in_batch_parent = match state_derivation_parent(payload) {
360            Some(parent_id) if by_id.contains_key(&parent_id) => {
361                children.entry(parent_id).or_default().push(*id);
362                true
363            }
364            _ => false,
365        };
366        pending_parent.insert(*id, has_in_batch_parent);
367    }
368    let mut remaining_children: BTreeMap<ObjectId, usize> = blocks
369        .iter()
370        .map(|(id, _)| (*id, children.get(id).map_or(0, Vec::len)))
371        .collect();
372
373    let mut ready: Vec<ObjectId> = pending_parent
374        .iter()
375        .filter(|&(_, has_parent)| !has_parent)
376        .map(|(id, _)| *id)
377        .collect();
378    ready.sort();
379    let mut queue: VecDeque<ObjectId> = ready.into();
380
381    let mut peak = memo.len();
382    let mut processed = BTreeSet::new();
383    let mut resolved: BTreeMap<ObjectId, BlockStateStatus> = BTreeMap::new();
384    let mut outcomes: Vec<BlockStateOutcome> = Vec::with_capacity(blocks.len());
385    while let Some(id) = queue.pop_front() {
386        let payload = by_id.get(&id).ok_or_else(|| {
387            PrikkError::Integrity("format-2 topological pass lost a block".into())
388        })?;
389        let in_batch_parent =
390            state_derivation_parent(payload).filter(|parent_id| by_id.contains_key(parent_id));
391        let blocking_parent =
392            in_batch_parent.and_then(|parent_id| match resolved.get(&parent_id) {
393                Some(BlockStateStatus::Verified) | None => None,
394                Some(BlockStateStatus::Failed { .. } | BlockStateStatus::NotEvaluated { .. }) => {
395                    Some(parent_id)
396                }
397            });
398        let status = if let Some(blocked_by) = blocking_parent {
399            BlockStateStatus::NotEvaluated { blocked_by }
400        } else {
401            match verify_block_v2_state(reader, id, payload, memo) {
402                Ok(()) => BlockStateStatus::Verified,
403                Err(err) => BlockStateStatus::Failed {
404                    message: err.to_string(),
405                },
406            }
407        };
408        peak = peak.max(memo.len());
409        resolved.insert(id, status.clone());
410        outcomes.push(BlockStateOutcome {
411            block_id: id,
412            status,
413        });
414        processed.insert(id);
415
416        if let Some(parent_id) = state_derivation_parent(payload) {
417            if let Some(count) = remaining_children.get_mut(&parent_id) {
418                *count = count.saturating_sub(1);
419                if *count == 0 {
420                    memo.evict(&parent_id);
421                }
422            }
423        }
424        if remaining_children.get(&id).copied() == Some(0) {
425            memo.evict(&id);
426        }
427
428        for child in children.get(&id).into_iter().flatten() {
429            let entry = pending_parent.get_mut(child).ok_or_else(|| {
430                PrikkError::Integrity("format-2 topological pass lost a tracked child".into())
431            })?;
432            *entry = false;
433            queue.push_back(*child);
434        }
435    }
436
437    if processed.len() != blocks.len() {
438        return Err(
439            match blocks
440                .iter()
441                .map(|(id, _)| *id)
442                .find(|id| !processed.contains(id))
443            {
444                Some(stuck) => {
445                    PrikkError::Integrity(format!("format-2 Block lineage cycle at {stuck}"))
446                }
447                None => PrikkError::Integrity(
448                    "format-2 topological pass detected an inconsistent cycle count".to_string(),
449                ),
450            },
451        );
452    }
453    Ok(TopologicalVerification {
454        outcomes,
455        peak_memo_entries: peak,
456    })
457}
458
459/// Walk parent pointers from `tip` back toward genesis, stopping at genesis *or* at the first
460/// ancestor already present in `memo` (DC-92) — whichever comes first. Reading, decoding, and
461/// shape-validating a block already known-good for this invocation is exactly the redundant work
462/// memoization exists to eliminate, so the walk itself stops there rather than only the replay that
463/// follows it; without this, `verify`'s outer loop would still cost O(N²) in lineage-pointer walks
464/// alone, even with every replay memoized.
465///
466/// Returns the *unresolved* suffix, ordered tip-to-boundary — every entry the caller still needs to
467/// verify. An empty result means `tip` itself was already in `memo`; the caller has nothing left to
468/// do for this lineage.
469fn validate_v2_lineage(
470    reader: &impl ObjectReader,
471    tip: ObjectId,
472    memo: &LineageStateMemo,
473) -> Result<Vec<(ObjectId, BlockPayload)>> {
474    let mut visited = BTreeSet::new();
475    let mut lineage = Vec::new();
476    let mut current = Some(tip);
477    while let Some(block_id) = current {
478        if memo.verified.contains_key(&block_id) {
479            break;
480        }
481        if !visited.insert(block_id) {
482            return Err(PrikkError::Integrity(format!(
483                "format-2 Block lineage cycle at {block_id}"
484            )));
485        }
486        let envelope = reader.read_object(block_id)?.ok_or_else(|| {
487            PrikkError::Integrity(format!("format-2 parent Block {block_id} is missing"))
488        })?;
489        if envelope.object_type != ObjectType::Block {
490            return Err(PrikkError::ObjectTypeMismatch {
491                expected: ObjectType::Block.to_string(),
492                actual: envelope.object_type.to_string(),
493            });
494        }
495        if envelope.schema_version != 2 {
496            return Err(PrikkError::Integrity(format!(
497                "format-2 lineage contains Block {block_id} with schema {}",
498                envelope.schema_version
499            )));
500        }
501        let payload = BlockPayload::decode_canonical(&envelope.canonical_payload)?;
502        validate_block_v2_shape(&payload)?;
503        current = state_derivation_parent(&payload);
504        lineage.push((block_id, payload));
505    }
506    Ok(lineage)
507}
508
509/// Verify and memoize every not-yet-memoized entry `validate_v2_lineage` returned, genesis-to-tip
510/// (DC-92). The starting state is either `memo`'s entry for the boundary ancestor
511/// `validate_v2_lineage` stopped at, or an empty state if the walk reached true genesis (`None`
512/// parent) — `validate_v2_lineage`'s own stopping rule guarantees one of those two is always the
513/// case, never a boundary with no recorded state.
514fn verify_v2_lineage_roots(
515    reader: &impl ObjectReader,
516    lineage_from_tip: &[(ObjectId, BlockPayload)],
517    memo: &mut LineageStateMemo,
518) -> Result<()> {
519    let Some((_, deepest)) = lineage_from_tip.last() else {
520        // Everything in this lineage was already memoized; validate_v2_lineage returned nothing
521        // left to do.
522        return Ok(());
523    };
524    let (mut state, mut text_cache) = match state_derivation_parent(deepest) {
525        Some(parent_id) => memo.verified.get(&parent_id).cloned().ok_or_else(|| {
526            PrikkError::Integrity(format!(
527                "format-2 parent Block {parent_id} was not verified before state derivation"
528            ))
529        })?,
530        None => (NodeLifecycleState::new(), TextCache::new()),
531    };
532    for (block_id, payload) in lineage_from_tip.iter().rev() {
533        apply_one_block_with_text_cache(reader, payload, &mut state, &mut text_cache)?;
534        let computed = compute_state_root(&entries_from_state(&state)?)?;
535        if computed != payload.state_merkle_root {
536            return Err(PrikkError::Integrity(format!(
537                "format-2 parent Block {block_id} state root does not match authoritative replay"
538            )));
539        }
540        memo.verified
541            .insert(*block_id, (state.clone(), text_cache.clone()));
542    }
543    Ok(())
544}
545
546#[cfg(test)]
547mod tests;