Skip to main content

wire/
object_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{HashSet, VecDeque};
3
4use objects::{
5    object::{
6        AnnotatedTag, BindingDelta, ContentHash, RedactionsBlob, ReverseDependencyIndex,
7        SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
8        StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, TreeEntryTarget,
9    },
10    store::{ObjectStore, pack::ObjectType as PackObjectType},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::{ProtocolError, Result};
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum ObjectId {
18    Hash(ContentHash),
19    StateId(StateId),
20    StateAttachment {
21        state: StateId,
22        id: StateAttachmentId,
23        /// The attachment's kind, a pure projection of its body
24        /// ([`StateAttachmentBody::kind`]). Carried through the wire so
25        /// descriptors self-describe their kind; the dedup/identity key is
26        /// still `(state, id)`, and kind is coherent under `Eq`/`Hash` because
27        /// it is a deterministic function of the same record.
28        kind: StateAttachmentKind,
29    },
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ObjectInfo {
34    pub id: ObjectId,
35    pub obj_type: ObjectType,
36    pub size: u64,
37    pub delta_base: Option<ContentHash>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41pub struct PlannedObject {
42    pub id: ObjectId,
43    pub obj_type: ObjectType,
44}
45
46#[derive(Debug, Clone)]
47pub struct StateClosureTransferObjects {
48    pub planned_objects: Vec<PlannedObject>,
49    pub full_objects: Option<Vec<ObjectInfo>>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub enum ObjectType {
54    Blob,
55    Tree,
56    State,
57    Action,
58    AnnotatedTag,
59    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
60    /// that a specific blob has been redacted by a writer. Keyed on the
61    /// wire by `ObjectId::Hash` of
62    /// the *redacted blob*, since `Repository`'s sidecar store is
63    /// indexed that way.
64    Redaction,
65    /// An owner-authorized purge sidecar. It carries the same encoded
66    /// `RedactionsBlob` as `Redaction`, but is a distinct operation because
67    /// receiving it can irreversibly erase blob bytes.
68    Purge,
69    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
70    /// declaring a non-public audience tier for a specific state. Keyed
71    /// on the wire by `ObjectId::StateId` of the state, since the
72    /// per-state sidecar store is indexed that way. Like `Redaction`, it
73    /// is a sidecar record that lives outside the content-addressed pack
74    /// and ships via the per-object transfer path, not the pack.
75    StateVisibility,
76    StateAttachment,
77    /// A content-addressed `KeyBindingRegistry` together with each binding's
78    /// revocation/liveness overlay. Hosted materializers append this object to
79    /// a closure and carry it out of pack because the native pack format has no
80    /// key-binding record kind.
81    KeyBinding,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum ObjectTypeBucket {
86    Blob,
87    Tree,
88    State,
89    Action,
90    AnnotatedTag,
91    Redaction,
92    Purge,
93    StateVisibility,
94    StateAttachment,
95    KeyBinding,
96}
97
98impl ObjectType {
99    pub fn wire_name(self) -> &'static str {
100        match self {
101            ObjectType::Blob => "blob",
102            ObjectType::Tree => "tree",
103            ObjectType::State => "state",
104            ObjectType::Action => "action",
105            ObjectType::AnnotatedTag => "annotated_tag",
106            ObjectType::Redaction => "redaction",
107            ObjectType::Purge => "purge",
108            ObjectType::StateVisibility => "state_visibility",
109            ObjectType::StateAttachment => "state_attachment",
110            ObjectType::KeyBinding => "key_binding",
111        }
112    }
113
114    pub fn from_wire(value: &str) -> Result<Self> {
115        match value {
116            "blob" => Ok(ObjectType::Blob),
117            "tree" => Ok(ObjectType::Tree),
118            "state" => Ok(ObjectType::State),
119            "action" => Ok(ObjectType::Action),
120            "annotated_tag" => Ok(ObjectType::AnnotatedTag),
121            "redaction" => Ok(ObjectType::Redaction),
122            "purge" => Ok(ObjectType::Purge),
123            "state_visibility" => Ok(ObjectType::StateVisibility),
124            "state_attachment" => Ok(ObjectType::StateAttachment),
125            "key_binding" => Ok(ObjectType::KeyBinding),
126            _ => Err(ProtocolError::InvalidState(format!(
127                "unknown object type: {value}"
128            ))),
129        }
130    }
131
132    /// Whether this object type can ride the content-addressed native pack in
133    /// general (the historical, direction-agnostic predicate). Sidecar records
134    /// (`Redaction`, `StateVisibility`) live outside `.heddle/objects/` and can
135    /// never be packed; everything else can.
136    ///
137    /// Prefer [`ObjectType::packable_for_push`] /
138    /// [`ObjectType::packable_for_pull`] at transfer sites: packability is
139    /// direction-dependent for `StateAttachment` (see those methods). This
140    /// method retains the pull/general semantics so pack construction,
141    /// have-set, and local-copy planners are unchanged.
142    pub fn packable(self) -> bool {
143        !matches!(
144            self,
145            ObjectType::Redaction
146                | ObjectType::Purge
147                | ObjectType::StateVisibility
148                | ObjectType::KeyBinding
149        )
150    }
151
152    /// Whether this object type may ride the client→server **push** pack.
153    ///
154    /// `StateAttachment` is deliberately excluded on push even though it is a
155    /// content-addressed object: a deployed server rejects any pack-carried
156    /// attachment as a forgery-prevention measure (weft#549). Pushed
157    /// attachments must instead ride the out-of-pack **sidecar** lane (the same
158    /// lane as `Redaction`/`StateVisibility`), where the server can verify them
159    /// per-kind at finalize while the pack itself stays forgery-sealed. Every
160    /// other type keeps its [`ObjectType::packable`] answer.
161    pub fn packable_for_push(self) -> bool {
162        self.packable() && !matches!(self, ObjectType::StateAttachment)
163    }
164
165    /// Whether this object type may ride the server→client **pull/clone** pack.
166    ///
167    /// Identical to [`ObjectType::packable`]: attachments are carried in the
168    /// pull pack exactly as today. The push/pull split exists solely so the
169    /// push direction can exclude `StateAttachment` without disturbing the
170    /// working pull carriage.
171    pub fn packable_for_pull(self) -> bool {
172        self.packable()
173    }
174
175    pub fn pack_object_type(self) -> Result<PackObjectType> {
176        match self {
177            ObjectType::Blob => Ok(PackObjectType::Blob),
178            ObjectType::Tree => Ok(PackObjectType::Tree),
179            ObjectType::State => Ok(PackObjectType::State),
180            ObjectType::Action => Ok(PackObjectType::Action),
181            ObjectType::AnnotatedTag => Ok(PackObjectType::AnnotatedTag),
182            ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
183            ObjectType::Redaction => Err(ProtocolError::InvalidState(
184                "Redaction sidecar records cannot be packed into the content-addressed object pack"
185                    .to_string(),
186            )),
187            ObjectType::Purge => Err(ProtocolError::InvalidState(
188                "Purge sidecar records cannot be packed into the content-addressed object pack"
189                    .to_string(),
190            )),
191            ObjectType::StateVisibility => Err(ProtocolError::InvalidState(
192                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
193                    .to_string(),
194            )),
195            ObjectType::KeyBinding => Err(ProtocolError::InvalidState(
196                "KeyBinding registry objects cannot be packed into the content-addressed object pack"
197                    .to_string(),
198            )),
199        }
200    }
201
202    pub fn bucket(self) -> ObjectTypeBucket {
203        match self {
204            ObjectType::Blob => ObjectTypeBucket::Blob,
205            ObjectType::Tree => ObjectTypeBucket::Tree,
206            ObjectType::State => ObjectTypeBucket::State,
207            ObjectType::Action => ObjectTypeBucket::Action,
208            ObjectType::AnnotatedTag => ObjectTypeBucket::AnnotatedTag,
209            ObjectType::Redaction => ObjectTypeBucket::Redaction,
210            ObjectType::Purge => ObjectTypeBucket::Purge,
211            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
212            ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
213            ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
214        }
215    }
216}
217
218#[derive(Debug, Clone, Default)]
219pub struct StateClosureOptions {
220    pub depth: Option<u32>,
221    pub exclude_states: Vec<StateId>,
222}
223
224pub fn enumerate_state_closure(
225    store: &impl ObjectStore,
226    state_id: StateId,
227) -> Result<Vec<ObjectInfo>> {
228    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
229}
230
231pub fn enumerate_state_closure_with_options(
232    store: &impl ObjectStore,
233    state_id: StateId,
234    options: StateClosureOptions,
235) -> Result<Vec<ObjectInfo>> {
236    let mut out = Vec::new();
237    walk_state_closure(store, state_id, options, |event| {
238        if let Some(info) = object_info_from_event(store, event)? {
239            out.push(info);
240        }
241        Ok(())
242    })?;
243    for (hash, tag) in annotated_tags_for_state(store, state_id)? {
244        out.push(annotated_tag_info(hash, &tag));
245    }
246
247    Ok(out)
248}
249
250pub fn enumerate_state_closure_plan(
251    store: &impl ObjectStore,
252    state_id: StateId,
253) -> Result<Vec<PlannedObject>> {
254    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
255}
256
257pub fn enumerate_state_closure_plan_with_options(
258    store: &impl ObjectStore,
259    state_id: StateId,
260    options: StateClosureOptions,
261) -> Result<Vec<PlannedObject>> {
262    let mut out = Vec::new();
263    walk_state_closure(store, state_id, options, |event| {
264        if let Some(object) = planned_object_from_event(store, event)? {
265            out.push(object);
266        }
267        Ok(())
268    })?;
269    out.extend(
270        annotated_tags_for_state(store, state_id)?
271            .into_iter()
272            .map(|(hash, _)| PlannedObject {
273                id: ObjectId::Hash(hash),
274                obj_type: ObjectType::AnnotatedTag,
275            }),
276    );
277
278    Ok(out)
279}
280
281pub fn enumerate_state_closure_transfer_with_options(
282    store: &impl ObjectStore,
283    state_id: StateId,
284    options: StateClosureOptions,
285    full_descriptor_object_threshold: usize,
286) -> Result<StateClosureTransferObjects> {
287    let mut planned_objects = Vec::new();
288    let mut full_objects = Some(Vec::new());
289
290    walk_state_closure(store, state_id, options, |event| {
291        if let Some(object) = planned_object_from_event(store, event)? {
292            planned_objects.push(object);
293        }
294
295        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
296            full_objects = None;
297        }
298        if let Some(objects) = full_objects.as_mut()
299            && let Some(info) = object_info_from_event(store, event)?
300        {
301            objects.push(info);
302        }
303
304        Ok(())
305    })?;
306    let tags = annotated_tags_for_state(store, state_id)?;
307    planned_objects.extend(tags.iter().map(|(hash, _)| PlannedObject {
308        id: ObjectId::Hash(*hash),
309        obj_type: ObjectType::AnnotatedTag,
310    }));
311    if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
312        full_objects = None;
313    }
314    if let Some(objects) = full_objects.as_mut() {
315        objects.extend(
316            tags.iter()
317                .map(|(hash, tag)| annotated_tag_info(*hash, tag)),
318        );
319    }
320
321    Ok(StateClosureTransferObjects {
322        planned_objects,
323        full_objects,
324    })
325}
326
327fn annotated_tags_for_state(
328    store: &impl ObjectStore,
329    state_id: StateId,
330) -> Result<Vec<(ContentHash, AnnotatedTag)>> {
331    let mut roots = Vec::new();
332    for hash in store.list_annotated_tags()? {
333        let Some(tag) = store.get_annotated_tag(&hash)? else {
334            continue;
335        };
336        if tag
337            .marker()
338            .is_some_and(|marker| marker.peeled_state == state_id)
339        {
340            roots.push((hash, tag));
341        }
342    }
343
344    let mut tags = Vec::new();
345    let mut seen = HashSet::new();
346    let mut stack = roots;
347    while let Some((hash, tag)) = stack.pop() {
348        if !seen.insert(hash) {
349            continue;
350        }
351        if let Some(inner_hash) = tag.target_tag() {
352            let inner = store.get_annotated_tag(&inner_hash)?.ok_or_else(|| {
353                ProtocolError::ObjectNotFound(format!(
354                    "annotated tag {hash} references missing inner tag {inner_hash}"
355                ))
356            })?;
357            stack.push((inner_hash, inner));
358        }
359        tags.push((hash, tag));
360    }
361    Ok(tags)
362}
363
364fn annotated_tag_info(hash: ContentHash, tag: &AnnotatedTag) -> ObjectInfo {
365    ObjectInfo {
366        id: ObjectId::Hash(hash),
367        obj_type: ObjectType::AnnotatedTag,
368        size: tag.encode_current_msgpack().len() as u64,
369        delta_base: None,
370    }
371}
372
373/// Enumerate a transfer delta while treating `boundary_states` as complete
374/// server-held roots. Unlike [`StateClosureOptions::exclude_states`], this does
375/// not expand each boundary's tree/history to build a hash exclusion set: the
376/// walk stops as soon as it reaches the boundary state. Objects reused by the
377/// new tip may still be advertised, and the receiver's have-set filters them.
378/// This keeps incremental push planning proportional to the new history.
379pub fn enumerate_state_closure_transfer_from_boundaries(
380    store: &impl ObjectStore,
381    state_id: StateId,
382    boundary_states: &[StateId],
383    full_descriptor_object_threshold: usize,
384) -> Result<StateClosureTransferObjects> {
385    let mut planned_objects = Vec::new();
386    let mut full_objects = Some(Vec::new());
387    let excluded_states = boundary_states.iter().copied().collect();
388
389    walk_state_closure_with_exclusions(
390        store,
391        state_id,
392        None,
393        excluded_states,
394        HashSet::new(),
395        |event| {
396            if let Some(object) = planned_object_from_event(store, event)? {
397                planned_objects.push(object);
398            }
399
400            if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
401                full_objects = None;
402            }
403            if let Some(objects) = full_objects.as_mut()
404                && let Some(info) = object_info_from_event(store, event)?
405            {
406                objects.push(info);
407            }
408
409            Ok(())
410        },
411    )?;
412
413    Ok(StateClosureTransferObjects {
414        planned_objects,
415        full_objects,
416    })
417}
418
419#[derive(Debug, Clone, Copy)]
420enum StateClosureEvent<'a> {
421    State {
422        id: StateId,
423        state: &'a State,
424    },
425    Tree {
426        hash: ContentHash,
427        tree: &'a objects::object::Tree,
428    },
429    Blob {
430        hash: ContentHash,
431    },
432    Redaction {
433        blob: ContentHash,
434    },
435    StateVisibility {
436        state: StateId,
437    },
438    StateAttachment {
439        state: StateId,
440        attachment: &'a StateAttachment,
441    },
442    ExcludedState {
443        id: StateId,
444    },
445    ExcludedHash {
446        hash: ContentHash,
447    },
448}
449
450fn walk_state_closure(
451    store: &impl ObjectStore,
452    state_id: StateId,
453    options: StateClosureOptions,
454    visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
455) -> Result<()> {
456    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
457
458    walk_state_closure_with_exclusions(
459        store,
460        state_id,
461        options.depth,
462        excluded_states,
463        excluded_hashes,
464        visit,
465    )
466}
467
468fn walk_state_closure_with_exclusions(
469    store: &impl ObjectStore,
470    state_id: StateId,
471    max_depth: Option<u32>,
472    excluded_states: HashSet<StateId>,
473    excluded_hashes: HashSet<ContentHash>,
474    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
475) -> Result<()> {
476    let mut seen_states: HashSet<StateId> = HashSet::new();
477    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
478    let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
479    queue.push_back((state_id, 0));
480
481    while let Some((id, depth)) = queue.pop_front() {
482        if excluded_states.contains(&id) {
483            visit(StateClosureEvent::ExcludedState { id })?;
484            continue;
485        }
486        if !seen_states.insert(id) {
487            continue;
488        }
489
490        let state = store
491            .get_state(&id)?
492            .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
493
494        visit(StateClosureEvent::State { id, state: &state })?;
495        if store.has_state_visibility_for_state(&id)? {
496            visit(StateClosureEvent::StateVisibility { state: id })?;
497        }
498        for attachment in store.list_state_attachments(&id)? {
499            visit(StateClosureEvent::StateAttachment {
500                state: id,
501                attachment: &attachment,
502            })?;
503            match attachment.body {
504                StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
505                    store,
506                    root,
507                    &excluded_hashes,
508                    &mut seen_hashes,
509                    &mut visit,
510                )?,
511                StateAttachmentBody::RiskSignals(hash)
512                | StateAttachmentBody::ReviewSignatures(hash)
513                | StateAttachmentBody::Discussions(hash)
514                | StateAttachmentBody::StructuredConflicts(hash) => {
515                    walk_blob_filtered(store, hash, &excluded_hashes, &mut seen_hashes, &mut visit)?
516                }
517                StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
518                    store,
519                    root,
520                    &excluded_hashes,
521                    &mut seen_hashes,
522                    &mut visit,
523                )?,
524                StateAttachmentBody::Signature(_) => {}
525            }
526        }
527
528        if max_depth.map(|max| depth < max).unwrap_or(true) {
529            for parent in &state.parents {
530                queue.push_back((*parent, depth + 1));
531            }
532        }
533
534        walk_tree_closure_filtered(
535            store,
536            state.tree,
537            &excluded_hashes,
538            &mut seen_hashes,
539            &mut visit,
540        )?;
541        if let Some(provenance_root) = state.provenance {
542            walk_tree_closure_filtered(
543                store,
544                provenance_root,
545                &excluded_hashes,
546                &mut seen_hashes,
547                &mut visit,
548            )?;
549        }
550    }
551
552    Ok(())
553}
554
555fn walk_tree_closure_filtered(
556    store: &impl ObjectStore,
557    tree_hash: ContentHash,
558    excluded: &HashSet<ContentHash>,
559    seen: &mut HashSet<ContentHash>,
560    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
561) -> Result<()> {
562    if excluded.contains(&tree_hash) {
563        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
564        return Ok(());
565    }
566    if !seen.insert(tree_hash) {
567        return Ok(());
568    }
569
570    let tree = store
571        .get_tree(&tree_hash)?
572        .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
573
574    visit(StateClosureEvent::Tree {
575        hash: tree_hash,
576        tree: &tree,
577    })?;
578
579    for entry in tree.entries() {
580        match entry.target() {
581            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
582                walk_blob_filtered(store, *hash, excluded, seen, visit)?;
583            }
584            TreeEntryTarget::Tree { hash } => {
585                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
586            }
587            TreeEntryTarget::Gitlink { .. } => {}
588            // Native child-spool edge: its target lives in a separate spool
589            // object graph, not this store, so it is not walked here.
590            TreeEntryTarget::Spoollink { .. } => {}
591        }
592    }
593
594    Ok(())
595}
596
597fn walk_blob_filtered(
598    store: &impl ObjectStore,
599    blob_hash: ContentHash,
600    excluded: &HashSet<ContentHash>,
601    seen: &mut HashSet<ContentHash>,
602    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
603) -> Result<()> {
604    if excluded.contains(&blob_hash) {
605        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
606        return Ok(());
607    }
608    if !seen.insert(blob_hash) {
609        return Ok(());
610    }
611    visit(StateClosureEvent::Blob { hash: blob_hash })?;
612    if store.has_redactions_for_blob(&blob_hash)? {
613        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
614    }
615    Ok(())
616}
617
618/// Walk the merkle semantic-index closure rooted at `root_hash` (a
619/// `SemanticIndexRoot` blob), emitting every reachable semantic node blob.
620///
621/// All semantic-index nodes (root, tree nodes, file nodes) are stored as
622/// ordinary content-addressed blobs, so replication just enumerates them.
623/// Opaque entries point back at raw source blobs already covered by the state's
624/// tree closure, so they are not re-walked here.
625///
626/// Iterative (explicit stack) so a crafted deep `SemanticTreeNode` chain in a
627/// pushed state can't overflow the stack. A missing or undecodable node in the
628/// closure is a HARD failure (`ObjectNotFound`/`Serialization`) — a partial or
629/// corrupt semantic closure must never be shipped silently.
630fn walk_semantic_index_closure(
631    store: &impl ObjectStore,
632    root_hash: ContentHash,
633    excluded: &HashSet<ContentHash>,
634    seen: &mut HashSet<ContentHash>,
635    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
636) -> Result<()> {
637    // Stack of (node_hash, is_tree_node): the root and dir nodes must be decoded
638    // to enumerate their children; file/opaque leaves are emitted only.
639    let mut stack: Vec<ContentHash> = vec![root_hash];
640    while let Some(node_hash) = stack.pop() {
641        if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
642            continue; // excluded (present on the far side) or already seen.
643        }
644        let blob = store
645            .get_blob(&node_hash)?
646            .ok_or_else(|| ProtocolError::ObjectNotFound(node_hash.to_hex()))?;
647        // The root and tree nodes decode to the same `SemanticTreeNode.entries`
648        // shape after the root's one indirection; walk uniformly by decoding
649        // every interior node as a tree node, tolerating the root shape.
650        let node = decode_semantic_container(&blob, node_hash)?;
651        for child in node {
652            match child {
653                SemanticChild::Interior(hash) => stack.push(hash),
654                SemanticChild::Leaf(hash) => {
655                    // Emit the leaf (file node) blob; it has no children.
656                    emit_semantic_blob(store, hash, excluded, seen, visit)?;
657                }
658                SemanticChild::BindingDelta(hash) => {
659                    // Binding deltas are state-scoped attachments. Emit this
660                    // state's direct delta; ancestor states contribute their
661                    // own deltas when (and only when) the state walk reaches
662                    // them. Following `delta.parent` here would cross a
663                    // shallow-clone boundary.
664                    emit_binding_delta(store, hash, excluded, seen, visit)?;
665                }
666                SemanticChild::ImporterIndex(hash) => {
667                    emit_importer_index(store, hash, excluded, seen, visit)?;
668                }
669            }
670        }
671    }
672    Ok(())
673}
674
675/// Child kinds encountered while walking a state's semantic closure.
676enum SemanticChild {
677    Interior(ContentHash),
678    Leaf(ContentHash),
679    BindingDelta(ContentHash),
680    ImporterIndex(ContentHash),
681}
682
683/// Decode a semantic-closure node — the root (which points at a tree node) or a
684/// tree node — into the child hashes to walk. Missing/corrupt is a hard error.
685fn decode_semantic_container(
686    blob: &objects::object::Blob,
687    node_hash: ContentHash,
688) -> Result<Vec<SemanticChild>> {
689    // Try the root shape first (it has an extra `tree` indirection), then the
690    // tree-node shape. Content-addressed hashes make this unambiguous in
691    // practice; a blob that decodes as neither is corrupt.
692    if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
693        let mut children = vec![SemanticChild::Interior(root.tree)];
694        if let Some(binding_delta) = root.binding_delta {
695            children.push(SemanticChild::BindingDelta(binding_delta));
696        }
697        if let Some(importer_index) = root.importer_index {
698            children.push(SemanticChild::ImporterIndex(importer_index));
699        }
700        return Ok(children);
701    }
702    let node = SemanticTreeNode::decode(blob.content())
703        .map_err(|err| ProtocolError::Serialization(format!("semantic node {node_hash}: {err}")))?;
704    Ok(node
705        .entries
706        .iter()
707        .filter_map(|entry| match entry.kind {
708            SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
709            SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
710            // Opaque `node` is the raw source blob, already in the tree closure.
711            SemanticEntryKind::Opaque => None,
712        })
713        .collect())
714}
715
716fn emit_binding_delta(
717    store: &impl ObjectStore,
718    hash: ContentHash,
719    excluded: &HashSet<ContentHash>,
720    seen: &mut HashSet<ContentHash>,
721    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
722) -> Result<()> {
723    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
724        return Ok(());
725    }
726    let blob = store
727        .get_blob(&hash)?
728        .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
729    BindingDelta::decode(blob.content()).map_err(|err| {
730        ProtocolError::Serialization(format!("semantic binding delta {hash}: {err}"))
731    })?;
732    Ok(())
733}
734
735fn emit_importer_index(
736    store: &impl ObjectStore,
737    hash: ContentHash,
738    excluded: &HashSet<ContentHash>,
739    seen: &mut HashSet<ContentHash>,
740    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
741) -> Result<()> {
742    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
743        return Ok(());
744    }
745    let blob = store
746        .get_blob(&hash)?
747        .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
748    ReverseDependencyIndex::decode(blob.content()).map_err(|err| {
749        ProtocolError::Serialization(format!("semantic reverse-dependency index {hash}: {err}"))
750    })?;
751    Ok(())
752}
753
754/// Emit a semantic node blob as state metadata. Returns `true` when the blob
755/// was newly visited (so the caller may descend into it), `false` when it was
756/// excluded (present on the far side) or already seen. A blob that is neither
757/// excluded nor present is a HARD failure — the closure must be complete.
758fn emit_semantic_blob(
759    store: &impl ObjectStore,
760    hash: ContentHash,
761    excluded: &HashSet<ContentHash>,
762    seen: &mut HashSet<ContentHash>,
763    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
764) -> Result<bool> {
765    if excluded.contains(&hash) {
766        visit(StateClosureEvent::ExcludedHash { hash })?;
767        return Ok(false);
768    }
769    if !seen.insert(hash) {
770        return Ok(false);
771    }
772    if store.get_blob(&hash)?.is_none() {
773        return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
774    }
775    visit(StateClosureEvent::Blob { hash })?;
776    Ok(true)
777}
778
779/// Collect every semantic-index node blob hash reachable from `root_hash` into
780/// `excluded`, for the have-set computation. Iterative + tolerant (a broken
781/// have-set index is not fatal — it just means fewer objects are marked
782/// already-present, which is safe over-fetching, not corruption).
783fn collect_semantic_hashes(
784    store: &impl ObjectStore,
785    root_hash: ContentHash,
786    excluded: &mut HashSet<ContentHash>,
787) -> Result<()> {
788    let mut stack: Vec<ContentHash> = vec![root_hash];
789    while let Some(node_hash) = stack.pop() {
790        if !excluded.insert(node_hash) {
791            continue;
792        }
793        let Some(blob) = store.get_blob(&node_hash)? else {
794            continue;
795        };
796        let children = match decode_semantic_container(&blob, node_hash) {
797            Ok(children) => children,
798            Err(_) => continue,
799        };
800        for child in children {
801            match child {
802                SemanticChild::Interior(hash) => stack.push(hash),
803                SemanticChild::Leaf(hash) => {
804                    excluded.insert(hash);
805                }
806                SemanticChild::BindingDelta(hash) | SemanticChild::ImporterIndex(hash) => {
807                    excluded.insert(hash);
808                }
809            }
810        }
811    }
812    Ok(())
813}
814
815fn object_info_from_event(
816    store: &impl ObjectStore,
817    event: StateClosureEvent<'_>,
818) -> Result<Option<ObjectInfo>> {
819    match event {
820        StateClosureEvent::State { id, state } => {
821            let state_bytes = rmp_serde::to_vec_named(state)?;
822            Ok(Some(ObjectInfo {
823                id: ObjectId::StateId(id),
824                obj_type: ObjectType::State,
825                size: state_bytes.len() as u64,
826                delta_base: None,
827            }))
828        }
829        StateClosureEvent::Tree { hash, tree } => {
830            let tree_bytes = rmp_serde::to_vec_named(tree)?;
831            Ok(Some(ObjectInfo {
832                id: ObjectId::Hash(hash),
833                obj_type: ObjectType::Tree,
834                size: tree_bytes.len() as u64,
835                delta_base: None,
836            }))
837        }
838        StateClosureEvent::Blob { hash, .. } => {
839            let Some(blob) = store.get_blob(&hash)? else {
840                if blob_has_purge_evidence(store, &hash)? {
841                    return Ok(None);
842                }
843                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
844            };
845            Ok(Some(ObjectInfo {
846                id: ObjectId::Hash(hash),
847                obj_type: ObjectType::Blob,
848                size: blob.size() as u64,
849                delta_base: None,
850            }))
851        }
852        StateClosureEvent::Redaction { blob } => Ok(store
853            .get_redactions_bytes_for_blob(&blob)?
854            .map(|bytes| ObjectInfo {
855                id: ObjectId::Hash(blob),
856                obj_type: ObjectType::Redaction,
857                size: bytes.len() as u64,
858                delta_base: None,
859            })),
860        StateClosureEvent::StateVisibility { state } => Ok(store
861            .get_state_visibility_bytes_for_state(&state)?
862            .map(|bytes| ObjectInfo {
863                id: ObjectId::StateId(state),
864                obj_type: ObjectType::StateVisibility,
865                size: bytes.len() as u64,
866                delta_base: None,
867            })),
868        StateClosureEvent::StateAttachment { state, attachment } => {
869            let bytes = rmp_serde::to_vec_named(attachment)?;
870            Ok(Some(ObjectInfo {
871                id: ObjectId::StateAttachment {
872                    state,
873                    id: attachment.id(),
874                    kind: attachment.body.kind(),
875                },
876                obj_type: ObjectType::StateAttachment,
877                size: bytes.len() as u64,
878                delta_base: None,
879            }))
880        }
881        StateClosureEvent::ExcludedState { id } => {
882            let _ = id;
883            Ok(None)
884        }
885        StateClosureEvent::ExcludedHash { hash } => {
886            let _ = hash;
887            Ok(None)
888        }
889    }
890}
891
892fn planned_object_from_event(
893    store: &impl ObjectStore,
894    event: StateClosureEvent<'_>,
895) -> Result<Option<PlannedObject>> {
896    match event {
897        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
898            id: ObjectId::StateId(id),
899            obj_type: ObjectType::State,
900        })),
901        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
902            id: ObjectId::Hash(hash),
903            obj_type: ObjectType::Tree,
904        })),
905        StateClosureEvent::Blob { hash, .. } => {
906            if store.get_blob(&hash)?.is_none() {
907                if blob_has_purge_evidence(store, &hash)? {
908                    return Ok(None);
909                }
910                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
911            }
912            Ok(Some(PlannedObject {
913                id: ObjectId::Hash(hash),
914                obj_type: ObjectType::Blob,
915            }))
916        }
917        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
918            id: ObjectId::Hash(blob),
919            obj_type: ObjectType::Redaction,
920        })),
921        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
922            id: ObjectId::StateId(state),
923            obj_type: ObjectType::StateVisibility,
924        })),
925        StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
926            id: ObjectId::StateAttachment {
927                state,
928                id: attachment.id(),
929                kind: attachment.body.kind(),
930            },
931            obj_type: ObjectType::StateAttachment,
932        })),
933        StateClosureEvent::ExcludedState { id } => {
934            let _ = id;
935            Ok(None)
936        }
937        StateClosureEvent::ExcludedHash { hash } => {
938            let _ = hash;
939            Ok(None)
940        }
941    }
942}
943
944/// A purged blob is intentionally absent from the object closure; its sidecar
945/// remains and is verified by the receiver before the absence is accepted.
946/// Mere redaction never excuses a missing blob.
947fn blob_has_purge_evidence(store: &impl ObjectStore, hash: &ContentHash) -> Result<bool> {
948    let Some(bytes) = store.get_redactions_bytes_for_blob(hash)? else {
949        return Ok(false);
950    };
951    let redactions = RedactionsBlob::decode(&bytes).map_err(|error| {
952        ProtocolError::InvalidState(format!(
953            "invalid redaction sidecar for missing blob {}: {error}",
954            hash.to_hex()
955        ))
956    })?;
957    Ok(redactions
958        .redactions
959        .iter()
960        .any(|redaction| redaction.redacted_blob == *hash && redaction.is_purged()))
961}
962
963pub fn missing_blobs_in_tree(
964    store: &impl ObjectStore,
965    tree_hash: ContentHash,
966) -> Result<Vec<ContentHash>> {
967    let mut missing = Vec::new();
968    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
969    Ok(missing)
970}
971
972fn collect_missing_blobs_recursive(
973    store: &impl ObjectStore,
974    tree_hash: &ContentHash,
975    missing: &mut Vec<ContentHash>,
976) -> Result<()> {
977    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
978        ProtocolError::InvalidState(format!(
979            "load tree {} while collecting lazy hydration missing blobs: {err}",
980            tree_hash.to_hex()
981        ))
982    })?
983    else {
984        return Ok(());
985    };
986
987    for entry in tree.entries() {
988        match entry.target() {
989            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
990                if !store.has_blob(hash).map_err(|err| {
991                    ProtocolError::InvalidState(format!(
992                        "check blob {} while collecting lazy hydration missing blobs: {err}",
993                        hash.to_hex()
994                    ))
995                })? {
996                    missing.push(*hash);
997                }
998            }
999            TreeEntryTarget::Tree { hash } => {
1000                collect_missing_blobs_recursive(store, hash, missing)?;
1001            }
1002            TreeEntryTarget::Gitlink { .. } => {}
1003            // Native child-spool edge: its target lives in a separate spool
1004            // object graph, not this store, so it is not walked here.
1005            TreeEntryTarget::Spoollink { .. } => {}
1006        }
1007    }
1008    Ok(())
1009}
1010
1011fn collect_excluded(
1012    store: &impl ObjectStore,
1013    roots: &[StateId],
1014) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
1015    if roots.is_empty() {
1016        return Ok((HashSet::new(), HashSet::new()));
1017    }
1018
1019    let mut excluded_states: HashSet<StateId> = HashSet::new();
1020    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
1021    let mut queue: VecDeque<StateId> = VecDeque::new();
1022
1023    for id in roots {
1024        queue.push_back(*id);
1025    }
1026
1027    while let Some(id) = queue.pop_front() {
1028        if !excluded_states.insert(id) {
1029            continue;
1030        }
1031
1032        let state = match store.get_state(&id)? {
1033            Some(state) => state,
1034            None => continue,
1035        };
1036
1037        for parent in &state.parents {
1038            queue.push_back(*parent);
1039        }
1040
1041        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
1042        if let Some(provenance_root) = state.provenance {
1043            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
1044        }
1045        for attachment in store.list_state_attachments(&id)? {
1046            match attachment.body {
1047                StateAttachmentBody::Context(root) => {
1048                    collect_tree_hashes(store, root, &mut excluded_hashes)?
1049                }
1050                StateAttachmentBody::RiskSignals(hash)
1051                | StateAttachmentBody::ReviewSignatures(hash)
1052                | StateAttachmentBody::Discussions(hash)
1053                | StateAttachmentBody::StructuredConflicts(hash) => {
1054                    excluded_hashes.insert(hash);
1055                }
1056                StateAttachmentBody::SemanticIndex(root) => {
1057                    collect_semantic_hashes(store, root, &mut excluded_hashes)?;
1058                }
1059                StateAttachmentBody::Signature(_) => {}
1060            }
1061        }
1062    }
1063
1064    Ok((excluded_states, excluded_hashes))
1065}
1066
1067fn collect_tree_hashes(
1068    store: &impl ObjectStore,
1069    tree_hash: ContentHash,
1070    excluded: &mut HashSet<ContentHash>,
1071) -> Result<()> {
1072    if !excluded.insert(tree_hash) {
1073        return Ok(());
1074    }
1075
1076    let tree = match store.get_tree(&tree_hash)? {
1077        Some(tree) => tree,
1078        None => return Ok(()),
1079    };
1080
1081    for entry in tree.entries() {
1082        match entry.target() {
1083            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1084                excluded.insert(*hash);
1085            }
1086            TreeEntryTarget::Tree { hash } => {
1087                collect_tree_hashes(store, *hash, excluded)?;
1088            }
1089            TreeEntryTarget::Gitlink { .. } => {}
1090            // Native child-spool edge: its target lives in a separate spool
1091            // object graph, not this store, so it is not walked here.
1092            TreeEntryTarget::Spoollink { .. } => {}
1093        }
1094    }
1095
1096    Ok(())
1097}
1098
1099pub fn is_ancestor(
1100    store: &impl ObjectStore,
1101    ancestor: StateId,
1102    descendant: StateId,
1103) -> Result<bool> {
1104    if ancestor == descendant {
1105        return Ok(true);
1106    }
1107
1108    let mut seen: HashSet<StateId> = HashSet::new();
1109    let mut queue: VecDeque<StateId> = VecDeque::new();
1110    queue.push_back(descendant);
1111
1112    while let Some(id) = queue.pop_front() {
1113        if !seen.insert(id) {
1114            continue;
1115        }
1116        let state = match store.get_state(&id)? {
1117            Some(s) => s,
1118            None => return Ok(false),
1119        };
1120        for parent in state.parents {
1121            if parent == ancestor {
1122                return Ok(true);
1123            }
1124            queue.push_back(parent);
1125        }
1126    }
1127
1128    Ok(false)
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use std::{
1134        collections::HashSet,
1135        sync::atomic::{AtomicUsize, Ordering},
1136    };
1137
1138    use chrono::Utc;
1139    use objects::{
1140        object::{
1141            Action, ActionId, AnnotatedTag, AnnotatedTagMarker, Attribution, Blob, ContentHash,
1142            Discussion, DiscussionResolution, DiscussionTurn, DiscussionsBlob, Principal,
1143            PurgeEvidence, Redaction, State, StateAttachment, StateAttachmentBody, StateId,
1144            StateSignature, StateVisibility, SymbolAnchor, Tree, TreeEntry, VisibilityTier,
1145        },
1146        store::{AnyStore, ObjectStore, Result as StoreResult},
1147    };
1148    use repo::Repository;
1149    use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
1150    use tempfile::TempDir;
1151
1152    use super::{
1153        ObjectId, ObjectInfo, ObjectType, PlannedObject, ProtocolError, StateClosureOptions,
1154        enumerate_state_closure_plan_with_options,
1155        enumerate_state_closure_transfer_from_boundaries,
1156        enumerate_state_closure_transfer_with_options, enumerate_state_closure_with_options,
1157        missing_blobs_in_tree,
1158    };
1159
1160    fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
1161        objects
1162            .iter()
1163            .map(|info| (info.id.clone(), info.obj_type))
1164            .collect()
1165    }
1166
1167    fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
1168        objects
1169            .iter()
1170            .map(|info| (info.id.clone(), info.obj_type))
1171            .collect()
1172    }
1173
1174    fn object_info_fingerprint(
1175        objects: &[ObjectInfo],
1176    ) -> Vec<(ObjectId, ObjectType, u64, Option<ContentHash>)> {
1177        objects
1178            .iter()
1179            .map(|info| (info.id.clone(), info.obj_type, info.size, info.delta_base))
1180            .collect()
1181    }
1182
1183    fn assert_plan_parity(
1184        repo: &Repository,
1185        state_id: StateId,
1186        options: StateClosureOptions,
1187    ) -> HashSet<(ObjectId, ObjectType)> {
1188        let full =
1189            enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
1190        let plan =
1191            enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
1192
1193        let full_pairs = pairs_from_full(&full);
1194        let plan_pairs = pairs_from_plan(&plan);
1195        assert_eq!(full_pairs, plan_pairs);
1196        full_pairs
1197    }
1198
1199    fn assert_contains_object(
1200        objects: &HashSet<(ObjectId, ObjectType)>,
1201        id: ObjectId,
1202        obj_type: ObjectType,
1203    ) {
1204        assert!(
1205            objects.contains(&(id.clone(), obj_type)),
1206            "expected closure to contain {id:?} as {obj_type:?}: {objects:?}"
1207        );
1208    }
1209
1210    struct CountingStore<'a, S> {
1211        inner: &'a S,
1212        state_reads: AtomicUsize,
1213    }
1214
1215    impl<'a, S> CountingStore<'a, S> {
1216        fn new(inner: &'a S) -> Self {
1217            Self {
1218                inner,
1219                state_reads: AtomicUsize::new(0),
1220            }
1221        }
1222
1223        fn state_reads(&self) -> usize {
1224            self.state_reads.load(Ordering::SeqCst)
1225        }
1226    }
1227
1228    impl<S: ObjectStore> ObjectStore for CountingStore<'_, S> {
1229        fn get_blob(&self, hash: &ContentHash) -> StoreResult<Option<Blob>> {
1230            self.inner.get_blob(hash)
1231        }
1232
1233        fn put_blob(&self, blob: &Blob) -> StoreResult<ContentHash> {
1234            self.inner.put_blob(blob)
1235        }
1236
1237        fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
1238            self.inner.has_blob(hash)
1239        }
1240
1241        fn get_tree(&self, hash: &ContentHash) -> StoreResult<Option<Tree>> {
1242            self.inner.get_tree(hash)
1243        }
1244
1245        fn put_tree(&self, tree: &Tree) -> StoreResult<ContentHash> {
1246            self.inner.put_tree(tree)
1247        }
1248
1249        fn has_tree(&self, hash: &ContentHash) -> StoreResult<bool> {
1250            self.inner.has_tree(hash)
1251        }
1252
1253        fn get_state(&self, id: &StateId) -> StoreResult<Option<State>> {
1254            self.state_reads.fetch_add(1, Ordering::SeqCst);
1255            self.inner.get_state(id)
1256        }
1257
1258        fn put_state(&self, state: &State) -> StoreResult<()> {
1259            self.inner.put_state(state)
1260        }
1261
1262        fn has_state(&self, id: &StateId) -> StoreResult<bool> {
1263            self.inner.has_state(id)
1264        }
1265
1266        fn list_states(&self) -> StoreResult<Vec<StateId>> {
1267            self.inner.list_states()
1268        }
1269
1270        fn get_action(&self, id: &ActionId) -> StoreResult<Option<Action>> {
1271            self.inner.get_action(id)
1272        }
1273
1274        fn put_action(&self, action: &mut Action) -> StoreResult<ActionId> {
1275            self.inner.put_action(action)
1276        }
1277
1278        fn list_actions(&self) -> StoreResult<Vec<ActionId>> {
1279            self.inner.list_actions()
1280        }
1281
1282        fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
1283            self.inner.list_blobs()
1284        }
1285
1286        fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
1287            self.inner.list_trees()
1288        }
1289    }
1290
1291    fn test_attribution() -> Attribution {
1292        Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
1293    }
1294
1295    #[test]
1296    fn lean_closure_planner_matches_object_info_ids_and_types() {
1297        let temp = TempDir::new().unwrap();
1298        let repo = Repository::init_default(temp.path()).unwrap();
1299        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1300        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1301        std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
1302        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1303
1304        let full = enumerate_state_closure_with_options(
1305            repo.store(),
1306            state.state_id,
1307            StateClosureOptions::default(),
1308        )
1309        .unwrap();
1310        let lean = enumerate_state_closure_plan_with_options(
1311            repo.store(),
1312            state.state_id,
1313            StateClosureOptions::default(),
1314        )
1315        .unwrap();
1316
1317        let full_pairs = full
1318            .into_iter()
1319            .map(|info| (info.id, info.obj_type))
1320            .collect::<std::collections::HashSet<_>>();
1321        let lean_pairs = lean
1322            .into_iter()
1323            .map(|info| (info.id, info.obj_type))
1324            .collect::<std::collections::HashSet<_>>();
1325
1326        assert_eq!(full_pairs, lean_pairs);
1327        assert!(
1328            full_pairs
1329                .iter()
1330                .any(|(id, _)| matches!(id, ObjectId::StateId(_)))
1331        );
1332    }
1333
1334    #[test]
1335    fn state_closure_includes_annotated_tag_chain() {
1336        let temp = TempDir::new().unwrap();
1337        let repo = Repository::init_default(temp.path()).unwrap();
1338        let state = repo.snapshot(Some("tagged".to_string()), None).unwrap();
1339        let inner = AnnotatedTag::new(
1340            GitObjectFormat::Sha1,
1341            b"object 1111111111111111111111111111111111111111\ntype commit\ntag inner\ntagger Test <test@example.com> 1700000000 +0100\n\ninner\n".to_vec(),
1342            None,
1343            None,
1344        )
1345        .unwrap();
1346        let inner_hash = repo.store().put_annotated_tag(&inner).unwrap();
1347        let outer = AnnotatedTag::new(
1348            GitObjectFormat::Sha1,
1349            b"object 2222222222222222222222222222222222222222\ntype tag\ntag outer\ntagger Test <test@example.com> 1700000001 -0730\n\nouter\n".to_vec(),
1350            Some(inner_hash),
1351            Some(AnnotatedTagMarker {
1352                name: "outer".to_string(),
1353                peeled_state: state.state_id,
1354            }),
1355        )
1356        .unwrap();
1357        let outer_hash = repo.store().put_annotated_tag(&outer).unwrap();
1358
1359        let closure = assert_plan_parity(&repo, state.state_id, StateClosureOptions::default());
1360        assert_contains_object(
1361            &closure,
1362            ObjectId::Hash(inner_hash),
1363            ObjectType::AnnotatedTag,
1364        );
1365        assert_contains_object(
1366            &closure,
1367            ObjectId::Hash(outer_hash),
1368            ObjectType::AnnotatedTag,
1369        );
1370    }
1371
1372    #[test]
1373    fn transfer_boundary_stops_at_server_head_without_walking_its_history() {
1374        let temp = TempDir::new().unwrap();
1375        let repo = Repository::init_default(temp.path()).unwrap();
1376        let path = temp.path().join("story.txt");
1377
1378        std::fs::write(&path, "base\n").unwrap();
1379        let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1380        std::fs::write(&path, "middle\n").unwrap();
1381        let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1382        std::fs::write(&path, "tip\n").unwrap();
1383        let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1384
1385        let counting = CountingStore::new(repo.store());
1386        let transfer = enumerate_state_closure_transfer_from_boundaries(
1387            &counting,
1388            tip.state_id,
1389            &[middle.state_id],
1390            512,
1391        )
1392        .unwrap();
1393        let states = transfer
1394            .planned_objects
1395            .iter()
1396            .filter_map(|object| match object.id {
1397                ObjectId::StateId(state) if object.obj_type == ObjectType::State => Some(state),
1398                _ => None,
1399            })
1400            .collect::<Vec<_>>();
1401
1402        assert_eq!(states, vec![tip.state_id]);
1403        assert!(!states.contains(&middle.state_id));
1404        assert!(!states.contains(&base.state_id));
1405        assert_eq!(
1406            counting.state_reads(),
1407            1,
1408            "the advertised server boundary must stop the walk before reading old states"
1409        );
1410    }
1411
1412    #[test]
1413    fn transfer_projection_matches_full_and_plan_on_mixed_state_closure_fixture() {
1414        let temp = TempDir::new().unwrap();
1415        let repo = Repository::init_default(temp.path()).unwrap();
1416
1417        let excluded_blob = repo
1418            .store()
1419            .put_blob(&Blob::from("excluded"))
1420            .expect("put excluded blob");
1421        let excluded_tree_hash = repo
1422            .store()
1423            .put_tree(&Tree::from_entries(vec![
1424                TreeEntry::file("excluded.txt", excluded_blob, false).unwrap(),
1425            ]))
1426            .expect("put excluded tree");
1427        let excluded_parent = State::new(excluded_tree_hash, Vec::new(), test_attribution());
1428        repo.store()
1429            .put_state(&excluded_parent)
1430            .expect("put excluded parent");
1431
1432        let redacted_blob = repo
1433            .store()
1434            .put_blob(&Blob::from("secret"))
1435            .expect("put redacted blob");
1436        let nested_blob = repo
1437            .store()
1438            .put_blob(&Blob::from("nested"))
1439            .expect("put nested blob");
1440        let symlink_blob = repo
1441            .store()
1442            .put_blob(&Blob::from("target"))
1443            .expect("put symlink blob");
1444        let context_blob = repo
1445            .store()
1446            .put_blob(&Blob::from("context"))
1447            .expect("put context blob");
1448        let provenance_blob = repo
1449            .store()
1450            .put_blob(&Blob::from("provenance"))
1451            .expect("put provenance blob");
1452        let risk_blob = repo
1453            .store()
1454            .put_blob(&Blob::from("risk"))
1455            .expect("put risk blob");
1456        let review_blob = repo
1457            .store()
1458            .put_blob(&Blob::from("review"))
1459            .expect("put review blob");
1460        let discussions_blob = repo
1461            .store()
1462            .put_blob(&Blob::from("discussion"))
1463            .expect("put discussion blob");
1464        let conflicts_blob = repo
1465            .store()
1466            .put_blob(&Blob::from("conflicts"))
1467            .expect("put conflicts blob");
1468
1469        let nested_tree_hash = repo
1470            .store()
1471            .put_tree(&Tree::from_entries(vec![
1472                TreeEntry::file("nested.txt", nested_blob, false).unwrap(),
1473                TreeEntry::symlink("latest", symlink_blob).unwrap(),
1474            ]))
1475            .expect("put nested tree");
1476        let context_tree_hash = repo
1477            .store()
1478            .put_tree(&Tree::from_entries(vec![
1479                TreeEntry::file("context.txt", context_blob, false).unwrap(),
1480            ]))
1481            .expect("put context tree");
1482        let provenance_tree_hash = repo
1483            .store()
1484            .put_tree(&Tree::from_entries(vec![
1485                TreeEntry::file("lineage.txt", provenance_blob, false).unwrap(),
1486            ]))
1487            .expect("put provenance tree");
1488        let gitlink_target: GitObjectId = "0303030303030303030303030303030303030303"
1489            .parse()
1490            .expect("git oid");
1491        let root_tree_hash = repo
1492            .store()
1493            .put_tree(&Tree::from_entries(vec![
1494                TreeEntry::file("secret.txt", redacted_blob, false).unwrap(),
1495                TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1496                TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1497            ]))
1498            .expect("put root tree");
1499        let state = State::new(
1500            root_tree_hash,
1501            vec![excluded_parent.state_id],
1502            test_attribution(),
1503        )
1504        .with_provenance(provenance_tree_hash);
1505        repo.store().put_state(&state).expect("put state");
1506        for body in [
1507            StateAttachmentBody::Context(context_tree_hash),
1508            StateAttachmentBody::RiskSignals(risk_blob),
1509            StateAttachmentBody::ReviewSignatures(review_blob),
1510            StateAttachmentBody::Discussions(discussions_blob),
1511            StateAttachmentBody::StructuredConflicts(conflicts_blob),
1512        ] {
1513            repo.put_state_attachment(&StateAttachment {
1514                state_id: state.id(),
1515                body,
1516                attribution: state.attribution.clone(),
1517                created_at: Utc::now(),
1518                supersedes: None,
1519            })
1520            .unwrap();
1521        }
1522
1523        repo.put_redaction(Redaction {
1524            redacted_blob,
1525            state: state.state_id,
1526            path: "secret.txt".to_string(),
1527            reason: "test leak".to_string(),
1528            redactor: Principal::new("Tester", "tester@example.test"),
1529            redacted_at: Utc::now(),
1530            signature: None,
1531            purge: None,
1532            supersedes: None,
1533        })
1534        .expect("put redaction");
1535        repo.put_state_visibility(StateVisibility {
1536            state: state.state_id,
1537            tier: VisibilityTier::Restricted {
1538                scope_label: "security".to_string(),
1539            },
1540            embargo_until: None,
1541            declarer: Principal::new("Tester", "tester@example.test"),
1542            declared_at: Utc::now(),
1543            signature: None,
1544            supersedes: None,
1545        })
1546        .expect("put visibility");
1547
1548        let options = StateClosureOptions {
1549            depth: None,
1550            exclude_states: vec![excluded_parent.state_id],
1551        };
1552        let transfer = enumerate_state_closure_transfer_with_options(
1553            repo.store(),
1554            state.state_id,
1555            options.clone(),
1556            512,
1557        )
1558        .expect("transfer projection");
1559
1560        let full =
1561            enumerate_state_closure_with_options(repo.store(), state.state_id, options.clone())
1562                .expect("full closure");
1563        let plan = enumerate_state_closure_plan_with_options(repo.store(), state.state_id, options)
1564            .expect("plan closure");
1565        assert_eq!(
1566            transfer
1567                .full_objects
1568                .as_deref()
1569                .map(object_info_fingerprint),
1570            Some(object_info_fingerprint(&full))
1571        );
1572        assert_eq!(transfer.planned_objects, plan);
1573
1574        let full_pairs = pairs_from_full(&full);
1575        assert_eq!(full_pairs, pairs_from_plan(&plan));
1576        assert_contains_object(
1577            &full_pairs,
1578            ObjectId::StateId(state.state_id),
1579            ObjectType::State,
1580        );
1581        assert_contains_object(
1582            &full_pairs,
1583            ObjectId::StateId(state.state_id),
1584            ObjectType::StateVisibility,
1585        );
1586        assert_contains_object(&full_pairs, ObjectId::Hash(redacted_blob), ObjectType::Blob);
1587        assert_contains_object(
1588            &full_pairs,
1589            ObjectId::Hash(redacted_blob),
1590            ObjectType::Redaction,
1591        );
1592        for hash in [
1593            root_tree_hash,
1594            nested_tree_hash,
1595            context_tree_hash,
1596            provenance_tree_hash,
1597        ] {
1598            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Tree);
1599        }
1600        for hash in [
1601            nested_blob,
1602            symlink_blob,
1603            context_blob,
1604            provenance_blob,
1605            risk_blob,
1606            review_blob,
1607            discussions_blob,
1608            conflicts_blob,
1609        ] {
1610            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Blob);
1611        }
1612        assert!(!full_pairs.contains(&(
1613            ObjectId::StateId(excluded_parent.state_id),
1614            ObjectType::State
1615        )));
1616        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_tree_hash), ObjectType::Tree)));
1617        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_blob), ObjectType::Blob)));
1618    }
1619
1620    #[test]
1621    fn transfer_projection_reads_root_state_once_on_small_transfer() {
1622        let temp = TempDir::new().unwrap();
1623        let repo = Repository::init_default(temp.path()).unwrap();
1624        let blob = repo
1625            .store()
1626            .put_blob(&Blob::from("hello\n"))
1627            .expect("put blob");
1628        let tree_hash = repo
1629            .store()
1630            .put_tree(&Tree::from_entries(vec![
1631                TreeEntry::file("README.md", blob, false).unwrap(),
1632            ]))
1633            .expect("put tree");
1634        let state = State::new(tree_hash, Vec::new(), test_attribution());
1635        repo.store().put_state(&state).expect("put state");
1636        let store = CountingStore::new(repo.store());
1637
1638        let transfer = enumerate_state_closure_transfer_with_options(
1639            &store,
1640            state.state_id,
1641            StateClosureOptions::default(),
1642            512,
1643        )
1644        .expect("transfer projection");
1645
1646        assert!(
1647            !transfer.planned_objects.is_empty(),
1648            "lean projection should be available"
1649        );
1650        assert!(transfer.full_objects.is_some());
1651        assert_eq!(
1652            store.state_reads(),
1653            1,
1654            "small transfer projection must not read the root state through a second closure walk"
1655        );
1656    }
1657
1658    #[test]
1659    fn transfer_projection_drops_full_descriptors_after_threshold() {
1660        let temp = TempDir::new().unwrap();
1661        let repo = Repository::init_default(temp.path()).unwrap();
1662        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1663        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1664
1665        let transfer = enumerate_state_closure_transfer_with_options(
1666            repo.store(),
1667            state.state_id,
1668            StateClosureOptions::default(),
1669            0,
1670        )
1671        .expect("transfer projection");
1672
1673        assert!(
1674            !transfer.planned_objects.is_empty(),
1675            "lean projection should still be available over the threshold"
1676        );
1677        assert!(transfer.full_objects.is_none());
1678    }
1679
1680    #[test]
1681    fn depth_and_exclude_options_match_between_full_and_plan() {
1682        use std::collections::BTreeMap;
1683
1684        use objects::object::{BindingDelta, SemanticIndexRoot, SemanticTreeNode};
1685
1686        let temp = TempDir::new().unwrap();
1687        let repo = Repository::init_default(temp.path()).unwrap();
1688        let path = temp.path().join("story.txt");
1689
1690        std::fs::write(&path, "base\n").unwrap();
1691        let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1692        std::fs::write(&path, "middle\n").unwrap();
1693        let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1694        std::fs::write(&path, "tip\n").unwrap();
1695        let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1696
1697        let (semantic_tree, semantic_digest) = SemanticTreeNode::new(Vec::new());
1698        let semantic_tree_hash = repo
1699            .store()
1700            .put_blob(&Blob::new(semantic_tree.encode().unwrap()))
1701            .unwrap();
1702        let attach_delta = |state: StateId, parent: Option<ContentHash>| {
1703            let delta = BindingDelta::new(parent, Vec::new());
1704            let delta_hash = repo
1705                .store()
1706                .put_blob(&Blob::new(delta.encode().unwrap()))
1707                .unwrap();
1708            let root =
1709                SemanticIndexRoot::new(1, BTreeMap::new(), semantic_tree_hash, semantic_digest)
1710                    .with_binding_delta(delta_hash, 1);
1711            let root_hash = repo
1712                .store()
1713                .put_blob(&Blob::new(root.encode().unwrap()))
1714                .unwrap();
1715            repo.put_state_attachment(&StateAttachment {
1716                state_id: state,
1717                body: StateAttachmentBody::SemanticIndex(root_hash),
1718                attribution: test_attribution(),
1719                created_at: Utc::now(),
1720                supersedes: None,
1721            })
1722            .unwrap();
1723            delta_hash
1724        };
1725        let base_delta = attach_delta(base.state_id, None);
1726        let middle_delta = attach_delta(middle.state_id, Some(base_delta));
1727        let tip_delta = attach_delta(tip.state_id, Some(middle_delta));
1728
1729        let depth_zero = assert_plan_parity(
1730            &repo,
1731            tip.state_id,
1732            StateClosureOptions {
1733                depth: Some(0),
1734                exclude_states: Vec::new(),
1735            },
1736        );
1737        assert!(depth_zero.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1738        assert!(!depth_zero.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1739        assert!(!depth_zero.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1740        assert!(depth_zero.contains(&(ObjectId::Hash(tip_delta), ObjectType::Blob)));
1741        assert!(!depth_zero.contains(&(ObjectId::Hash(middle_delta), ObjectType::Blob)));
1742        assert!(!depth_zero.contains(&(ObjectId::Hash(base_delta), ObjectType::Blob)));
1743
1744        let depth_one = assert_plan_parity(
1745            &repo,
1746            tip.state_id,
1747            StateClosureOptions {
1748                depth: Some(1),
1749                exclude_states: Vec::new(),
1750            },
1751        );
1752        assert!(depth_one.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1753        assert!(depth_one.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1754        assert!(!depth_one.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1755        assert!(depth_one.contains(&(ObjectId::Hash(tip_delta), ObjectType::Blob)));
1756        assert!(depth_one.contains(&(ObjectId::Hash(middle_delta), ObjectType::Blob)));
1757        assert!(!depth_one.contains(&(ObjectId::Hash(base_delta), ObjectType::Blob)));
1758
1759        let exclude_middle = assert_plan_parity(
1760            &repo,
1761            tip.state_id,
1762            StateClosureOptions {
1763                depth: None,
1764                exclude_states: vec![middle.state_id],
1765            },
1766        );
1767        assert!(exclude_middle.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1768        assert!(!exclude_middle.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1769        assert!(!exclude_middle.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1770    }
1771
1772    #[test]
1773    fn shared_tree_and_blob_references_are_emitted_once() {
1774        let temp = TempDir::new().unwrap();
1775        let repo = Repository::init_default(temp.path()).unwrap();
1776
1777        let shared_blob = Blob::from("shared contents\n");
1778        let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
1779        let shared_tree = Tree::from_entries(vec![
1780            TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
1781        ]);
1782        let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
1783        let root = Tree::from_entries(vec![
1784            TreeEntry::directory("left", shared_tree_hash).unwrap(),
1785            TreeEntry::directory("right", shared_tree_hash).unwrap(),
1786        ]);
1787        let root_hash = repo.store().put_tree(&root).unwrap();
1788        let state = State::new(root_hash, Vec::new(), test_attribution());
1789        repo.store().put_state(&state).unwrap();
1790
1791        let full = enumerate_state_closure_with_options(
1792            repo.store(),
1793            state.state_id,
1794            StateClosureOptions::default(),
1795        )
1796        .unwrap();
1797        let plan = enumerate_state_closure_plan_with_options(
1798            repo.store(),
1799            state.state_id,
1800            StateClosureOptions::default(),
1801        )
1802        .unwrap();
1803
1804        assert_eq!(
1805            pairs_from_full(&full),
1806            pairs_from_plan(&plan),
1807            "full and lean closure enumerators must dedup the same objects"
1808        );
1809
1810        assert_eq!(
1811            full.iter()
1812                .filter(|info| info.id == ObjectId::Hash(root_hash)
1813                    && info.obj_type == ObjectType::Tree)
1814                .count(),
1815            1
1816        );
1817        assert_eq!(
1818            full.iter()
1819                .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
1820                    && info.obj_type == ObjectType::Tree)
1821                .count(),
1822            1
1823        );
1824        assert_eq!(
1825            full.iter()
1826                .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
1827                    && info.obj_type == ObjectType::Blob)
1828                .count(),
1829            1
1830        );
1831    }
1832
1833    #[test]
1834    fn state_closure_skips_gitlink_targets() {
1835        let temp = TempDir::new().unwrap();
1836        let repo = Repository::init_default(temp.path()).unwrap();
1837        let target: GitObjectId = "0303030303030303030303030303030303030303"
1838            .parse()
1839            .expect("git oid");
1840        let root = Tree::from_entries(vec![
1841            TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
1842        ]);
1843        let root_hash = repo.store().put_tree(&root).unwrap();
1844        let state = State::new(root_hash, Vec::new(), test_attribution());
1845        repo.store().put_state(&state).unwrap();
1846
1847        let full = enumerate_state_closure_with_options(
1848            repo.store(),
1849            state.state_id,
1850            StateClosureOptions::default(),
1851        )
1852        .unwrap();
1853        let plan = enumerate_state_closure_plan_with_options(
1854            repo.store(),
1855            state.state_id,
1856            StateClosureOptions::default(),
1857        )
1858        .unwrap();
1859
1860        assert_eq!(pairs_from_full(&full), pairs_from_plan(&plan));
1861        assert!(
1862            !full.iter().any(|info| info.obj_type == ObjectType::Blob),
1863            "gitlinks carry foreign Git commit ids, not Heddle blob dependencies: {full:?}"
1864        );
1865        assert!(full.iter().any(|info| {
1866            info.id == ObjectId::Hash(root_hash) && info.obj_type == ObjectType::Tree
1867        }));
1868    }
1869
1870    #[test]
1871    fn missing_blobs_in_tree_skips_gitlinks_and_walks_nested_side_paths() {
1872        let temp = TempDir::new().unwrap();
1873        let repo = Repository::init_default(temp.path()).unwrap();
1874        let present_blob = repo
1875            .store()
1876            .put_blob(&Blob::from("already local"))
1877            .expect("put present blob");
1878        let missing_nested = ContentHash::from_bytes([7; 32]);
1879        let missing_symlink = ContentHash::from_bytes([8; 32]);
1880        let nested_tree = Tree::from_entries(vec![
1881            TreeEntry::file("remote.txt", missing_nested, false).unwrap(),
1882            TreeEntry::symlink("remote-link", missing_symlink).unwrap(),
1883        ]);
1884        let nested_tree_hash = repo
1885            .store()
1886            .put_tree(&nested_tree)
1887            .expect("put nested tree");
1888        let gitlink_target: GitObjectId = "0404040404040404040404040404040404040404"
1889            .parse()
1890            .expect("git oid");
1891        let root = Tree::from_entries(vec![
1892            TreeEntry::file("local.txt", present_blob, false).unwrap(),
1893            TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1894            TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1895        ]);
1896        let root_hash = repo.store().put_tree(&root).expect("put root tree");
1897
1898        let missing = missing_blobs_in_tree(repo.store(), root_hash).expect("missing blobs");
1899
1900        assert_eq!(
1901            missing.into_iter().collect::<HashSet<_>>(),
1902            HashSet::from([missing_nested, missing_symlink])
1903        );
1904    }
1905
1906    /// Once a redaction is declared for a blob in a snapshot, the
1907    /// state closure must include an `ObjectType::Redaction` entry
1908    /// keyed on that blob's hash — that's the wire-side signal the
1909    /// receiver replays.
1910    #[test]
1911    fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
1912        let temp = TempDir::new().unwrap();
1913        let repo = Repository::init_default(temp.path()).unwrap();
1914        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1915        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1916
1917        // Find the blob hash for secret.toml by walking the snapshot's tree.
1918        let tree = repo
1919            .store()
1920            .get_tree(&state.tree)
1921            .unwrap()
1922            .expect("tree present");
1923        let blob_hash = tree
1924            .iter()
1925            .find(|e| e.name() == "secret.toml")
1926            .expect("entry present")
1927            .blob_hash()
1928            .expect("secret.toml is a blob");
1929
1930        let redaction = Redaction {
1931            redacted_blob: blob_hash,
1932            state: state.state_id,
1933            path: "secret.toml".to_string(),
1934            reason: "test leak".to_string(),
1935            redactor: Principal {
1936                name: "Tester".into(),
1937                email: "tester@heddle.sh".into(),
1938            },
1939            redacted_at: Utc::now(),
1940            signature: None,
1941            purge: None,
1942            supersedes: None,
1943        };
1944        repo.put_redaction(redaction).unwrap();
1945
1946        let full = enumerate_state_closure_with_options(
1947            repo.store(),
1948            state.state_id,
1949            StateClosureOptions::default(),
1950        )
1951        .unwrap();
1952        let plan = enumerate_state_closure_plan_with_options(
1953            repo.store(),
1954            state.state_id,
1955            StateClosureOptions::default(),
1956        )
1957        .unwrap();
1958
1959        assert!(
1960            full.iter()
1961                .any(|info| info.obj_type == ObjectType::Redaction
1962                    && info.id == ObjectId::Hash(blob_hash)),
1963            "full closure must include a Redaction entry for the redacted blob"
1964        );
1965        assert!(
1966            plan.iter()
1967                .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
1968            "plan closure must include a Redaction entry for the redacted blob"
1969        );
1970    }
1971
1972    #[test]
1973    fn missing_merely_redacted_blob_still_fails_closure_planning() {
1974        let temp = TempDir::new().unwrap();
1975        let repo = Repository::init_default(temp.path()).unwrap();
1976        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1977        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1978        let blob_hash = repo
1979            .store()
1980            .get_tree(&state.tree)
1981            .unwrap()
1982            .unwrap()
1983            .iter()
1984            .find(|entry| entry.name() == "secret.toml")
1985            .unwrap()
1986            .blob_hash()
1987            .unwrap();
1988        repo.put_redaction(Redaction {
1989            redacted_blob: blob_hash,
1990            state: state.state_id,
1991            path: "secret.toml".to_string(),
1992            reason: "test leak".to_string(),
1993            redactor: Principal::new("Tester", "tester@heddle.sh"),
1994            redacted_at: Utc::now(),
1995            signature: None,
1996            purge: None,
1997            supersedes: None,
1998        })
1999        .unwrap();
2000        let AnyStore::Fs(store) = repo.store();
2001        store.remove_blob_everywhere(&blob_hash).unwrap();
2002
2003        let error = enumerate_state_closure_plan_with_options(
2004            repo.store(),
2005            state.state_id,
2006            StateClosureOptions::default(),
2007        )
2008        .expect_err("redaction without purge authority must not excuse missing bytes");
2009        assert!(matches!(error, ProtocolError::ObjectNotFound(_)));
2010    }
2011
2012    #[test]
2013    fn purged_blob_closure_carries_sidecar_without_deleted_bytes() {
2014        let temp = TempDir::new().unwrap();
2015        let repo = Repository::init_default(temp.path()).unwrap();
2016        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
2017        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
2018        let blob_hash = repo
2019            .store()
2020            .get_tree(&state.tree)
2021            .unwrap()
2022            .unwrap()
2023            .iter()
2024            .find(|entry| entry.name() == "secret.toml")
2025            .unwrap()
2026            .blob_hash()
2027            .unwrap();
2028        repo.put_redaction(Redaction {
2029            redacted_blob: blob_hash,
2030            state: state.state_id,
2031            path: "secret.toml".to_string(),
2032            reason: "test leak".to_string(),
2033            redactor: Principal::new("Tester", "tester@heddle.sh"),
2034            redacted_at: Utc::now(),
2035            signature: None,
2036            purge: Some(PurgeEvidence {
2037                purger: Principal::new("Owner", "owner@heddle.sh"),
2038                purged_at: Utc::now(),
2039                signature: StateSignature {
2040                    algorithm: "ed25519".to_string(),
2041                    public_key: "11".repeat(32),
2042                    signature: "22".repeat(64),
2043                },
2044            }),
2045            supersedes: None,
2046        })
2047        .unwrap();
2048        let AnyStore::Fs(store) = repo.store();
2049        store.remove_blob_everywhere(&blob_hash).unwrap();
2050
2051        let plan = enumerate_state_closure_plan_with_options(
2052            repo.store(),
2053            state.state_id,
2054            StateClosureOptions::default(),
2055        )
2056        .expect("purge sidecar replaces intentionally deleted blob in closure");
2057        assert!(!plan.iter().any(|object| {
2058            object.id == ObjectId::Hash(blob_hash) && object.obj_type == ObjectType::Blob
2059        }));
2060        assert!(plan.iter().any(|object| {
2061            object.id == ObjectId::Hash(blob_hash) && object.obj_type == ObjectType::Redaction
2062        }));
2063    }
2064
2065    #[test]
2066    fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
2067        let temp = TempDir::new().unwrap();
2068        let repo = Repository::init_default(temp.path()).unwrap();
2069        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
2070        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
2071
2072        repo.put_state_visibility(StateVisibility {
2073            state: state.state_id,
2074            tier: VisibilityTier::Restricted {
2075                scope_label: "security-embargo".into(),
2076            },
2077            embargo_until: None,
2078            declarer: Principal {
2079                name: "Tester".into(),
2080                email: "tester@heddle.sh".into(),
2081            },
2082            declared_at: Utc::now(),
2083            signature: None,
2084            supersedes: None,
2085        })
2086        .unwrap();
2087
2088        let full = enumerate_state_closure_with_options(
2089            repo.store(),
2090            state.state_id,
2091            StateClosureOptions::default(),
2092        )
2093        .unwrap();
2094        let plan = enumerate_state_closure_plan_with_options(
2095            repo.store(),
2096            state.state_id,
2097            StateClosureOptions::default(),
2098        )
2099        .unwrap();
2100
2101        assert!(
2102            full.iter()
2103                .any(|info| info.obj_type == ObjectType::StateVisibility
2104                    && info.id == ObjectId::StateId(state.state_id)),
2105            "full closure must include a StateVisibility entry for the visible state"
2106        );
2107        assert!(
2108            plan.iter()
2109                .any(|p| p.obj_type == ObjectType::StateVisibility
2110                    && p.id == ObjectId::StateId(state.state_id)),
2111            "plan closure must include a StateVisibility entry for the visible state"
2112        );
2113    }
2114
2115    #[test]
2116    fn enumerate_state_closure_emits_state_metadata_blobs() {
2117        let temp = TempDir::new().unwrap();
2118        let repo = Repository::init_default(temp.path()).unwrap();
2119        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
2120        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
2121
2122        let principal = Principal::new("Tester", "tester@example.test");
2123        let discussion_bytes = DiscussionsBlob::new(vec![Discussion {
2124            id: "disc-1".to_string(),
2125            anchor: SymbolAnchor::new("src/lib.rs", "answer"),
2126            opened_against_state: state.state_id,
2127            opened_at: 1_782_400_000,
2128            thread_ref: None,
2129            turns: vec![DiscussionTurn {
2130                author: principal,
2131                body: "Should this sync?".to_string(),
2132                posted_at: 1_782_400_000,
2133                references: Vec::new(),
2134            }],
2135            resolution: DiscussionResolution::Open,
2136            body_changed_since_open: false,
2137            orphaned: false,
2138            visibility: VisibilityTier::default(),
2139            resolved_annotation_id: None,
2140        }])
2141        .encode()
2142        .expect("encode discussions");
2143        let discussion_hash = repo
2144            .store()
2145            .put_blob(&Blob::new(discussion_bytes))
2146            .expect("put discussions blob");
2147        let risk_hash = repo
2148            .store()
2149            .put_blob(&Blob::from_slice(b"risk signals"))
2150            .expect("put risk blob");
2151        let review_hash = repo
2152            .store()
2153            .put_blob(&Blob::from_slice(b"review signatures"))
2154            .expect("put review blob");
2155        let conflicts_hash = repo
2156            .store()
2157            .put_blob(&Blob::from_slice(b"structured conflicts"))
2158            .expect("put conflicts blob");
2159        for body in [
2160            StateAttachmentBody::RiskSignals(risk_hash),
2161            StateAttachmentBody::ReviewSignatures(review_hash),
2162            StateAttachmentBody::Discussions(discussion_hash),
2163            StateAttachmentBody::StructuredConflicts(conflicts_hash),
2164        ] {
2165            repo.put_state_attachment(&StateAttachment {
2166                state_id: state.id(),
2167                body,
2168                attribution: state.attribution.clone(),
2169                created_at: Utc::now(),
2170                supersedes: None,
2171            })
2172            .unwrap();
2173        }
2174
2175        let full = enumerate_state_closure_with_options(
2176            repo.store(),
2177            state.state_id,
2178            StateClosureOptions::default(),
2179        )
2180        .unwrap();
2181        let plan = enumerate_state_closure_plan_with_options(
2182            repo.store(),
2183            state.state_id,
2184            StateClosureOptions::default(),
2185        )
2186        .unwrap();
2187
2188        for metadata_hash in [risk_hash, review_hash, discussion_hash, conflicts_hash] {
2189            assert!(
2190                full.iter().any(|info| info.obj_type == ObjectType::Blob
2191                    && info.id == ObjectId::Hash(metadata_hash)),
2192                "full closure must include state metadata blob {metadata_hash}"
2193            );
2194            assert!(
2195                plan.iter().any(
2196                    |p| p.obj_type == ObjectType::Blob && p.id == ObjectId::Hash(metadata_hash)
2197                ),
2198                "plan closure must include state metadata blob {metadata_hash}"
2199            );
2200        }
2201    }
2202
2203    /// The push/pull packability split routes `StateAttachment` off the push
2204    /// pack (weft#549 forgery seal) while leaving pull carriage and every other
2205    /// type untouched.
2206    #[test]
2207    fn packable_predicates_split_state_attachment_by_direction() {
2208        // Sidecar records are never packable in either direction.
2209        for sidecar in [
2210            ObjectType::Redaction,
2211            ObjectType::StateVisibility,
2212            ObjectType::KeyBinding,
2213        ] {
2214            assert!(!sidecar.packable_for_push(), "{sidecar:?} push");
2215            assert!(!sidecar.packable_for_pull(), "{sidecar:?} pull");
2216        }
2217        // Content-addressed objects ride the pack in both directions.
2218        for packable in [
2219            ObjectType::Blob,
2220            ObjectType::Tree,
2221            ObjectType::State,
2222            ObjectType::Action,
2223        ] {
2224            assert!(packable.packable_for_push(), "{packable:?} push");
2225            assert!(packable.packable_for_pull(), "{packable:?} pull");
2226        }
2227        // The attachment record: excluded from the push pack, kept on pull.
2228        assert!(!ObjectType::StateAttachment.packable_for_push());
2229        assert!(ObjectType::StateAttachment.packable_for_pull());
2230    }
2231
2232    /// A pushed state's semantic-index attachment RECORD must be excluded from
2233    /// the push pack (it rides the sidecar lane) while its semantic-index
2234    /// content blobs still ride the pack in both directions, and the same
2235    /// record stays packable server->client on pull.
2236    #[test]
2237    fn semantic_index_attachment_excluded_from_push_pack_but_kept_for_pull() {
2238        use std::collections::BTreeMap;
2239
2240        use objects::object::{
2241            BindingDelta, FileBindingDelta, SemanticIndexRoot, SemanticTreeNode,
2242        };
2243
2244        let temp = TempDir::new().unwrap();
2245        let repo = Repository::init_default(temp.path()).unwrap();
2246        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
2247        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
2248
2249        // Minimal valid semantic-index fixture: an empty tree node under a root.
2250        let (node, node_digest) = SemanticTreeNode::new(Vec::new());
2251        let node_hash = repo
2252            .store()
2253            .put_blob(&Blob::new(node.encode().unwrap()))
2254            .expect("put semantic tree node");
2255        let base_delta = BindingDelta::new(
2256            None,
2257            vec![FileBindingDelta::new(
2258                "unreachable-parent.rs",
2259                None,
2260                Vec::new(),
2261            )],
2262        );
2263        let base_delta_hash = repo
2264            .store()
2265            .put_blob(&Blob::new(base_delta.encode().unwrap()))
2266            .expect("put base binding delta");
2267        let delta = BindingDelta::new(Some(base_delta_hash), Vec::new());
2268        let delta_hash = repo
2269            .store()
2270            .put_blob(&Blob::new(delta.encode().unwrap()))
2271            .expect("put binding delta");
2272        let root = SemanticIndexRoot::new(1, BTreeMap::new(), node_hash, node_digest)
2273            .with_binding_delta(delta_hash, 1);
2274        let root_hash = repo
2275            .store()
2276            .put_blob(&Blob::new(root.encode().unwrap()))
2277            .expect("put semantic index root");
2278        repo.put_state_attachment(&StateAttachment {
2279            state_id: state.state_id,
2280            body: StateAttachmentBody::SemanticIndex(root_hash),
2281            attribution: test_attribution(),
2282            created_at: Utc::now(),
2283            supersedes: None,
2284        })
2285        .unwrap();
2286
2287        let plan = enumerate_state_closure_plan_with_options(
2288            repo.store(),
2289            state.state_id,
2290            StateClosureOptions::default(),
2291        )
2292        .unwrap();
2293
2294        // Every StateAttachment record (at least the semantic index authored
2295        // above) is push-excluded and pull-included.
2296        let attachments: Vec<_> = plan
2297            .iter()
2298            .filter(|p| p.obj_type == ObjectType::StateAttachment)
2299            .collect();
2300        assert!(
2301            !attachments.is_empty(),
2302            "closure must contain the semantic-index attachment record"
2303        );
2304        for attachment in &attachments {
2305            assert!(matches!(attachment.id, ObjectId::StateAttachment { .. }));
2306            // Push: excluded from the pack (sidecar lane). Pull: kept in pack.
2307            assert!(
2308                !attachment.obj_type.packable_for_push(),
2309                "attachment record must be excluded from the push pack"
2310            );
2311            assert!(
2312                attachment.obj_type.packable_for_pull(),
2313                "attachment record must stay in the pull pack"
2314            );
2315        }
2316
2317        // The semantic-index CONTENT blobs are ordinary content-addressed
2318        // objects and still ride the pack in both directions — only the
2319        // attachment record is sidecar'd on push.
2320        for content in [root_hash, node_hash, delta_hash] {
2321            let obj = plan
2322                .iter()
2323                .find(|p| p.id == ObjectId::Hash(content))
2324                .unwrap_or_else(|| panic!("semantic content blob {content} in closure"));
2325            assert_eq!(obj.obj_type, ObjectType::Blob);
2326            assert!(obj.obj_type.packable_for_push());
2327            assert!(obj.obj_type.packable_for_pull());
2328        }
2329        assert!(
2330            !plan
2331                .iter()
2332                .any(|object| object.id == ObjectId::Hash(base_delta_hash)),
2333            "a binding delta belonging to an unreachable parent state must not ride this state's closure"
2334        );
2335
2336        // Partitioning the plan by push-packability puts the record on the
2337        // sidecar side and never in the pack side.
2338        let (push_pack, push_sidecar): (Vec<_>, Vec<_>) =
2339            plan.iter().partition(|p| p.obj_type.packable_for_push());
2340        assert!(
2341            push_sidecar
2342                .iter()
2343                .any(|p| p.obj_type == ObjectType::StateAttachment),
2344            "attachment record routed to the push sidecar partition"
2345        );
2346        assert!(
2347            !push_pack
2348                .iter()
2349                .any(|p| p.obj_type == ObjectType::StateAttachment),
2350            "attachment record must not be in the push pack partition"
2351        );
2352    }
2353}