Skip to main content

memstead_base/engine/mutation/
delete.rs

1//! `Engine::delete_entity` — remove an entity from a mount's backend
2//! and the in-memory store.
3
4use std::path::Path;
5
6use crate::entity::EntityId;
7use crate::ops::WarningHint;
8use crate::provenance::{Provenance, ProvenanceKind};
9use crate::vcs::{Actor, ClientId, CommitContext};
10use crate::workspace::MountCapability;
11
12use super::super::{DeleteEntityArgs, DeleteEntityOutcome, Engine, EngineError, ReferrerInfo};
13use super::{gc_orphan_stubs, make_stub};
14
15/// The would-be delete outcome for an entity, derived purely from the
16/// in-memory graph (no mutation). `write_referrers` are the Write-Mem
17/// sources that block a delete (`HAS_INCOMING_REFS`); `readonly_referrers`
18/// are ReadOnly-mount sources that instead trigger the residual-stub
19/// demotion. Both empty ⇒ a clean removal. Shared by [`Engine::delete_entity`]
20/// (the real guard) and the CLI `delete --dry-run` preview so the preview's
21/// verdict cannot drift from the real outcome.
22#[derive(Debug, Clone)]
23pub struct DeleteReferrers {
24    pub write_referrers: Vec<ReferrerInfo>,
25    pub readonly_referrers: Vec<EntityId>,
26}
27
28impl DeleteReferrers {
29    /// Whether the real delete would refuse with `HAS_INCOMING_REFS`.
30    pub fn would_refuse(&self) -> bool {
31        !self.write_referrers.is_empty()
32    }
33}
34
35impl Engine {
36    /// Classify an entity's incoming referrers by the source mount's
37    /// capability — the read-only core of the delete guard. Write-Mem
38    /// referrers block the delete; ReadOnly referrers trigger the
39    /// residual-stub demotion. Per-source dedup collapses an N-edge
40    /// source into one [`ReferrerInfo`] carrying every rel-type. A
41    /// referrer in an unmounted mem is treated as Write
42    /// (safe-by-default: refuse rather than silently demote).
43    ///
44    /// Pure read — no disk, commit, lock, or store mutation. Used by
45    /// [`Self::delete_entity`] and the CLI `delete --dry-run` preview so
46    /// both compute the same verdict from one implementation.
47    pub fn classify_delete_referrers(&self, id: &EntityId) -> DeleteReferrers {
48        let mut write_referrers: Vec<ReferrerInfo> = Vec::new();
49        let mut readonly_referrers: Vec<EntityId> = Vec::new();
50        for edge in self.store.incoming(id) {
51            let from_mem = edge.from.mem().to_string();
52            let cap = self
53                .mounts
54                .iter()
55                .find(|m| m.mount.mem == from_mem)
56                .map(|m| m.mount.capability)
57                .unwrap_or(MountCapability::Write);
58            match cap {
59                MountCapability::Write => {
60                    let from_id = edge.from.to_string();
61                    if let Some(existing) =
62                        write_referrers.iter_mut().find(|r| r.from_id == from_id)
63                    {
64                        if !existing.rel_types.contains(&edge.rel_type) {
65                            existing.rel_types.push(edge.rel_type.clone());
66                        }
67                    } else {
68                        write_referrers.push(ReferrerInfo {
69                            from_id,
70                            rel_types: vec![edge.rel_type.clone()],
71                            mem: from_mem,
72                        });
73                    }
74                }
75                MountCapability::ReadOnly => readonly_referrers.push(edge.from.clone()),
76            }
77        }
78        DeleteReferrers {
79            write_referrers,
80            readonly_referrers,
81        }
82    }
83
84    /// Delete an entity from its mount.
85    ///
86    /// Binary semantics — there is no force flag. The engine partitions
87    /// incoming references by the source mem's
88    /// [`MountCapability`]:
89    ///
90    /// - any **Write-Mem** referrers → refuse with typed
91    ///   [`EngineError::HasIncomingRefs`] carrying the structured
92    ///   referrer list;
93    /// - only **ReadOnly-mount** referrers → delete the file +
94    ///   commit, then demote the in-memory entity to a stub at the
95    ///   same id so the surviving incoming edges keep a valid target.
96    ///   A `RESIDUAL_STUB_FOR_READONLY_REFERRERS` warning rides on
97    ///   the outcome;
98    /// - no referrers → clean removal (file + store entry + cascading
99    ///   edges). Orphaned stubs whose last incoming edge was the
100    ///   deleted entity are GC'd.
101    ///
102    /// Optimistic-locking via `args.expected_hash` matches
103    /// `update_entity`. Stubs (no on-disk file) skip the backend
104    /// write and commit but still log provenance.
105    pub fn delete_entity(
106        &mut self,
107        args: DeleteEntityArgs,
108        actor: Actor,
109        client: Option<&ClientId>,
110        note: Option<&str>,
111    ) -> Result<DeleteEntityOutcome, EngineError> {
112        let id = &args.id;
113        let mem = id.mem().to_string();
114
115        let mount_idx = self
116            .mounts
117            .iter()
118            .position(|m| m.mount.mem == mem)
119            .ok_or_else(|| EngineError::UnknownMem(mem.clone()))?;
120        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
121            return Err(EngineError::ReadOnlyMount(mem));
122        }
123
124        // Reload-before-operation: reload if a sibling advanced the
125        // mem ref, so the `expected_hash` compare and the
126        // referrer classification below see current truth. Notice
127        // rides the outcome's `warnings`.
128        let mut drift_warnings = self.reload_if_stale(Some(&mem));
129
130        let entity = self
131            .store
132            .get(id)
133            .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
134
135        if let Some(expected) = args.expected_hash.as_deref()
136            && entity.content_hash != expected
137        {
138            // Stubs have no `content_hash` to compare against, so the
139            // pre-fix `(current: )` paren misdirected callers toward
140            // hash-recovery via `memstead_entity` (which returns the same
141            // empty hash). Surface `details.is_stub: true` and a
142            // corrective-action message — the actual fix is
143            // `expected_hash: ""`. Non-stub mismatches keep the prior
144            // contract.
145            return Err(EngineError::HashMismatch {
146                id: id.to_string(),
147                current: entity.content_hash.clone(),
148                is_stub: entity.stub,
149            });
150        }
151
152        let file_path = entity.file_path.clone();
153        let entity_is_stub = entity.stub;
154
155        // Partition incoming refs by the source mem's mount capability
156        // (Write-Mem referrers block; ReadOnly trigger the residual-stub
157        // demotion). The classification is shared with the CLI
158        // `delete --dry-run` preview so the two cannot disagree about the
159        // outcome.
160        let DeleteReferrers {
161            write_referrers,
162            readonly_referrers,
163        } = self.classify_delete_referrers(id);
164        if !write_referrers.is_empty() {
165            return Err(EngineError::HasIncomingRefs {
166                id: id.to_string(),
167                referrers: write_referrers,
168            });
169        }
170
171        let demote_to_stub = !readonly_referrers.is_empty();
172        let removed_incoming: Vec<String> =
173            readonly_referrers.iter().map(|id| id.to_string()).collect();
174
175        // Pre-delete edge count so the response carries the correct
176        // value regardless of whether we drop the entity or demote it
177        // to a stub. On the demote path we only retain incoming edges
178        // (outgoing are severed because the source entity is gone),
179        // so the cascade still removes `outgoing` worth of edges.
180        let relations_removed = if demote_to_stub {
181            self.store.outgoing(id).len()
182        } else {
183            self.store.outgoing(id).len() + self.store.incoming(id).len()
184        };
185
186        // Stubs have no on-disk file (empty file_path) and were never
187        // committed as their own grain — the relate that materialised
188        // them committed the source entity's markdown, the stub itself
189        // is in-memory + edge-index only. Routing a stub delete through
190        // `backend.delete_entity` trips `MemWriter(Path("mem-
191        // relative path is empty"))`. Skip the backend write + commit
192        // for stubs; provenance still records the explicit drop so
193        // memstead_changes_since consumers see the event.
194        let backend = self.mounts[mount_idx].backend.as_ref();
195        let commit_sha = if entity_is_stub {
196            String::new()
197        } else {
198            backend.delete_entity(Path::new(&file_path))?;
199            // Remove the entity's anchor row in the SAME commit as the
200            // delete so no orphaned anchor for a deleted entity survives.
201            // A no-op when the entity had no anchors (byte-identical).
202            super::stage_anchors_removal(backend, id)?;
203            let commit_subject = format!("memstead: delete {id}");
204            let ctx = CommitContext {
205                actor,
206                client: client.cloned(),
207                tool: Some("delete_entity"),
208                note: note.map(String::from),
209                logical_operation_id: None,
210                entity_ids: None,
211            };
212            backend.commit(&commit_subject, &ctx)?
213        };
214
215        backend.append_provenance(&Provenance::new(
216            std::time::SystemTime::now(),
217            ProvenanceKind::Delete,
218            Some(id.to_string()),
219            actor,
220            client.cloned(),
221            note.map(String::from),
222        ))?;
223
224        if !commit_sha.is_empty() {
225            self.record_self_write(mount_idx, &commit_sha);
226        }
227
228        let mut warnings: Vec<WarningHint> = Vec::new();
229        // Reload-before-operation drift notice, surfaced first.
230        warnings.append(&mut drift_warnings);
231        let orphan_stubs_removed = if demote_to_stub {
232            // Demote: drop outgoing edges (the source is gone), then
233            // upsert a stub at the same id so the surviving incoming
234            // edges from ReadOnly mounts retain a valid target. This
235            // mirrors the state a fresh boot would produce — the
236            // parser would re-emit a `LoadTime` stub at this id from
237            // the surviving ReadOnly wiki-links.
238            self.store.remove_edges_from(id);
239            self.store.upsert(
240                id.clone(),
241                make_stub(
242                    id,
243                    crate::entity::StubKind::Residual {
244                        since_commit: commit_sha.clone(),
245                        readonly_referrers: readonly_referrers.clone(),
246                    },
247                ),
248            );
249            warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
250                id: id.clone(),
251                referrers: readonly_referrers,
252            });
253            // Don't GC orphan stubs on the demote path — the entity we
254            // just demoted is itself a stub now and would be flagged
255            // as orphan if we count its surviving incoming edges
256            // wrong. Its incoming edges from ReadOnly are real and
257            // keep it alive; siblings are unaffected.
258            Vec::new()
259        } else {
260            self.store.remove(id);
261            // Sweep stubs whose last referrer was the deleted entity.
262            // Common case: the deleted entity had a relate edge to a
263            // stub target and was the only one holding it in-graph.
264            gc_orphan_stubs(&mut self.store)
265        };
266
267        self.invalidate_communities();
268        self.invalidate_search_indexes();
269
270        // `require_notes` provenance nudge — single engine-level
271        // enforcement point. Gated on a landed commit: a stub delete
272        // skips the backend write (empty `commit_sha`, nothing to
273        // attribute), so it doesn't demand a note.
274        if !commit_sha.is_empty()
275            && let Some(w) = self.note_missing_warning("delete_entity", note)
276        {
277            warnings.push(w);
278        }
279
280        Ok(DeleteEntityOutcome {
281            id: id.clone(),
282            file_path,
283            removed_incoming,
284            relations_removed,
285            commit_sha,
286            orphan_stubs_removed,
287            warnings,
288        })
289    }
290
291    /// Positional + CommitContext wrapper around
292    /// [`Self::delete_entity`]. Bundles `id` + `expected_hash` into
293    /// a [`DeleteEntityArgs`].
294    pub fn delete_entity_with_ctx(
295        &mut self,
296        id: &EntityId,
297        expected_hash: &str,
298        ctx: &CommitContext<'_>,
299    ) -> Result<DeleteEntityOutcome, EngineError> {
300        let args = DeleteEntityArgs {
301            id: id.clone(),
302            expected_hash: Some(expected_hash.to_string()),
303        };
304        self.delete_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
305    }
306}
307
308#[cfg(test)]
309mod tests {
310
311    use tempfile::TempDir;
312
313    use crate::backend::MemBackend;
314    use crate::engine::test_helpers::*;
315    use crate::engine::{
316        CreateEntityArgs, DeleteEntityArgs, Engine, EngineError, RelateEntityArgs,
317    };
318    use crate::ops::WarningHint;
319    use crate::storage::FilesystemMemWriter;
320
321    /// Seed a folder-backed engine with one anchored entity; return the
322    /// engine and the create outcome.
323    fn engine_with_anchored(
324        tmp: &TempDir,
325        title: &str,
326    ) -> (Engine, crate::engine::CreateEntityOutcome) {
327        let mem_dir = tmp.path().to_path_buf();
328        let writer = FilesystemMemWriter::new(mem_dir.clone());
329        let mut engine = Engine::from_mounts(vec![(
330            folder_mount("specs", mem_dir),
331            Box::new(writer) as Box<dyn MemBackend>,
332        )])
333        .unwrap();
334        let (actor, client) = cli_actor();
335        let mut args: CreateEntityArgs = empty_create_args("specs", title);
336        args.anchors = vec![crate::anchor::AnchorInput {
337            artifact: Some("src/lib.rs".into()),
338            grain: Some("file".into()),
339            class: Some("anchored".into()),
340            hash: Some("h1".into()),
341            hash_stability: Some("stable".into()),
342            ..Default::default()
343        }];
344        let outcome = engine
345            .create_entity(args, actor, Some(&client), None)
346            .unwrap();
347        (engine, outcome)
348    }
349
350    #[test]
351    fn delete_removes_entity_anchors_no_orphan() {
352        let tmp = TempDir::new().unwrap();
353        let (mut engine, seeded) = engine_with_anchored(&tmp, "Anchored Doomed");
354        assert_eq!(engine.entity_anchors(&seeded.id).len(), 1);
355        let (actor, client) = cli_actor();
356        engine
357            .delete_entity(
358                DeleteEntityArgs {
359                    id: seeded.id.clone(),
360                    expected_hash: Some(seeded.content_hash.clone()),
361                },
362                actor,
363                Some(&client),
364                None,
365            )
366            .unwrap();
367        // The entity's anchor row is gone — no orphaned anchor for a
368        // deleted entity.
369        assert!(engine.entity_anchors(&seeded.id).is_empty());
370        assert!(engine.anchors_referencing_artifact("src/lib.rs").is_empty());
371    }
372
373    #[test]
374    fn delete_entity_removes_file_and_store_entry() {
375        let tmp = TempDir::new().unwrap();
376        let (mut engine, seeded) = engine_with_seed(&tmp, "Doomed");
377        let (actor, client) = cli_actor();
378        let outcome = engine
379            .delete_entity(
380                DeleteEntityArgs {
381                    id: seeded.id.clone(),
382                    expected_hash: Some(seeded.content_hash.clone()),
383                },
384                actor,
385                Some(&client),
386                None,
387            )
388            .unwrap();
389        assert_eq!(outcome.id, seeded.id);
390        assert_eq!(outcome.removed_incoming, Vec::<String>::new());
391        // Store no longer carries the entity.
392        assert!(engine.get_entity(&seeded.id).is_none());
393        // On-disk file gone.
394        assert!(!tmp.path().join(&seeded.file_path).exists());
395        // Provenance log records the delete.
396        let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
397        assert!(log.contains("\"kind\":\"delete\""));
398    }
399
400    #[test]
401    fn delete_entity_rejects_hash_mismatch() {
402        let tmp = TempDir::new().unwrap();
403        let (mut engine, seeded) = engine_with_seed(&tmp, "Locked");
404        let (actor, client) = cli_actor();
405        let err = engine
406            .delete_entity(
407                DeleteEntityArgs {
408                    id: seeded.id.clone(),
409                    expected_hash: Some("nope".to_string()),
410                },
411                actor,
412                Some(&client),
413                None,
414            )
415            .unwrap_err();
416        match err {
417            EngineError::HashMismatch {
418                id,
419                current,
420                is_stub,
421            } => {
422                assert_eq!(id, seeded.id.to_string());
423                assert_eq!(current, seeded.content_hash);
424                assert!(!is_stub, "real entity must not flag as stub");
425            }
426            other => panic!("expected HashMismatch, got {other:?}"),
427        }
428    }
429
430    /// Item 04 — a stub delete with a non-empty `expected_hash` used to
431    /// trip the hash-mismatch path with `(current: )` empty paren,
432    /// misdirecting the agent toward `memstead_entity`-based hash recovery.
433    /// The recovery is `expected_hash: ""` — stubs have no content
434    /// hash. The typed envelope now surfaces `is_stub: true` and the
435    /// message names the corrective action directly.
436    #[test]
437    fn delete_entity_on_stub_with_non_empty_hash_surfaces_is_stub_flag() {
438        let tmp = TempDir::new().unwrap();
439        let (mut engine, _seed) = engine_with_seed(&tmp, "Anchor");
440        let (actor, client) = cli_actor();
441        // Materialise a stub via the forward-reference relate path: the
442        // stub keeps its incoming USES edge from the source, but
443        // `delete_entity` checks `expected_hash` BEFORE it partitions
444        // incoming refs, so the bogus-hash mismatch below fires
445        // regardless of the referrer. (The former body-wiki-link-drop
446        // trick no longer leaves an orphan to delete — the update path
447        // now runs the orphan-stub GC sweep alongside relate-remove and
448        // delete.)
449        let stub_id = crate::EntityId::new("specs", "stub-target");
450        let source = engine
451            .create_entity(
452                empty_create_args("specs", "Source With Link"),
453                actor,
454                Some(&client),
455                None,
456            )
457            .unwrap();
458        engine
459            .relate_entity(
460                RelateEntityArgs {
461                    source: source.id.clone(),
462                    expected_hash: Some(source.content_hash.clone()),
463                    rel_type: "USES".to_string(),
464                    target: stub_id.clone(),
465                    remove: false,
466                    description: None,
467                },
468                actor,
469                Some(&client),
470                None,
471            )
472            .unwrap();
473        assert!(
474            engine.store().contains(&stub_id),
475            "forward-reference relate must materialise stub"
476        );
477
478        // Stub delete with a non-empty (bogus) expected_hash —
479        // pre-fix the message read `current is ` with an empty trailing
480        // value; now `is_stub: true` and the message names the fix.
481        let err = engine
482            .delete_entity(
483                DeleteEntityArgs {
484                    id: stub_id.clone(),
485                    expected_hash: Some("definitely-non-empty".to_string()),
486                },
487                actor,
488                Some(&client),
489                None,
490            )
491            .unwrap_err();
492        match err {
493            EngineError::HashMismatch {
494                id,
495                current,
496                is_stub,
497            } => {
498                assert_eq!(id, stub_id.to_string());
499                assert!(current.is_empty(), "stub has no content hash");
500                assert!(is_stub, "is_stub must be true on a stub mismatch");
501                let msg = format!(
502                    "{}",
503                    EngineError::HashMismatch {
504                        id,
505                        current,
506                        is_stub
507                    }
508                );
509                assert!(
510                    msg.contains("stub") && msg.contains("expected_hash: \"\""),
511                    "stub message must name the corrective action; got: {msg}",
512                );
513            }
514            other => panic!("expected HashMismatch, got {other:?}"),
515        }
516    }
517
518    /// `memstead_delete id=<stub> expected_hash=""` end-to-end. The pre-fix
519    /// path tripped `BackendError::MemWriter(Path("mem-relative
520    /// path is empty"))` because stubs carry an empty `file_path` and
521    /// the backend's `delete_entity` rejected the empty path. The fix
522    /// routes stub deletes around the backend write — stubs are
523    /// in-memory + edge-index only, never committed as their own grain.
524    #[test]
525    fn delete_entity_on_stub_with_empty_hash_succeeds_via_in_memory_route() {
526        let tmp = TempDir::new().unwrap();
527        let (mut engine, _anchor) = engine_with_seed(&tmp, "Anchor");
528        let (actor, client) = cli_actor();
529
530        // Inject an orphan stub (zero incoming edges) directly into the
531        // store. Organic mutation paths can no longer leave one — the
532        // relate-remove, delete, and update-via-alias-resync sweeps all
533        // GC orphans the moment a stub's last referrer drops — so a
534        // white-box insertion is the only way to set up the "delete a
535        // pre-existing orphan stub" case this test exercises (e.g. a
536        // legacy in-memory artifact).
537        let stub_id = crate::EntityId::new("specs", "ghost-target");
538        engine.store.upsert(
539            stub_id.clone(),
540            super::make_stub(&stub_id, crate::entity::StubKind::ForwardReference),
541        );
542        assert!(
543            engine.store().contains(&stub_id),
544            "injected orphan stub must be in store"
545        );
546
547        let outcome = engine
548            .delete_entity(
549                DeleteEntityArgs {
550                    id: stub_id.clone(),
551                    expected_hash: Some(String::new()),
552                },
553                actor,
554                Some(&client),
555                None,
556            )
557            .expect("stub delete with expected_hash=\"\" must succeed");
558
559        assert_eq!(outcome.id, stub_id);
560        // Backend write was skipped — no commit grain for a stub.
561        assert!(
562            outcome.commit_sha.is_empty(),
563            "stub deletes skip the backend write; commit_sha is empty"
564        );
565        // In-memory store no longer carries the stub.
566        assert!(
567            !engine.store().contains(&stub_id),
568            "stub must be gone from the in-memory store"
569        );
570        // Subsequent lookups behave like a normal not-found.
571        assert!(engine.get_entity(&stub_id).is_none());
572        // Provenance log records the delete (the changes feed must see
573        // explicit stub drops just like real deletes).
574        let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
575        assert!(log.contains("\"kind\":\"delete\""));
576    }
577
578    #[test]
579    fn delete_entity_refuses_on_write_mem_referrers_with_typed_payload() {
580        let tmp = TempDir::new().unwrap();
581        let (mut engine, target) = engine_with_seed(&tmp, "Target");
582        let (actor, client) = cli_actor();
583        // Create a second entity that points at `target`.
584        let source = engine
585            .create_entity(
586                empty_create_args("specs", "Source"),
587                actor,
588                Some(&client),
589                None,
590            )
591            .unwrap();
592        engine
593            .relate_entity(
594                RelateEntityArgs {
595                    source: source.id.clone(),
596                    expected_hash: Some(source.content_hash.clone()),
597                    rel_type: "USES".to_string(),
598                    target: target.id.clone(),
599                    remove: false,
600                    description: None,
601                },
602                actor,
603                Some(&client),
604                None,
605            )
606            .unwrap();
607
608        // Delete refuses — the engine has no force flag; the agent
609        // removes the offending references first.
610        let err = engine
611            .delete_entity(
612                DeleteEntityArgs {
613                    id: target.id.clone(),
614                    expected_hash: None,
615                },
616                actor,
617                Some(&client),
618                None,
619            )
620            .unwrap_err();
621        match err {
622            EngineError::HasIncomingRefs { id, referrers } => {
623                assert_eq!(id, target.id.to_string());
624                assert_eq!(referrers.len(), 1);
625                let r = &referrers[0];
626                assert_eq!(r.from_id, source.id.to_string());
627                assert_eq!(r.rel_types, vec!["USES".to_string()]);
628                assert_eq!(r.mem, "specs");
629            }
630            other => panic!("expected HasIncomingRefs, got {other:?}"),
631        }
632
633        // The entity is still in the store and the file still on disk —
634        // no partial state from a refused delete.
635        assert!(engine.get_entity(&target.id).is_some());
636        assert!(tmp.path().join(&target.file_path).exists());
637    }
638
639    #[test]
640    fn delete_entity_returns_commit_sha_and_relations_removed() {
641        let tmp = TempDir::new().unwrap();
642        let (mut engine, target) = engine_with_seed(&tmp, "Target");
643        let (actor, client) = cli_actor();
644
645        // Build a small graph: source --USES--> target. Delete `source`
646        // (no incoming refs on it) and observe relations_removed
647        // counts the one outgoing edge.
648        let source = engine
649            .create_entity(
650                empty_create_args("specs", "Source"),
651                actor,
652                Some(&client),
653                None,
654            )
655            .unwrap();
656        let related = engine
657            .relate_entity(
658                RelateEntityArgs {
659                    source: source.id.clone(),
660                    expected_hash: Some(source.content_hash.clone()),
661                    rel_type: "USES".to_string(),
662                    target: target.id.clone(),
663                    remove: false,
664                    description: None,
665                },
666                actor,
667                Some(&client),
668                None,
669            )
670            .unwrap();
671
672        let outcome = engine
673            .delete_entity(
674                DeleteEntityArgs {
675                    id: source.id.clone(),
676                    expected_hash: Some(related.content_hash.clone()),
677                },
678                actor,
679                Some(&client),
680                None,
681            )
682            .unwrap();
683
684        // Real write — folder backend produces a synthetic CommitId.
685        assert!(
686            !outcome.commit_sha.is_empty(),
687            "commit_sha must be populated on a real delete"
688        );
689        // Zero incoming + one outgoing edge removed.
690        assert_eq!(outcome.relations_removed, 1);
691        // No stubs in this graph; orphan_stubs_removed is empty.
692        assert!(outcome.orphan_stubs_removed.is_empty());
693        // No residual-stub warning — pure clean removal.
694        assert!(outcome.warnings.is_empty());
695    }
696
697    #[test]
698    fn delete_entity_garbage_collects_orphaned_stubs() {
699        let tmp = TempDir::new().unwrap();
700        let (mut engine, source) = engine_with_seed(&tmp, "Source");
701        let (actor, client) = cli_actor();
702        let stub_id = crate::EntityId::new("specs", "ghost-stub");
703
704        // Relate source → stub_id; engine creates the stub since the
705        // target was absent.
706        let related = engine
707            .relate_entity(
708                RelateEntityArgs {
709                    source: source.id.clone(),
710                    expected_hash: Some(source.content_hash.clone()),
711                    rel_type: "USES".to_string(),
712                    target: stub_id.clone(),
713                    remove: false,
714                    description: None,
715                },
716                actor,
717                Some(&client),
718                None,
719            )
720            .unwrap();
721        assert!(engine.store().contains(&stub_id), "stub must be in store");
722
723        // Delete `source` — its outgoing edge to `stub_id` was the
724        // stub's only incoming edge, so the GC sweep drops the stub.
725        let outcome = engine
726            .delete_entity(
727                DeleteEntityArgs {
728                    id: source.id.clone(),
729                    expected_hash: Some(related.content_hash.clone()),
730                },
731                actor,
732                Some(&client),
733                None,
734            )
735            .unwrap();
736
737        assert_eq!(outcome.orphan_stubs_removed, vec![stub_id.clone()]);
738        assert!(
739            !engine.store().contains(&stub_id),
740            "GC must drop the orphaned stub"
741        );
742    }
743
744    /// ReadOnly-only referrer path: the entity has no Write-Mem
745    /// referrers but is referenced from a ReadOnly archive. Delete
746    /// removes the file and demotes the entity in-memory to a stub at
747    /// the same id, preserving the incoming edges from the archive
748    /// and surfacing a `RESIDUAL_STUB_FOR_READONLY_REFERRERS` warning.
749    /// The post-mutation in-memory state matches what a fresh boot
750    /// would produce: the parser would re-emit a stub at this id from
751    /// the surviving wiki-link in the archive's markdown.
752    #[test]
753    fn delete_entity_demotes_to_stub_when_only_readonly_referrers_remain() {
754        use crate::engine::test_helpers::{archive_mount, build_archive};
755        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
756
757        let tmp = TempDir::new().unwrap();
758        let writable_dir = tmp.path().join("writable");
759        std::fs::create_dir_all(&writable_dir).unwrap();
760        let writer = FilesystemMemWriter::new(writable_dir.clone());
761
762        // Build an archive that declares an explicit cross-mem
763        // relation into the writable mem. Under the alias model
764        // edges originate from `## Relationships` only — the body
765        // wiki-link aliases the declared relation.
766        let archive_md = "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Archived Source\n\n## Identity\n\nLinks to [[specs:target]].\n\n## Purpose\n\nFixture for residual-stub demotion.\n\n## Relationships\n\n- **REFERENCES**: [[specs:target]]\n";
767        let archive_path = build_archive(
768            tmp.path(),
769            "archive",
770            &[("archived-source.md", archive_md.as_bytes())],
771        );
772
773        let folder_mount = Mount {
774            mem: "specs".to_string(),
775            schema: Some(crate::engine::test_helpers::pin("default")),
776            storage: MountStorage::Folder {
777                path: writable_dir.clone(),
778            },
779            capability: MountCapability::Write,
780            lifecycle: MountLifecycle::Eager,
781            cross_linkable: true,
782            migration_target: None,
783        };
784        let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
785        let mut engine = Engine::from_mounts(vec![
786            (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
787            (
788                archive_mount("archive", archive_path.clone()),
789                Box::new(archive_reader) as Box<dyn MemBackend>,
790            ),
791        ])
792        .unwrap();
793
794        let (actor, client) = cli_actor();
795        let target = engine
796            .create_entity(
797                empty_create_args("specs", "Target"),
798                actor,
799                Some(&client),
800                None,
801            )
802            .unwrap();
803
804        // Sanity: the archive's wiki-link surfaces as an incoming
805        // edge on `specs--target`.
806        let archived_source_id = crate::EntityId::new("archive", "archived-source");
807        assert!(
808            engine.store().contains(&archived_source_id),
809            "archive entity must load into the store"
810        );
811        let incoming_pre: Vec<_> = engine
812            .store()
813            .incoming(&target.id)
814            .iter()
815            .map(|e| e.from.clone())
816            .collect();
817        assert!(
818            incoming_pre.contains(&archived_source_id),
819            "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
820        );
821
822        // Delete: only-ReadOnly referrer → file removed, entity
823        // demoted to a stub, warning surfaces, incoming edge survives.
824        let outcome = engine
825            .delete_entity(
826                DeleteEntityArgs {
827                    id: target.id.clone(),
828                    expected_hash: Some(target.content_hash.clone()),
829                },
830                actor,
831                Some(&client),
832                None,
833            )
834            .unwrap();
835
836        // File is gone from the writable mem.
837        assert!(!writable_dir.join(&target.file_path).exists());
838        // Entity in the store is now a stub at the same id.
839        let demoted = engine
840            .get_entity(&target.id)
841            .expect("residual stub must remain in store");
842        assert!(demoted.stub, "demoted entity must be flagged as stub");
843        assert!(demoted.entity_type.is_empty());
844        // Typed provenance: the residual stub records its origin so
845        // an agent reading via `memstead_entity` later sees the diagnostic
846        // context the mutation-time warning carried.
847        match &demoted.stub_kind {
848            Some(crate::entity::StubKind::Residual {
849                since_commit: _,
850                readonly_referrers,
851            }) => {
852                assert_eq!(
853                    readonly_referrers,
854                    &vec![archived_source_id.clone()],
855                    "Residual.readonly_referrers must snapshot the surviving referrers at mutation time"
856                );
857            }
858            other => panic!("demoted stub must be tagged Residual; got {other:?}"),
859        }
860        // Incoming edge from archive survives.
861        let incoming_post: Vec<_> = engine
862            .store()
863            .incoming(&target.id)
864            .iter()
865            .map(|e| e.from.clone())
866            .collect();
867        assert!(
868            incoming_post.contains(&archived_source_id),
869            "archive incoming edge must survive demotion"
870        );
871        // Warning carries the surviving referrer.
872        let referrers = outcome
873            .warnings
874            .iter()
875            .find_map(|w| match w {
876                WarningHint::ResidualStubForReadOnlyReferrers { referrers, .. } => {
877                    Some(referrers.clone())
878                }
879                _ => None,
880            })
881            .expect("ResidualStubForReadOnlyReferrers warning must surface");
882        assert_eq!(referrers, vec![archived_source_id]);
883        assert_eq!(outcome.removed_incoming.len(), 1);
884    }
885
886    // ---- Engine::relate_entity --------------------------------------
887}