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;