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