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