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