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::{Binding, 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: &Binding,
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 THIS BINDING'S anchors by entity (consistency-sweep 03/01).
200 // Proposing deletion over another binding's anchors, or over artifacts
201 // this binding's scope excludes, would act on a population it does not
202 // answer for, and prune acts rather than merely reports.
203 struct Acc {
204 classes: Vec<AnchorProvenanceClass>,
205 artifacts: Vec<String>,
206 base_retrievable: bool,
207 derived_inputs: Vec<String>,
208 all_orphaned: bool,
209 any: bool,
210 }
211 let mut by_entity: BTreeMap<String, Acc> = BTreeMap::new();
212 let population = crate::ingest::anchor_population::population_for(
213 engine,
214 resolved,
215 Some(crate::binding::hash_binding(binding).as_str()),
216 );
217 for (eid, resolved_anchor) in population.included {
218 let entry = by_entity.entry(eid.as_ref().to_string()).or_insert(Acc {
219 classes: Vec::new(),
220 artifacts: Vec::new(),
221 base_retrievable: false,
222 derived_inputs: Vec::new(),
223 all_orphaned: true,
224 any: false,
225 });
226 entry.any = true;
227 let anchor = &resolved_anchor.anchor;
228 entry.classes.push(anchor.class);
229 entry.artifacts.push(anchor.artifact.clone());
230 // A git-pinned commit is a retrievable base leg for the three-way merge.
231 if matches!(anchor.at_version, Some(AnchorVersion::Commit(_))) {
232 entry.base_retrievable = true;
233 }
234 if anchor.class == AnchorProvenanceClass::Derived {
235 entry
236 .derived_inputs
237 .extend(anchor.derived_from.iter().cloned());
238 }
239 // Every anchor must resolve orphaned for the whole basis to be gone;
240 // an unobserved anchor (state None) blocks the candidate — prune never
241 // asserts a removal it could not observe.
242 match resolved_anchor.state {
243 Some(AnchorState::Orphaned) => {}
244 _ => entry.all_orphaned = false,
245 }
246 }
247
248 let mut proposals: Vec<PruneProposal> = Vec::new();
249 for (entity, acc) in by_entity {
250 if !acc.any || !acc.all_orphaned {
251 continue;
252 }
253 // Dominant class precedence: authored (exclude) > derived (flag) >
254 // anchored > informed-by.
255 let dominant = if acc.classes.contains(&AnchorProvenanceClass::Authored) {
256 AnchorProvenanceClass::Authored
257 } else if acc.classes.contains(&AnchorProvenanceClass::Derived) {
258 AnchorProvenanceClass::Derived
259 } else if acc.classes.contains(&AnchorProvenanceClass::Anchored) {
260 AnchorProvenanceClass::Anchored
261 } else {
262 AnchorProvenanceClass::InformedBy
263 };
264
265 // Merge outcome is unwired this cycle → None → conservative conflict-flag.
266 let Some(disposition) =
267 classify_prune_candidate(dominant, mode, acc.base_retrievable, None)
268 else {
269 // Authored → excluded, never a prune target (F3).
270 continue;
271 };
272
273 let mut artifacts = acc.artifacts;
274 artifacts.sort();
275 artifacts.dedup();
276 let mut derived_inputs = acc.derived_inputs;
277 derived_inputs.sort();
278 derived_inputs.dedup();
279
280 proposals.push(PruneProposal {
281 entity,
282 artifacts,
283 class: dominant.as_wire().to_string(),
284 disposition,
285 base_retrievable: acc.base_retrievable,
286 derived_inputs,
287 });
288 }
289 proposals
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 // ---- F2/F3: pure classifier -----------------------------------------
297
298 /// F3 — an authored entity is never a prune target: excluded (no proposal),
299 /// in either mode.
300 #[test]
301 fn authored_is_never_a_prune_target() {
302 for mode in [PruneMode::NeverClobber, PruneMode::ConflictFlag] {
303 assert_eq!(
304 classify_prune_candidate(
305 AnchorProvenanceClass::Authored,
306 mode,
307 true,
308 Some(PruneMerge::Clean),
309 ),
310 None,
311 "authored must never be proposed for deletion"
312 );
313 }
314 }
315
316 /// F3 — a derived entity is flagged (with inputs), never auto-proposed for
317 /// deletion, in either mode.
318 #[test]
319 fn derived_is_flagged_not_deleted() {
320 for mode in [PruneMode::NeverClobber, PruneMode::ConflictFlag] {
321 assert_eq!(
322 classify_prune_candidate(
323 AnchorProvenanceClass::Derived,
324 mode,
325 true,
326 Some(PruneMerge::Clean),
327 ),
328 Some(PruneDisposition::DerivedFlagged),
329 "derived is flagged, never a clean delete"
330 );
331 }
332 }
333
334 /// F2 — conflict-flag mode always presents both sides (never a clean
335 /// delete), whatever the base/merge state.
336 #[test]
337 fn conflict_flag_mode_never_clean_deletes() {
338 for base in [true, false] {
339 for merge in [None, Some(PruneMerge::Clean), Some(PruneMerge::Conflict)] {
340 assert_eq!(
341 classify_prune_candidate(
342 AnchorProvenanceClass::Anchored,
343 PruneMode::ConflictFlag,
344 base,
345 merge,
346 ),
347 Some(PruneDisposition::ConflictFlag),
348 "conflict-flag mode never auto-clean-deletes"
349 );
350 }
351 }
352 }
353
354 /// F2 — never-clobber degrades to conflict-flag when the base leg is not
355 /// retrievable (a non-git source), or when the merge is divergent /
356 /// undetermined; it clean-deletes only with a retrievable base AND a clean
357 /// merge.
358 #[test]
359 fn never_clobber_clean_delete_needs_base_and_clean_merge() {
360 let anchored = AnchorProvenanceClass::Anchored;
361 // Retrievable base + clean merge → the one clean-delete path.
362 assert_eq!(
363 classify_prune_candidate(
364 anchored,
365 PruneMode::NeverClobber,
366 true,
367 Some(PruneMerge::Clean)
368 ),
369 Some(PruneDisposition::CleanDelete)
370 );
371 // No retrievable base (non-git) → conflict-flag degradation.
372 assert_eq!(
373 classify_prune_candidate(
374 anchored,
375 PruneMode::NeverClobber,
376 false,
377 Some(PruneMerge::Clean)
378 ),
379 Some(PruneDisposition::ConflictFlag),
380 "no base leg degrades to conflict-flag"
381 );
382 // Divergent merge → conflict-flag (never clobber the model edit).
383 assert_eq!(
384 classify_prune_candidate(
385 anchored,
386 PruneMode::NeverClobber,
387 true,
388 Some(PruneMerge::Conflict)
389 ),
390 Some(PruneDisposition::ConflictFlag),
391 "a divergent merge is never a clean delete"
392 );
393 // Undetermined merge (signal unwired) → conflict-flag (safe default).
394 assert_eq!(
395 classify_prune_candidate(anchored, PruneMode::NeverClobber, true, None),
396 Some(PruneDisposition::ConflictFlag),
397 "an undetermined merge conservatively conflict-flags"
398 );
399 }
400
401 /// `informed-by` is a delete candidate too (a non-hash class that still owns
402 /// a concept), following the same mode rules as `anchored`.
403 #[test]
404 fn informed_by_follows_the_same_mode_rules() {
405 assert_eq!(
406 classify_prune_candidate(
407 AnchorProvenanceClass::InformedBy,
408 PruneMode::ConflictFlag,
409 false,
410 None,
411 ),
412 Some(PruneDisposition::ConflictFlag)
413 );
414 }
415
416 // ---- F2/F3: end-to-end over a real engine ----------------------------
417
418 use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorSidecar};
419 use crate::binding::{
420 BINDING_VERSION, BuildMode, BuildOperation, DEFAULT_ADJUDICATION_CAP,
421 DEFAULT_FULL_RESYNC_EVERY, Operations, PruneConfig, VerifyOperation,
422 };
423 use crate::ingest::render::render_sync_brief_for;
424 use crate::ingest::resolve::resolve_binding_run;
425 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
426 use crate::pipeline_store::write_binding;
427 use crate::workspace::{
428 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
429 };
430 use crate::workspace_store::WorkspaceStoreAdapter;
431
432 /// An orphan-bound anchor of `class` on `artifact`, git-pinned when
433 /// `commit` is set (a retrievable base leg).
434 fn orphan_anchor(
435 artifact: &str,
436 class: AnchorProvenanceClass,
437 derived_from: Vec<&str>,
438 commit: Option<&str>,
439 ) -> Anchor {
440 Anchor {
441 artifact: artifact.to_string(),
442 grain: AnchorGrain::File,
443 class,
444 at_version: commit.map(|c| AnchorVersion::Commit(c.to_string())),
445 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
446 hash_stability: AnchorHashStability::Stable,
447 derived_from: derived_from.into_iter().map(str::to_string).collect(),
448 binding: None,
449 source: None,
450 span_unvalidated: false,
451 hash_source: None,
452 last_observed: None,
453 }
454 }
455
456 /// Scaffold a filesystem-medium mem whose anchors reference **absent** source
457 /// files (so every anchor resolves orphaned), with a `prune` block at
458 /// `guarantee`. Returns the engine, workspace root, binding and resolved run.
459 /// The source is deliberately **non-git** (a plain filesystem medium, no
460 /// `at_version` unless the fixture pins one) so the base leg is not
461 /// retrievable — the F2 degradation case.
462 fn setup(
463 tmp: &Path,
464 guarantee: PruneGuarantee,
465 entity_anchors: &[(&str, Vec<Anchor>)],
466 ) -> (Engine, std::path::PathBuf, Binding, ResolvedIngest) {
467 let root = tmp.to_path_buf();
468 let mem_dir = root.join("mem");
469 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
470 std::fs::write(
471 mem_dir.join(".memstead").join("config.json"),
472 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
473 )
474 .unwrap();
475 std::fs::create_dir_all(root.join(".memstead")).unwrap();
476 std::fs::write(
477 root.join(".memstead").join("workspace.toml"),
478 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
479 )
480 .unwrap();
481 let mount = Mount {
482 mem: "engine".to_string(),
483 schema: Some("default@1.0.0".parse().unwrap()),
484 storage: MountStorage::Folder {
485 path: mem_dir.clone(),
486 },
487 capability: MountCapability::Write,
488 lifecycle: MountLifecycle::Eager,
489 cross_linkable: false,
490 migration_target: None,
491 };
492 crate::FileWorkspaceStore::new()
493 .save_state(
494 &root,
495 &Workspace {
496 mounts: vec![mount],
497 settings: WorkspaceSettings::default(),
498 },
499 )
500 .unwrap();
501
502 // Seed the anchors sidecar (test fixture — the production write path is
503 // the mutation surface, not prune). No source files are created, so every
504 // anchor resolves orphaned.
505 let mut sidecar = AnchorSidecar::default();
506 for (eid, anchors) in entity_anchors {
507 // The entity each row is keyed to. Written, because it exists: a
508 // row whose entity does not is DANGLING and is partitioned out of
509 // the population before prune sees it (consistency-sweep 03/02),
510 // which is exactly the phantom-entity proposal criterion 6 bans.
511 // A `!` prefix on the id means "seed the row but NOT the entity",
512 // which is the phantom-entity condition criterion 6 is about.
513 let (write_entity, eid) = match eid.strip_prefix('!') {
514 Some(rest) => (false, rest),
515 None => (true, *eid),
516 };
517 if write_entity {
518 let slug = eid.split_once("--").map_or(eid, |(_, s)| s);
519 std::fs::write(
520 mem_dir.join(format!("{slug}.md")),
521 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
522 )
523 .unwrap();
524 }
525 sidecar.set(eid, anchors.clone());
526 }
527 std::fs::write(
528 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
529 sidecar.to_bytes(),
530 )
531 .unwrap();
532
533 // A filesystem-source binding (namespace `path`, so mem_anchors_resolved
534 // observes it) with the requested prune guarantee.
535 let binding = Binding {
536 version: BINDING_VERSION,
537 intent: None,
538 sources: vec![crate::pipeline::Source {
539 name: "graph".to_string(),
540 medium_type: MediumType::Filesystem,
541 pointer: String::new(),
542 change_detection: None,
543 scope: vec![PatternEntry {
544 path: "src/**/*.rs".to_string(),
545 mode: PatternMode::Allow,
546 }],
547 engagement: None,
548 preparation: None,
549 }],
550 reference_mems: Vec::new(),
551 destination_mem: "engine".to_string(),
552 deny_paths: Vec::new(),
553 coverage_semantics: None,
554 rules: None,
555 prune: Some(PruneConfig { guarantee }),
556 operations: Operations {
557 build: Some(BuildOperation {
558 mode: BuildMode::Discovery,
559 trigger: IngestTrigger::Loop,
560 batch_size: 20,
561 post_actions: None,
562 }),
563 sync: Some(crate::binding::SyncOperation {
564 trigger: IngestTrigger::Manual,
565 batch_size: 20,
566 }),
567 verify: Some(VerifyOperation {
568 trigger: IngestTrigger::Manual,
569 batch_size: 20,
570 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
571 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
572 }),
573 },
574 };
575 write_binding(&root, "engine", "graph", &binding).unwrap();
576
577 let engine = Engine::from_workspace_root(&root).unwrap();
578 let resolved = resolve_binding_run("engine/graph", &binding).unwrap();
579 (engine, root, binding, resolved)
580 }
581
582 /// F2 — conflict-flag degradation on a **non-git** source: a model-side
583 /// entity whose source artifact was removed surfaces BOTH sides in the sync
584 /// brief and is NEVER auto-deleted. Requesting never-clobber over a non-git
585 /// anchor (no retrievable base leg) degrades to conflict-flag.
586 /// Criterion 6 (consistency-sweep 03/02): prune must not propose deleting
587 /// an entity that is already gone. Its anchor is orphaned, which is
588 /// precisely the shape that made a phantom entity a candidate: prune
589 /// walked the sidecar by key and never asked whether the key still names
590 /// anything.
591 #[test]
592 fn prune_never_proposes_an_entity_that_does_not_exist() {
593 let tmp = tempfile::tempdir().unwrap();
594 let (engine, root, binding, resolved) = setup(
595 tmp.path(),
596 PruneGuarantee::ConflictFlag,
597 &[
598 (
599 "!engine--phantom",
600 vec![orphan_anchor(
601 "src/phantom.rs",
602 AnchorProvenanceClass::Anchored,
603 vec![],
604 None,
605 )],
606 ),
607 (
608 "engine--real",
609 vec![orphan_anchor(
610 "src/real.rs",
611 AnchorProvenanceClass::Anchored,
612 vec![],
613 None,
614 )],
615 ),
616 ],
617 );
618
619 let proposals = prune_proposals(&engine, &root, &binding, &resolved);
620 assert_eq!(
621 proposals
622 .iter()
623 .map(|p| p.entity.as_str())
624 .collect::<Vec<_>>(),
625 vec!["engine--real"],
626 "the entity that exists is still a candidate; the phantom is not proposed"
627 );
628 }
629
630 #[test]
631 fn f2_conflict_flag_on_non_git_surfaces_both_sides_no_auto_delete() {
632 let tmp = tempfile::tempdir().unwrap();
633 // Request never-clobber; the non-git anchor has no base leg → degrades.
634 let (engine, root, binding, resolved) = setup(
635 tmp.path(),
636 PruneGuarantee::NeverClobber,
637 &[(
638 "engine--removed",
639 vec![orphan_anchor(
640 "src/removed.rs",
641 AnchorProvenanceClass::Anchored,
642 vec![],
643 None, // non-git: no retrievable base leg
644 )],
645 )],
646 );
647
648 let proposals = prune_proposals(&engine, &root, &binding, &resolved);
649 assert_eq!(proposals.len(), 1, "the orphaned entity is a candidate");
650 let p = &proposals[0];
651 assert_eq!(p.entity, "engine--removed");
652 assert!(
653 !p.base_retrievable,
654 "non-git anchor has no retrievable base"
655 );
656 assert_eq!(
657 p.disposition,
658 PruneDisposition::ConflictFlag,
659 "no base leg → conflict-flag degradation, never a clean delete"
660 );
661
662 // The rendered sync brief presents BOTH sides and frames it as a proposal.
663 let brief = render_sync_brief_for(&engine, &root, "engine/graph").unwrap();
664 assert!(brief.contains("Prune — proposed removals"));
665 assert!(brief.contains("source side:"), "source side surfaced");
666 assert!(brief.contains("model side:"), "model side surfaced");
667 assert!(
668 brief.contains("never overwrites a model-side edit"),
669 "no auto-overwrite is stated"
670 );
671 // A5: the pass never mutated the mem — the entity's anchor is still there
672 // (prune_proposals took a shared &Engine; a delete is structurally
673 // impossible). Re-read the sidecar to confirm.
674 let after = engine.mem_anchors_resolved("engine");
675 assert!(
676 after.iter().any(|(e, _)| e.as_ref() == "engine--removed"),
677 "prune must not delete the entity's anchors — it only proposes"
678 );
679 }
680
681 /// F3 — provenance guards: an `authored` entity is NEVER a prune target
682 /// (excluded, no proposal); a `derived` entity is flagged with its inputs,
683 /// never proposed for deletion; a plain `anchored` entity is proposed.
684 #[test]
685 fn f3_authored_excluded_and_derived_flagged_not_deleted() {
686 let tmp = tempfile::tempdir().unwrap();
687 let (engine, root, binding, resolved) = setup(
688 tmp.path(),
689 PruneGuarantee::ConflictFlag,
690 &[
691 (
692 "engine--handwritten",
693 vec![orphan_anchor(
694 "src/authored.rs",
695 AnchorProvenanceClass::Authored,
696 vec![],
697 None,
698 )],
699 ),
700 (
701 "engine--synthesised",
702 vec![orphan_anchor(
703 "src/derived.rs",
704 AnchorProvenanceClass::Derived,
705 vec!["src/in_a.rs", "src/in_b.rs"],
706 None,
707 )],
708 ),
709 (
710 "engine--plain",
711 vec![orphan_anchor(
712 "src/plain.rs",
713 AnchorProvenanceClass::Anchored,
714 vec![],
715 None,
716 )],
717 ),
718 ],
719 );
720
721 let proposals = prune_proposals(&engine, &root, &binding, &resolved);
722
723 // F3 — authored is never a prune target: no proposal names it.
724 assert!(
725 !proposals.iter().any(|p| p.entity == "engine--handwritten"),
726 "an authored entity is never proposed for deletion"
727 );
728
729 // F3 — derived is flagged with its inputs, not proposed for deletion.
730 let derived = proposals
731 .iter()
732 .find(|p| p.entity == "engine--synthesised")
733 .expect("the derived entity is flagged");
734 assert_eq!(derived.disposition, PruneDisposition::DerivedFlagged);
735 assert_eq!(derived.class, "derived");
736 assert_eq!(
737 derived.derived_inputs,
738 vec!["src/in_a.rs".to_string(), "src/in_b.rs".to_string()],
739 "the derived entity carries its inputs to re-examine"
740 );
741
742 // The plain anchored entity IS proposed (conflict-flag).
743 let plain = proposals
744 .iter()
745 .find(|p| p.entity == "engine--plain")
746 .expect("a plain anchored entity is proposed");
747 assert_eq!(plain.disposition, PruneDisposition::ConflictFlag);
748
749 // The rendered sync brief flags the derived entity as NOT-for-deletion,
750 // never emits an auto-delete instruction, and never names the authored one.
751 let brief = render_sync_brief_for(&engine, &root, "engine/graph").unwrap();
752 assert!(brief.contains("flagged, NOT proposed for deletion"));
753 assert!(brief.contains("`engine--synthesised`"));
754 assert!(
755 !brief.contains("engine--handwritten"),
756 "the authored entity never appears in a prune proposal"
757 );
758 assert!(brief.contains("nothing is auto-deleted"));
759 }
760
761 /// A `never-clobber` binding whose anchor IS git-pinned has a retrievable
762 /// base leg — the proposal reports it (the never-clobber posture), while
763 /// still degrading to conflict-flag until the model-divergence merge signal
764 /// is wired (the gatherer supplies no merge outcome this cycle).
765 #[test]
766 fn git_pinned_anchor_reports_a_retrievable_base_leg() {
767 let tmp = tempfile::tempdir().unwrap();
768 let (engine, root, binding, resolved) = setup(
769 tmp.path(),
770 PruneGuarantee::NeverClobber,
771 &[(
772 "engine--pinned",
773 vec![orphan_anchor(
774 "src/pinned.rs",
775 AnchorProvenanceClass::Anchored,
776 vec![],
777 Some("deadbeef"),
778 )],
779 )],
780 );
781 let proposals = prune_proposals(&engine, &root, &binding, &resolved);
782 assert_eq!(proposals.len(), 1);
783 assert!(
784 proposals[0].base_retrievable,
785 "a git-pinned anchor exposes a retrievable base leg"
786 );
787 // Merge outcome unwired → still conflict-flag (never a silent clobber).
788 assert_eq!(proposals[0].disposition, PruneDisposition::ConflictFlag);
789 }
790
791 /// An entity with a **still-resolving** anchor is NOT a prune candidate —
792 /// the whole basis must be gone (conservatism). Here one anchor's file
793 /// exists, so the entity is skipped.
794 #[test]
795 fn entity_with_a_surviving_anchor_is_not_pruned() {
796 let tmp = tempfile::tempdir().unwrap();
797 let (engine, root, binding, resolved) = setup(
798 tmp.path(),
799 PruneGuarantee::ConflictFlag,
800 &[(
801 "engine--partly-gone",
802 vec![
803 orphan_anchor("src/gone.rs", AnchorProvenanceClass::Anchored, vec![], None),
804 orphan_anchor(
805 "src/present.rs",
806 AnchorProvenanceClass::InformedBy,
807 vec![],
808 None,
809 ),
810 ],
811 )],
812 );
813 // Create only the second file so its anchor resolves (not orphaned).
814 std::fs::create_dir_all(root.join("src")).unwrap();
815 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
816
817 let proposals = prune_proposals(&engine, &root, &binding, &resolved);
818 assert!(
819 proposals.is_empty(),
820 "an entity whose basis is not entirely gone is not a prune candidate"
821 );
822 }
823}