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