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::{ChangeId, ContentHash, State, TreeEntryTarget},
6    store::{ObjectStore, pack::ObjectType as PackObjectType},
7};
8use serde::{Deserialize, Serialize};
9
10use crate::{ProtocolError, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum ObjectId {
14    Hash(ContentHash),
15    ChangeId(ChangeId),
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ObjectInfo {
20    pub id: ObjectId,
21    pub obj_type: ObjectType,
22    pub size: u64,
23    pub delta_base: Option<ContentHash>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct PlannedObject {
28    pub id: ObjectId,
29    pub obj_type: ObjectType,
30}
31
32#[derive(Debug, Clone)]
33pub struct StateClosureTransferObjects {
34    pub planned_objects: Vec<PlannedObject>,
35    pub full_objects: Option<Vec<ObjectInfo>>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum ObjectType {
40    Blob,
41    Tree,
42    State,
43    Action,
44    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
45    /// that a specific blob has been redacted (and possibly purged) by
46    /// an authorized operator. Keyed on the wire by `ObjectId::Hash` of
47    /// the *redacted blob*, since `Repository`'s sidecar store is
48    /// indexed that way.
49    Redaction,
50    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
51    /// declaring a non-public audience tier for a specific state. Keyed
52    /// on the wire by `ObjectId::ChangeId` of the *state*, since the
53    /// per-state sidecar store is indexed that way. Like `Redaction`, it
54    /// is a sidecar record that lives outside the content-addressed pack
55    /// and ships via the per-object transfer path, not the pack.
56    StateVisibility,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum ObjectTypeBucket {
61    Blob,
62    Tree,
63    State,
64    Action,
65    Redaction,
66    StateVisibility,
67}
68
69impl ObjectType {
70    pub fn wire_name(self) -> &'static str {
71        match self {
72            ObjectType::Blob => "blob",
73            ObjectType::Tree => "tree",
74            ObjectType::State => "state",
75            ObjectType::Action => "action",
76            ObjectType::Redaction => "redaction",
77            ObjectType::StateVisibility => "state_visibility",
78        }
79    }
80
81    pub fn from_wire(value: &str) -> Result<Self> {
82        match value {
83            "blob" => Ok(ObjectType::Blob),
84            "tree" => Ok(ObjectType::Tree),
85            "state" => Ok(ObjectType::State),
86            "action" => Ok(ObjectType::Action),
87            "redaction" => Ok(ObjectType::Redaction),
88            "state_visibility" => Ok(ObjectType::StateVisibility),
89            _ => Err(ProtocolError::InvalidState(format!(
90                "unknown object type: {value}"
91            ))),
92        }
93    }
94
95    pub fn packable(self) -> bool {
96        !matches!(self, ObjectType::Redaction | ObjectType::StateVisibility)
97    }
98
99    pub fn pack_object_type(self) -> Result<PackObjectType> {
100        match self {
101            ObjectType::Blob => Ok(PackObjectType::Blob),
102            ObjectType::Tree => Ok(PackObjectType::Tree),
103            ObjectType::State => Ok(PackObjectType::State),
104            ObjectType::Action => Ok(PackObjectType::Action),
105            ObjectType::Redaction => Err(ProtocolError::InvalidState(
106                "Redaction sidecar records cannot be packed into the content-addressed object pack"
107                    .to_string(),
108            )),
109            ObjectType::StateVisibility => Err(ProtocolError::InvalidState(
110                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
111                    .to_string(),
112            )),
113        }
114    }
115
116    pub fn bucket(self) -> ObjectTypeBucket {
117        match self {
118            ObjectType::Blob => ObjectTypeBucket::Blob,
119            ObjectType::Tree => ObjectTypeBucket::Tree,
120            ObjectType::State => ObjectTypeBucket::State,
121            ObjectType::Action => ObjectTypeBucket::Action,
122            ObjectType::Redaction => ObjectTypeBucket::Redaction,
123            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
124        }
125    }
126}
127
128#[derive(Debug, Clone, Default)]
129pub struct StateClosureOptions {
130    pub depth: Option<u32>,
131    pub exclude_states: Vec<ChangeId>,
132}
133
134pub fn enumerate_state_closure(
135    store: &impl ObjectStore,
136    state_id: ChangeId,
137) -> Result<Vec<ObjectInfo>> {
138    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
139}
140
141pub fn enumerate_state_closure_with_options(
142    store: &impl ObjectStore,
143    state_id: ChangeId,
144    options: StateClosureOptions,
145) -> Result<Vec<ObjectInfo>> {
146    let mut out = Vec::new();
147    walk_state_closure(store, state_id, options, |event| {
148        if let Some(info) = object_info_from_event(store, event)? {
149            out.push(info);
150        }
151        Ok(())
152    })?;
153
154    Ok(out)
155}
156
157pub fn enumerate_state_closure_plan(
158    store: &impl ObjectStore,
159    state_id: ChangeId,
160) -> Result<Vec<PlannedObject>> {
161    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
162}
163
164pub fn enumerate_state_closure_plan_with_options(
165    store: &impl ObjectStore,
166    state_id: ChangeId,
167    options: StateClosureOptions,
168) -> Result<Vec<PlannedObject>> {
169    let mut out = Vec::new();
170    walk_state_closure(store, state_id, options, |event| {
171        if let Some(object) = planned_object_from_event(store, event)? {
172            out.push(object);
173        }
174        Ok(())
175    })?;
176
177    Ok(out)
178}
179
180pub fn enumerate_state_closure_transfer_with_options(
181    store: &impl ObjectStore,
182    state_id: ChangeId,
183    options: StateClosureOptions,
184    full_descriptor_object_threshold: usize,
185) -> Result<StateClosureTransferObjects> {
186    let mut planned_objects = Vec::new();
187    let mut full_objects = Some(Vec::new());
188
189    walk_state_closure(store, state_id, options, |event| {
190        if let Some(object) = planned_object_from_event(store, event)? {
191            planned_objects.push(object);
192        }
193
194        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
195            full_objects = None;
196        }
197        if let Some(objects) = full_objects.as_mut()
198            && let Some(info) = object_info_from_event(store, event)?
199        {
200            objects.push(info);
201        }
202
203        Ok(())
204    })?;
205
206    Ok(StateClosureTransferObjects {
207        planned_objects,
208        full_objects,
209    })
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213enum BlobSource {
214    Tree,
215    StateMetadata,
216}
217
218#[derive(Debug, Clone, Copy)]
219enum StateClosureEvent<'a> {
220    State {
221        id: ChangeId,
222        state: &'a State,
223    },
224    Tree {
225        hash: ContentHash,
226        tree: &'a objects::object::Tree,
227    },
228    Blob {
229        hash: ContentHash,
230        source: BlobSource,
231    },
232    Redaction {
233        blob: ContentHash,
234    },
235    StateVisibility {
236        state: ChangeId,
237    },
238    ExcludedState {
239        id: ChangeId,
240    },
241    ExcludedHash {
242        hash: ContentHash,
243    },
244}
245
246fn walk_state_closure(
247    store: &impl ObjectStore,
248    state_id: ChangeId,
249    options: StateClosureOptions,
250    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
251) -> Result<()> {
252    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
253
254    let mut seen_states: HashSet<ChangeId> = HashSet::new();
255    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
256    let mut queue: VecDeque<(ChangeId, u32)> = VecDeque::new();
257    queue.push_back((state_id, 0));
258
259    while let Some((id, depth)) = queue.pop_front() {
260        if excluded_states.contains(&id) {
261            visit(StateClosureEvent::ExcludedState { id })?;
262            continue;
263        }
264        if !seen_states.insert(id) {
265            continue;
266        }
267
268        let state = store
269            .get_state(&id)?
270            .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
271
272        visit(StateClosureEvent::State { id, state: &state })?;
273        if store.has_state_visibility_for_state(&id)? {
274            visit(StateClosureEvent::StateVisibility { state: id })?;
275        }
276
277        if options.depth.map(|max| depth < max).unwrap_or(true) {
278            for parent in &state.parents {
279                queue.push_back((*parent, depth + 1));
280            }
281        }
282
283        walk_tree_closure_filtered(
284            store,
285            state.tree,
286            &excluded_hashes,
287            &mut seen_hashes,
288            &mut visit,
289        )?;
290        if let Some(provenance_root) = state.provenance {
291            walk_tree_closure_filtered(
292                store,
293                provenance_root,
294                &excluded_hashes,
295                &mut seen_hashes,
296                &mut visit,
297            )?;
298        }
299        if let Some(context_root) = state.context {
300            walk_tree_closure_filtered(
301                store,
302                context_root,
303                &excluded_hashes,
304                &mut seen_hashes,
305                &mut visit,
306            )?;
307        }
308        for metadata_blob in state_blob_dependencies(&state) {
309            walk_blob_filtered(
310                store,
311                metadata_blob,
312                BlobSource::StateMetadata,
313                &excluded_hashes,
314                &mut seen_hashes,
315                &mut visit,
316            )?;
317        }
318    }
319
320    Ok(())
321}
322
323fn walk_tree_closure_filtered(
324    store: &impl ObjectStore,
325    tree_hash: ContentHash,
326    excluded: &HashSet<ContentHash>,
327    seen: &mut HashSet<ContentHash>,
328    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
329) -> Result<()> {
330    if excluded.contains(&tree_hash) {
331        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
332        return Ok(());
333    }
334    if !seen.insert(tree_hash) {
335        return Ok(());
336    }
337
338    let tree = store
339        .get_tree(&tree_hash)?
340        .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
341
342    visit(StateClosureEvent::Tree {
343        hash: tree_hash,
344        tree: &tree,
345    })?;
346
347    for entry in tree.entries() {
348        match entry.target() {
349            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
350                walk_blob_filtered(store, *hash, BlobSource::Tree, excluded, seen, visit)?;
351            }
352            TreeEntryTarget::Tree { hash } => {
353                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
354            }
355            TreeEntryTarget::Gitlink { .. } => {}
356            // Native child-spool edge: its target lives in a separate spool
357            // object graph, not this store, so it is not walked here.
358            TreeEntryTarget::Spoollink { .. } => {}
359        }
360    }
361
362    Ok(())
363}
364
365fn walk_blob_filtered(
366    store: &impl ObjectStore,
367    blob_hash: ContentHash,
368    source: BlobSource,
369    excluded: &HashSet<ContentHash>,
370    seen: &mut HashSet<ContentHash>,
371    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
372) -> Result<()> {
373    if excluded.contains(&blob_hash) {
374        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
375        return Ok(());
376    }
377    if !seen.insert(blob_hash) {
378        return Ok(());
379    }
380    visit(StateClosureEvent::Blob {
381        hash: blob_hash,
382        source,
383    })?;
384    if store.has_redactions_for_blob(&blob_hash)? {
385        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
386    }
387    Ok(())
388}
389
390fn object_info_from_event(
391    store: &impl ObjectStore,
392    event: StateClosureEvent<'_>,
393) -> Result<Option<ObjectInfo>> {
394    match event {
395        StateClosureEvent::State { id, state } => {
396            let state_bytes = rmp_serde::to_vec_named(state)?;
397            Ok(Some(ObjectInfo {
398                id: ObjectId::ChangeId(id),
399                obj_type: ObjectType::State,
400                size: state_bytes.len() as u64,
401                delta_base: None,
402            }))
403        }
404        StateClosureEvent::Tree { hash, tree } => {
405            let tree_bytes = rmp_serde::to_vec_named(tree)?;
406            Ok(Some(ObjectInfo {
407                id: ObjectId::Hash(hash),
408                obj_type: ObjectType::Tree,
409                size: tree_bytes.len() as u64,
410                delta_base: None,
411            }))
412        }
413        StateClosureEvent::Blob { hash, .. } => {
414            let blob = store
415                .get_blob(&hash)?
416                .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
417            Ok(Some(ObjectInfo {
418                id: ObjectId::Hash(hash),
419                obj_type: ObjectType::Blob,
420                size: blob.size() as u64,
421                delta_base: None,
422            }))
423        }
424        StateClosureEvent::Redaction { blob } => Ok(store
425            .get_redactions_bytes_for_blob(&blob)?
426            .map(|bytes| ObjectInfo {
427                id: ObjectId::Hash(blob),
428                obj_type: ObjectType::Redaction,
429                size: bytes.len() as u64,
430                delta_base: None,
431            })),
432        StateClosureEvent::StateVisibility { state } => Ok(store
433            .get_state_visibility_bytes_for_state(&state)?
434            .map(|bytes| ObjectInfo {
435                id: ObjectId::ChangeId(state),
436                obj_type: ObjectType::StateVisibility,
437                size: bytes.len() as u64,
438                delta_base: None,
439            })),
440        StateClosureEvent::ExcludedState { id } => {
441            let _ = id;
442            Ok(None)
443        }
444        StateClosureEvent::ExcludedHash { hash } => {
445            let _ = hash;
446            Ok(None)
447        }
448    }
449}
450
451fn planned_object_from_event(
452    store: &impl ObjectStore,
453    event: StateClosureEvent<'_>,
454) -> Result<Option<PlannedObject>> {
455    match event {
456        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
457            id: ObjectId::ChangeId(id),
458            obj_type: ObjectType::State,
459        })),
460        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
461            id: ObjectId::Hash(hash),
462            obj_type: ObjectType::Tree,
463        })),
464        StateClosureEvent::Blob { hash, source } => {
465            if source == BlobSource::StateMetadata && store.get_blob(&hash)?.is_none() {
466                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
467            }
468            Ok(Some(PlannedObject {
469                id: ObjectId::Hash(hash),
470                obj_type: ObjectType::Blob,
471            }))
472        }
473        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
474            id: ObjectId::Hash(blob),
475            obj_type: ObjectType::Redaction,
476        })),
477        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
478            id: ObjectId::ChangeId(state),
479            obj_type: ObjectType::StateVisibility,
480        })),
481        StateClosureEvent::ExcludedState { id } => {
482            let _ = id;
483            Ok(None)
484        }
485        StateClosureEvent::ExcludedHash { hash } => {
486            let _ = hash;
487            Ok(None)
488        }
489    }
490}
491
492pub fn missing_blobs_in_tree(
493    store: &impl ObjectStore,
494    tree_hash: ContentHash,
495) -> Result<Vec<ContentHash>> {
496    let mut missing = Vec::new();
497    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
498    Ok(missing)
499}
500
501fn collect_missing_blobs_recursive(
502    store: &impl ObjectStore,
503    tree_hash: &ContentHash,
504    missing: &mut Vec<ContentHash>,
505) -> Result<()> {
506    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
507        ProtocolError::InvalidState(format!(
508            "load tree {} while collecting lazy hydration missing blobs: {err}",
509            tree_hash.to_hex()
510        ))
511    })?
512    else {
513        return Ok(());
514    };
515
516    for entry in tree.entries() {
517        match entry.target() {
518            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
519                if !store.has_blob(hash).map_err(|err| {
520                    ProtocolError::InvalidState(format!(
521                        "check blob {} while collecting lazy hydration missing blobs: {err}",
522                        hash.to_hex()
523                    ))
524                })? {
525                    missing.push(*hash);
526                }
527            }
528            TreeEntryTarget::Tree { hash } => {
529                collect_missing_blobs_recursive(store, hash, missing)?;
530            }
531            TreeEntryTarget::Gitlink { .. } => {}
532            // Native child-spool edge: its target lives in a separate spool
533            // object graph, not this store, so it is not walked here.
534            TreeEntryTarget::Spoollink { .. } => {}
535        }
536    }
537    Ok(())
538}
539
540fn collect_excluded(
541    store: &impl ObjectStore,
542    roots: &[ChangeId],
543) -> Result<(HashSet<ChangeId>, HashSet<ContentHash>)> {
544    if roots.is_empty() {
545        return Ok((HashSet::new(), HashSet::new()));
546    }
547
548    let mut excluded_states: HashSet<ChangeId> = HashSet::new();
549    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
550    let mut queue: VecDeque<ChangeId> = VecDeque::new();
551
552    for id in roots {
553        queue.push_back(*id);
554    }
555
556    while let Some(id) = queue.pop_front() {
557        if !excluded_states.insert(id) {
558            continue;
559        }
560
561        let state = match store.get_state(&id)? {
562            Some(state) => state,
563            None => continue,
564        };
565
566        for parent in &state.parents {
567            queue.push_back(*parent);
568        }
569
570        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
571        if let Some(provenance_root) = state.provenance {
572            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
573        }
574        if let Some(context_root) = state.context {
575            collect_tree_hashes(store, context_root, &mut excluded_hashes)?;
576        }
577        for metadata_blob in state_blob_dependencies(&state) {
578            excluded_hashes.insert(metadata_blob);
579        }
580    }
581
582    Ok((excluded_states, excluded_hashes))
583}
584
585fn state_blob_dependencies(state: &State) -> impl Iterator<Item = ContentHash> + '_ {
586    [
587        state.risk_signals,
588        state.review_signatures,
589        state.discussions,
590        state.structured_conflicts,
591    ]
592    .into_iter()
593    .flatten()
594}
595
596fn collect_tree_hashes(
597    store: &impl ObjectStore,
598    tree_hash: ContentHash,
599    excluded: &mut HashSet<ContentHash>,
600) -> Result<()> {
601    if !excluded.insert(tree_hash) {
602        return Ok(());
603    }
604
605    let tree = match store.get_tree(&tree_hash)? {
606        Some(tree) => tree,
607        None => return Ok(()),
608    };
609
610    for entry in tree.entries() {
611        match entry.target() {
612            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
613                excluded.insert(*hash);
614            }
615            TreeEntryTarget::Tree { hash } => {
616                collect_tree_hashes(store, *hash, excluded)?;
617            }
618            TreeEntryTarget::Gitlink { .. } => {}
619            // Native child-spool edge: its target lives in a separate spool
620            // object graph, not this store, so it is not walked here.
621            TreeEntryTarget::Spoollink { .. } => {}
622        }
623    }
624
625    Ok(())
626}
627
628pub fn is_ancestor(
629    store: &impl ObjectStore,
630    ancestor: ChangeId,
631    descendant: ChangeId,
632) -> Result<bool> {
633    if ancestor == descendant {
634        return Ok(true);
635    }
636
637    let mut seen: HashSet<ChangeId> = HashSet::new();
638    let mut queue: VecDeque<ChangeId> = VecDeque::new();
639    queue.push_back(descendant);
640
641    while let Some(id) = queue.pop_front() {
642        if !seen.insert(id) {
643            continue;
644        }
645        let state = match store.get_state(&id)? {
646            Some(s) => s,
647            None => return Ok(false),
648        };
649        for parent in state.parents {
650            if parent == ancestor {
651                return Ok(true);
652            }
653            queue.push_back(parent);
654        }
655    }
656
657    Ok(false)
658}
659
660#[cfg(test)]
661mod tests {
662    use std::{
663        collections::HashSet,
664        sync::atomic::{AtomicUsize, Ordering},
665    };
666
667    use chrono::Utc;
668    use objects::{
669        object::{
670            Action, ActionId, Attribution, Blob, ChangeId, ContentHash, Discussion,
671            DiscussionResolution, DiscussionTurn, DiscussionsBlob, Principal, Redaction, State,
672            StateVisibility, SymbolAnchor, Tree, TreeEntry, VisibilityTier,
673        },
674        store::{ObjectStore, Result as StoreResult},
675    };
676    use repo::Repository;
677    use sley::ObjectId as GitObjectId;
678    use tempfile::TempDir;
679
680    use super::{
681        ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
682        enumerate_state_closure_plan_with_options, enumerate_state_closure_transfer_with_options,
683        enumerate_state_closure_with_options, missing_blobs_in_tree,
684    };
685
686    fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
687        objects
688            .iter()
689            .map(|info| (info.id.clone(), info.obj_type))
690            .collect()
691    }
692
693    fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
694        objects
695            .iter()
696            .map(|info| (info.id.clone(), info.obj_type))
697            .collect()
698    }
699
700    fn object_info_fingerprint(
701        objects: &[ObjectInfo],
702    ) -> Vec<(ObjectId, ObjectType, u64, Option<ContentHash>)> {
703        objects
704            .iter()
705            .map(|info| (info.id.clone(), info.obj_type, info.size, info.delta_base))
706            .collect()
707    }
708
709    fn assert_plan_parity(
710        repo: &Repository,
711        state_id: ChangeId,
712        options: StateClosureOptions,
713    ) -> HashSet<(ObjectId, ObjectType)> {
714        let full =
715            enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
716        let plan =
717            enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
718
719        let full_pairs = pairs_from_full(&full);
720        let plan_pairs = pairs_from_plan(&plan);
721        assert_eq!(full_pairs, plan_pairs);
722        full_pairs
723    }
724
725    fn assert_contains_object(
726        objects: &HashSet<(ObjectId, ObjectType)>,
727        id: ObjectId,
728        obj_type: ObjectType,
729    ) {
730        assert!(
731            objects.contains(&(id.clone(), obj_type)),
732            "expected closure to contain {id:?} as {obj_type:?}: {objects:?}"
733        );
734    }
735
736    struct CountingStore<'a, S> {
737        inner: &'a S,
738        state_reads: AtomicUsize,
739    }
740
741    impl<'a, S> CountingStore<'a, S> {
742        fn new(inner: &'a S) -> Self {
743            Self {
744                inner,
745                state_reads: AtomicUsize::new(0),
746            }
747        }
748
749        fn state_reads(&self) -> usize {
750            self.state_reads.load(Ordering::SeqCst)
751        }
752    }
753
754    impl<S: ObjectStore> ObjectStore for CountingStore<'_, S> {
755        fn get_blob(&self, hash: &ContentHash) -> StoreResult<Option<Blob>> {
756            self.inner.get_blob(hash)
757        }
758
759        fn put_blob(&self, blob: &Blob) -> StoreResult<ContentHash> {
760            self.inner.put_blob(blob)
761        }
762
763        fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
764            self.inner.has_blob(hash)
765        }
766
767        fn get_tree(&self, hash: &ContentHash) -> StoreResult<Option<Tree>> {
768            self.inner.get_tree(hash)
769        }
770
771        fn put_tree(&self, tree: &Tree) -> StoreResult<ContentHash> {
772            self.inner.put_tree(tree)
773        }
774
775        fn has_tree(&self, hash: &ContentHash) -> StoreResult<bool> {
776            self.inner.has_tree(hash)
777        }
778
779        fn get_state(&self, id: &ChangeId) -> StoreResult<Option<State>> {
780            self.state_reads.fetch_add(1, Ordering::SeqCst);
781            self.inner.get_state(id)
782        }
783
784        fn put_state(&self, state: &State) -> StoreResult<()> {
785            self.inner.put_state(state)
786        }
787
788        fn has_state(&self, id: &ChangeId) -> StoreResult<bool> {
789            self.inner.has_state(id)
790        }
791
792        fn list_states(&self) -> StoreResult<Vec<ChangeId>> {
793            self.inner.list_states()
794        }
795
796        fn get_action(&self, id: &ActionId) -> StoreResult<Option<Action>> {
797            self.inner.get_action(id)
798        }
799
800        fn put_action(&self, action: &mut Action) -> StoreResult<ActionId> {
801            self.inner.put_action(action)
802        }
803
804        fn list_actions(&self) -> StoreResult<Vec<ActionId>> {
805            self.inner.list_actions()
806        }
807
808        fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
809            self.inner.list_blobs()
810        }
811
812        fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
813            self.inner.list_trees()
814        }
815    }
816
817    fn test_attribution() -> Attribution {
818        Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
819    }
820
821    #[test]
822    fn lean_closure_planner_matches_object_info_ids_and_types() {
823        let temp = TempDir::new().unwrap();
824        let repo = Repository::init_default(temp.path()).unwrap();
825        std::fs::create_dir_all(temp.path().join("src")).unwrap();
826        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
827        std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
828        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
829
830        let full = enumerate_state_closure_with_options(
831            repo.store(),
832            state.change_id,
833            StateClosureOptions::default(),
834        )
835        .unwrap();
836        let lean = enumerate_state_closure_plan_with_options(
837            repo.store(),
838            state.change_id,
839            StateClosureOptions::default(),
840        )
841        .unwrap();
842
843        let full_pairs = full
844            .into_iter()
845            .map(|info| (info.id, info.obj_type))
846            .collect::<std::collections::HashSet<_>>();
847        let lean_pairs = lean
848            .into_iter()
849            .map(|info| (info.id, info.obj_type))
850            .collect::<std::collections::HashSet<_>>();
851
852        assert_eq!(full_pairs, lean_pairs);
853        assert!(
854            full_pairs
855                .iter()
856                .any(|(id, _)| matches!(id, ObjectId::ChangeId(_)))
857        );
858    }
859
860    #[test]
861    fn transfer_projection_matches_full_and_plan_on_mixed_state_closure_fixture() {
862        let temp = TempDir::new().unwrap();
863        let repo = Repository::init_default(temp.path()).unwrap();
864
865        let excluded_blob = repo
866            .store()
867            .put_blob(&Blob::from("excluded"))
868            .expect("put excluded blob");
869        let excluded_tree_hash = repo
870            .store()
871            .put_tree(&Tree::from_entries(vec![
872                TreeEntry::file("excluded.txt", excluded_blob, false).unwrap(),
873            ]))
874            .expect("put excluded tree");
875        let excluded_parent = State::new(excluded_tree_hash, Vec::new(), test_attribution());
876        repo.store()
877            .put_state(&excluded_parent)
878            .expect("put excluded parent");
879
880        let redacted_blob = repo
881            .store()
882            .put_blob(&Blob::from("secret"))
883            .expect("put redacted blob");
884        let nested_blob = repo
885            .store()
886            .put_blob(&Blob::from("nested"))
887            .expect("put nested blob");
888        let symlink_blob = repo
889            .store()
890            .put_blob(&Blob::from("target"))
891            .expect("put symlink blob");
892        let context_blob = repo
893            .store()
894            .put_blob(&Blob::from("context"))
895            .expect("put context blob");
896        let provenance_blob = repo
897            .store()
898            .put_blob(&Blob::from("provenance"))
899            .expect("put provenance blob");
900        let risk_blob = repo
901            .store()
902            .put_blob(&Blob::from("risk"))
903            .expect("put risk blob");
904        let review_blob = repo
905            .store()
906            .put_blob(&Blob::from("review"))
907            .expect("put review blob");
908        let discussions_blob = repo
909            .store()
910            .put_blob(&Blob::from("discussion"))
911            .expect("put discussion blob");
912        let conflicts_blob = repo
913            .store()
914            .put_blob(&Blob::from("conflicts"))
915            .expect("put conflicts blob");
916
917        let nested_tree_hash = repo
918            .store()
919            .put_tree(&Tree::from_entries(vec![
920                TreeEntry::file("nested.txt", nested_blob, false).unwrap(),
921                TreeEntry::symlink("latest", symlink_blob).unwrap(),
922            ]))
923            .expect("put nested tree");
924        let context_tree_hash = repo
925            .store()
926            .put_tree(&Tree::from_entries(vec![
927                TreeEntry::file("context.txt", context_blob, false).unwrap(),
928            ]))
929            .expect("put context tree");
930        let provenance_tree_hash = repo
931            .store()
932            .put_tree(&Tree::from_entries(vec![
933                TreeEntry::file("lineage.txt", provenance_blob, false).unwrap(),
934            ]))
935            .expect("put provenance tree");
936        let gitlink_target: GitObjectId = "0303030303030303030303030303030303030303"
937            .parse()
938            .expect("git oid");
939        let root_tree_hash = repo
940            .store()
941            .put_tree(&Tree::from_entries(vec![
942                TreeEntry::file("secret.txt", redacted_blob, false).unwrap(),
943                TreeEntry::directory("nested", nested_tree_hash).unwrap(),
944                TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
945            ]))
946            .expect("put root tree");
947        let state = State::new(
948            root_tree_hash,
949            vec![excluded_parent.change_id],
950            test_attribution(),
951        )
952        .with_context(context_tree_hash)
953        .with_provenance(provenance_tree_hash)
954        .with_risk_signals(risk_blob)
955        .with_review_signatures(review_blob)
956        .with_discussions(discussions_blob)
957        .with_structured_conflicts(conflicts_blob);
958        repo.store().put_state(&state).expect("put state");
959
960        repo.put_redaction(Redaction {
961            redacted_blob,
962            state: state.change_id,
963            path: "secret.txt".to_string(),
964            reason: "test leak".to_string(),
965            redactor: Principal::new("Tester", "tester@example.test"),
966            redacted_at: Utc::now(),
967            signature: None,
968            purged_at: None,
969            supersedes: None,
970        })
971        .expect("put redaction");
972        repo.put_state_visibility(StateVisibility {
973            state: state.change_id,
974            tier: VisibilityTier::Restricted {
975                scope_label: "security".to_string(),
976            },
977            embargo_until: None,
978            declarer: Principal::new("Tester", "tester@example.test"),
979            declared_at: Utc::now(),
980            signature: None,
981            supersedes: None,
982        })
983        .expect("put visibility");
984
985        let options = StateClosureOptions {
986            depth: None,
987            exclude_states: vec![excluded_parent.change_id],
988        };
989        let transfer = enumerate_state_closure_transfer_with_options(
990            repo.store(),
991            state.change_id,
992            options.clone(),
993            512,
994        )
995        .expect("transfer projection");
996
997        let full =
998            enumerate_state_closure_with_options(repo.store(), state.change_id, options.clone())
999                .expect("full closure");
1000        let plan =
1001            enumerate_state_closure_plan_with_options(repo.store(), state.change_id, options)
1002                .expect("plan closure");
1003        assert_eq!(
1004            transfer
1005                .full_objects
1006                .as_deref()
1007                .map(object_info_fingerprint),
1008            Some(object_info_fingerprint(&full))
1009        );
1010        assert_eq!(transfer.planned_objects, plan);
1011
1012        let full_pairs = pairs_from_full(&full);
1013        assert_eq!(full_pairs, pairs_from_plan(&plan));
1014        assert_contains_object(
1015            &full_pairs,
1016            ObjectId::ChangeId(state.change_id),
1017            ObjectType::State,
1018        );
1019        assert_contains_object(
1020            &full_pairs,
1021            ObjectId::ChangeId(state.change_id),
1022            ObjectType::StateVisibility,
1023        );
1024        assert_contains_object(&full_pairs, ObjectId::Hash(redacted_blob), ObjectType::Blob);
1025        assert_contains_object(
1026            &full_pairs,
1027            ObjectId::Hash(redacted_blob),
1028            ObjectType::Redaction,
1029        );
1030        for hash in [
1031            root_tree_hash,
1032            nested_tree_hash,
1033            context_tree_hash,
1034            provenance_tree_hash,
1035        ] {
1036            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Tree);
1037        }
1038        for hash in [
1039            nested_blob,
1040            symlink_blob,
1041            context_blob,
1042            provenance_blob,
1043            risk_blob,
1044            review_blob,
1045            discussions_blob,
1046            conflicts_blob,
1047        ] {
1048            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Blob);
1049        }
1050        assert!(!full_pairs.contains(&(
1051            ObjectId::ChangeId(excluded_parent.change_id),
1052            ObjectType::State
1053        )));
1054        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_tree_hash), ObjectType::Tree)));
1055        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_blob), ObjectType::Blob)));
1056    }
1057
1058    #[test]
1059    fn transfer_projection_reads_root_state_once_on_small_transfer() {
1060        let temp = TempDir::new().unwrap();
1061        let repo = Repository::init_default(temp.path()).unwrap();
1062        let blob = repo
1063            .store()
1064            .put_blob(&Blob::from("hello\n"))
1065            .expect("put blob");
1066        let tree_hash = repo
1067            .store()
1068            .put_tree(&Tree::from_entries(vec![
1069                TreeEntry::file("README.md", blob, false).unwrap(),
1070            ]))
1071            .expect("put tree");
1072        let state = State::new(tree_hash, Vec::new(), test_attribution());
1073        repo.store().put_state(&state).expect("put state");
1074        let store = CountingStore::new(repo.store());
1075
1076        let transfer = enumerate_state_closure_transfer_with_options(
1077            &store,
1078            state.change_id,
1079            StateClosureOptions::default(),
1080            512,
1081        )
1082        .expect("transfer projection");
1083
1084        assert!(
1085            !transfer.planned_objects.is_empty(),
1086            "lean projection should be available"
1087        );
1088        assert!(transfer.full_objects.is_some());
1089        assert_eq!(
1090            store.state_reads(),
1091            1,
1092            "small transfer projection must not read the root state through a second closure walk"
1093        );
1094    }
1095
1096    #[test]
1097    fn transfer_projection_drops_full_descriptors_after_threshold() {
1098        let temp = TempDir::new().unwrap();
1099        let repo = Repository::init_default(temp.path()).unwrap();
1100        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1101        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1102
1103        let transfer = enumerate_state_closure_transfer_with_options(
1104            repo.store(),
1105            state.change_id,
1106            StateClosureOptions::default(),
1107            0,
1108        )
1109        .expect("transfer projection");
1110
1111        assert!(
1112            !transfer.planned_objects.is_empty(),
1113            "lean projection should still be available over the threshold"
1114        );
1115        assert!(transfer.full_objects.is_none());
1116    }
1117
1118    #[test]
1119    fn depth_and_exclude_options_match_between_full_and_plan() {
1120        let temp = TempDir::new().unwrap();
1121        let repo = Repository::init_default(temp.path()).unwrap();
1122        let path = temp.path().join("story.txt");
1123
1124        std::fs::write(&path, "base\n").unwrap();
1125        let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1126        std::fs::write(&path, "middle\n").unwrap();
1127        let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1128        std::fs::write(&path, "tip\n").unwrap();
1129        let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1130
1131        let depth_zero = assert_plan_parity(
1132            &repo,
1133            tip.change_id,
1134            StateClosureOptions {
1135                depth: Some(0),
1136                exclude_states: Vec::new(),
1137            },
1138        );
1139        assert!(depth_zero.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
1140        assert!(!depth_zero.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
1141        assert!(!depth_zero.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
1142
1143        let depth_one = assert_plan_parity(
1144            &repo,
1145            tip.change_id,
1146            StateClosureOptions {
1147                depth: Some(1),
1148                exclude_states: Vec::new(),
1149            },
1150        );
1151        assert!(depth_one.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
1152        assert!(depth_one.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
1153        assert!(!depth_one.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
1154
1155        let exclude_middle = assert_plan_parity(
1156            &repo,
1157            tip.change_id,
1158            StateClosureOptions {
1159                depth: None,
1160                exclude_states: vec![middle.change_id],
1161            },
1162        );
1163        assert!(exclude_middle.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
1164        assert!(
1165            !exclude_middle.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State))
1166        );
1167        assert!(!exclude_middle.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
1168    }
1169
1170    #[test]
1171    fn shared_tree_and_blob_references_are_emitted_once() {
1172        let temp = TempDir::new().unwrap();
1173        let repo = Repository::init_default(temp.path()).unwrap();
1174
1175        let shared_blob = Blob::from("shared contents\n");
1176        let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
1177        let shared_tree = Tree::from_entries(vec![
1178            TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
1179        ]);
1180        let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
1181        let root = Tree::from_entries(vec![
1182            TreeEntry::directory("left", shared_tree_hash).unwrap(),
1183            TreeEntry::directory("right", shared_tree_hash).unwrap(),
1184        ]);
1185        let root_hash = repo.store().put_tree(&root).unwrap();
1186        let state = State::new(root_hash, Vec::new(), test_attribution());
1187        repo.store().put_state(&state).unwrap();
1188
1189        let full = enumerate_state_closure_with_options(
1190            repo.store(),
1191            state.change_id,
1192            StateClosureOptions::default(),
1193        )
1194        .unwrap();
1195        let plan = enumerate_state_closure_plan_with_options(
1196            repo.store(),
1197            state.change_id,
1198            StateClosureOptions::default(),
1199        )
1200        .unwrap();
1201
1202        assert_eq!(
1203            pairs_from_full(&full),
1204            pairs_from_plan(&plan),
1205            "full and lean closure enumerators must dedup the same objects"
1206        );
1207
1208        assert_eq!(
1209            full.iter()
1210                .filter(|info| info.id == ObjectId::Hash(root_hash)
1211                    && info.obj_type == ObjectType::Tree)
1212                .count(),
1213            1
1214        );
1215        assert_eq!(
1216            full.iter()
1217                .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
1218                    && info.obj_type == ObjectType::Tree)
1219                .count(),
1220            1
1221        );
1222        assert_eq!(
1223            full.iter()
1224                .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
1225                    && info.obj_type == ObjectType::Blob)
1226                .count(),
1227            1
1228        );
1229    }
1230
1231    #[test]
1232    fn state_closure_skips_gitlink_targets() {
1233        let temp = TempDir::new().unwrap();
1234        let repo = Repository::init_default(temp.path()).unwrap();
1235        let target: GitObjectId = "0303030303030303030303030303030303030303"
1236            .parse()
1237            .expect("git oid");
1238        let root = Tree::from_entries(vec![
1239            TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
1240        ]);
1241        let root_hash = repo.store().put_tree(&root).unwrap();
1242        let state = State::new(root_hash, Vec::new(), test_attribution());
1243        repo.store().put_state(&state).unwrap();
1244
1245        let full = enumerate_state_closure_with_options(
1246            repo.store(),
1247            state.change_id,
1248            StateClosureOptions::default(),
1249        )
1250        .unwrap();
1251        let plan = enumerate_state_closure_plan_with_options(
1252            repo.store(),
1253            state.change_id,
1254            StateClosureOptions::default(),
1255        )
1256        .unwrap();
1257
1258        assert_eq!(pairs_from_full(&full), pairs_from_plan(&plan));
1259        assert!(
1260            !full.iter().any(|info| info.obj_type == ObjectType::Blob),
1261            "gitlinks carry foreign Git commit ids, not Heddle blob dependencies: {full:?}"
1262        );
1263        assert!(full.iter().any(|info| {
1264            info.id == ObjectId::Hash(root_hash) && info.obj_type == ObjectType::Tree
1265        }));
1266    }
1267
1268    #[test]
1269    fn missing_blobs_in_tree_skips_gitlinks_and_walks_nested_side_paths() {
1270        let temp = TempDir::new().unwrap();
1271        let repo = Repository::init_default(temp.path()).unwrap();
1272        let present_blob = repo
1273            .store()
1274            .put_blob(&Blob::from("already local"))
1275            .expect("put present blob");
1276        let missing_nested = ContentHash::from_bytes([7; 32]);
1277        let missing_symlink = ContentHash::from_bytes([8; 32]);
1278        let nested_tree = Tree::from_entries(vec![
1279            TreeEntry::file("remote.txt", missing_nested, false).unwrap(),
1280            TreeEntry::symlink("remote-link", missing_symlink).unwrap(),
1281        ]);
1282        let nested_tree_hash = repo
1283            .store()
1284            .put_tree(&nested_tree)
1285            .expect("put nested tree");
1286        let gitlink_target: GitObjectId = "0404040404040404040404040404040404040404"
1287            .parse()
1288            .expect("git oid");
1289        let root = Tree::from_entries(vec![
1290            TreeEntry::file("local.txt", present_blob, false).unwrap(),
1291            TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1292            TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1293        ]);
1294        let root_hash = repo.store().put_tree(&root).expect("put root tree");
1295
1296        let missing = missing_blobs_in_tree(repo.store(), root_hash).expect("missing blobs");
1297
1298        assert_eq!(
1299            missing.into_iter().collect::<HashSet<_>>(),
1300            HashSet::from([missing_nested, missing_symlink])
1301        );
1302    }
1303
1304    /// Once a redaction is declared for a blob in a snapshot, the
1305    /// state closure must include an `ObjectType::Redaction` entry
1306    /// keyed on that blob's hash — that's the wire-side signal the
1307    /// receiver replays.
1308    #[test]
1309    fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
1310        let temp = TempDir::new().unwrap();
1311        let repo = Repository::init_default(temp.path()).unwrap();
1312        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1313        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1314
1315        // Find the blob hash for secret.toml by walking the snapshot's tree.
1316        let tree = repo
1317            .store()
1318            .get_tree(&state.tree)
1319            .unwrap()
1320            .expect("tree present");
1321        let blob_hash = tree
1322            .iter()
1323            .find(|e| e.name() == "secret.toml")
1324            .expect("entry present")
1325            .blob_hash()
1326            .expect("secret.toml is a blob");
1327
1328        let redaction = Redaction {
1329            redacted_blob: blob_hash,
1330            state: state.change_id,
1331            path: "secret.toml".to_string(),
1332            reason: "test leak".to_string(),
1333            redactor: Principal {
1334                name: "Tester".into(),
1335                email: "tester@heddle.sh".into(),
1336            },
1337            redacted_at: Utc::now(),
1338            signature: None,
1339            purged_at: None,
1340            supersedes: None,
1341        };
1342        repo.put_redaction(redaction).unwrap();
1343
1344        let full = enumerate_state_closure_with_options(
1345            repo.store(),
1346            state.change_id,
1347            StateClosureOptions::default(),
1348        )
1349        .unwrap();
1350        let plan = enumerate_state_closure_plan_with_options(
1351            repo.store(),
1352            state.change_id,
1353            StateClosureOptions::default(),
1354        )
1355        .unwrap();
1356
1357        assert!(
1358            full.iter()
1359                .any(|info| info.obj_type == ObjectType::Redaction
1360                    && info.id == ObjectId::Hash(blob_hash)),
1361            "full closure must include a Redaction entry for the redacted blob"
1362        );
1363        assert!(
1364            plan.iter()
1365                .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
1366            "plan closure must include a Redaction entry for the redacted blob"
1367        );
1368    }
1369
1370    #[test]
1371    fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
1372        let temp = TempDir::new().unwrap();
1373        let repo = Repository::init_default(temp.path()).unwrap();
1374        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1375        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1376
1377        repo.put_state_visibility(StateVisibility {
1378            state: state.change_id,
1379            tier: VisibilityTier::Restricted {
1380                scope_label: "security-embargo".into(),
1381            },
1382            embargo_until: None,
1383            declarer: Principal {
1384                name: "Tester".into(),
1385                email: "tester@heddle.sh".into(),
1386            },
1387            declared_at: Utc::now(),
1388            signature: None,
1389            supersedes: None,
1390        })
1391        .unwrap();
1392
1393        let full = enumerate_state_closure_with_options(
1394            repo.store(),
1395            state.change_id,
1396            StateClosureOptions::default(),
1397        )
1398        .unwrap();
1399        let plan = enumerate_state_closure_plan_with_options(
1400            repo.store(),
1401            state.change_id,
1402            StateClosureOptions::default(),
1403        )
1404        .unwrap();
1405
1406        assert!(
1407            full.iter()
1408                .any(|info| info.obj_type == ObjectType::StateVisibility
1409                    && info.id == ObjectId::ChangeId(state.change_id)),
1410            "full closure must include a StateVisibility entry for the visible state"
1411        );
1412        assert!(
1413            plan.iter()
1414                .any(|p| p.obj_type == ObjectType::StateVisibility
1415                    && p.id == ObjectId::ChangeId(state.change_id)),
1416            "plan closure must include a StateVisibility entry for the visible state"
1417        );
1418    }
1419
1420    #[test]
1421    fn enumerate_state_closure_emits_state_metadata_blobs() {
1422        let temp = TempDir::new().unwrap();
1423        let repo = Repository::init_default(temp.path()).unwrap();
1424        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1425        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1426
1427        let principal = Principal::new("Tester", "tester@example.test");
1428        let discussion_bytes = DiscussionsBlob::new(vec![Discussion {
1429            id: "disc-1".to_string(),
1430            anchor: SymbolAnchor::new("src/lib.rs", "answer"),
1431            opened_against_state: state.change_id,
1432            opened_at: 1_782_400_000,
1433            thread_ref: None,
1434            turns: vec![DiscussionTurn {
1435                author: principal,
1436                body: "Should this sync?".to_string(),
1437                posted_at: 1_782_400_000,
1438            }],
1439            resolution: DiscussionResolution::Open,
1440            body_changed_since_open: false,
1441            orphaned: false,
1442            visibility: VisibilityTier::default(),
1443            resolved_annotation_id: None,
1444        }])
1445        .encode()
1446        .expect("encode discussions");
1447        let discussion_hash = repo
1448            .store()
1449            .put_blob(&Blob::new(discussion_bytes))
1450            .expect("put discussions blob");
1451        let risk_hash = repo
1452            .store()
1453            .put_blob(&Blob::from_slice(b"risk signals"))
1454            .expect("put risk blob");
1455        let review_hash = repo
1456            .store()
1457            .put_blob(&Blob::from_slice(b"review signatures"))
1458            .expect("put review blob");
1459        let conflicts_hash = repo
1460            .store()
1461            .put_blob(&Blob::from_slice(b"structured conflicts"))
1462            .expect("put conflicts blob");
1463        let state_with_metadata = state
1464            .with_risk_signals(risk_hash)
1465            .with_review_signatures(review_hash)
1466            .with_discussions(discussion_hash)
1467            .with_structured_conflicts(conflicts_hash);
1468        repo.store()
1469            .put_state(&state_with_metadata)
1470            .expect("put state with metadata");
1471
1472        let full = enumerate_state_closure_with_options(
1473            repo.store(),
1474            state_with_metadata.change_id,
1475            StateClosureOptions::default(),
1476        )
1477        .unwrap();
1478        let plan = enumerate_state_closure_plan_with_options(
1479            repo.store(),
1480            state_with_metadata.change_id,
1481            StateClosureOptions::default(),
1482        )
1483        .unwrap();
1484
1485        for metadata_hash in [risk_hash, review_hash, discussion_hash, conflicts_hash] {
1486            assert!(
1487                full.iter().any(|info| info.obj_type == ObjectType::Blob
1488                    && info.id == ObjectId::Hash(metadata_hash)),
1489                "full closure must include state metadata blob {metadata_hash}"
1490            );
1491            assert!(
1492                plan.iter().any(
1493                    |p| p.obj_type == ObjectType::Blob && p.id == ObjectId::Hash(metadata_hash)
1494                ),
1495                "plan closure must include state metadata blob {metadata_hash}"
1496            );
1497        }
1498    }
1499}