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;