Skip to main content

memstead_base/ingest/
prune.rs

1//! Prune — deletion **proposal** machinery (bundle plan `05-verify-sync-engine`,
2//! group F).
3//!
4//! Prune answers "the source removed this artifact entirely — should the entity
5//! describing it be deleted?". It **never** mutates the destination mem: it
6//! produces [`PruneProposal`]s the **sync brief** surfaces, and the deletion
7//! reaches the mem only when an agent acts on that brief through the normal MCP
8//! mutation surface (A5 holds — there is no engine path from here that deletes
9//! or writes a mem entity). [`prune_proposals`] takes a shared `&Engine`, so it
10//! is structurally incapable of a mem mutation.
11//!
12//! ## Guarantee (F1) and degradation (F2)
13//!
14//! A binding requests a [`crate::binding::PruneGuarantee`]. The guarantee a
15//! medium can *support* is stated at binding-validation time (a `never-clobber`
16//! request over a non-base-retrievable medium is refused there, never at run
17//! time). At proposal time prune resolves the **effective** posture per
18//! candidate:
19//!
20//! - **never-clobber** — where the candidate's source **base leg is
21//!   retrievable** (a git-pinned anchor: `at_version` is a commit), a three-way
22//!   merge can tell a model-side edit apart from a clean removal, so a clean
23//!   removal can be proposed as a confident (agent-enacted) delete.
24//! - **conflict-flag degradation** — everywhere else (a `conflict-flag`
25//!   request, or a candidate with **no** retrievable base leg — a non-git
26//!   source): prune presents **both** sides and never proposes a clean delete,
27//!   so a model-side edit is never silently clobbered. This is the decided
28//!   posture; span-snapshot base legs for non-git sources are out of scope (no
29//!   current payer).
30//!
31//! ## Provenance guards (F3)
32//!
33//! - an `authored`-provenance entity is **never** a prune target (excluded
34//!   entirely — no proposal is produced);
35//! - a `derived` entity is **flagged with its inputs**, never auto-proposed for
36//!   deletion — its inputs must be re-examined first;
37//! - only `anchored` / `informed-by` entities whose whole source basis vanished
38//!   become delete proposals, and only conservatively (every anchor orphaned).
39
40use std::collections::BTreeMap;
41use std::path::Path;
42
43use crate::Engine;
44use crate::anchor::{AnchorProvenanceClass, AnchorState, AnchorVersion};
45use crate::binding::{BindingV1, PruneGuarantee};
46
47use super::resolve::ResolvedIngest;
48
49/// The **effective** prune posture for a candidate (F1/F2) — the requested
50/// guarantee resolved against what is actually retrievable.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum PruneMode {
53    /// Never-clobber three-way merge is in force (a `never-clobber` request).
54    /// Whether a *given* candidate can use it still depends on that candidate's
55    /// base-leg retrievability — a candidate with no retrievable base degrades
56    /// to conflict-flagging.
57    NeverClobber,
58    /// Conflict-flag degradation is in force (a `conflict-flag` request): both
59    /// sides are always presented, a clean delete is never proposed.
60    ConflictFlag,
61}
62
63impl PruneMode {
64    /// The effective posture a binding's requested guarantee selects.
65    pub fn from_guarantee(guarantee: PruneGuarantee) -> Self {
66        match guarantee {
67            PruneGuarantee::NeverClobber => PruneMode::NeverClobber,
68            PruneGuarantee::ConflictFlag => PruneMode::ConflictFlag,
69        }
70    }
71}
72
73/// The three-way-merge outcome for a never-clobber candidate whose base leg was
74/// retrieved: did the model side diverge from the retrieved base?
75///
76/// The model-divergence signal (comparing the current entity against the base
77/// leg) is not wired this cycle, so [`prune_proposals`] supplies `None` and
78/// every candidate conservatively conflict-flags. The [`PruneMerge::Clean`]
79/// branch is the reachable, tested seam a future model-divergence check drives.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PruneMerge {
82    /// Base retrieved, model side unchanged from it — a clean removal.
83    Clean,
84    /// Base retrieved, model side diverged (a hand edit) — a real conflict.
85    Conflict,
86}
87
88/// The disposition a prune proposal carries (F2/F3).
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum PruneDisposition {
91    /// Never-clobber, base leg retrieved, three-way merge clean → a confident
92    /// (still agent-enacted) delete proposal.
93    CleanDelete,
94    /// Both sides presented; the agent decides. Never an auto-write — the model
95    /// side may carry a deliberate edit. Conflict-flag degradation, or a
96    /// never-clobber merge that found (or could not rule out) a divergence.
97    ConflictFlag,
98    /// A `derived` entity — flagged with its inputs, never auto-proposed for
99    /// deletion (F3).
100    DerivedFlagged,
101}
102
103impl PruneDisposition {
104    /// Stable wire form.
105    pub fn as_wire(&self) -> &'static str {
106        match self {
107            PruneDisposition::CleanDelete => "clean-delete",
108            PruneDisposition::ConflictFlag => "conflict-flag",
109            PruneDisposition::DerivedFlagged => "derived-flagged",
110        }
111    }
112}
113
114/// Classify one candidate entity into a prune disposition, or `None` when it is
115/// **excluded entirely** — an `authored`-provenance entity is never a prune
116/// target (F3).
117///
118/// - `authored` → `None` (never targeted);
119/// - `derived` → [`PruneDisposition::DerivedFlagged`] (flagged with inputs,
120///   never a delete);
121/// - `anchored` / `informed-by`:
122///   - conflict-flag mode → [`PruneDisposition::ConflictFlag`] (both sides);
123///   - never-clobber mode → [`PruneDisposition::CleanDelete`] **only** when the
124///     base leg is retrievable **and** the merge is clean; otherwise
125///     [`PruneDisposition::ConflictFlag`] (no retrievable base, or a divergent /
126///     undetermined merge — never a silent clobber).
127pub fn classify_prune_candidate(
128    class: AnchorProvenanceClass,
129    mode: PruneMode,
130    base_retrievable: bool,
131    merge: Option<PruneMerge>,
132) -> Option<PruneDisposition> {
133    match class {
134        // F3 — an authored entity is never a prune target.
135        AnchorProvenanceClass::Authored => None,
136        // F3 — a derived entity is flagged with its inputs, never a delete.
137        AnchorProvenanceClass::Derived => Some(PruneDisposition::DerivedFlagged),
138        AnchorProvenanceClass::Anchored | AnchorProvenanceClass::InformedBy => match mode {
139            PruneMode::ConflictFlag => Some(PruneDisposition::ConflictFlag),
140            PruneMode::NeverClobber => {
141                if base_retrievable && matches!(merge, Some(PruneMerge::Clean)) {
142                    Some(PruneDisposition::CleanDelete)
143                } else {
144                    // No retrievable base, a divergent merge, or an
145                    // undetermined merge — degrade, never clobber.
146                    Some(PruneDisposition::ConflictFlag)
147                }
148            }
149        },
150    }
151}
152
153/// A single prune proposal — a proposed removal the sync brief surfaces. The
154/// engine never enacts it: an agent acting on the sync brief deletes (or keeps)
155/// the entity through the MCP mutation surface (A5).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct PruneProposal {
158    /// The destination-mem entity id (`mem--slug`) the proposal concerns.
159    pub entity: String,
160    /// The now-gone source artifacts the entity's (all-orphaned) anchors
161    /// referenced, deduplicated and sorted.
162    pub artifacts: Vec<String>,
163    /// The entity's dominant provenance class wire string (the class the
164    /// disposition was decided from).
165    pub class: String,
166    /// The disposition (F2/F3).
167    pub disposition: PruneDisposition,
168    /// Whether the candidate's source base leg is retrievable (a git-pinned
169    /// anchor). Drives the never-clobber vs. conflict-flag posture and is
170    /// surfaced so the brief can state which one applies.
171    pub base_retrievable: bool,
172    /// For a `derived` candidate: the input artifact refs to re-examine before
173    /// any removal (F3). Empty for every other class.
174    pub derived_inputs: Vec<String>,
175}
176
177/// Gather the prune proposals for a binding — **read-only** on the destination
178/// mem (shared `&Engine`; no mutation is structurally possible, A5). Returns an
179/// empty vec when the binding declares no `prune` block (prune disabled).
180///
181/// A **candidate** is an entity whose *entire* source basis vanished — every one
182/// of its anchors resolves [`AnchorState::Orphaned`] against the live source
183/// (the conservative "concept removed entirely" signal; an entity with any
184/// still-resolving anchor is left to sync's ordinary drift path, not prune). An
185/// entity with any *unobserved* anchor is skipped — prune never asserts a
186/// removal it could not observe.
187pub fn prune_proposals(
188    engine: &Engine,
189    _workspace_root: &Path,
190    binding: &BindingV1,
191    resolved: &ResolvedIngest,
192) -> Vec<PruneProposal> {
193    // Prune disabled → no proposals.
194    let Some(prune) = binding.prune.as_ref() else {
195        return Vec::new();
196    };
197    let mode = PruneMode::from_guarantee(prune.guarantee);
198
199    // Group the destination mem's resolved anchors by entity.
200    struct Acc {
201        classes: Vec<AnchorProvenanceClass>,
202        artifacts: Vec<String>,
203        base_retrievable: bool,
204        derived_inputs: Vec<String>,
205        all_orphaned: bool,
206        any: bool,
207    }
208    let mut by_entity: BTreeMap<String, Acc> = BTreeMap::new();
209    for (eid, resolved_anchor) in engine.mem_anchors_resolved(&resolved.destination_mem) {
210        let entry = by_entity.entry(eid.as_ref().to_string()).or_insert(Acc {
211            classes: Vec::new(),
212            artifacts: Vec::new(),
213            base_retrievable: false,
214            derived_inputs: Vec::new(),
215            all_orphaned: true,
216            any: false,
217        });
218        entry.any = true;
219        let anchor = &resolved_anchor.anchor;
220        entry.classes.push(anchor.class);
221        entry.artifacts.push(anchor.artifact.clone());
222        // A git-pinned commit is a retrievable base leg for the three-way merge.
223        if matches!(anchor.at_version, Some(AnchorVersion::Commit(_))) {
224            entry.base_retrievable = true;
225        }
226        if anchor.class == AnchorProvenanceClass::Derived {
227            entry
228                .derived_inputs
229                .extend(anchor.derived_from.iter().cloned());
230        }
231        // Every anchor must resolve orphaned for the whole basis to be gone;
232        // an unobserved anchor (state None) blocks the candidate — prune never
233        // asserts a removal it could not observe.
234        match resolved_anchor.state {
235            Some(AnchorState::Orphaned) => {}
236            _ => entry.all_orphaned = false,
237        }
238    }
239
240    let mut proposals: Vec<PruneProposal> = Vec::new();
241    for (entity, acc) in by_entity {
242        if !acc.any || !acc.all_orphaned {
243            continue;
244        }
245        // Dominant class precedence: authored (exclude) > derived (flag) >
246        // anchored > informed-by.
247        let dominant = if acc.classes.contains(&AnchorProvenanceClass::Authored) {
248            AnchorProvenanceClass::Authored
249        } else if acc.classes.contains(&AnchorProvenanceClass::Derived) {
250            AnchorProvenanceClass::Derived
251        } else if acc.classes.contains(&AnchorProvenanceClass::Anchored) {
252            AnchorProvenanceClass::Anchored
253        } else {
254            AnchorProvenanceClass::InformedBy
255        };
256
257        // Merge outcome is unwired this cycle → None → conservative conflict-flag.
258        let Some(disposition) =
259            classify_prune_candidate(dominant, mode, acc.base_retrievable, None)
260        else {
261            // Authored → excluded, never a prune target (F3).
262            continue;
263        };
264
265        let mut artifacts = acc.artifacts;
266        artifacts.sort();
267        artifacts.dedup();
268        let mut derived_inputs = acc.derived_inputs;
269        derived_inputs.sort();
270        derived_inputs.dedup();
271
272        proposals.push(PruneProposal {
273            entity,
274            artifacts,
275            class: dominant.as_wire().to_string(),
276            disposition,
277            base_retrievable: acc.base_retrievable,
278            derived_inputs,
279        });
280    }
281    proposals
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    // ---- F2/F3: pure classifier -----------------------------------------
289
290    /// F3 — an authored entity is never a prune target: excluded (no proposal),
291    /// in either mode.
292    #[test]
293    fn authored_is_never_a_prune_target() {
294        for mode in [PruneMode::NeverClobber, PruneMode::ConflictFlag] {
295            assert_eq!(
296                classify_prune_candidate(
297                    AnchorProvenanceClass::Authored,
298                    mode,
299                    true,
300                    Some(PruneMerge::Clean),
301                ),
302                None,
303                "authored must never be proposed for deletion"
304            );
305        }
306    }
307
308    /// F3 — a derived entity is flagged (with inputs), never auto-proposed for
309    /// deletion, in either mode.
310    #[test]
311    fn derived_is_flagged_not_deleted() {
312        for mode in [PruneMode::NeverClobber, PruneMode::ConflictFlag] {
313            assert_eq!(
314                classify_prune_candidate(
315                    AnchorProvenanceClass::Derived,
316                    mode,
317                    true,
318                    Some(PruneMerge::Clean),
319                ),
320                Some(PruneDisposition::DerivedFlagged),
321                "derived is flagged, never a clean delete"
322            );
323        }
324    }
325
326    /// F2 — conflict-flag mode always presents both sides (never a clean
327    /// delete), whatever the base/merge state.
328    #[test]
329    fn conflict_flag_mode_never_clean_deletes() {
330        for base in [true, false] {
331            for merge in [None, Some(PruneMerge::Clean), Some(PruneMerge::Conflict)] {
332                assert_eq!(
333                    classify_prune_candidate(
334                        AnchorProvenanceClass::Anchored,
335                        PruneMode::ConflictFlag,
336                        base,
337                        merge,
338                    ),
339                    Some(PruneDisposition::ConflictFlag),
340                    "conflict-flag mode never auto-clean-deletes"
341                );
342            }
343        }
344    }
345
346    /// F2 — never-clobber degrades to conflict-flag when the base leg is not
347    /// retrievable (a non-git source), or when the merge is divergent /
348    /// undetermined; it clean-deletes only with a retrievable base AND a clean
349    /// merge.
350    #[test]
351    fn never_clobber_clean_delete_needs_base_and_clean_merge() {
352        let anchored = AnchorProvenanceClass::Anchored;
353        // Retrievable base + clean merge → the one clean-delete path.
354        assert_eq!(
355            classify_prune_candidate(
356                anchored,
357                PruneMode::NeverClobber,
358                true,
359                Some(PruneMerge::Clean)
360            ),
361            Some(PruneDisposition::CleanDelete)
362        );
363        // No retrievable base (non-git) → conflict-flag degradation.
364        assert_eq!(
365            classify_prune_candidate(
366                anchored,
367                PruneMode::NeverClobber,
368                false,
369                Some(PruneMerge::Clean)
370            ),
371            Some(PruneDisposition::ConflictFlag),
372            "no base leg degrades to conflict-flag"
373        );
374        // Divergent merge → conflict-flag (never clobber the model edit).
375        assert_eq!(
376            classify_prune_candidate(
377                anchored,
378                PruneMode::NeverClobber,
379                true,
380                Some(PruneMerge::Conflict)
381            ),
382            Some(PruneDisposition::ConflictFlag),
383            "a divergent merge is never a clean delete"
384        );
385        // Undetermined merge (signal unwired) → conflict-flag (safe default).
386        assert_eq!(
387            classify_prune_candidate(anchored, PruneMode::NeverClobber, true, None),
388            Some(PruneDisposition::ConflictFlag),
389            "an undetermined merge conservatively conflict-flags"
390        );
391    }
392
393    /// `informed-by` is a delete candidate too (a non-hash class that still owns
394    /// a concept), following the same mode rules as `anchored`.
395    #[test]
396    fn informed_by_follows_the_same_mode_rules() {
397        assert_eq!(
398            classify_prune_candidate(
399                AnchorProvenanceClass::InformedBy,
400                PruneMode::ConflictFlag,
401                false,
402                None,
403            ),
404            Some(PruneDisposition::ConflictFlag)
405        );
406    }
407
408    // ---- F2/F3: end-to-end over a real engine ----------------------------
409
410    use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorSidecar};
411    use crate::binding::{
412        BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
413        DEFAULT_FULL_RESYNC_EVERY, Operations, PruneConfig, VerifyOperation,
414    };
415    use crate::ingest::render::render_sync_brief_for;
416    use crate::ingest::resolve::resolve_binding_run;
417    use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
418    use crate::pipeline_store::{load_pipeline_configs, write_binding, write_facet, write_medium};
419    use crate::workspace::{
420        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
421    };
422    use crate::workspace_store::WorkspaceStoreAdapter;
423
424    /// An orphan-bound anchor of `class` on `artifact`, git-pinned when
425    /// `commit` is set (a retrievable base leg).
426    fn orphan_anchor(
427        artifact: &str,
428        class: AnchorProvenanceClass,
429        derived_from: Vec<&str>,
430        commit: Option<&str>,
431    ) -> Anchor {
432        Anchor {
433            artifact: artifact.to_string(),
434            grain: AnchorGrain::File,
435            class,
436            at_version: commit.map(|c| AnchorVersion::Commit(c.to_string())),
437            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
438            hash_stability: AnchorHashStability::Stable,
439            derived_from: derived_from.into_iter().map(str::to_string).collect(),
440            binding: None,
441        }
442    }
443
444    /// Scaffold a filesystem-medium mem whose anchors reference **absent** source
445    /// files (so every anchor resolves orphaned), with a `prune` block at
446    /// `guarantee`. Returns the engine, workspace root, binding and resolved run.
447    /// The source is deliberately **non-git** (a plain filesystem medium, no
448    /// `at_version` unless the fixture pins one) so the base leg is not
449    /// retrievable — the F2 degradation case.
450    fn setup(
451        tmp: &Path,
452        guarantee: PruneGuarantee,
453        entity_anchors: &[(&str, Vec<Anchor>)],
454    ) -> (Engine, std::path::PathBuf, BindingV1, ResolvedIngest) {
455        let root = tmp.to_path_buf();
456        let mem_dir = root.join("mem");
457        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
458        std::fs::write(
459            mem_dir.join(".memstead").join("config.json"),
460            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
461        )
462        .unwrap();
463        std::fs::create_dir_all(root.join(".memstead")).unwrap();
464        std::fs::write(
465            root.join(".memstead").join("workspace.toml"),
466            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
467        )
468        .unwrap();
469        let mount = Mount {
470            mem: "engine".to_string(),
471            schema: Some("default@1.0.0".parse().unwrap()),
472            storage: MountStorage::Folder {
473                path: mem_dir.clone(),
474            },
475            capability: MountCapability::Write,
476            lifecycle: MountLifecycle::Eager,
477            cross_linkable: false,
478            migration_target: None,
479        };
480        crate::FileWorkspaceStore::new()
481            .save_state(
482                &root,
483                &Workspace {
484                    mounts: vec![mount],
485                    settings: WorkspaceSettings::default(),
486                },
487            )
488            .unwrap();
489
490        // Seed the anchors sidecar (test fixture — the production write path is
491        // the mutation surface, not prune). No source files are created, so every
492        // anchor resolves orphaned.
493        let mut sidecar = AnchorSidecar::default();
494        for (eid, anchors) in entity_anchors {
495            sidecar.set(eid, anchors.clone());
496        }
497        std::fs::write(
498            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
499            sidecar.to_bytes(),
500        )
501        .unwrap();
502
503        // A filesystem-medium binding (namespace `path`, so mem_anchors_resolved
504        // observes it) with the requested prune guarantee.
505        write_medium(
506            &root,
507            "engine",
508            "graph",
509            &Medium {
510                name: "graph".to_string(),
511                medium_type: MediumType::Filesystem,
512                pointer: String::new(),
513                change_detection: None,
514            },
515        )
516        .unwrap();
517        write_facet(
518            &root,
519            "engine",
520            "graph",
521            &Facet {
522                name: "graph".to_string(),
523                medium: "graph".to_string(),
524                scope: vec![PatternEntry {
525                    path: "src/**/*.rs".to_string(),
526                    mode: PatternMode::Allow,
527                }],
528                engagement: None,
529                preparation: None,
530            },
531        )
532        .unwrap();
533        let binding = BindingV1 {
534            version: BINDING_VERSION,
535            intent: None,
536            source_facets: vec!["graph".to_string()],
537            reference_mems: Vec::new(),
538            destination_mem: "engine".to_string(),
539            deny_paths: Vec::new(),
540            coverage_semantics: CoverageSemantics::Exhaustive,
541            rules: None,
542            prune: Some(PruneConfig { guarantee }),
543            operations: Operations {
544                build: Some(BuildOperation {
545                    mode: BuildMode::Discovery,
546                    trigger: IngestTrigger::Loop,
547                    batch_size: 20,
548                    post_actions: None,
549                }),
550                sync: Some(crate::binding::SyncOperation {
551                    trigger: IngestTrigger::Manual,
552                    batch_size: 20,
553                }),
554                verify: Some(VerifyOperation {
555                    trigger: IngestTrigger::Manual,
556                    batch_size: 20,
557                    adjudication_cap: DEFAULT_ADJUDICATION_CAP,
558                    full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
559                }),
560            },
561        };
562        write_binding(&root, "engine", "graph", &binding).unwrap();
563
564        let engine = Engine::from_workspace_root(&root).unwrap();
565        let configs = load_pipeline_configs(&root).unwrap();
566        let resolved = resolve_binding_run(&configs, "engine/graph", &binding).unwrap();
567        (engine, root, binding, resolved)
568    }
569
570    /// F2 — conflict-flag degradation on a **non-git** source: a model-side
571    /// entity whose source artifact was removed surfaces BOTH sides in the sync
572    /// brief and is NEVER auto-deleted. Requesting never-clobber over a non-git
573    /// anchor (no retrievable base leg) degrades to conflict-flag.
574    #[test]
575    fn f2_conflict_flag_on_non_git_surfaces_both_sides_no_auto_delete() {
576        let tmp = tempfile::tempdir().unwrap();
577        // Request never-clobber; the non-git anchor has no base leg → degrades.
578        let (engine, root, binding, resolved) = setup(
579            tmp.path(),
580            PruneGuarantee::NeverClobber,
581            &[(
582                "engine--removed",
583                vec![orphan_anchor(
584                    "src/removed.rs",
585                    AnchorProvenanceClass::Anchored,
586                    vec![],
587                    None, // non-git: no retrievable base leg
588                )],
589            )],
590        );
591
592        let proposals = prune_proposals(&engine, &root, &binding, &resolved);
593        assert_eq!(proposals.len(), 1, "the orphaned entity is a candidate");
594        let p = &proposals[0];
595        assert_eq!(p.entity, "engine--removed");
596        assert!(
597            !p.base_retrievable,
598            "non-git anchor has no retrievable base"
599        );
600        assert_eq!(
601            p.disposition,
602            PruneDisposition::ConflictFlag,
603            "no base leg → conflict-flag degradation, never a clean delete"
604        );
605
606        // The rendered sync brief presents BOTH sides and frames it as a proposal.
607        let brief = render_sync_brief_for(&engine, &root, "engine/graph").unwrap();
608        assert!(brief.contains("Prune — proposed removals"));
609        assert!(brief.contains("source side:"), "source side surfaced");
610        assert!(brief.contains("model side:"), "model side surfaced");
611        assert!(
612            brief.contains("never overwrites a model-side edit"),
613            "no auto-overwrite is stated"
614        );
615        // A5: the pass never mutated the mem — the entity's anchor is still there
616        // (prune_proposals took a shared &Engine; a delete is structurally
617        // impossible). Re-read the sidecar to confirm.
618        let after = engine.mem_anchors_resolved("engine");
619        assert!(
620            after.iter().any(|(e, _)| e.as_ref() == "engine--removed"),
621            "prune must not delete the entity's anchors — it only proposes"
622        );
623    }
624
625    /// F3 — provenance guards: an `authored` entity is NEVER a prune target
626    /// (excluded, no proposal); a `derived` entity is flagged with its inputs,
627    /// never proposed for deletion; a plain `anchored` entity is proposed.
628    #[test]
629    fn f3_authored_excluded_and_derived_flagged_not_deleted() {
630        let tmp = tempfile::tempdir().unwrap();
631        let (engine, root, binding, resolved) = setup(
632            tmp.path(),
633            PruneGuarantee::ConflictFlag,
634            &[
635                (
636                    "engine--handwritten",
637                    vec![orphan_anchor(
638                        "src/authored.rs",
639                        AnchorProvenanceClass::Authored,
640                        vec![],
641                        None,
642                    )],
643                ),
644                (
645                    "engine--synthesised",
646                    vec![orphan_anchor(
647                        "src/derived.rs",
648                        AnchorProvenanceClass::Derived,
649                        vec!["src/in_a.rs", "src/in_b.rs"],
650                        None,
651                    )],
652                ),
653                (
654                    "engine--plain",
655                    vec![orphan_anchor(
656                        "src/plain.rs",
657                        AnchorProvenanceClass::Anchored,
658                        vec![],
659                        None,
660                    )],
661                ),
662            ],
663        );
664
665        let proposals = prune_proposals(&engine, &root, &binding, &resolved);
666
667        // F3 — authored is never a prune target: no proposal names it.
668        assert!(
669            !proposals.iter().any(|p| p.entity == "engine--handwritten"),
670            "an authored entity is never proposed for deletion"
671        );
672
673        // F3 — derived is flagged with its inputs, not proposed for deletion.
674        let derived = proposals
675            .iter()
676            .find(|p| p.entity == "engine--synthesised")
677            .expect("the derived entity is flagged");
678        assert_eq!(derived.disposition, PruneDisposition::DerivedFlagged);
679        assert_eq!(derived.class, "derived");
680        assert_eq!(
681            derived.derived_inputs,
682            vec!["src/in_a.rs".to_string(), "src/in_b.rs".to_string()],
683            "the derived entity carries its inputs to re-examine"
684        );
685
686        // The plain anchored entity IS proposed (conflict-flag).
687        let plain = proposals
688            .iter()
689            .find(|p| p.entity == "engine--plain")
690            .expect("a plain anchored entity is proposed");
691        assert_eq!(plain.disposition, PruneDisposition::ConflictFlag);
692
693        // The rendered sync brief flags the derived entity as NOT-for-deletion,
694        // never emits an auto-delete instruction, and never names the authored one.
695        let brief = render_sync_brief_for(&engine, &root, "engine/graph").unwrap();
696        assert!(brief.contains("flagged, NOT proposed for deletion"));
697        assert!(brief.contains("`engine--synthesised`"));
698        assert!(
699            !brief.contains("engine--handwritten"),
700            "the authored entity never appears in a prune proposal"
701        );
702        assert!(brief.contains("nothing is auto-deleted"));
703    }
704
705    /// A `never-clobber` binding whose anchor IS git-pinned has a retrievable
706    /// base leg — the proposal reports it (the never-clobber posture), while
707    /// still degrading to conflict-flag until the model-divergence merge signal
708    /// is wired (the gatherer supplies no merge outcome this cycle).
709    #[test]
710    fn git_pinned_anchor_reports_a_retrievable_base_leg() {
711        let tmp = tempfile::tempdir().unwrap();
712        let (engine, root, binding, resolved) = setup(
713            tmp.path(),
714            PruneGuarantee::NeverClobber,
715            &[(
716                "engine--pinned",
717                vec![orphan_anchor(
718                    "src/pinned.rs",
719                    AnchorProvenanceClass::Anchored,
720                    vec![],
721                    Some("deadbeef"),
722                )],
723            )],
724        );
725        let proposals = prune_proposals(&engine, &root, &binding, &resolved);
726        assert_eq!(proposals.len(), 1);
727        assert!(
728            proposals[0].base_retrievable,
729            "a git-pinned anchor exposes a retrievable base leg"
730        );
731        // Merge outcome unwired → still conflict-flag (never a silent clobber).
732        assert_eq!(proposals[0].disposition, PruneDisposition::ConflictFlag);
733    }
734
735    /// An entity with a **still-resolving** anchor is NOT a prune candidate —
736    /// the whole basis must be gone (conservatism). Here one anchor's file
737    /// exists, so the entity is skipped.
738    #[test]
739    fn entity_with_a_surviving_anchor_is_not_pruned() {
740        let tmp = tempfile::tempdir().unwrap();
741        let (engine, root, binding, resolved) = setup(
742            tmp.path(),
743            PruneGuarantee::ConflictFlag,
744            &[(
745                "engine--partly-gone",
746                vec![
747                    orphan_anchor("src/gone.rs", AnchorProvenanceClass::Anchored, vec![], None),
748                    orphan_anchor(
749                        "src/present.rs",
750                        AnchorProvenanceClass::InformedBy,
751                        vec![],
752                        None,
753                    ),
754                ],
755            )],
756        );
757        // Create only the second file so its anchor resolves (not orphaned).
758        std::fs::create_dir_all(root.join("src")).unwrap();
759        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
760
761        let proposals = prune_proposals(&engine, &root, &binding, &resolved);
762        assert!(
763            proposals.is_empty(),
764            "an entity whose basis is not entirely gone is not a prune candidate"
765        );
766    }
767}