Skip to main content

objects/transfer/
graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{HashSet, VecDeque};
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    error::{HeddleError, Result},
8    object::{
9        AnnotatedTag, BindingDelta, ContentHash, RedactionsBlob, ReverseDependencyIndex,
10        SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
11        StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, TreeEntryTarget,
12        decode_tree_delta_header, is_delta_tree,
13    },
14    store::{ObjectStore, pack::ObjectType as PackObjectType},
15};
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum ObjectId {
19    Hash(ContentHash),
20    StateId(StateId),
21    StateAttachment {
22        state: StateId,
23        id: StateAttachmentId,
24        /// The attachment's kind, a pure projection of its body
25        /// ([`StateAttachmentBody::kind`]). Carried through the wire so
26        /// descriptors self-describe their kind; the dedup/identity key is
27        /// still `(state, id)`, and kind is coherent under `Eq`/`Hash` because
28        /// it is a deterministic function of the same record.
29        kind: StateAttachmentKind,
30    },
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ObjectInfo {
35    pub id: ObjectId,
36    pub obj_type: ObjectType,
37    pub size: u64,
38    pub delta_base: Option<ContentHash>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub struct PlannedObject {
43    pub id: ObjectId,
44    pub obj_type: ObjectType,
45}
46
47#[derive(Debug, Clone)]
48pub struct StateClosureTransferObjects {
49    pub planned_objects: Vec<PlannedObject>,
50    pub full_objects: Option<Vec<ObjectInfo>>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum ObjectType {
55    Blob,
56    Tree,
57    State,
58    Action,
59    AnnotatedTag,
60    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
61    /// that a specific blob has been redacted by a writer. Keyed on the
62    /// wire by `ObjectId::Hash` of
63    /// the *redacted blob*, since `Repository`'s sidecar store is
64    /// indexed that way.
65    Redaction,
66    /// An owner-authorized purge sidecar. It carries the same encoded
67    /// `RedactionsBlob` as `Redaction`, but is a distinct operation because
68    /// receiving it can irreversibly erase blob bytes.
69    Purge,
70    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
71    /// declaring a non-public audience tier for a specific state. Keyed
72    /// on the wire by `ObjectId::StateId` of the state, since the
73    /// per-state sidecar store is indexed that way. Like `Redaction`, it
74    /// is a sidecar record that lives outside the content-addressed pack
75    /// and ships via the per-object transfer path, not the pack.
76    StateVisibility,
77    StateAttachment,
78    /// A content-addressed `KeyBindingRegistry` together with each binding's
79    /// revocation/liveness overlay. Hosted materializers append this object to
80    /// a closure and carry it out of pack because the native pack format has no
81    /// key-binding record kind.
82    KeyBinding,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ObjectTypeBucket {
87    Blob,
88    Tree,
89    State,
90    Action,
91    AnnotatedTag,
92    Redaction,
93    Purge,
94    StateVisibility,
95    StateAttachment,
96    KeyBinding,
97}
98
99impl ObjectType {
100    pub fn wire_name(self) -> &'static str {
101        match self {
102            ObjectType::Blob => "blob",
103            ObjectType::Tree => "tree",
104            ObjectType::State => "state",
105            ObjectType::Action => "action",
106            ObjectType::AnnotatedTag => "annotated_tag",
107            ObjectType::Redaction => "redaction",
108            ObjectType::Purge => "purge",
109            ObjectType::StateVisibility => "state_visibility",
110            ObjectType::StateAttachment => "state_attachment",
111            ObjectType::KeyBinding => "key_binding",
112        }
113    }
114
115    pub fn from_wire(value: &str) -> Result<Self> {
116        match value {
117            "blob" => Ok(ObjectType::Blob),
118            "tree" => Ok(ObjectType::Tree),
119            "state" => Ok(ObjectType::State),
120            "action" => Ok(ObjectType::Action),
121            "annotated_tag" => Ok(ObjectType::AnnotatedTag),
122            "redaction" => Ok(ObjectType::Redaction),
123            "purge" => Ok(ObjectType::Purge),
124            "state_visibility" => Ok(ObjectType::StateVisibility),
125            "state_attachment" => Ok(ObjectType::StateAttachment),
126            "key_binding" => Ok(ObjectType::KeyBinding),
127            _ => Err(HeddleError::InvalidObject(format!(
128                "unknown object type: {value}"
129            ))),
130        }
131    }
132
133    /// Whether this object type can ride the content-addressed native pack in
134    /// general (the historical, direction-agnostic predicate). Sidecar records
135    /// (`Redaction`, `StateVisibility`) live outside `.heddle/objects/` and can
136    /// never be packed; everything else can.
137    ///
138    /// Prefer [`ObjectType::packable_for_push`] /
139    /// [`ObjectType::packable_for_pull`] at transfer sites: packability is
140    /// direction-dependent for `StateAttachment` (see those methods). This
141    /// method retains the pull/general semantics so pack construction,
142    /// have-set, and local-copy planners are unchanged.
143    pub fn packable(self) -> bool {
144        !matches!(
145            self,
146            ObjectType::Redaction
147                | ObjectType::Purge
148                | ObjectType::StateVisibility
149                | ObjectType::KeyBinding
150        )
151    }
152
153    /// Whether this object type may ride the client→server **push** pack.
154    ///
155    /// `StateAttachment` is deliberately excluded on push even though it is a
156    /// content-addressed object: a deployed server rejects any pack-carried
157    /// attachment as a forgery-prevention measure (weft#549). Pushed
158    /// attachments must instead ride the out-of-pack **sidecar** lane (the same
159    /// lane as `Redaction`/`StateVisibility`), where the server can verify them
160    /// per-kind at finalize while the pack itself stays forgery-sealed. Every
161    /// other type keeps its [`ObjectType::packable`] answer.
162    pub fn packable_for_push(self) -> bool {
163        self.packable() && !matches!(self, ObjectType::StateAttachment)
164    }
165
166    /// Whether this object type may ride the server→client **pull/clone** pack.
167    ///
168    /// Same boolean as [`ObjectType::packable`] so planners still list
169    /// `StateAttachment`. Weft does not put those records in the native pack:
170    /// it streams `PullServerFrame::StateAttachment`. Discussions attachments
171    /// are not the live transport (v2 chain vs this client's v1 blob); clone
172    /// treats an advertised-but-unconsumable pack as a ListByState fallback.
173    pub fn packable_for_pull(self) -> bool {
174        self.packable()
175    }
176
177    pub fn pack_object_type(self) -> Result<PackObjectType> {
178        match self {
179            ObjectType::Blob => Ok(PackObjectType::Blob),
180            ObjectType::Tree => Ok(PackObjectType::Tree),
181            ObjectType::State => Ok(PackObjectType::State),
182            ObjectType::Action => Ok(PackObjectType::Action),
183            ObjectType::AnnotatedTag => Ok(PackObjectType::AnnotatedTag),
184            ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
185            ObjectType::Redaction => Err(HeddleError::InvalidObject(
186                "Redaction sidecar records cannot be packed into the content-addressed object pack"
187                    .to_string(),
188            )),
189            ObjectType::Purge => Err(HeddleError::InvalidObject(
190                "Purge sidecar records cannot be packed into the content-addressed object pack"
191                    .to_string(),
192            )),
193            ObjectType::StateVisibility => Err(HeddleError::InvalidObject(
194                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
195                    .to_string(),
196            )),
197            ObjectType::KeyBinding => Err(HeddleError::InvalidObject(
198                "KeyBinding registry objects cannot be packed into the content-addressed object pack"
199                    .to_string(),
200            )),
201        }
202    }
203
204    pub fn bucket(self) -> ObjectTypeBucket {
205        match self {
206            ObjectType::Blob => ObjectTypeBucket::Blob,
207            ObjectType::Tree => ObjectTypeBucket::Tree,
208            ObjectType::State => ObjectTypeBucket::State,
209            ObjectType::Action => ObjectTypeBucket::Action,
210            ObjectType::AnnotatedTag => ObjectTypeBucket::AnnotatedTag,
211            ObjectType::Redaction => ObjectTypeBucket::Redaction,
212            ObjectType::Purge => ObjectTypeBucket::Purge,
213            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
214            ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
215            ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
216        }
217    }
218}
219
220#[derive(Debug, Clone, Default)]
221pub struct StateClosureOptions {
222    pub depth: Option<u32>,
223    pub exclude_states: Vec<StateId>,
224}
225
226pub fn enumerate_state_closure(
227    store: &impl ObjectStore,
228    state_id: StateId,
229) -> Result<Vec<ObjectInfo>> {
230    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
231}
232
233pub fn enumerate_state_closure_with_options(
234    store: &impl ObjectStore,
235    state_id: StateId,
236    options: StateClosureOptions,
237) -> Result<Vec<ObjectInfo>> {
238    let mut out = Vec::new();
239    walk_state_closure(store, state_id, options, |event| {
240        if let Some(info) = object_info_from_event(store, event)? {
241            out.push(info);
242        }
243        Ok(())
244    })?;
245    for (hash, tag) in annotated_tags_for_state(store, state_id)? {
246        out.push(annotated_tag_info(hash, &tag));
247    }
248
249    Ok(out)
250}
251
252pub fn enumerate_state_closure_plan(
253    store: &impl ObjectStore,
254    state_id: StateId,
255) -> Result<Vec<PlannedObject>> {
256    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
257}
258
259pub fn enumerate_state_closure_plan_with_options(
260    store: &impl ObjectStore,
261    state_id: StateId,
262    options: StateClosureOptions,
263) -> Result<Vec<PlannedObject>> {
264    let mut out = Vec::new();
265    walk_state_closure(store, state_id, options, |event| {
266        if let Some(object) = planned_object_from_event(store, event)? {
267            out.push(object);
268        }
269        Ok(())
270    })?;
271    out.extend(
272        annotated_tags_for_state(store, state_id)?
273            .into_iter()
274            .map(|(hash, _)| PlannedObject {
275                id: ObjectId::Hash(hash),
276                obj_type: ObjectType::AnnotatedTag,
277            }),
278    );
279
280    Ok(out)
281}
282
283pub fn enumerate_state_closure_transfer_with_options(
284    store: &impl ObjectStore,
285    state_id: StateId,
286    options: StateClosureOptions,
287    full_descriptor_object_threshold: usize,
288) -> Result<StateClosureTransferObjects> {
289    let mut planned_objects = Vec::new();
290    let mut full_objects = Some(Vec::new());
291
292    walk_state_closure(store, state_id, options, |event| {
293        if let Some(object) = planned_object_from_event(store, event)? {
294            planned_objects.push(object);
295        }
296
297        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
298            full_objects = None;
299        }
300        if let Some(objects) = full_objects.as_mut()
301            && let Some(info) = object_info_from_event(store, event)?
302        {
303            objects.push(info);
304        }
305
306        Ok(())
307    })?;
308    let tags = annotated_tags_for_state(store, state_id)?;
309    planned_objects.extend(tags.iter().map(|(hash, _)| PlannedObject {
310        id: ObjectId::Hash(*hash),
311        obj_type: ObjectType::AnnotatedTag,
312    }));
313    if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
314        full_objects = None;
315    }
316    if let Some(objects) = full_objects.as_mut() {
317        objects.extend(
318            tags.iter()
319                .map(|(hash, tag)| annotated_tag_info(*hash, tag)),
320        );
321    }
322
323    Ok(StateClosureTransferObjects {
324        planned_objects,
325        full_objects,
326    })
327}
328
329fn annotated_tags_for_state(
330    store: &impl ObjectStore,
331    state_id: StateId,
332) -> Result<Vec<(ContentHash, AnnotatedTag)>> {
333    let mut roots = Vec::new();
334    for hash in store.list_annotated_tags()? {
335        let Some(tag) = store.get_annotated_tag(&hash)? else {
336            continue;
337        };
338        if tag
339            .marker()
340            .is_some_and(|marker| marker.peeled_state == state_id)
341        {
342            roots.push((hash, tag));
343        }
344    }
345
346    let mut tags = Vec::new();
347    let mut seen = HashSet::new();
348    let mut stack = roots;
349    while let Some((hash, tag)) = stack.pop() {
350        if !seen.insert(hash) {
351            continue;
352        }
353        if let Some(inner_hash) = tag.target_tag() {
354            let inner = store.get_annotated_tag(&inner_hash)?.ok_or_else(|| {
355                HeddleError::NotFound(format!(
356                    "annotated tag {hash} references missing inner tag {inner_hash}"
357                ))
358            })?;
359            stack.push((inner_hash, inner));
360        }
361        tags.push((hash, tag));
362    }
363    Ok(tags)
364}
365
366fn annotated_tag_info(hash: ContentHash, tag: &AnnotatedTag) -> ObjectInfo {
367    ObjectInfo {
368        id: ObjectId::Hash(hash),
369        obj_type: ObjectType::AnnotatedTag,
370        size: tag.encode_current_msgpack().len() as u64,
371        delta_base: None,
372    }
373}
374
375/// Enumerate a transfer delta while treating `boundary_states` as complete
376/// server-held roots. Unlike [`StateClosureOptions::exclude_states`], this does
377/// not expand each boundary's tree/history to build a hash exclusion set: the
378/// walk stops as soon as it reaches the boundary state. Objects reused by the
379/// new tip may still be advertised, and the receiver's have-set filters them.
380/// This keeps incremental push planning proportional to the new history.
381pub fn enumerate_state_closure_transfer_from_boundaries(
382    store: &impl ObjectStore,
383    state_id: StateId,
384    boundary_states: &[StateId],
385    full_descriptor_object_threshold: usize,
386) -> Result<StateClosureTransferObjects> {
387    let mut planned_objects = Vec::new();
388    let mut full_objects = Some(Vec::new());
389    let excluded_states = boundary_states.iter().copied().collect();
390
391    walk_state_closure_with_exclusions(
392        store,
393        state_id,
394        None,
395        excluded_states,
396        HashSet::new(),
397        |event| {
398            if let Some(object) = planned_object_from_event(store, event)? {
399                planned_objects.push(object);
400            }
401
402            if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
403                full_objects = None;
404            }
405            if let Some(objects) = full_objects.as_mut()
406                && let Some(info) = object_info_from_event(store, event)?
407            {
408                objects.push(info);
409            }
410
411            Ok(())
412        },
413    )?;
414
415    Ok(StateClosureTransferObjects {
416        planned_objects,
417        full_objects,
418    })
419}
420
421#[derive(Debug, Clone, Copy)]
422enum StateClosureEvent<'a> {
423    State {
424        id: StateId,
425        state: &'a State,
426    },
427    Tree {
428        hash: ContentHash,
429        storage_size: u64,
430        delta_base: Option<ContentHash>,
431    },
432    Blob {
433        hash: ContentHash,
434    },
435    Redaction {
436        blob: ContentHash,
437    },
438    StateVisibility {
439        state: StateId,
440    },
441    StateAttachment {
442        state: StateId,
443        attachment: &'a StateAttachment,
444    },
445    ExcludedState {
446        id: StateId,
447    },
448    ExcludedHash {
449        hash: ContentHash,
450    },
451}
452
453fn walk_state_closure(
454    store: &impl ObjectStore,
455    state_id: StateId,
456    options: StateClosureOptions,
457    visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
458) -> Result<()> {
459    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
460
461    walk_state_closure_with_exclusions(
462        store,
463        state_id,
464        options.depth,
465        excluded_states,
466        excluded_hashes,
467        visit,
468    )
469}
470
471fn walk_state_closure_with_exclusions(
472    store: &impl ObjectStore,
473    state_id: StateId,
474    max_depth: Option<u32>,
475    excluded_states: HashSet<StateId>,
476    excluded_hashes: HashSet<ContentHash>,
477    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
478) -> Result<()> {
479    let mut seen_states: HashSet<StateId> = HashSet::new();
480    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
481    let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
482    queue.push_back((state_id, 0));
483
484    while let Some((id, depth)) = queue.pop_front() {
485        if excluded_states.contains(&id) {
486            visit(StateClosureEvent::ExcludedState { id })?;
487            continue;
488        }
489        if !seen_states.insert(id) {
490            continue;
491        }
492
493        let state = store
494            .get_state(&id)?
495            .ok_or_else(|| HeddleError::MissingObject {
496                object_type: "state".to_string(),
497                id: id.to_string(),
498            })?;
499
500        visit(StateClosureEvent::State { id, state: &state })?;
501        if store.has_state_visibility_for_state(&id)? {
502            visit(StateClosureEvent::StateVisibility { state: id })?;
503        }
504        for attachment in store.list_state_attachments(&id)? {
505            visit(StateClosureEvent::StateAttachment {
506                state: id,
507                attachment: &attachment,
508            })?;
509            match attachment.body {
510                StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
511                    store,
512                    root,
513                    &excluded_hashes,
514                    &mut seen_hashes,
515                    &mut visit,
516                )?,
517                StateAttachmentBody::RiskSignals(hash)
518                | StateAttachmentBody::ReviewSignatures(hash)
519                | StateAttachmentBody::Discussions(hash)
520                | StateAttachmentBody::StructuredConflicts(hash) => {
521                    walk_blob_filtered(store, hash, &excluded_hashes, &mut seen_hashes, &mut visit)?
522                }
523                StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
524                    store,
525                    root,
526                    &excluded_hashes,
527                    &mut seen_hashes,
528                    &mut visit,
529                )?,
530                StateAttachmentBody::Signature(_) => {}
531            }
532        }
533
534        if max_depth.map(|max| depth < max).unwrap_or(true) {
535            for parent in &state.parents {
536                queue.push_back((*parent, depth + 1));
537            }
538        }
539
540        walk_tree_closure_filtered(
541            store,
542            state.tree,
543            &excluded_hashes,
544            &mut seen_hashes,
545            &mut visit,
546        )?;
547        if let Some(provenance_root) = state.provenance {
548            walk_tree_closure_filtered(
549                store,
550                provenance_root,
551                &excluded_hashes,
552                &mut seen_hashes,
553                &mut visit,
554            )?;
555        }
556    }
557
558    Ok(())
559}
560
561fn walk_tree_closure_filtered(
562    store: &impl ObjectStore,
563    tree_hash: ContentHash,
564    excluded: &HashSet<ContentHash>,
565    seen: &mut HashSet<ContentHash>,
566    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
567) -> Result<()> {
568    if excluded.contains(&tree_hash) {
569        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
570        return Ok(());
571    }
572    if !seen.insert(tree_hash) {
573        return Ok(());
574    }
575
576    let tree = store
577        .get_tree(&tree_hash)?
578        .ok_or_else(|| HeddleError::MissingObject {
579            object_type: "tree".to_string(),
580            id: tree_hash.to_hex(),
581        })?;
582
583    let (storage_size, delta_base) = tree_storage_metadata(store, tree_hash)?;
584    visit(StateClosureEvent::Tree {
585        hash: tree_hash,
586        storage_size,
587        delta_base,
588    })?;
589
590    if let Some(anchor) = delta_base {
591        walk_tree_storage_anchor(store, anchor, seen, visit)?;
592    }
593
594    for entry in tree.entries() {
595        match entry.target() {
596            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
597                walk_blob_filtered(store, *hash, excluded, seen, visit)?;
598            }
599            TreeEntryTarget::Tree { hash } => {
600                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
601            }
602            TreeEntryTarget::Gitlink { .. } => {}
603            // Native child-spool edge: its target lives in a separate spool
604            // object graph, not this store, so it is not walked here.
605            TreeEntryTarget::Spoollink { .. } => {}
606        }
607    }
608
609    Ok(())
610}
611
612fn tree_storage_metadata(
613    store: &impl ObjectStore,
614    tree_hash: ContentHash,
615) -> Result<(u64, Option<ContentHash>)> {
616    let body =
617        store
618            .get_tree_serialized(&tree_hash)?
619            .ok_or_else(|| HeddleError::MissingObject {
620                object_type: "tree".to_string(),
621                id: tree_hash.to_hex(),
622            })?;
623    let delta_base = if is_delta_tree(&body) {
624        let header = decode_tree_delta_header(&body)?;
625        if header.anchor == tree_hash {
626            return Err(HeddleError::InvalidObject(
627                "HDC1 result id must differ from its anchor id".to_string(),
628            ));
629        }
630        Some(header.anchor)
631    } else {
632        None
633    };
634    Ok((body.len() as u64, delta_base))
635}
636
637fn walk_tree_storage_anchor(
638    store: &impl ObjectStore,
639    anchor_hash: ContentHash,
640    seen: &mut HashSet<ContentHash>,
641    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
642) -> Result<()> {
643    if !seen.insert(anchor_hash) {
644        return Ok(());
645    }
646    if store.get_tree(&anchor_hash)?.is_none() {
647        return Err(HeddleError::MissingObject {
648            object_type: "tree delta anchor".to_string(),
649            id: anchor_hash.to_hex(),
650        });
651    }
652    let (storage_size, delta_base) = tree_storage_metadata(store, anchor_hash)?;
653    if delta_base.is_some() {
654        return Err(HeddleError::InvalidObject(
655            "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
656        ));
657    }
658    visit(StateClosureEvent::Tree {
659        hash: anchor_hash,
660        storage_size,
661        delta_base: None,
662    })
663}
664
665fn walk_blob_filtered(
666    store: &impl ObjectStore,
667    blob_hash: ContentHash,
668    excluded: &HashSet<ContentHash>,
669    seen: &mut HashSet<ContentHash>,
670    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
671) -> Result<()> {
672    if excluded.contains(&blob_hash) {
673        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
674        return Ok(());
675    }
676    if !seen.insert(blob_hash) {
677        return Ok(());
678    }
679    visit(StateClosureEvent::Blob { hash: blob_hash })?;
680    if store.has_redactions_for_blob(&blob_hash)? {
681        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
682    }
683    Ok(())
684}
685
686/// Walk the merkle semantic-index closure rooted at `root_hash` (a
687/// `SemanticIndexRoot` blob), emitting every reachable semantic node blob.
688///
689/// All semantic-index nodes (root, tree nodes, file nodes) are stored as
690/// ordinary content-addressed blobs, so replication just enumerates them.
691/// Opaque entries point back at raw source blobs already covered by the state's
692/// tree closure, so they are not re-walked here.
693///
694/// Iterative (explicit stack) so a crafted deep `SemanticTreeNode` chain in a
695/// pushed state can't overflow the stack. A missing or undecodable node in the
696/// closure is a HARD failure (`ObjectNotFound`/`Serialization`) — a partial or
697/// corrupt semantic closure must never be shipped silently.
698fn walk_semantic_index_closure(
699    store: &impl ObjectStore,
700    root_hash: ContentHash,
701    excluded: &HashSet<ContentHash>,
702    seen: &mut HashSet<ContentHash>,
703    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
704) -> Result<()> {
705    // Stack of (node_hash, is_tree_node): the root and dir nodes must be decoded
706    // to enumerate their children; file/opaque leaves are emitted only.
707    let mut stack: Vec<ContentHash> = vec![root_hash];
708    while let Some(node_hash) = stack.pop() {
709        if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
710            continue; // excluded (present on the far side) or already seen.
711        }
712        let blob = store
713            .get_blob(&node_hash)?
714            .ok_or_else(|| missing_blob(node_hash))?;
715        // The root and tree nodes decode to the same `SemanticTreeNode.entries`
716        // shape after the root's one indirection; walk uniformly by decoding
717        // every interior node as a tree node, tolerating the root shape.
718        let node = decode_semantic_container(&blob, node_hash)?;
719        for child in node {
720            match child {
721                SemanticChild::Interior(hash) => stack.push(hash),
722                SemanticChild::Leaf(hash) => {
723                    // Emit the leaf (file node) blob; it has no children.
724                    emit_semantic_blob(store, hash, excluded, seen, visit)?;
725                }
726                SemanticChild::BindingDelta(hash) => {
727                    // Binding deltas are state-scoped attachments. Emit this
728                    // state's direct delta; ancestor states contribute their
729                    // own deltas when (and only when) the state walk reaches
730                    // them. Following `delta.parent` here would cross a
731                    // shallow-clone boundary.
732                    emit_binding_delta(store, hash, excluded, seen, visit)?;
733                }
734                SemanticChild::ImporterIndex(hash) => {
735                    emit_importer_index(store, hash, excluded, seen, visit)?;
736                }
737            }
738        }
739    }
740    Ok(())
741}
742
743/// Child kinds encountered while walking a state's semantic closure.
744enum SemanticChild {
745    Interior(ContentHash),
746    Leaf(ContentHash),
747    BindingDelta(ContentHash),
748    ImporterIndex(ContentHash),
749}
750
751/// Decode a semantic-closure node — the root (which points at a tree node) or a
752/// tree node — into the child hashes to walk. Missing/corrupt is a hard error.
753fn decode_semantic_container(
754    blob: &crate::object::Blob,
755    node_hash: ContentHash,
756) -> Result<Vec<SemanticChild>> {
757    // Try the root shape first (it has an extra `tree` indirection), then the
758    // tree-node shape. Content-addressed hashes make this unambiguous in
759    // practice; a blob that decodes as neither is corrupt.
760    if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
761        let mut children = vec![SemanticChild::Interior(root.tree)];
762        if let Some(binding_delta) = root.binding_delta {
763            children.push(SemanticChild::BindingDelta(binding_delta));
764        }
765        if let Some(importer_index) = root.importer_index {
766            children.push(SemanticChild::ImporterIndex(importer_index));
767        }
768        return Ok(children);
769    }
770    let node = SemanticTreeNode::decode(blob.content())
771        .map_err(|err| HeddleError::Serialization(format!("semantic node {node_hash}: {err}")))?;
772    Ok(node
773        .entries
774        .iter()
775        .filter_map(|entry| match entry.kind {
776            SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
777            SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
778            // Opaque `node` is the raw source blob, already in the tree closure.
779            SemanticEntryKind::Opaque => None,
780        })
781        .collect())
782}
783
784fn emit_binding_delta(
785    store: &impl ObjectStore,
786    hash: ContentHash,
787    excluded: &HashSet<ContentHash>,
788    seen: &mut HashSet<ContentHash>,
789    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
790) -> Result<()> {
791    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
792        return Ok(());
793    }
794    let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
795    BindingDelta::decode(blob.content()).map_err(|err| {
796        HeddleError::Serialization(format!("semantic binding delta {hash}: {err}"))
797    })?;
798    Ok(())
799}
800
801fn emit_importer_index(
802    store: &impl ObjectStore,
803    hash: ContentHash,
804    excluded: &HashSet<ContentHash>,
805    seen: &mut HashSet<ContentHash>,
806    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
807) -> Result<()> {
808    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
809        return Ok(());
810    }
811    let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
812    ReverseDependencyIndex::decode(blob.content()).map_err(|err| {
813        HeddleError::Serialization(format!("semantic reverse-dependency index {hash}: {err}"))
814    })?;
815    Ok(())
816}
817
818/// Emit a semantic node blob as state metadata. Returns `true` when the blob
819/// was newly visited (so the caller may descend into it), `false` when it was
820/// excluded (present on the far side) or already seen. A blob that is neither
821/// excluded nor present is a HARD failure — the closure must be complete.
822fn emit_semantic_blob(
823    store: &impl ObjectStore,
824    hash: ContentHash,
825    excluded: &HashSet<ContentHash>,
826    seen: &mut HashSet<ContentHash>,
827    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
828) -> Result<bool> {
829    if excluded.contains(&hash) {
830        visit(StateClosureEvent::ExcludedHash { hash })?;
831        return Ok(false);
832    }
833    if !seen.insert(hash) {
834        return Ok(false);
835    }
836    if store.get_blob(&hash)?.is_none() {
837        return Err(missing_blob(hash));
838    }
839    visit(StateClosureEvent::Blob { hash })?;
840    Ok(true)
841}
842
843/// Collect every semantic-index node blob hash reachable from `root_hash` into
844/// `excluded`, for the have-set computation. Iterative + tolerant (a broken
845/// have-set index is not fatal — it just means fewer objects are marked
846/// already-present, which is safe over-fetching, not corruption).
847fn collect_semantic_hashes(
848    store: &impl ObjectStore,
849    root_hash: ContentHash,
850    excluded: &mut HashSet<ContentHash>,
851) -> Result<()> {
852    let mut stack: Vec<ContentHash> = vec![root_hash];
853    while let Some(node_hash) = stack.pop() {
854        if !excluded.insert(node_hash) {
855            continue;
856        }
857        let Some(blob) = store.get_blob(&node_hash)? else {
858            continue;
859        };
860        let children = match decode_semantic_container(&blob, node_hash) {
861            Ok(children) => children,
862            Err(_) => continue,
863        };
864        for child in children {
865            match child {
866                SemanticChild::Interior(hash) => stack.push(hash),
867                SemanticChild::Leaf(hash) => {
868                    excluded.insert(hash);
869                }
870                SemanticChild::BindingDelta(hash) | SemanticChild::ImporterIndex(hash) => {
871                    excluded.insert(hash);
872                }
873            }
874        }
875    }
876    Ok(())
877}
878
879fn object_info_from_event(
880    store: &impl ObjectStore,
881    event: StateClosureEvent<'_>,
882) -> Result<Option<ObjectInfo>> {
883    match event {
884        StateClosureEvent::State { id, state } => {
885            let state_bytes = rmp_serde::to_vec_named(state)?;
886            Ok(Some(ObjectInfo {
887                id: ObjectId::StateId(id),
888                obj_type: ObjectType::State,
889                size: state_bytes.len() as u64,
890                delta_base: None,
891            }))
892        }
893        StateClosureEvent::Tree {
894            hash,
895            storage_size,
896            delta_base,
897        } => Ok(Some(ObjectInfo {
898            id: ObjectId::Hash(hash),
899            obj_type: ObjectType::Tree,
900            size: storage_size,
901            delta_base,
902        })),
903        StateClosureEvent::Blob { hash, .. } => {
904            let Some(blob) = store.get_blob(&hash)? else {
905                if blob_has_purge_evidence(store, &hash)? {
906                    return Ok(None);
907                }
908                return Err(missing_blob(hash));
909            };
910            Ok(Some(ObjectInfo {
911                id: ObjectId::Hash(hash),
912                obj_type: ObjectType::Blob,
913                size: blob.size() as u64,
914                delta_base: None,
915            }))
916        }
917        StateClosureEvent::Redaction { blob } => Ok(store
918            .get_redactions_bytes_for_blob(&blob)?
919            .map(|bytes| ObjectInfo {
920                id: ObjectId::Hash(blob),
921                obj_type: ObjectType::Redaction,
922                size: bytes.len() as u64,
923                delta_base: None,
924            })),
925        StateClosureEvent::StateVisibility { state } => Ok(store
926            .get_state_visibility_bytes_for_state(&state)?
927            .map(|bytes| ObjectInfo {
928                id: ObjectId::StateId(state),
929                obj_type: ObjectType::StateVisibility,
930                size: bytes.len() as u64,
931                delta_base: None,
932            })),
933        StateClosureEvent::StateAttachment { state, attachment } => {
934            let bytes = rmp_serde::to_vec_named(attachment)?;
935            Ok(Some(ObjectInfo {
936                id: ObjectId::StateAttachment {
937                    state,
938                    id: attachment.id(),
939                    kind: attachment.body.kind(),
940                },
941                obj_type: ObjectType::StateAttachment,
942                size: bytes.len() as u64,
943                delta_base: None,
944            }))
945        }
946        StateClosureEvent::ExcludedState { id } => {
947            let _ = id;
948            Ok(None)
949        }
950        StateClosureEvent::ExcludedHash { hash } => {
951            let _ = hash;
952            Ok(None)
953        }
954    }
955}
956
957fn planned_object_from_event(
958    store: &impl ObjectStore,
959    event: StateClosureEvent<'_>,
960) -> Result<Option<PlannedObject>> {
961    match event {
962        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
963            id: ObjectId::StateId(id),
964            obj_type: ObjectType::State,
965        })),
966        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
967            id: ObjectId::Hash(hash),
968            obj_type: ObjectType::Tree,
969        })),
970        StateClosureEvent::Blob { hash, .. } => {
971            if store.get_blob(&hash)?.is_none() {
972                if blob_has_purge_evidence(store, &hash)? {
973                    return Ok(None);
974                }
975                return Err(missing_blob(hash));
976            }
977            Ok(Some(PlannedObject {
978                id: ObjectId::Hash(hash),
979                obj_type: ObjectType::Blob,
980            }))
981        }
982        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
983            id: ObjectId::Hash(blob),
984            obj_type: ObjectType::Redaction,
985        })),
986        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
987            id: ObjectId::StateId(state),
988            obj_type: ObjectType::StateVisibility,
989        })),
990        StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
991            id: ObjectId::StateAttachment {
992                state,
993                id: attachment.id(),
994                kind: attachment.body.kind(),
995            },
996            obj_type: ObjectType::StateAttachment,
997        })),
998        StateClosureEvent::ExcludedState { id } => {
999            let _ = id;
1000            Ok(None)
1001        }
1002        StateClosureEvent::ExcludedHash { hash } => {
1003            let _ = hash;
1004            Ok(None)
1005        }
1006    }
1007}
1008
1009/// A purged blob is intentionally absent from the object closure; its sidecar
1010/// remains and is verified by the receiver before the absence is accepted.
1011/// Mere redaction never excuses a missing blob.
1012fn missing_blob(hash: ContentHash) -> HeddleError {
1013    HeddleError::MissingObject {
1014        object_type: "blob".to_string(),
1015        id: hash.to_hex(),
1016    }
1017}
1018
1019fn blob_has_purge_evidence(store: &impl ObjectStore, hash: &ContentHash) -> Result<bool> {
1020    let Some(bytes) = store.get_redactions_bytes_for_blob(hash)? else {
1021        return Ok(false);
1022    };
1023    let redactions = RedactionsBlob::decode(&bytes).map_err(|error| {
1024        HeddleError::InvalidObject(format!(
1025            "invalid redaction sidecar for missing blob {}: {error}",
1026            hash.to_hex()
1027        ))
1028    })?;
1029    Ok(redactions
1030        .redactions
1031        .iter()
1032        .any(|redaction| redaction.redacted_blob == *hash && redaction.is_purged()))
1033}
1034
1035pub fn missing_blobs_in_tree(
1036    store: &impl ObjectStore,
1037    tree_hash: ContentHash,
1038) -> Result<Vec<ContentHash>> {
1039    let mut missing = Vec::new();
1040    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
1041    Ok(missing)
1042}
1043
1044fn collect_missing_blobs_recursive(
1045    store: &impl ObjectStore,
1046    tree_hash: &ContentHash,
1047    missing: &mut Vec<ContentHash>,
1048) -> Result<()> {
1049    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
1050        HeddleError::InvalidObject(format!(
1051            "load tree {} while collecting lazy hydration missing blobs: {err}",
1052            tree_hash.to_hex()
1053        ))
1054    })?
1055    else {
1056        return Ok(());
1057    };
1058
1059    for entry in tree.entries() {
1060        match entry.target() {
1061            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1062                if !store.has_blob(hash).map_err(|err| {
1063                    HeddleError::InvalidObject(format!(
1064                        "check blob {} while collecting lazy hydration missing blobs: {err}",
1065                        hash.to_hex()
1066                    ))
1067                })? {
1068                    missing.push(*hash);
1069                }
1070            }
1071            TreeEntryTarget::Tree { hash } => {
1072                collect_missing_blobs_recursive(store, hash, missing)?;
1073            }
1074            TreeEntryTarget::Gitlink { .. } => {}
1075            // Native child-spool edge: its target lives in a separate spool
1076            // object graph, not this store, so it is not walked here.
1077            TreeEntryTarget::Spoollink { .. } => {}
1078        }
1079    }
1080    Ok(())
1081}
1082
1083fn collect_excluded(
1084    store: &impl ObjectStore,
1085    roots: &[StateId],
1086) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
1087    if roots.is_empty() {
1088        return Ok((HashSet::new(), HashSet::new()));
1089    }
1090
1091    let mut excluded_states: HashSet<StateId> = HashSet::new();
1092    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
1093    let mut queue: VecDeque<StateId> = VecDeque::new();
1094
1095    for id in roots {
1096        queue.push_back(*id);
1097    }
1098
1099    while let Some(id) = queue.pop_front() {
1100        if !excluded_states.insert(id) {
1101            continue;
1102        }
1103
1104        let state = match store.get_state(&id)? {
1105            Some(state) => state,
1106            None => continue,
1107        };
1108
1109        for parent in &state.parents {
1110            queue.push_back(*parent);
1111        }
1112
1113        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
1114        if let Some(provenance_root) = state.provenance {
1115            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
1116        }
1117        for attachment in store.list_state_attachments(&id)? {
1118            match attachment.body {
1119                StateAttachmentBody::Context(root) => {
1120                    collect_tree_hashes(store, root, &mut excluded_hashes)?
1121                }
1122                StateAttachmentBody::RiskSignals(hash)
1123                | StateAttachmentBody::ReviewSignatures(hash)
1124                | StateAttachmentBody::Discussions(hash)
1125                | StateAttachmentBody::StructuredConflicts(hash) => {
1126                    excluded_hashes.insert(hash);
1127                }
1128                StateAttachmentBody::SemanticIndex(root) => {
1129                    collect_semantic_hashes(store, root, &mut excluded_hashes)?;
1130                }
1131                StateAttachmentBody::Signature(_) => {}
1132            }
1133        }
1134    }
1135
1136    Ok((excluded_states, excluded_hashes))
1137}
1138
1139fn collect_tree_hashes(
1140    store: &impl ObjectStore,
1141    tree_hash: ContentHash,
1142    excluded: &mut HashSet<ContentHash>,
1143) -> Result<()> {
1144    if !excluded.insert(tree_hash) {
1145        return Ok(());
1146    }
1147
1148    let tree = match store.get_tree(&tree_hash)? {
1149        Some(tree) => tree,
1150        None => return Ok(()),
1151    };
1152
1153    for entry in tree.entries() {
1154        match entry.target() {
1155            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1156                excluded.insert(*hash);
1157            }
1158            TreeEntryTarget::Tree { hash } => {
1159                collect_tree_hashes(store, *hash, excluded)?;
1160            }
1161            TreeEntryTarget::Gitlink { .. } => {}
1162            // Native child-spool edge: its target lives in a separate spool
1163            // object graph, not this store, so it is not walked here.
1164            TreeEntryTarget::Spoollink { .. } => {}
1165        }
1166    }
1167
1168    Ok(())
1169}
1170
1171pub fn is_ancestor(
1172    store: &impl ObjectStore,
1173    ancestor: StateId,
1174    descendant: StateId,
1175) -> Result<bool> {
1176    if ancestor == descendant {
1177        return Ok(true);
1178    }
1179
1180    let mut seen: HashSet<StateId> = HashSet::new();
1181    let mut queue: VecDeque<StateId> = VecDeque::new();
1182    queue.push_back(descendant);
1183
1184    while let Some(id) = queue.pop_front() {
1185        if !seen.insert(id) {
1186            continue;
1187        }
1188        let state = match store.get_state(&id)? {
1189            Some(s) => s,
1190            None => return Ok(false),
1191        };
1192        for parent in state.parents {
1193            if parent == ancestor {
1194                return Ok(true);
1195            }
1196            queue.push_back(parent);
1197        }
1198    }
1199
1200    Ok(false)
1201}