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