Skip to main content

prikk_store/
patch_set_digest.rs

1//! RFC 115 Stage 1 (design-v1.md §5, D4) — the patch-set digest: a canonical value over the set of
2//! patch ids reachable from a ref, answering "are these two repositories the same?" at the level
3//! where identity actually holds (RFC 115 §2.5-§2.7). Shaped like `state_root.rs`, not `id.rs`: a
4//! comparison value, not a storable object -- a dedicated newtype, never an `ObjectId`, and no
5//! `(object_type, schema_version)` pair.
6//!
7//! **RFC 117 T1: `PatchSetDigest` itself now lives in `prikk-object`**, re-exported here unchanged
8//! (see this module's own `pub use` below) -- `TagPayload` carries one as its field 6, and
9//! `prikk-object` cannot depend on `prikk-store`. Every function that *computes* a digest stays
10//! here, the same split `MerkleRoot` (`prikk-object`) and `compute_state_root` (`prikk-store`)
11//! already have.
12//!
13//! **Ref-kind support, per `RFC-115-stage-1-reachable-set-ruling-v1.md`:**
14//! - `heads/*` (`RefKind::Branch`): `target_object_id` names the Block directly.
15//! - `tags/*` (`RefKind::Tag`): `target_object_id` names a Tag object one hop away from the Block
16//!   (`tag.rs`'s own model: "ref -> tag object -> block"); this module resolves that second hop
17//!   itself, which `export_bundle` does not (`bundle-export-tag-ref-gap-v1.md`, a separate defect,
18//!   not fixed here). The digest is the digest of the target Block's own patch-set closure only --
19//!   the Tag object carries a name, an optional message and a signature, none of which are patches,
20//!   and folding them in would make two repositories holding identical patches compare unequal over
21//!   tag metadata, which inverts the whole purpose (ruling §2.2).
22//! - `remotes/*` (received pointers): **refused explicitly**, not resolved. Not a difficulty --
23//!   `received_index.rs`'s resolution is straightforward -- but the received namespace is precisely
24//!   what patch-level exchange itself restructures (design D2/D5), and its digest semantics belong
25//!   to Stage 3, not assumed here. Refusing by name check, before any `RefStore` lookup, so the
26//!   error names the real reason rather than a misleading "ref does not exist" (ruling §2.3).
27//! - Closed refs need no special case: `RefStatePayload.closed` gates further publication, it does
28//!   not change what `target_object_id` names (ruling §2.4).
29
30use std::collections::{BTreeMap, BTreeSet, VecDeque};
31
32use prikk_error::{PrikkError, Result};
33use prikk_hash::sha256;
34use prikk_object::{BlockPayload, ObjectId, ObjectType, RefStatePayload};
35
36use crate::layout::RepositoryLayout;
37use crate::merge_evidence::ancestors_inclusive;
38use crate::object_store::{ObjectReadSnapshot, ObjectReader};
39use crate::refs::{RefStore, resolve_ref_tip_block};
40
41/// RFC 117 T1: the newtype itself now lives in `prikk-object` (`TagPayload` carries one, and
42/// `prikk-object` cannot depend on this crate) -- re-exported here so every existing
43/// `crate::patch_set_digest::PatchSetDigest` / `prikk_store::PatchSetDigest` path keeps resolving
44/// to the same type, unchanged. Every function below that *computes* one stays here, matching
45/// `MerkleRoot`/`compute_state_root`'s own split.
46pub use prikk_object::PatchSetDigest;
47
48const PATCH_SET_DIGEST_DOMAIN: &[u8] = b"PRIKK-PATCH-SET-DIGEST-v1";
49
50/// Construct the exact preimage over an already-sorted, deduplicated, strictly-ascending slice of
51/// patch ids. **Identity-bearing** (documented in `release-compatibility.md`'s frozen list): two
52/// prikk versions must produce identical bytes over the same patch set, or the comparison this
53/// digest exists for means nothing across an upgrade.
54pub fn patch_set_digest_preimage(patch_ids: &[ObjectId]) -> Result<Vec<u8>> {
55    if !prikk_object::canonical::is_strictly_sorted(patch_ids) {
56        return Err(PrikkError::Integrity(
57            "patch-set digest input is not strictly sorted and deduplicated".to_string(),
58        ));
59    }
60    let count = u64::try_from(patch_ids.len())
61        .map_err(|_| PrikkError::Integrity("patch-set digest count exceeds u64".to_string()))?;
62    let mut preimage = Vec::with_capacity(PATCH_SET_DIGEST_DOMAIN.len() + 8 + patch_ids.len() * 32);
63    preimage.extend_from_slice(PATCH_SET_DIGEST_DOMAIN);
64    preimage.extend_from_slice(&count.to_be_bytes());
65    for patch_id in patch_ids {
66        preimage.extend_from_slice(patch_id.as_bytes());
67    }
68    Ok(preimage)
69}
70
71/// Compute the patch-set digest over an already-sorted, deduplicated slice of patch ids. The count
72/// is hashed even when `patch_ids` is empty, so an empty set is distinguishable from a degenerate
73/// one (matching `state_root.rs`'s own empty-case discipline, `compute_state_root`).
74pub fn compute_patch_set_digest(patch_ids: &[ObjectId]) -> Result<PatchSetDigest> {
75    Ok(PatchSetDigest(sha256(&patch_set_digest_preimage(
76        patch_ids,
77    )?)))
78}
79
80/// Every patch id reachable from `tip_block_id`'s ancestry, sorted and deduplicated -- the same
81/// closure `export_bundle` walks (`bundle.rs:189,208-209`), narrowed to patch ids only (no blobs,
82/// no attestations: Stage 1's scope is the patch set, nothing else, per the handoff's §6).
83pub fn patch_ids_reachable_from_block(
84    object_store: &impl ObjectReader,
85    tip_block_id: ObjectId,
86) -> Result<Vec<ObjectId>> {
87    let ancestors = ancestors_inclusive(object_store, tip_block_id)?;
88    let mut patch_ids: BTreeSet<ObjectId> = BTreeSet::new();
89    for block in ancestors.values() {
90        patch_ids.extend(block.patch_ids.iter().copied());
91    }
92    Ok(patch_ids.into_iter().collect())
93}
94
95/// Compute the patch-set digest for the closure reachable from `tip_block_id` directly -- the
96/// block-rooted core, ref-resolution-agnostic, so a caller that has already resolved a ref through
97/// any mechanism (`RefStore`, a received pointer, a future one) can reach the same computation
98/// without this module re-deriving how to resolve it.
99pub fn compute_patch_set_digest_from_block(
100    object_store: &impl ObjectReader,
101    tip_block_id: ObjectId,
102) -> Result<PatchSetDigest> {
103    compute_patch_set_digest(&patch_ids_reachable_from_block(object_store, tip_block_id)?)
104}
105
106/// RFC 117 T7: the digest and the count together, over one traversal -- `patch_count` is not new
107/// information (`patch_set_digest_preimage` already hashes it), so a caller populating both of a
108/// `TagPayload`'s field 6/7 should never pay for `patch_ids_reachable_from_block`'s own
109/// `ancestors_inclusive` walk twice.
110pub fn compute_patch_set_digest_and_count_from_block(
111    object_store: &impl ObjectReader,
112    tip_block_id: ObjectId,
113) -> Result<(PatchSetDigest, u64)> {
114    let patch_ids = patch_ids_reachable_from_block(object_store, tip_block_id)?;
115    let count = crate::fsutil::len_to_u64(patch_ids.len())?;
116    Ok((compute_patch_set_digest(&patch_ids)?, count))
117}
118
119/// Resolve `ref_name` to its target Block, per this module's own doc: `heads/*` directly, `tags/*`
120/// through the Tag object's own `target_block_id` (the two-hop core itself is
121/// `refs::resolve_ref_tip_block`, shared with `bundle.rs` and `patch_exchange.rs`). `remotes/*` is
122/// refused explicitly before any `RefStore` lookup is attempted, so the refusal names the real
123/// reason (ruling §2.3) rather than a misleading "ref does not exist".
124fn resolve_ref_to_tip_block(
125    layout: &RepositoryLayout,
126    object_store: &impl ObjectReader,
127    ref_name: &str,
128) -> Result<ObjectId> {
129    if ref_name.starts_with("remotes/") {
130        return Err(PrikkError::Integrity(format!(
131            "patch-set digest does not support received refs ({ref_name}) yet -- the received \
132             namespace is what patch-level exchange itself restructures (RFC 115 design D2/D5); \
133             its digest semantics belong to a later stage, not assumed here"
134        )));
135    }
136    let ref_store = RefStore::new(layout.clone());
137    let Some(ref_state_id) = ref_store.read_current_ref_state_id(ref_name)? else {
138        return Err(PrikkError::Integrity(format!(
139            "ref {ref_name} does not exist, nothing to compute a patch-set digest for"
140        )));
141    };
142    let ref_state_envelope = object_store
143        .read_typed(ref_state_id, ObjectType::RefState)?
144        .ok_or_else(|| PrikkError::Integrity(format!("missing RefState object: {ref_state_id}")))?;
145    let ref_state_payload = RefStatePayload::decode_canonical(
146        &ref_state_envelope.canonical_payload,
147        ref_state_envelope.schema_version,
148    )?;
149    let (target_block_id, _tag_envelope) = resolve_ref_tip_block(object_store, &ref_state_payload)?;
150    Ok(target_block_id)
151}
152
153/// The ref-rooted entry point, and the one two independent repositories can actually use: neither
154/// side can name the other's Block id (that is the premise the digest exists to work around, RFC
155/// 115 design §7), so a Block-rooted call alone would not serve the digest's own purpose.
156pub fn compute_patch_set_digest_for_ref(
157    layout: &RepositoryLayout,
158    ref_name: &str,
159) -> Result<PatchSetDigest> {
160    let object_store = ObjectReadSnapshot::open(layout)?;
161    let tip_block_id = resolve_ref_to_tip_block(layout, &object_store, ref_name)?;
162    compute_patch_set_digest_from_block(&object_store, tip_block_id)
163}
164
165/// RFC 117 T2: the outcome of [`resolve_patch_set_digest`]. `NotHeld` is not an error -- the
166/// ordinary "you have not synced that far yet" case -- and ambiguity is never a variant here:
167/// design T2 rules more-than-one-match a refusal, so a caller cannot accidentally proceed on an
168/// ambiguous answer by pattern-matching past it.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum PatchSetResolution {
171    /// No local block reachable from any `heads/*`/`tags/*` ref has this patch set.
172    NotHeld,
173    /// Exactly one local block has this patch set.
174    Resolved(ObjectId),
175}
176
177/// RFC 117 T2: resolve a patch-set digest to the local block it names, among every block reachable
178/// from any local `heads/*`/`tags/*` ref (`remotes/*` excluded -- unsealed received history the
179/// operator has not adopted, consistent with `resolve_ref_to_tip_block`'s own refusal of it).
180///
181/// **Single pass over the reachable block DAG, not a per-candidate re-walk** (§3): a naive
182/// "for each candidate block, re-walk its ancestry" is O(blocks × closure), the same shape that made
183/// `verify` roughly O(N³) before RFC 111. Instead this computes every candidate's patch-closure
184/// *once*, in topological (parents-before-children) order -- the same Kahn's-algorithm scaffolding
185/// `merge_evidence::topological_order` and `order_claims_for_sealing` already use for their own,
186/// different orderings, restated here rather than shared (each has its own node/edge shape) --
187/// accumulating each block's closure from its parents' already-computed closures plus its own
188/// `patch_ids`. A parent's closure is **moved**, not cloned, into its child whenever that child is
189/// its last remaining consumer (the ordinary single-parent, single-child case, i.e. most of any real
190/// history) -- cloned only at genuine fan-out (a block with more than one child in the candidate
191/// set, or a merge
192/// block with more than one parent) -- and dropped entirely once every consumer has taken it. Peak
193/// memory therefore tracks the DAG's width, not its length.
194///
195/// **RFC 117 T7: `patch_count` prunes before hashing, and is never trusted alone.** Stage 2 measured
196/// this function at O(N²) for one long linear branch: every candidate's closure had to be *hashed in
197/// full* to compare against the caller's opaque target, because `PatchSetDigest` alone reveals
198/// nothing about the closure it summarizes -- no candidate could be skipped. `patch_count` is not new
199/// information (`patch_set_digest_preimage` already hashes `DOMAIN ‖ count ‖ sorted ids`); it exposes
200/// a fact the digest already commits to, cheaply enough to compare before hashing rather than after.
201/// The comparison is inserted **into** the existing single pass below (`closure.len()` against the
202/// caller's `patch_count`, immediately before the existing `compute_patch_set_digest` call) -- no
203/// second traversal, no materializing every closure up front, and the move/clone-on-fan-out scheme
204/// is untouched. In a linear history closure sizes are 1, 2, 3, … N, all distinct, so exactly one
205/// candidate is ever hashed; O(N²) collapses to O(N log N) (RFC 117 stage 2a report has the
206/// remeasured numbers). A branchy history prunes less completely but still enormously.
207///
208/// **The count is a hint that prunes, never an authority (design §9.4): a wrong `patch_count` can
209/// only cause the right candidate to be skipped (→ `NotHeld`) or extra candidates to be hashed (→
210/// slower) -- it can never produce a wrong resolution, because the digest still has to match.** The
211/// same tried-not-trusted shape D6 §11.6 already established for a different object, one field over.
212/// A tag whose count disagrees with its own digest is simply self-inconsistent and never resolves.
213///
214/// **Two or more matching blocks refuse, naming every one** -- never picked, never the ref tip,
215/// never the newest. Ambiguity is reachable in production, not only in fixtures: since RFC 115
216/// Stage 4, accepted patches are sealed locally, so two branches can seal the same accepted patch
217/// set in a different order, giving the same patch-set digest and two distinct block ids (every
218/// block has at least one patch -- `seal`/`merge_execute` both refuse an empty one -- so this can
219/// only happen via a genuinely different patch order, not an accidentally-shared closure).
220pub fn resolve_patch_set_digest(
221    layout: &RepositoryLayout,
222    digest: PatchSetDigest,
223    patch_count: u64,
224) -> Result<PatchSetResolution> {
225    let object_store = ObjectReadSnapshot::open(layout)?;
226    let ref_store = RefStore::new(layout.clone());
227
228    // §2's candidate set: every block reachable from any local heads/*-or-tags/* ref. Reuses this
229    // module's own `resolve_ref_to_tip_block` (not a fifth ref-tip resolution copy) and
230    // `merge_evidence::ancestors_inclusive` (not a third traversal) -- `list_ref_pointers` itself
231    // never names a `remotes/*` entry, since received pointers live in a wholly separate index
232    // (`received_index.rs`), so no explicit exclusion is needed here beyond what both already do.
233    let mut candidates: BTreeMap<ObjectId, BlockPayload> = BTreeMap::new();
234    for pointer in ref_store.list_ref_pointers()? {
235        let tip_block_id = resolve_ref_to_tip_block(layout, &object_store, &pointer.ref_name)?;
236        candidates.extend(ancestors_inclusive(&object_store, tip_block_id)?);
237    }
238
239    // Build the forward (parent -> child) edges and each block's remaining-parent/remaining-child
240    // counts. Every parent of a candidate block is itself a candidate: `ancestors_inclusive` walks
241    // all the way to genesis for each tip, so the union above is already closed under "parent of".
242    let mut remaining_parents: BTreeMap<ObjectId, usize> = BTreeMap::new();
243    let mut children: BTreeMap<ObjectId, Vec<ObjectId>> = BTreeMap::new();
244    for (&block_id, block) in &candidates {
245        remaining_parents.insert(block_id, block.parent_block_ids.len());
246        for &parent_id in &block.parent_block_ids {
247            children.entry(parent_id).or_default().push(block_id);
248        }
249    }
250    let mut remaining_children: BTreeMap<ObjectId, usize> = candidates
251        .keys()
252        .map(|&block_id| {
253            let count = children.get(&block_id).map_or(0, Vec::len);
254            (block_id, count)
255        })
256        .collect();
257
258    let mut ready: Vec<ObjectId> = remaining_parents
259        .iter()
260        .filter(|&(_, &count)| count == 0)
261        .map(|(&block_id, _)| block_id)
262        .collect();
263    ready.sort_unstable();
264    let mut queue: VecDeque<ObjectId> = ready.into();
265
266    let mut live_closures: BTreeMap<ObjectId, BTreeSet<ObjectId>> = BTreeMap::new();
267    let mut matches: Vec<ObjectId> = Vec::new();
268
269    while let Some(block_id) = queue.pop_front() {
270        let block = candidates.get(&block_id).ok_or_else(|| {
271            PrikkError::Integrity(
272                "patch-set digest resolution lost a tracked candidate -- internal inconsistency"
273                    .to_string(),
274            )
275        })?;
276
277        let mut closure: BTreeSet<ObjectId> = BTreeSet::new();
278        for &parent_id in &block.parent_block_ids {
279            let parent_closure =
280                take_parent_closure(parent_id, &mut live_closures, &mut remaining_children)?;
281            if closure.is_empty() {
282                // First parent (the overwhelmingly common single-parent case): move it in directly
283                // rather than merging into an empty set.
284                closure = parent_closure;
285            } else {
286                closure.extend(parent_closure);
287            }
288        }
289        closure.extend(block.patch_ids.iter().copied());
290
291        // RFC 117 T7 §9.2: the size is free -- this pass already built the set -- so try it before
292        // paying for a hash. A mismatch here only ever means "not this candidate," never "not a
293        // match despite matching," since a size match alone never enters `matches`: the digest
294        // comparison below still has to agree too.
295        if crate::fsutil::len_to_u64(closure.len())? == patch_count {
296            let sorted: Vec<ObjectId> = closure.iter().copied().collect();
297            if compute_patch_set_digest(&sorted)? == digest {
298                matches.push(block_id);
299            }
300        }
301
302        if remaining_children.get(&block_id).copied().unwrap_or(0) > 0 {
303            live_closures.insert(block_id, closure);
304        }
305
306        for &child_id in children.get(&block_id).into_iter().flatten() {
307            let entry = remaining_parents.get_mut(&child_id).ok_or_else(|| {
308                PrikkError::Integrity(
309                    "patch-set digest resolution lost a tracked child -- internal inconsistency"
310                        .to_string(),
311                )
312            })?;
313            *entry -= 1;
314            if *entry == 0 {
315                queue.push_back(child_id);
316            }
317        }
318    }
319
320    match matches.len() {
321        0 => Ok(PatchSetResolution::NotHeld),
322        1 => {
323            let block_id = matches.pop().ok_or_else(|| {
324                PrikkError::Integrity(
325                    "patch-set digest resolution: exactly one match reported but none present -- \
326                     internal inconsistency"
327                        .to_string(),
328                )
329            })?;
330            Ok(PatchSetResolution::Resolved(block_id))
331        }
332        _ => {
333            matches.sort_unstable();
334            let names = matches
335                .iter()
336                .map(ObjectId::to_string)
337                .collect::<Vec<_>>()
338                .join(", ");
339            Err(PrikkError::Integrity(format!(
340                "patch-set digest resolves to {} distinct local blocks, refusing to pick: {names}",
341                matches.len()
342            )))
343        }
344    }
345}
346
347/// Take one parent's already-computed closure for a child now consuming it: **moved** out of
348/// `live_closures` if this is the parent's last remaining child (the ordinary case), **cloned** if
349/// other children still need it. Either way the parent's own remaining-child count is decremented,
350/// and its stored closure is dropped once that count reaches zero.
351fn take_parent_closure(
352    parent_id: ObjectId,
353    live_closures: &mut BTreeMap<ObjectId, BTreeSet<ObjectId>>,
354    remaining_children: &mut BTreeMap<ObjectId, usize>,
355) -> Result<BTreeSet<ObjectId>> {
356    let count = remaining_children.get_mut(&parent_id).ok_or_else(|| {
357        PrikkError::Integrity(
358            "patch-set digest resolution lost a parent's remaining-child count -- internal \
359             inconsistency"
360                .to_string(),
361        )
362    })?;
363    *count = count.checked_sub(1).ok_or_else(|| {
364        PrikkError::Integrity(
365            "patch-set digest resolution consumed a parent's closure more times than it has \
366             children -- internal inconsistency"
367                .to_string(),
368        )
369    })?;
370    if *count == 0 {
371        live_closures.remove(&parent_id).ok_or_else(|| {
372            PrikkError::Integrity(format!(
373                "patch-set digest resolution: parent {parent_id} has no live closure to take"
374            ))
375        })
376    } else {
377        live_closures.get(&parent_id).cloned().ok_or_else(|| {
378            PrikkError::Integrity(format!(
379                "patch-set digest resolution: parent {parent_id} has no live closure to clone"
380            ))
381        })
382    }
383}
384
385#[cfg(test)]
386mod tests;