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