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