Skip to main content

memstead_base/engine/mutation/
rename.rs

1//! `Engine::rename_entity` — change an entity's slug, move its file
2//! on the backend, and rewrite the in-memory store.
3
4use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::id::validate_and_derive_slug;
9use crate::entity::parser::parse_markdown;
10use crate::entity::store_builder::push_entities_into_store;
11use crate::ops::WarningHint;
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::vcs::{Actor, ClientId, CommitContext};
14use crate::workspace::MountCapability;
15
16use super::super::{Engine, EngineError, RenameEntityArgs, RenameEntityOutcome};
17use super::{make_stub, unknown_type_error};
18
19impl Engine {
20    /// Positional + CommitContext wrapper around
21    /// [`Self::rename_entity`].
22    pub fn rename_entity_with_ctx(
23        &mut self,
24        old_id: &EntityId,
25        new_title: &str,
26        expected_hash: &str,
27        ctx: &CommitContext<'_>,
28    ) -> Result<RenameEntityOutcome, EngineError> {
29        let args = RenameEntityArgs {
30            id: old_id.clone(),
31            expected_hash: Some(expected_hash.to_string()),
32            new_title: new_title.to_string(),
33        };
34        self.rename_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
35    }
36
37    /// Rename an entity by changing its title — the slug, id, and
38    /// on-disk file path follow.
39    ///
40    /// **Same-mem referrers and self-references are rewritten
41    /// atomically.** The renaming entity is treated as the first
42    /// referrer of itself: every entry in its own `relationships`
43    /// list whose target equals the old id is updated to point at
44    /// the new id, and every `[[<old-slug>]]` token in its own
45    /// section bodies is rewritten to the new slug (respecting
46    /// fenced-code and inline-code masking). Every other entity in
47    /// the same mem that pointed at the old id (via an explicit
48    /// relation or an inline body wiki-link) gets the same two-
49    /// surface rewrite. All rewrites land in one per-mem commit.
50    /// Cross-mem referrers and ReadOnly-mount referrers are not
51    /// yet walked — those land with the multi-mem atomicity
52    /// machinery and the residual-stub demotion path respectively.
53    pub fn rename_entity(
54        &mut self,
55        args: RenameEntityArgs,
56        actor: Actor,
57        client: Option<&ClientId>,
58        note: Option<&str>,
59    ) -> Result<RenameEntityOutcome, EngineError> {
60        let id = &args.id;
61        let mem = id.mem().to_string();
62
63        let mount_idx = self
64            .mounts
65            .iter()
66            .position(|m| m.mount.mem == mem)
67            .ok_or_else(|| self.unknown_mem_error(&mem))?;
68        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
69            return Err(EngineError::ReadOnlyMount(mem));
70        }
71
72        // Reload-before-operation: reload if a sibling advanced the
73        // mem ref so the `expected_hash` compare below runs against
74        // current truth. The drift notice rides the outcome's
75        // `warnings` (real-rename path).
76        let mut drift_warnings = self.reload_if_stale(Some(&mem));
77
78        let entity = self
79            .store
80            .get(id)
81            .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
82
83        // Stub guard — stubs derive their title from the id and have
84        // no `entity_type` to validate against. Recovery is
85        // `memstead_create` (stub adoption). Item 02 realised the
86        // `STUB_NOT_RENAMABLE` code the description list had
87        // advertised since the strictness work landed.
88        if entity.stub {
89            return Err(EngineError::StubNotRenamable { id: id.to_string() });
90        }
91
92        if let Some(expected) = args.expected_hash.as_deref()
93            && entity.content_hash != expected
94        {
95            return Err(EngineError::HashMismatch {
96                id: id.to_string(),
97                current: entity.content_hash.clone(),
98                is_stub: entity.stub,
99            });
100        }
101
102        let derivation = validate_and_derive_slug(&args.new_title)?;
103        let new_slug = derivation.slug.clone();
104        let new_id = EntityId::new(&mem, &new_slug);
105        crate::entity::id::enforce_id_length(new_id.as_ref())?;
106
107        if new_id == *id {
108            // Title-with-same-slug: surface a Tier-2 warning rather
109            // than touching disk for nothing. Matches full's
110            // RenameResult slug-noop shape so autonomous skills
111            // can distinguish the silent no-op from a successful
112            // cosmetic rewrite via the typed warning code.
113            return Ok(RenameEntityOutcome {
114                old_id: id.clone(),
115                new_id: new_id.clone(),
116                old_path: entity.file_path.clone(),
117                new_path: entity.file_path.clone(),
118                content_hash: entity.content_hash.clone(),
119                write_id: String::new(),
120                warnings: vec![WarningHint::TitleNormalizedToSlugNoop {
121                    requested_title: args.new_title.clone(),
122                    current_slug: id.name().to_string(),
123                }],
124            });
125        }
126        if let Some(existing) = self.store.get(&new_id) {
127            return Err(EngineError::AlreadyExists {
128                id: new_id.to_string(),
129                existing_title: existing.title.clone(),
130                existing_is_stub: existing.stub,
131            });
132        }
133
134        let schema = self
135            .schemas
136            .get(&mem)
137            .expect("schema present for every registered mount");
138        let type_def = schema
139            .get_type(&entity.entity_type)
140            .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
141
142        let old_file_path = entity.file_path.clone();
143        let new_file_path = format!("{new_slug}.md");
144
145        let mut next = entity.clone();
146        next.id = new_id.clone();
147        next.title = args.new_title.clone();
148        next.file_path = new_file_path.clone();
149
150        // Self-reference rewrite, surface 1/2 — `relationships` list.
151        // Any explicit edge `<this> --<type>--> <old_id>` is repointed
152        // to `<this> --<type>--> <new_id>` so the regenerated
153        // `## Relationships` section reflects the new id rather than
154        // a relation that would re-emit a stub of the old slug at
155        // next read.
156        for rel in next.relationships.iter_mut() {
157            if rel.target == *id {
158                rel.target = new_id.clone();
159            }
160        }
161
162        // Self-reference rewrite, surface 2/2 — section bodies.
163        // Every `[[<old-slug>]]` token in the renaming entity's own
164        // section bodies becomes `[[<new-slug>]]`. Code-fenced and
165        // inline-code matches are preserved (the rewriter shares the
166        // masking discipline of `extract_inline_links`).
167        //
168        // The body parser admits both short form `[[slug]]` and
169        // full-id form `[[mem--slug]]` (same mem) as references to
170        // the same entity. The bare-slug rewriter only catches the
171        // first; `rewrite_cross_mem_slug` (which already powers the
172        // cross-mem Tier-2 rewrite) covers the second by matching
173        // `<mem>--<slug>` and `<mem>:<slug>` forms. Calling both
174        // here preserves the form the author wrote — short stays short,
175        // full-id stays full-id — just retargeted to the new slug.
176        let old_slug = id.name().to_string();
177        let new_slug_owned = new_slug.clone();
178        for body in next.sections.values_mut() {
179            let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
180                body,
181                &old_slug,
182                &new_slug_owned,
183            );
184            if count > 0 {
185                *body = rewritten;
186            }
187            let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
188                body,
189                &mem,
190                &old_slug,
191                &new_slug_owned,
192            );
193            if count > 0 {
194                *body = rewritten;
195            }
196        }
197
198        // Every entity whose on-disk file the rename rewrites
199        // (the renaming entity itself plus every Write-mem referrer
200        // touched by the body/relationships rewrite cascade) gets
201        // `auto_timestamp` metadata fields stamped before
202        // `generate_markdown` so the new file carries the stamp.
203        // Pre-compute `today` once so all entities rewritten by this
204        // logical operation receive the same timestamp.
205        let today = self.now_iso();
206        super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
207
208        let markdown = super::render_for_write(&next, type_def.as_ref())?;
209
210        // Referrer collection. Walk every incoming edge — explicit
211        // relations and `EdgeSource::BodyLink` synthesised mirrors
212        // alike — and bucket each unique referrer by its mem's
213        // capability. Same-mem referrers are guaranteed Write (the
214        // mount-capability gate at the top of this fn refused the
215        // rename if the renaming mem itself isn't Write). Cross-
216        // mem referrers split into Write (rewriteable, subject to
217        // the `cross_mem_links` policy gate below) and ReadOnly
218        // (the engine has no write access; their handling lands with
219        // the rename-path residual-stub demotion in the next cut and
220        // is filtered out here).
221        let mut seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
222        let mut same_mem_ids: Vec<EntityId> = Vec::new();
223        let mut cross_mem_ids: Vec<EntityId> = Vec::new();
224        for in_edge in self.store.incoming(id) {
225            if in_edge.from == *id || !seen.insert(in_edge.from.clone()) {
226                continue;
227            }
228            if in_edge.from.mem() == mem {
229                same_mem_ids.push(in_edge.from.clone());
230            } else {
231                cross_mem_ids.push(in_edge.from.clone());
232            }
233        }
234        // Prose-only referrers. Body wiki-links are not edge sources
235        // (every store edge originates from the auto-managed
236        // `## Relationships` section), so a hand-authored file carrying
237        // an inline `[[old-slug]]` with no relationship row has no
238        // incoming edge and the walk above cannot see it — its link
239        // would silently go stale. Scan section bodies with the same
240        // lenient extractor the load-time drift scan uses (code-fence
241        // and inline-code masking included), gated on a cheap substring
242        // probe so the store-wide pass stays proportional to actual
243        // mentions. Engine-written entities are covered either way:
244        // alias synthesis always emits the row on write.
245        for entity in self.store.all_entities() {
246            if entity.id == *id || entity.stub || seen.contains(&entity.id) {
247                continue;
248            }
249            let mentions = entity.sections.values().any(|body| {
250                body.contains(old_slug.as_str())
251                    && crate::entity::parser::extract_inline_links_lenient(body, entity.id.mem())
252                        .iter()
253                        .any(|t| t == id)
254            });
255            if !mentions {
256                continue;
257            }
258            seen.insert(entity.id.clone());
259            if entity.id.mem() == mem {
260                same_mem_ids.push(entity.id.clone());
261            } else {
262                cross_mem_ids.push(entity.id.clone());
263            }
264        }
265
266        // Cross-mem peers are partitioned by mount capability.
267        // Write peers feed the cross-mem rewrite plan; ReadOnly
268        // peers feed the residual-stub demotion path (the engine
269        // can't rewrite their on-disk markdown, so we materialise an
270        // in-memory stub at the OLD id that holds the surviving
271        // incoming edges from the ReadOnly mount — mirrors the
272        // delete-path's same-shaped demotion).
273        let mut cross_mem_write_ids: Vec<EntityId> = Vec::new();
274        let mut readonly_referrers: Vec<EntityId> = Vec::new();
275        for from_id in cross_mem_ids {
276            match self
277                .mount(from_id.mem())
278                .map(|m| m.capability)
279                .unwrap_or(MountCapability::Write)
280            {
281                MountCapability::Write => cross_mem_write_ids.push(from_id),
282                MountCapability::ReadOnly => readonly_referrers.push(from_id),
283            }
284        }
285        readonly_referrers.sort_by_key(|a| a.to_string());
286
287        // Pre-flight policy gate. Each propagated referrer rewrite
288        // is an edge of the form `referrer ∈ peer_mem → renamed ∈
289        // mem` — same direction as the original edge. The gate
290        // consults `cross_mem_link_allowed(peer_mem, mem)`,
291        // which is the direction the policy gates new edges with
292        // (forward-looking add-filter). A blocked peer aborts the
293        // rename up-front — no writes have happened yet, so the
294        // refusal is clean. This is the edge direction, not its
295        // inverse.
296        let mut blocked_counts: std::collections::BTreeMap<String, usize> =
297            std::collections::BTreeMap::new();
298        for from_id in &cross_mem_write_ids {
299            let peer_mem = from_id.mem().to_string();
300            if !self.cross_mem_link_allowed(&peer_mem, &mem) {
301                *blocked_counts.entry(peer_mem).or_insert(0) += 1;
302            }
303        }
304        if !blocked_counts.is_empty() {
305            let blocked_referrers: Vec<crate::engine::error::BlockedReferrer> = blocked_counts
306                .into_iter()
307                .map(|(peer_mem, count)| crate::engine::error::BlockedReferrer {
308                    from_mem: peer_mem,
309                    to_mem: mem.clone(),
310                    count,
311                })
312                .collect();
313            return Err(EngineError::RenameBlockedByCrossMemPolicy {
314                from_mem: mem.clone(),
315                blocked_referrers,
316            });
317        }
318
319        // Deterministic iteration order so the resulting per-mem
320        // pending-op replay is stable across runs (helpful for test
321        // snapshots and human reviewers).
322        same_mem_ids.sort_by_key(|a| a.to_string());
323        cross_mem_write_ids.sort_by_key(|a| a.to_string());
324
325        // ----- Same-mem rewrite plan -----
326        // (rewritten_markdown, file_path, type_def) per same-mem
327        // referrer — collected before any backend write so a
328        // per-referrer schema or rewrite failure aborts the rename
329        // before anything lands.
330        let mut same_mem_writes: Vec<(
331            String,
332            String,
333            std::sync::Arc<memstead_schema::TypeDefinition>,
334        )> = Vec::with_capacity(same_mem_ids.len());
335        for from_id in &same_mem_ids {
336            let Some(referrer) = self.store.get(from_id) else {
337                continue;
338            };
339            if referrer.stub {
340                continue;
341            }
342            let referrer_type_def = schema
343                .get_type(&referrer.entity_type)
344                .ok_or_else(|| unknown_type_error(schema, &referrer.entity_type))?;
345            let mut next_ref = referrer.clone();
346            for rel in next_ref.relationships.iter_mut() {
347                if rel.target == *id {
348                    rel.target = new_id.clone();
349                }
350            }
351            for body in next_ref.sections.values_mut() {
352                let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_bare_slug(
353                    body,
354                    &old_slug,
355                    &new_slug_owned,
356                );
357                if count > 0 {
358                    *body = rewritten;
359                }
360                // A same-mem referrer may also use
361                // full-id form `[[<mem>--<slug>]]` to point at the
362                // renaming entity — covered by the cross-mem helper
363                // which matches the `--` and `:` separator forms.
364                let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
365                    body,
366                    &mem,
367                    &old_slug,
368                    &new_slug_owned,
369                );
370                if count > 0 {
371                    *body = rewritten;
372                }
373            }
374            // Do NOT bump the referrer's `auto_timestamp` fields. A
375            // rename rewrites the referrer's body wiki-link to the new
376            // slug — a foreign-key change, not a semantic edit — so its
377            // `last_modified` staleness clock must NOT reset (a staleness
378            // audit would otherwise read every referrer of a renamed
379            // entity as freshly-touched). The referrer's content hash
380            // still changes (its body now holds the new slug); only the
381            // staleness clock is preserved by carrying the prior
382            // timestamp through from the cloned referrer. (Pre-fix this
383            // stamped the shared `today` for cross-entity consistency.)
384            let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
385            same_mem_writes.push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
386        }
387
388        // ----- Cross-mem rewrite plan -----
389        // Group cross-mem Write referrers by their mem so each
390        // peer mem's backend gets one commit. Each entry holds the
391        // peer mount index, the peer mem's schema, and the list of
392        // (markdown, file_path, type_def) for that mem's referrers.
393        struct PeerMemPlan {
394            mount_idx: usize,
395            mem: String,
396            writes: Vec<(
397                String,
398                String,
399                std::sync::Arc<memstead_schema::TypeDefinition>,
400            )>,
401        }
402        let mut peer_plans: std::collections::BTreeMap<String, PeerMemPlan> =
403            std::collections::BTreeMap::new();
404        for from_id in &cross_mem_write_ids {
405            let peer_mem = from_id.mem().to_string();
406            let peer_mount_idx = self
407                .mounts
408                .iter()
409                .position(|m| m.mount.mem == peer_mem)
410                .expect("peer mount present for collected referrer id");
411            let peer_schema = self
412                .schemas
413                .get(&peer_mem)
414                .expect("schema present for every registered mount");
415
416            let Some(referrer) = self.store.get(from_id) else {
417                continue;
418            };
419            if referrer.stub {
420                continue;
421            }
422            let referrer_type_def = peer_schema
423                .get_type(&referrer.entity_type)
424                .ok_or_else(|| unknown_type_error(peer_schema, &referrer.entity_type))?;
425            let mut next_ref = referrer.clone();
426            for rel in next_ref.relationships.iter_mut() {
427                if rel.target == *id {
428                    rel.target = new_id.clone();
429                }
430            }
431            // Cross-mem referrers reference the renaming entity
432            // via the cross-mem wiki-link forms (`[[<mem>:<slug>]]`
433            // or the legacy `[[<mem>--<slug>]]`). The bare-slug
434            // form is reserved for same-mem refs and never appears
435            // here.
436            for body in next_ref.sections.values_mut() {
437                let (rewritten, count) = crate::entity::wikilink_rewrite::rewrite_cross_mem_slug(
438                    body,
439                    &mem,
440                    &old_slug,
441                    &new_slug_owned,
442                );
443                if count > 0 {
444                    *body = rewritten;
445                }
446            }
447            // A cross-mem Write peer is a referrer too — preserve its
448            // `last_modified` for the same reason as the same-mem case
449            // above (slug rewrite is a foreign-key change, not a semantic
450            // edit). Its content hash still changes; the staleness clock
451            // does not reset.
452            let ref_markdown = super::render_for_write(&next_ref, referrer_type_def.as_ref())?;
453
454            peer_plans
455                .entry(peer_mem.clone())
456                .or_insert_with(|| PeerMemPlan {
457                    mount_idx: peer_mount_idx,
458                    mem: peer_mem.clone(),
459                    writes: Vec::new(),
460                })
461                .writes
462                .push((ref_markdown, next_ref.file_path.clone(), referrer_type_def));
463        }
464
465        // ----- Apply: renaming entity's own mem first -----
466        // Mint a single `logical_operation_id` up front so every
467        // commit produced by this rename — source mem + every peer
468        // mem — carries the same correlation id in its provenance
469        // entry. Single-mem renames also tag with an id (it just
470        // maps to one commit); consumers branch on whether the id
471        // recurs to identify a multi-commit logical operation.
472        let logical_op_id = crate::provenance::mint_logical_operation_id();
473
474        let backend = self.mounts[mount_idx].backend.as_ref();
475        backend.write_entity(Path::new(&new_file_path), markdown.as_bytes())?;
476        backend.delete_entity(Path::new(&old_file_path))?;
477        for (ref_markdown, ref_file_path, _) in &same_mem_writes {
478            backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
479        }
480        // Move the renamed entity's anchor row old_id → new_id in the SAME
481        // commit as the file move so entity + anchors rewind together and
482        // resolution finds every anchor under the new id (zero under the
483        // old). A no-op when the entity had no anchors (byte-identical).
484        super::stage_anchors_rename(backend, id, &new_id)?;
485        let commit_subject = format!("memstead: rename {} → {new_id}", id);
486        let ctx = CommitContext {
487            actor,
488            client: client.cloned(),
489            tool: Some("rename_entity"),
490            note: note.map(String::from),
491            role: self.current_role,
492            identity: self.current_identity.clone(),
493            logical_operation_id: Some(logical_op_id.as_str()),
494            entity_ids: None,
495        };
496        let write_id = backend.commit(&commit_subject, &ctx)?;
497
498        backend.append_provenance(
499            &Provenance::new(
500                std::time::SystemTime::now(),
501                ProvenanceKind::Rename,
502                Some(new_id.to_string()),
503                actor,
504                client.cloned(),
505                note.map(String::from),
506            )
507            .with_role(self.current_role)
508            .with_identity(self.current_identity.clone())
509            .with_logical_operation_id(logical_op_id.clone()),
510        )?;
511
512        self.record_self_write(mount_idx, &write_id);
513        let stamp_warnings = self.stamp_mutation_versions(mount_idx);
514        let mut peer_stamp_warnings: Vec<WarningHint> = Vec::new();
515
516        // ----- Apply: cross-mem peer mems (parent-pinned) -----
517        // Snapshot every peer mem's current head before any peer
518        // writes begin. The snapshots are pinned through
519        // `commit_with_expected_parent`, so a sibling writer that
520        // advances a peer mem's head between snapshot and commit
521        // aborts the commit with `BackendError::ParentMismatch`. The
522        // engine layer maps this to `RENAME_PARTIAL_FAILURE` (the
523        // source mem has already committed by this point — its
524        // state is durable; only the failed peer's writes are lost).
525        // Folder and archive backends inherit the trait's default
526        // `commit_with_expected_parent` (which ignores the parent
527        // and delegates to `commit`); the git-branch backend
528        // overrides to check the per-mem branch tip.
529        let mut peer_snapshots: std::collections::BTreeMap<String, Option<String>> =
530            std::collections::BTreeMap::new();
531        for plan in peer_plans.values() {
532            let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
533            let snapshot = peer_backend.current_head()?;
534            peer_snapshots.insert(plan.mem.clone(), snapshot);
535        }
536
537        // Track which mems have already committed in this logical
538        // operation. On a peer-commit failure, the engine surfaces
539        // the partial-state envelope so the agent can decide whether
540        // to retry, reconcile, or accept.
541        let mut committed_mems: Vec<String> = vec![mem.clone()];
542        for plan in peer_plans.values() {
543            let peer_backend = self.mounts[plan.mount_idx].backend.as_ref();
544            for (ref_markdown, ref_file_path, _) in &plan.writes {
545                peer_backend.write_entity(Path::new(ref_file_path), ref_markdown.as_bytes())?;
546            }
547            let peer_commit_subject = format!(
548                "memstead: rename {} → {new_id} (cross-mem rewrite in `{}`)",
549                id, plan.mem
550            );
551            let peer_ctx = CommitContext {
552                actor,
553                client: client.cloned(),
554                tool: Some("rename_entity"),
555                note: note.map(String::from),
556                role: self.current_role,
557                identity: self.current_identity.clone(),
558                logical_operation_id: Some(logical_op_id.as_str()),
559                entity_ids: None,
560            };
561            let expected = peer_snapshots.get(&plan.mem).cloned().unwrap_or(None);
562            let peer_commit_result = peer_backend.commit_with_expected_parent(
563                &peer_commit_subject,
564                &peer_ctx,
565                expected.as_deref(),
566            );
567            let peer_write_id = match peer_commit_result {
568                Ok(sha) => sha,
569                Err(crate::backend::BackendError::ParentMismatch { .. }) => {
570                    return Err(EngineError::RenamePartialFailure {
571                        committed_mems: std::mem::take(&mut committed_mems),
572                        failed_mem: plan.mem.clone(),
573                        failure_cause: "drift".to_string(),
574                    });
575                }
576                Err(e) => return Err(e.into()),
577            };
578            peer_backend.append_provenance(
579                &Provenance::new(
580                    std::time::SystemTime::now(),
581                    ProvenanceKind::Rename,
582                    Some(new_id.to_string()),
583                    actor,
584                    client.cloned(),
585                    note.map(String::from),
586                )
587                .with_role(self.current_role)
588                .with_identity(self.current_identity.clone())
589                .with_logical_operation_id(logical_op_id.clone()),
590            )?;
591            self.record_self_write(plan.mount_idx, &peer_write_id);
592            // Per peer mount, not only the source mem: a rename touches every
593            // pinned peer, and each one's config write can meet its own
594            // intervening writer.
595            peer_stamp_warnings.extend(self.stamp_mutation_versions(plan.mount_idx));
596            committed_mems.push(plan.mem.clone());
597        }
598
599        // ----- Re-parse and push -----
600        let parse_result = parse_markdown(&markdown, &new_file_path, type_def.as_ref(), &mem)
601            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
602        let content_hash = parse_result.entity.content_hash.clone();
603
604        let mut parse_results = vec![parse_result];
605        for (ref_markdown, ref_file_path, ref_type_def) in &same_mem_writes {
606            let pr = parse_markdown(ref_markdown, ref_file_path, ref_type_def.as_ref(), &mem)
607                .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
608            parse_results.push(pr);
609        }
610        for plan in peer_plans.values() {
611            for (ref_markdown, ref_file_path, ref_type_def) in &plan.writes {
612                let pr = parse_markdown(
613                    ref_markdown,
614                    ref_file_path,
615                    ref_type_def.as_ref(),
616                    &plan.mem,
617                )
618                .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
619                parse_results.push(pr);
620            }
621        }
622
623        // Residual-stub demotion for ReadOnly cross-mem referrers.
624        // The engine can't rewrite ReadOnly-mount markdown, so the
625        // wiki-links there still point at the OLD slug after the
626        // rename. To keep `incoming(<new_id>)` aligned with what a
627        // fresh boot would produce (and to surface the dangling
628        // reference to the agent), we demote the OLD-id store entry
629        // to a stub instead of removing it outright. Its surviving
630        // `in_edges` from the ReadOnly mount remain valid — they
631        // point at the now-stub at the old id.
632        //
633        // When no ReadOnly referrers exist, the old entry is
634        // removed cleanly (the existing Write-path behaviour).
635        let mut outcome_warnings: Vec<WarningHint> = Vec::new();
636        outcome_warnings.extend(stamp_warnings);
637        outcome_warnings.extend(peer_stamp_warnings);
638        // Reload-before-operation drift notice, surfaced first.
639        outcome_warnings.append(&mut drift_warnings);
640        // Title↔slug divergence of the NEW title — same visibility
641        // contract as create's.
642        if !derivation.dropped_chars.is_empty() {
643            outcome_warnings.push(WarningHint::TitleCharsDroppedFromSlug {
644                title: args.new_title.trim().to_string(),
645                dropped_chars: derivation.dropped_chars.clone(),
646                slug: new_slug.clone(),
647            });
648        }
649        if readonly_referrers.is_empty() {
650            self.store.remove(id);
651        } else {
652            // Sever outgoing edges from the old id (the entity is
653            // gone — its body and relations live at the new id now)
654            // and replace the node with a stub at the same id. The
655            // in_edges from the ReadOnly mount survive untouched.
656            self.store.remove_edges_from(id);
657            self.store.upsert(
658                id.clone(),
659                make_stub(
660                    id,
661                    crate::entity::StubKind::Residual {
662                        since_commit: write_id.clone(),
663                        readonly_referrers: readonly_referrers.clone(),
664                    },
665                ),
666            );
667            outcome_warnings.push(WarningHint::ResidualStubForReadOnlyReferrers {
668                id: id.clone(),
669                referrers: readonly_referrers,
670            });
671        }
672
673        let fallback = engine_fallback_type();
674        // Incremental (flywheel W8/01): the touched set is the OLD id
675        // (its document must leave the index) plus every re-parsed
676        // entity — the renamed entity at its new id and each rewritten
677        // referrer.
678        let mut touched: Vec<crate::EntityId> = parse_results
679            .iter()
680            .map(|pr| pr.entity.id.clone())
681            .collect();
682        touched.push(id.clone());
683        push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
684        crate::entity::store_builder::remap_alias_target_edge_sources(
685            &mut self.store,
686            &self.schemas,
687        );
688
689        self.invalidate_communities();
690        self.maintain_search_indexes(&touched);
691
692        // `require_notes` provenance nudge — single engine-level
693        // enforcement point. Only reached on the real-rename path; the
694        // slug-noop short-circuit returns early above with an empty
695        // `write_id` and never demands a note.
696        if let Some(w) = self.note_missing_warning("rename_entity", note) {
697            outcome_warnings.push(w);
698        }
699
700        Ok(RenameEntityOutcome {
701            old_id: id.clone(),
702            new_id,
703            old_path: old_file_path,
704            new_path: new_file_path,
705            content_hash,
706            write_id,
707            warnings: outcome_warnings,
708        })
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use std::path::PathBuf;
715
716    use tempfile::TempDir;
717
718    use crate::backend::MemBackend;
719    use crate::engine::test_helpers::*;
720    use crate::engine::{Engine, EngineError, RenameEntityArgs};
721    use crate::ops::WarningHint;
722    use crate::storage::FilesystemMemWriter;
723
724    #[test]
725    fn rename_moves_entity_anchors_to_new_id() {
726        let tmp = TempDir::new().unwrap();
727        let mem_dir = tmp.path().to_path_buf();
728        let writer = FilesystemMemWriter::new(mem_dir.clone());
729        let mut engine = Engine::from_mounts(vec![(
730            folder_mount("specs", mem_dir.clone()),
731            Box::new(writer) as Box<dyn MemBackend>,
732        )])
733        .unwrap();
734        let (actor, client) = cli_actor();
735        let mut args = empty_create_args("specs", "Old Anchored");
736        args.anchors = vec![crate::anchor::AnchorInput {
737            artifact: Some("src/lib.rs".into()),
738            grain: Some("file".into()),
739            class: Some("anchored".into()),
740            hash: Some("h1".into()),
741            hash_stability: Some("stable".into()),
742            ..Default::default()
743        }];
744        let seeded = engine
745            .create_entity(args, actor, Some(&client), None)
746            .unwrap();
747        let old_id = seeded.id.clone();
748
749        let outcome = engine
750            .rename_entity(
751                RenameEntityArgs {
752                    id: old_id.clone(),
753                    expected_hash: Some(seeded.content_hash.clone()),
754                    new_title: "New Anchored".to_string(),
755                },
756                actor,
757                Some(&client),
758                None,
759            )
760            .unwrap();
761
762        // Zero rows under the old id; all anchors resolve under the new id.
763        assert!(engine.entity_anchors(&old_id).is_empty());
764        assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
765        assert_eq!(
766            engine.anchors_referencing_artifact("src/lib.rs"),
767            vec![(
768                outcome.new_id.clone(),
769                engine.entity_anchors(&outcome.new_id)[0].clone()
770            )]
771        );
772    }
773
774    #[test]
775    fn rename_entity_renames_file_and_id_persists_across_restart() {
776        let tmp = TempDir::new().unwrap();
777        let mem_dir = tmp.path().to_path_buf();
778
779        let (old_id, new_id, new_file) = {
780            let writer = FilesystemMemWriter::new(mem_dir.clone());
781            let mut engine = Engine::from_mounts(vec![(
782                folder_mount("specs", mem_dir.clone()),
783                Box::new(writer) as Box<dyn MemBackend>,
784            )])
785            .unwrap();
786            let (actor, client) = cli_actor();
787            let seeded = engine
788                .create_entity(
789                    empty_create_args("specs", "Old Name"),
790                    actor,
791                    Some(&client),
792                    None,
793                )
794                .unwrap();
795            let outcome = engine
796                .rename_entity(
797                    RenameEntityArgs {
798                        id: seeded.id.clone(),
799                        expected_hash: Some(seeded.content_hash.clone()),
800                        new_title: "New Name".to_string(),
801                    },
802                    actor,
803                    Some(&client),
804                    None,
805                )
806                .unwrap();
807            assert_eq!(outcome.old_id.to_string(), "specs--old-name");
808            assert_eq!(outcome.new_id.to_string(), "specs--new-name");
809            assert_eq!(outcome.new_path, "new-name.md");
810            // Old file gone, new file present.
811            assert!(!mem_dir.join(&outcome.old_path).exists());
812            assert!(mem_dir.join(&outcome.new_path).exists());
813            (outcome.old_id, outcome.new_id, outcome.new_path)
814        };
815
816        // New engine reading the same mem sees only the new id.
817        let writer2 = FilesystemMemWriter::new(mem_dir.clone());
818        let engine2 = Engine::from_mounts(vec![(
819            folder_mount("specs", mem_dir),
820            Box::new(writer2) as Box<dyn MemBackend>,
821        )])
822        .unwrap();
823        assert!(engine2.get_entity(&old_id).is_none());
824        let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
825        assert_eq!(new_entity.title, "New Name");
826        assert_eq!(new_entity.file_path, new_file);
827    }
828
829    #[test]
830    fn rename_entity_returns_typed_warning_on_slug_noop() {
831        let tmp = TempDir::new().unwrap();
832        let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
833        let (actor, client) = cli_actor();
834        let outcome = engine
835            .rename_entity(
836                RenameEntityArgs {
837                    id: seeded.id.clone(),
838                    expected_hash: Some(seeded.content_hash.clone()),
839                    new_title: "Same  Slug".to_string(), // slugifies to same
840                },
841                actor,
842                Some(&client),
843                None,
844            )
845            .unwrap();
846        // Wire-shape parity with full: slug-noop is Ok+warning, not
847        // an error. Old/new IDs are equal; old/new paths are equal;
848        // write_id is empty (no disk write); warnings carries the
849        // typed TitleNormalizedToSlugNoop hint.
850        assert_eq!(outcome.old_id, outcome.new_id);
851        assert_eq!(outcome.old_path, outcome.new_path);
852        assert!(outcome.write_id.is_empty());
853        assert_eq!(outcome.warnings.len(), 1);
854        assert!(matches!(
855            outcome.warnings[0],
856            WarningHint::TitleNormalizedToSlugNoop { .. }
857        ));
858    }
859
860    #[test]
861    fn rename_entity_returns_write_id_on_real_rename() {
862        let tmp = TempDir::new().unwrap();
863        let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
864        let (actor, client) = cli_actor();
865        let outcome = engine
866            .rename_entity(
867                RenameEntityArgs {
868                    id: seeded.id.clone(),
869                    expected_hash: Some(seeded.content_hash.clone()),
870                    new_title: "Brand New Name".to_string(),
871                },
872                actor,
873                Some(&client),
874                None,
875            )
876            .unwrap();
877        // Real rename: write_id non-empty (folder backend produces
878        // a synthetic CommitId), warnings empty, IDs differ.
879        assert_ne!(outcome.old_id, outcome.new_id);
880        assert!(
881            !outcome.write_id.is_empty(),
882            "write_id must be populated on a real rename"
883        );
884        assert!(outcome.warnings.is_empty());
885    }
886
887    #[test]
888    fn rename_entity_rewrites_self_references_in_body_and_relationships() {
889        use crate::entity::EntityId;
890        use indexmap::IndexMap;
891
892        let tmp = TempDir::new().unwrap();
893        let mem_dir = tmp.path().to_path_buf();
894        let writer = FilesystemMemWriter::new(mem_dir.clone());
895        let mut engine = Engine::from_mounts(vec![(
896            folder_mount("specs", mem_dir.clone()),
897            Box::new(writer) as Box<dyn MemBackend>,
898        )])
899        .unwrap();
900        let (actor, client) = cli_actor();
901
902        // Create the entity with a body section that contains a
903        // self-reference. The slug is `old-name`; the body literally
904        // names `[[old-name]]`. After rename, both surfaces — the
905        // file on disk and the in-memory entity — must point at the
906        // new slug.
907        let mut sections: IndexMap<String, String> = IndexMap::new();
908        sections.insert("identity".to_string(), "the seed identity".to_string());
909        sections.insert(
910            "purpose".to_string(),
911            "see also [[old-name]] for prior context".to_string(),
912        );
913        // F11: the `[[old-name]]` self-reference body link does NOT
914        // synthesise a self-edge (the alias pass drops vacuous self-edges
915        // and emits `SELF_LINK_IGNORED`); `scan_wikilinks_without_relation`
916        // also skips self-targets, so the unbacked self-link is admitted.
917        // The body link still rewrites on rename — this test pins that the
918        // body follows the slug while no self-relation is ever created.
919        let seeded = engine
920            .create_entity(
921                crate::engine::CreateEntityArgs {
922                    anchors: Vec::new(),
923                    mem: "specs".to_string(),
924                    title: "Old Name".to_string(),
925                    entity_type: "spec".to_string(),
926                    sections,
927                    metadata: IndexMap::new(),
928                    relations: Vec::new(),
929                    dry_run: false,
930                },
931                actor,
932                Some(&client),
933                None,
934            )
935            .unwrap();
936        assert_eq!(seeded.id.to_string(), "specs--old-name");
937        let related = seeded.clone();
938
939        let outcome = engine
940            .rename_entity(
941                RenameEntityArgs {
942                    id: seeded.id.clone(),
943                    expected_hash: Some(related.content_hash.clone()),
944                    new_title: "Brand New Name".to_string(),
945                },
946                actor,
947                Some(&client),
948                None,
949            )
950            .unwrap();
951        assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
952
953        // File on disk reflects the rewrite — old slug must not
954        // appear anywhere in the new file's bytes.
955        let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
956        assert!(
957            new_bytes.contains("[[brand-new-name]]"),
958            "expected new slug in body, got:\n{new_bytes}"
959        );
960        assert!(
961            !new_bytes.contains("[[old-name]]"),
962            "old slug must not survive in the rewritten file, got:\n{new_bytes}"
963        );
964
965        // In-memory entity: section body rewritten, relationships
966        // list points at the new id.
967        let in_mem = engine.get_entity(&outcome.new_id).unwrap();
968        assert!(
969            in_mem
970                .sections
971                .get("purpose")
972                .map(|s| s.contains("[[brand-new-name]]"))
973                .unwrap_or(false),
974            "section body must be rewritten in-memory; got {:?}",
975            in_mem.sections.get("purpose")
976        );
977        // F11: no self-relation is ever synthesised — neither to the old
978        // id nor (after the body rewrite) to the new id. The body link
979        // followed the rename, but it produces no self-edge.
980        let new_self_target = EntityId::new("specs", "brand-new-name");
981        assert!(
982            in_mem
983                .relationships
984                .iter()
985                .all(|r| r.target != seeded.id && r.target != new_self_target),
986            "a self-referential body link must produce no self-relation (F11), got: {:?}",
987            in_mem.relationships
988        );
989    }
990
991    /// A
992    /// rename rewrites a referrer's body wiki-link to the new slug — a
993    /// foreign-key change, not a semantic edit — so the referrer's
994    /// `last_modified` staleness clock must NOT reset. Pre-written files
995    /// carry an old `last_modified` (2020-01-01) so the assertion is
996    /// distinctive: after a same-day rename the clock stays at the old
997    /// date (it would jump to today if the re-commit still stamped it),
998    /// while the body link is correctly rewritten.
999    #[test]
1000    fn rename_preserves_referrer_last_modified_but_rewrites_link() {
1001        let tmp = TempDir::new().unwrap();
1002        let mem_dir = tmp.path().to_path_buf();
1003
1004        std::fs::write(
1005            mem_dir.join("target.md"),
1006            "---\ntype: spec\ncreated_date: 2020-01-01\nlast_modified: 2020-01-01\nlevel: M0\n---\n# Target\n\n## Identity\n\nT\n\n## Purpose\n\nP\n",
1007        )
1008        .unwrap();
1009        std::fs::write(
1010            mem_dir.join("referrer.md"),
1011            "---\ntype: spec\ncreated_date: 2020-01-01\nlast_modified: 2020-01-01\nlevel: M0\n---\n# Referrer\n\n## Identity\n\nR\n\n## Purpose\n\nDepends on [[target]] for context.\n\n## Relationships\n\n- **REFERENCES**: [[target]]\n",
1012        )
1013        .unwrap();
1014
1015        let writer = FilesystemMemWriter::new(mem_dir.clone());
1016        let mut engine = Engine::from_mounts(vec![(
1017            folder_mount("specs", mem_dir.clone()),
1018            Box::new(writer) as Box<dyn MemBackend>,
1019        )])
1020        .unwrap();
1021        let (actor, client) = cli_actor();
1022
1023        let target_id = crate::entity::EntityId::new("specs", "target");
1024        let target_hash = engine
1025            .store()
1026            .get(&target_id)
1027            .expect("target loaded from disk")
1028            .content_hash
1029            .clone();
1030
1031        engine
1032            .rename_entity(
1033                RenameEntityArgs {
1034                    id: target_id,
1035                    expected_hash: Some(target_hash),
1036                    new_title: "Target Renamed".to_string(),
1037                },
1038                actor,
1039                Some(&client),
1040                None,
1041            )
1042            .expect("rename succeeds");
1043
1044        let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1045        // Staleness clock preserved — NOT bumped to today.
1046        assert!(
1047            referrer_md.contains("last_modified: 2020-01-01"),
1048            "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
1049        );
1050        // Core rename job intact — the body wiki-link points at the new slug.
1051        assert!(
1052            referrer_md.contains("[[target-renamed]]"),
1053            "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1054        );
1055        assert!(
1056            !referrer_md.contains("[[target]]"),
1057            "old slug must not survive in the referrer body; got:\n{referrer_md}"
1058        );
1059    }
1060
1061    /// A hand-authored folder-mem file may carry a prose `[[target]]` with NO
1062    /// `## Relationships` row — body wiki-links are not edge sources, so the
1063    /// store holds no incoming edge for it and the edge-driven referrer walk
1064    /// cannot see it. The rename must rewrite it anyway: the referrer
1065    /// collection scans section bodies for links resolving to the renamed id,
1066    /// not only the store's incoming edges. (Engine-written entities are
1067    /// covered either way — alias synthesis always emits the row on write —
1068    /// so this bites the hand-commit folder-mem model specifically.)
1069    #[test]
1070    fn rename_rewrites_prose_only_referrer_without_relationships_row() {
1071        let tmp = TempDir::new().unwrap();
1072        let mem_dir = tmp.path().to_path_buf();
1073
1074        std::fs::write(
1075            mem_dir.join("target.md"),
1076            "---\ntype: spec\ncreated_date: 2020-01-01\nlast_modified: 2020-01-01\nlevel: M0\n---\n# Target\n\n## Identity\n\nT\n\n## Purpose\n\nP\n",
1077        )
1078        .unwrap();
1079        // Prose link only — deliberately NO `## Relationships` section.
1080        std::fs::write(
1081            mem_dir.join("referrer.md"),
1082            "---\ntype: spec\ncreated_date: 2020-01-01\nlast_modified: 2020-01-01\nlevel: M0\n---\n# Referrer\n\n## Identity\n\nR\n\n## Purpose\n\nDepends on [[target]] for context.\n",
1083        )
1084        .unwrap();
1085
1086        let writer = FilesystemMemWriter::new(mem_dir.clone());
1087        let mut engine = Engine::from_mounts(vec![(
1088            folder_mount("specs", mem_dir.clone()),
1089            Box::new(writer) as Box<dyn MemBackend>,
1090        )])
1091        .unwrap();
1092        let (actor, client) = cli_actor();
1093
1094        let target_id = crate::entity::EntityId::new("specs", "target");
1095        let target_hash = engine
1096            .store()
1097            .get(&target_id)
1098            .expect("target loaded from disk")
1099            .content_hash
1100            .clone();
1101
1102        engine
1103            .rename_entity(
1104                RenameEntityArgs {
1105                    id: target_id,
1106                    expected_hash: Some(target_hash),
1107                    new_title: "Target Renamed".to_string(),
1108                },
1109                actor,
1110                Some(&client),
1111                None,
1112            )
1113            .expect("rename succeeds");
1114
1115        let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1116        assert!(
1117            referrer_md.contains("[[target-renamed]]"),
1118            "the prose-only wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1119        );
1120        assert!(
1121            !referrer_md.contains("[[target]]"),
1122            "the old slug must not survive in the prose-only referrer; got:\n{referrer_md}"
1123        );
1124    }
1125
1126    #[test]
1127    fn rename_entity_rewrites_same_mem_referrers_atomically() {
1128        use crate::engine::CreateEntityArgs;
1129        use crate::entity::EntityId;
1130        use indexmap::IndexMap;
1131
1132        let tmp = TempDir::new().unwrap();
1133        let mem_dir = tmp.path().to_path_buf();
1134        let writer = FilesystemMemWriter::new(mem_dir.clone());
1135        let mut engine = Engine::from_mounts(vec![(
1136            folder_mount("specs", mem_dir.clone()),
1137            Box::new(writer) as Box<dyn MemBackend>,
1138        )])
1139        .unwrap();
1140        let (actor, client) = cli_actor();
1141
1142        // Target — the entity that will be renamed.
1143        let target = engine
1144            .create_entity(
1145                empty_create_args("specs", "Target Spec"),
1146                actor,
1147                Some(&client),
1148                None,
1149            )
1150            .unwrap();
1151        assert_eq!(target.id.to_string(), "specs--target-spec");
1152
1153        // Referrer A — explicit relation declared atomically with the
1154        // body wiki-link. Both surfaces must be rewritten.
1155        let mut sections_a: IndexMap<String, String> = IndexMap::new();
1156        sections_a.insert(
1157            "identity".to_string(),
1158            "referrer alpha identity".to_string(),
1159        );
1160        sections_a.insert(
1161            "purpose".to_string(),
1162            "rationale relies on [[target-spec]] for context".to_string(),
1163        );
1164        let referrer_a = engine
1165            .create_entity(
1166                CreateEntityArgs {
1167                    anchors: Vec::new(),
1168                    mem: "specs".to_string(),
1169                    title: "Referrer Alpha".to_string(),
1170                    entity_type: "spec".to_string(),
1171                    sections: sections_a,
1172                    metadata: IndexMap::new(),
1173                    // REFERENCES is engine-emitted from the body wiki-link
1174                    // via the alias-synthesis pass; explicit author is
1175                    // refused under `manual_authoring: forbidden`.
1176                    relations: Vec::new(),
1177                    dry_run: false,
1178                },
1179                actor,
1180                Some(&client),
1181                None,
1182            )
1183            .unwrap();
1184
1185        // Referrer B — second referrer with body wiki-link + atomic
1186        // backing relation. Confirms multi-referrer body rewrites.
1187        let mut sections_b: IndexMap<String, String> = IndexMap::new();
1188        sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1189        sections_b.insert(
1190            "purpose".to_string(),
1191            "consult [[target-spec]] for the canonical phrasing".to_string(),
1192        );
1193        let referrer_b = engine
1194            .create_entity(
1195                CreateEntityArgs {
1196                    anchors: Vec::new(),
1197                    mem: "specs".to_string(),
1198                    title: "Referrer Bravo".to_string(),
1199                    entity_type: "spec".to_string(),
1200                    sections: sections_b,
1201                    metadata: IndexMap::new(),
1202                    // REFERENCES is engine-emitted from the body wiki-link
1203                    // via the alias-synthesis pass; explicit author is
1204                    // refused under `manual_authoring: forbidden`.
1205                    relations: Vec::new(),
1206                    dry_run: false,
1207                },
1208                actor,
1209                Some(&client),
1210                None,
1211            )
1212            .unwrap();
1213
1214        // Bystander — no reference to the target. Must not be
1215        // touched on disk (its content_hash must be unchanged).
1216        let bystander = engine
1217            .create_entity(
1218                empty_create_args("specs", "Bystander"),
1219                actor,
1220                Some(&client),
1221                None,
1222            )
1223            .unwrap();
1224        let bystander_bytes_before =
1225            std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1226
1227        let renamed = engine
1228            .rename_entity(
1229                RenameEntityArgs {
1230                    id: target.id.clone(),
1231                    expected_hash: Some(target.content_hash.clone()),
1232                    new_title: "Renamed Spec".to_string(),
1233                },
1234                actor,
1235                Some(&client),
1236                None,
1237            )
1238            .unwrap();
1239        assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1240
1241        // Old slug must not survive in any mem file — grep-clean,
1242        // scoped to this single-mem workspace.
1243        for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1244            let p = path.path();
1245            if p.extension().and_then(|s| s.to_str()) != Some("md") {
1246                continue;
1247            }
1248            let body = std::fs::read_to_string(&p).unwrap();
1249            assert!(
1250                !body.contains("[[target-spec]]"),
1251                "old slug must not survive in {}, got:\n{body}",
1252                p.display()
1253            );
1254        }
1255
1256        // Referrer A's explicit relation now points at the new id.
1257        let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1258        assert!(
1259            in_mem_a
1260                .relationships
1261                .iter()
1262                .any(|r| r.rel_type == "REFERENCES"
1263                    && r.target == EntityId::new("specs", "renamed-spec")),
1264            "expected referrer A's relation to point at renamed-spec, got {:?}",
1265            in_mem_a.relationships
1266        );
1267        assert!(
1268            in_mem_a
1269                .sections
1270                .get("purpose")
1271                .map(|s| s.contains("[[renamed-spec]]"))
1272                .unwrap_or(false),
1273            "referrer A's body must be rewritten"
1274        );
1275
1276        // Referrer B's body is rewritten; it had no explicit
1277        // relation, so the relationships list stays empty.
1278        let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1279        assert!(
1280            in_mem_b
1281                .sections
1282                .get("purpose")
1283                .map(|s| s.contains("[[renamed-spec]]"))
1284                .unwrap_or(false),
1285            "referrer B's body must be rewritten"
1286        );
1287
1288        // Bystander untouched — exact byte equality on disk.
1289        let bystander_bytes_after =
1290            std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1291        assert_eq!(
1292            bystander_bytes_before, bystander_bytes_after,
1293            "bystander must not be rewritten"
1294        );
1295    }
1296
1297    /// Two-mem test scaffolding: build an engine with `specs` and
1298    /// `memos` Write mounts, and set `cross_mem_links` so each is
1299    /// permitted to link into the other. Returns the engine, both
1300    /// mem directories, and the actor/client tuple. The test then
1301    /// seeds whatever entities it needs.
1302    fn engine_with_two_mems_and_bidirectional_policy(
1303        specs_dir: PathBuf,
1304        memos_dir: PathBuf,
1305    ) -> Engine {
1306        use memstead_schema::workspace_config::CrossLinkValue;
1307        let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1308        let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1309        let mut engine = Engine::from_mounts(vec![
1310            (
1311                folder_mount("specs", specs_dir),
1312                Box::new(writer_specs) as Box<dyn MemBackend>,
1313            ),
1314            (
1315                folder_mount("memos", memos_dir),
1316                Box::new(writer_memos) as Box<dyn MemBackend>,
1317            ),
1318        ])
1319        .unwrap();
1320        let mut settings = crate::workspace::WorkspaceSettings::default();
1321        settings.cross_mem_links.insert(
1322            "memos".to_string(),
1323            CrossLinkValue::List(vec!["specs".to_string()]),
1324        );
1325        settings.cross_mem_links.insert(
1326            "specs".to_string(),
1327            CrossLinkValue::List(vec!["memos".to_string()]),
1328        );
1329        engine.set_settings(settings);
1330        engine
1331    }
1332
1333    #[test]
1334    fn rename_entity_rewrites_cross_mem_write_referrer() {
1335        use crate::engine::CreateEntityArgs;
1336        use crate::entity::EntityId;
1337        use indexmap::IndexMap;
1338
1339        let tmp_specs = TempDir::new().unwrap();
1340        let tmp_memos = TempDir::new().unwrap();
1341        let specs_dir = tmp_specs.path().to_path_buf();
1342        let memos_dir = tmp_memos.path().to_path_buf();
1343        let mut engine =
1344            engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1345        let (actor, client) = cli_actor();
1346
1347        // Renaming target lives in `specs`.
1348        let target = engine
1349            .create_entity(
1350                empty_create_args("specs", "Target Spec"),
1351                actor,
1352                Some(&client),
1353                None,
1354            )
1355            .unwrap();
1356
1357        // Cross-mem referrer in `memos` has a body wiki-link in the
1358        // `:` form atomically backed by an explicit cross-mem relation.
1359        // The legacy `--` form is a same-mem nested-prefix drift
1360        // (resolves to `memos--specs--target-spec`); under the alias
1361        // model it cannot be backed and the engine surfaces it as
1362        // `SuspiciousNestedPrefix`, so it stays out of fresh fixtures.
1363        let mut sections: IndexMap<String, String> = IndexMap::new();
1364        sections.insert("claim".to_string(), "the claim".to_string());
1365        sections.insert(
1366            "context".to_string(),
1367            "discussion stems from [[specs:target-spec]]".to_string(),
1368        );
1369        let referrer = engine
1370            .create_entity(
1371                CreateEntityArgs {
1372                    anchors: Vec::new(),
1373                    mem: "memos".to_string(),
1374                    title: "Cross Note".to_string(),
1375                    entity_type: "memo".to_string(),
1376                    sections,
1377                    metadata: IndexMap::new(),
1378                    // REFERENCES is engine-emitted from the body wiki-link
1379                    // via the alias-synthesis pass; explicit author is
1380                    // refused under `manual_authoring: forbidden`.
1381                    relations: Vec::new(),
1382                    dry_run: false,
1383                },
1384                actor,
1385                Some(&client),
1386                None,
1387            )
1388            .unwrap();
1389
1390        // Perform the rename.
1391        let renamed = engine
1392            .rename_entity(
1393                RenameEntityArgs {
1394                    id: target.id.clone(),
1395                    expected_hash: Some(target.content_hash.clone()),
1396                    new_title: "Renamed Spec".to_string(),
1397                },
1398                actor,
1399                Some(&client),
1400                None,
1401            )
1402            .unwrap();
1403        assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1404
1405        // Cross-mem referrer's on-disk file now carries the new
1406        // slug in the colon form and no `target-spec` remnants survive.
1407        let referrer_path = memos_dir.join(&referrer.file_path);
1408        let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1409        assert!(
1410            referrer_bytes.contains("[[specs:renamed-spec]]"),
1411            "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1412        );
1413        assert!(
1414            !referrer_bytes.contains("target-spec"),
1415            "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1416        );
1417
1418        // In-memory referrer's relationship list points at the new id.
1419        let in_mem = engine.get_entity(&referrer.id).unwrap();
1420        assert!(
1421            in_mem
1422                .relationships
1423                .iter()
1424                .any(|r| r.rel_type == "REFERENCES"
1425                    && r.target == EntityId::new("specs", "renamed-spec")),
1426            "expected cross-mem relation to point at renamed-spec, got {:?}",
1427            in_mem.relationships
1428        );
1429        assert!(
1430            in_mem.relationships.iter().all(|r| r.target != target.id),
1431            "no relationship may still target the old id, got: {:?}",
1432            in_mem.relationships
1433        );
1434    }
1435
1436    /// Wraps a real `MemBackend` and forwards every method
1437    /// verbatim, except `commit_with_expected_parent` returns
1438    /// `BackendError::ParentMismatch` whenever the caller passes a
1439    /// non-`None` `expected_parent`. Models the "sibling writer
1440    /// advanced the head between snapshot and our commit" case
1441    /// without needing a real git-branch repository.
1442    struct DriftingBackend {
1443        inner: Box<dyn MemBackend>,
1444    }
1445    impl DriftingBackend {
1446        fn new(inner: Box<dyn MemBackend>) -> Self {
1447            Self { inner }
1448        }
1449    }
1450    impl crate::backend::MemBackend for DriftingBackend {
1451        fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1452            self.inner.list_entities()
1453        }
1454        fn read_entity(
1455            &self,
1456            rel: &std::path::Path,
1457        ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1458            self.inner.read_entity(rel)
1459        }
1460        fn write_entity(
1461            &self,
1462            rel: &std::path::Path,
1463            b: &[u8],
1464        ) -> Result<(), crate::backend::BackendError> {
1465            self.inner.write_entity(rel, b)
1466        }
1467        fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1468            self.inner.delete_entity(rel)
1469        }
1470        fn move_entity(
1471            &self,
1472            f: &std::path::Path,
1473            t: &std::path::Path,
1474        ) -> Result<(), crate::backend::BackendError> {
1475            self.inner.move_entity(f, t)
1476        }
1477        fn commit(
1478            &self,
1479            m: &str,
1480            c: &crate::vcs::CommitContext<'_>,
1481        ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1482            self.inner.commit(m, c)
1483        }
1484        fn commit_with_expected_parent(
1485            &self,
1486            m: &str,
1487            c: &crate::vcs::CommitContext<'_>,
1488            expected_parent: Option<&str>,
1489        ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1490            if let Some(expected) = expected_parent {
1491                Err(crate::backend::BackendError::ParentMismatch {
1492                    expected: expected.to_string(),
1493                    actual: "drifted-by-sibling-writer".to_string(),
1494                })
1495            } else {
1496                self.inner.commit(m, c)
1497            }
1498        }
1499        fn append_provenance(
1500            &self,
1501            r: &crate::Provenance,
1502        ) -> Result<(), crate::backend::BackendError> {
1503            self.inner.append_provenance(r)
1504        }
1505        fn read_provenance(
1506            &self,
1507            c: Option<&str>,
1508        ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1509            self.inner.read_provenance(c)
1510        }
1511        fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1512            // Return a non-None head so the rename's snapshot is
1513            // populated and the parent-pin path is exercised.
1514            Ok(Some("snapshot-head-sha".to_string()))
1515        }
1516    }
1517
1518    #[test]
1519    fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1520        use crate::engine::CreateEntityArgs;
1521        use indexmap::IndexMap;
1522        use memstead_schema::workspace_config::CrossLinkValue;
1523
1524        let tmp_specs = TempDir::new().unwrap();
1525        let tmp_memos = TempDir::new().unwrap();
1526        let specs_dir = tmp_specs.path().to_path_buf();
1527        let memos_dir = tmp_memos.path().to_path_buf();
1528
1529        // specs uses a plain filesystem backend; memos uses one
1530        // wrapped in DriftingBackend so its peer-mem commit during
1531        // rename fails with ParentMismatch (the parent-pin tripped
1532        // by a hypothetical sibling writer).
1533        let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1534        let writer_memos_inner: Box<dyn MemBackend> =
1535            Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1536        let writer_memos = DriftingBackend::new(writer_memos_inner);
1537
1538        let mut engine = Engine::from_mounts(vec![
1539            (
1540                folder_mount("specs", specs_dir.clone()),
1541                Box::new(writer_specs) as Box<dyn MemBackend>,
1542            ),
1543            (
1544                folder_mount("memos", memos_dir.clone()),
1545                Box::new(writer_memos) as Box<dyn MemBackend>,
1546            ),
1547        ])
1548        .unwrap();
1549        let mut settings = crate::workspace::WorkspaceSettings::default();
1550        settings.cross_mem_links.insert(
1551            "memos".to_string(),
1552            CrossLinkValue::List(vec!["specs".to_string()]),
1553        );
1554        settings.cross_mem_links.insert(
1555            "specs".to_string(),
1556            CrossLinkValue::List(vec!["memos".to_string()]),
1557        );
1558        engine.set_settings(settings);
1559
1560        let (actor, client) = cli_actor();
1561        let target = engine
1562            .create_entity(
1563                empty_create_args("specs", "Target Spec"),
1564                actor,
1565                Some(&client),
1566                None,
1567            )
1568            .unwrap();
1569        let mut sections: IndexMap<String, String> = IndexMap::new();
1570        sections.insert("claim".to_string(), "the claim".to_string());
1571        sections.insert(
1572            "context".to_string(),
1573            "see [[specs:target-spec]]".to_string(),
1574        );
1575        let _referrer = engine
1576            .create_entity(
1577                CreateEntityArgs {
1578                    anchors: Vec::new(),
1579                    mem: "memos".to_string(),
1580                    title: "Cross Note".to_string(),
1581                    entity_type: "memo".to_string(),
1582                    sections,
1583                    metadata: IndexMap::new(),
1584                    // REFERENCES is engine-emitted from the body wiki-link
1585                    // via the alias-synthesis pass; explicit author is
1586                    // refused under `manual_authoring: forbidden`.
1587                    relations: Vec::new(),
1588                    dry_run: false,
1589                },
1590                actor,
1591                Some(&client),
1592                None,
1593            )
1594            .unwrap();
1595
1596        let err = engine
1597            .rename_entity(
1598                RenameEntityArgs {
1599                    id: target.id.clone(),
1600                    expected_hash: Some(target.content_hash.clone()),
1601                    new_title: "Renamed Spec".to_string(),
1602                },
1603                actor,
1604                Some(&client),
1605                None,
1606            )
1607            .unwrap_err();
1608        match err {
1609            EngineError::RenamePartialFailure {
1610                committed_mems,
1611                failed_mem,
1612                failure_cause,
1613            } => {
1614                // The renaming entity's own mem committed before
1615                // the peer-mem commit was attempted, so it must be
1616                // listed as already-committed.
1617                assert_eq!(committed_mems, vec!["specs".to_string()]);
1618                assert_eq!(failed_mem, "memos");
1619                assert_eq!(failure_cause, "drift");
1620            }
1621            other => panic!("expected RenamePartialFailure, got {other:?}"),
1622        }
1623        // The renaming entity's own mem has the new file (its
1624        // commit landed) — that's the whole point of the partial-
1625        // failure envelope: source mem is durable, peer is not.
1626        assert!(specs_dir.join("renamed-spec.md").exists());
1627        assert!(!specs_dir.join(&target.file_path).exists());
1628    }
1629
1630    #[test]
1631    fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1632        use crate::backend::MemBackend;
1633        use crate::engine::CreateEntityArgs;
1634        use indexmap::IndexMap;
1635
1636        let tmp_specs = TempDir::new().unwrap();
1637        let tmp_memos = TempDir::new().unwrap();
1638        let specs_dir = tmp_specs.path().to_path_buf();
1639        let memos_dir = tmp_memos.path().to_path_buf();
1640        let mut engine =
1641            engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1642        let (actor, client) = cli_actor();
1643
1644        let target = engine
1645            .create_entity(
1646                empty_create_args("specs", "Target Spec"),
1647                actor,
1648                Some(&client),
1649                None,
1650            )
1651            .unwrap();
1652        let mut sections: IndexMap<String, String> = IndexMap::new();
1653        sections.insert("claim".to_string(), "the claim".to_string());
1654        sections.insert(
1655            "context".to_string(),
1656            "discussion stems from [[specs:target-spec]]".to_string(),
1657        );
1658        let _referrer = engine
1659            .create_entity(
1660                CreateEntityArgs {
1661                    anchors: Vec::new(),
1662                    mem: "memos".to_string(),
1663                    title: "Cross Note".to_string(),
1664                    entity_type: "memo".to_string(),
1665                    sections,
1666                    metadata: IndexMap::new(),
1667                    // REFERENCES is engine-emitted from the body wiki-link
1668                    // via the alias-synthesis pass; explicit author is
1669                    // refused under `manual_authoring: forbidden`.
1670                    relations: Vec::new(),
1671                    dry_run: false,
1672                },
1673                actor,
1674                Some(&client),
1675                None,
1676            )
1677            .unwrap();
1678
1679        let _ = engine
1680            .rename_entity(
1681                RenameEntityArgs {
1682                    id: target.id.clone(),
1683                    expected_hash: Some(target.content_hash.clone()),
1684                    new_title: "Renamed Spec".to_string(),
1685                },
1686                actor,
1687                Some(&client),
1688                None,
1689            )
1690            .unwrap();
1691
1692        // Read provenance from each mem's backend and find the
1693        // rename entries. Both mems must record a Rename entry, and
1694        // both entries must share the same logical_operation_id.
1695        let specs_backend: Box<dyn MemBackend> =
1696            Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1697        let memos_backend: Box<dyn MemBackend> =
1698            Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1699        let specs_provenance = specs_backend.read_provenance(None).unwrap();
1700        let memos_provenance = memos_backend.read_provenance(None).unwrap();
1701
1702        let specs_rename = specs_provenance
1703            .iter()
1704            .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1705            .expect("specs mem must have a rename provenance entry");
1706        let memos_rename = memos_provenance
1707            .iter()
1708            .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1709            .expect("memos mem must have a rename provenance entry");
1710
1711        let specs_id = specs_rename
1712            .logical_operation_id
1713            .as_deref()
1714            .expect("specs rename entry must carry a logical_operation_id");
1715        let memos_id = memos_rename
1716            .logical_operation_id
1717            .as_deref()
1718            .expect("memos rename entry must carry a logical_operation_id");
1719        assert_eq!(
1720            specs_id, memos_id,
1721            "both per-mem rename commits must share the same logical_operation_id"
1722        );
1723        assert!(
1724            specs_id.starts_with("logop-"),
1725            "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1726        );
1727    }
1728
1729    #[test]
1730    fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1731        use crate::engine::CreateEntityArgs;
1732        use indexmap::IndexMap;
1733        use memstead_schema::workspace_config::CrossLinkValue;
1734
1735        let tmp_specs = TempDir::new().unwrap();
1736        let tmp_memos = TempDir::new().unwrap();
1737        let specs_dir = tmp_specs.path().to_path_buf();
1738        let memos_dir = tmp_memos.path().to_path_buf();
1739
1740        // Start with full policy so the create + cross-mem relate
1741        // succeed during setup.
1742        let mut engine =
1743            engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1744        let (actor, client) = cli_actor();
1745
1746        let target = engine
1747            .create_entity(
1748                empty_create_args("specs", "Target Spec"),
1749                actor,
1750                Some(&client),
1751                None,
1752            )
1753            .unwrap();
1754        let mut sections: IndexMap<String, String> = IndexMap::new();
1755        sections.insert("claim".to_string(), "the claim".to_string());
1756        sections.insert(
1757            "context".to_string(),
1758            "see [[specs:target-spec]]".to_string(),
1759        );
1760        let _referrer = engine
1761            .create_entity(
1762                CreateEntityArgs {
1763                    anchors: Vec::new(),
1764                    mem: "memos".to_string(),
1765                    title: "Cross Note".to_string(),
1766                    entity_type: "memo".to_string(),
1767                    sections,
1768                    metadata: IndexMap::new(),
1769                    // REFERENCES is engine-emitted from the body wiki-link
1770                    // via the alias-synthesis pass; explicit author is
1771                    // refused under `manual_authoring: forbidden`.
1772                    relations: Vec::new(),
1773                    dry_run: false,
1774                },
1775                actor,
1776                Some(&client),
1777                None,
1778            )
1779            .unwrap();
1780
1781        // Tighten policy: revoke `memos → specs`, which is the
1782        // direction of the existing referrer edge (`memos--cross-note
1783        // REFERENCES specs--target-spec`). The propagated rewrite
1784        // preserves that direction, so the rename gate must refuse
1785        // up-front with the now-blocked direction named.
1786        let mut settings = crate::workspace::WorkspaceSettings::default();
1787        settings.cross_mem_links.insert(
1788            "specs".to_string(),
1789            CrossLinkValue::List(vec!["memos".to_string()]),
1790        );
1791        // No entry for `memos` → `cross_mem_link_allowed("memos", "specs")` = false.
1792        engine.set_settings(settings);
1793
1794        let err = engine
1795            .rename_entity(
1796                RenameEntityArgs {
1797                    id: target.id.clone(),
1798                    expected_hash: Some(target.content_hash.clone()),
1799                    new_title: "Renamed Spec".to_string(),
1800                },
1801                actor,
1802                Some(&client),
1803                None,
1804            )
1805            .unwrap_err();
1806        match err {
1807            EngineError::RenameBlockedByCrossMemPolicy {
1808                from_mem,
1809                blocked_referrers,
1810            } => {
1811                assert_eq!(from_mem, "specs");
1812                assert_eq!(blocked_referrers.len(), 1);
1813                assert_eq!(blocked_referrers[0].from_mem, "memos");
1814                assert_eq!(blocked_referrers[0].to_mem, "specs");
1815                assert_eq!(blocked_referrers[0].count, 1);
1816            }
1817            other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1818        }
1819        // Nothing landed: target file is still at the old path, no
1820        // new file was created.
1821        assert!(specs_dir.join(&target.file_path).exists());
1822        assert!(!specs_dir.join("renamed-spec.md").exists());
1823    }
1824
1825    /// Rename target has no Write-mem referrers but is referenced
1826    /// from a ReadOnly archive. The rename rewrites the renaming
1827    /// entity's own mem but cannot reach into the archive — the
1828    /// archive's wiki-link still points at the old slug. To keep
1829    /// `incoming(<new_id>)` aligned with what a fresh boot would
1830    /// produce and surface the dangling reference, the OLD-id store
1831    /// entry is demoted to a stub holding the surviving archive
1832    /// incoming edges, with a `ResidualStubForReadOnlyReferrers`
1833    /// warning on the outcome. Mirrors delete-path's same-shaped
1834    /// demotion.
1835    #[test]
1836    fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1837        use crate::engine::test_helpers::{archive_mount, build_archive};
1838        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1839
1840        let tmp = TempDir::new().unwrap();
1841        let writable_dir = tmp.path().join("writable");
1842        std::fs::create_dir_all(&writable_dir).unwrap();
1843        let writer = FilesystemMemWriter::new(writable_dir.clone());
1844
1845        // Archive entity declares an explicit cross-mem relation
1846        // into the writable mem; under the alias model every edge
1847        // originates from `## Relationships`.
1848        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 rename residual-stub demotion.\n\n## Relationships\n\n- **REFERENCES**: [[specs:target]]\n";
1849        let archive_path = build_archive(
1850            tmp.path(),
1851            "archive",
1852            &[("archived-source.md", archive_md.as_bytes())],
1853        );
1854
1855        let folder_mount = Mount {
1856            mem: "specs".to_string(),
1857            schema: Some(crate::engine::test_helpers::pin("default")),
1858            storage: MountStorage::Folder {
1859                path: writable_dir.clone(),
1860            },
1861            capability: MountCapability::Write,
1862            lifecycle: MountLifecycle::Eager,
1863            cross_linkable: true,
1864            migration_target: None,
1865        };
1866        let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1867        let mut engine = Engine::from_mounts(vec![
1868            (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1869            (
1870                archive_mount("archive", archive_path.clone()),
1871                Box::new(archive_reader) as Box<dyn MemBackend>,
1872            ),
1873        ])
1874        .unwrap();
1875
1876        let (actor, client) = cli_actor();
1877        let target = engine
1878            .create_entity(
1879                empty_create_args("specs", "Target"),
1880                actor,
1881                Some(&client),
1882                None,
1883            )
1884            .unwrap();
1885
1886        // Sanity check: the archive's wiki-link produces an incoming
1887        // edge on the target.
1888        let archived_source_id = crate::EntityId::new("archive", "archived-source");
1889        let incoming_pre: Vec<_> = engine
1890            .store()
1891            .incoming(&target.id)
1892            .iter()
1893            .map(|e| e.from.clone())
1894            .collect();
1895        assert!(
1896            incoming_pre.contains(&archived_source_id),
1897            "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1898        );
1899
1900        // Rename. The archive can't be rewritten; engine demotes the
1901        // OLD-id store entry to a stub and emits the warning.
1902        let outcome = engine
1903            .rename_entity(
1904                RenameEntityArgs {
1905                    id: target.id.clone(),
1906                    expected_hash: Some(target.content_hash.clone()),
1907                    new_title: "Renamed Target".to_string(),
1908                },
1909                actor,
1910                Some(&client),
1911                None,
1912            )
1913            .unwrap();
1914        assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1915
1916        // New file landed.
1917        assert!(writable_dir.join(&outcome.new_path).exists());
1918        // Old slug demoted to a stub at the original id.
1919        let demoted = engine
1920            .get_entity(&target.id)
1921            .expect("residual stub must remain at old id");
1922        assert!(demoted.stub, "demoted entity must be flagged as stub");
1923        assert!(demoted.entity_type.is_empty());
1924        // Archive's incoming edge still points at the old id (the
1925        // archive markdown wasn't rewritten).
1926        let incoming_old: Vec<_> = engine
1927            .store()
1928            .incoming(&target.id)
1929            .iter()
1930            .map(|e| e.from.clone())
1931            .collect();
1932        assert!(
1933            incoming_old.contains(&archived_source_id),
1934            "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1935        );
1936        // New id has no incoming edge from the archive (its wiki-link
1937        // points at the old slug, not the new one).
1938        let incoming_new: Vec<_> = engine
1939            .store()
1940            .incoming(&outcome.new_id)
1941            .iter()
1942            .map(|e| e.from.clone())
1943            .collect();
1944        assert!(
1945            !incoming_new.contains(&archived_source_id),
1946            "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1947        );
1948        // Warning carries the surviving referrer.
1949        let referrers = outcome
1950            .warnings
1951            .iter()
1952            .find_map(|w| match w {
1953                WarningHint::ResidualStubForReadOnlyReferrers {
1954                    id: warn_id,
1955                    referrers,
1956                } => {
1957                    assert_eq!(warn_id, &target.id);
1958                    Some(referrers.clone())
1959                }
1960                _ => None,
1961            })
1962            .expect("ResidualStubForReadOnlyReferrers warning must surface");
1963        assert_eq!(referrers, vec![archived_source_id]);
1964    }
1965
1966    /// A same-mem referrer whose body uses the full-id form
1967    /// `[[<mem>--<slug>]]` to point at the renaming entity must have
1968    /// that token retargeted to the new slug. Pre-fix the rewrite
1969    /// pass only matched short-form `[[<slug>]]`; full-id tokens
1970    /// survived and pointed at the dead id. The new code calls
1971    /// `rewrite_cross_mem_slug` on the same-mem path alongside
1972    /// the bare-slug helper, covering both legal slug-form variants.
1973    #[test]
1974    fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1975        use crate::engine::CreateEntityArgs;
1976        use indexmap::IndexMap;
1977        let tmp = TempDir::new().unwrap();
1978        let mem_dir = tmp.path().to_path_buf();
1979        let writer = FilesystemMemWriter::new(mem_dir.clone());
1980        let mut engine = Engine::from_mounts(vec![(
1981            folder_mount("specs", mem_dir.clone()),
1982            Box::new(writer) as Box<dyn MemBackend>,
1983        )])
1984        .unwrap();
1985        let (actor, client) = cli_actor();
1986
1987        // Target entity to rename.
1988        let target = engine
1989            .create_entity(
1990                empty_create_args("specs", "Target Spec"),
1991                actor,
1992                Some(&client),
1993                None,
1994            )
1995            .unwrap();
1996        assert_eq!(target.id.to_string(), "specs--target-spec");
1997
1998        // Referrer body uses the full-id form `[[specs--target-spec]]`.
1999        // The body parser admits both short and full-id forms; the
2000        // alias-synthesis pass emits one REFERENCES edge regardless
2001        // of which form the author wrote.
2002        let mut sections: IndexMap<String, String> = IndexMap::new();
2003        sections.insert("identity".to_string(), "referrer identity".to_string());
2004        sections.insert(
2005            "purpose".to_string(),
2006            "see also [[specs--target-spec]] for context".to_string(),
2007        );
2008        let referrer = engine
2009            .create_entity(
2010                CreateEntityArgs {
2011                    anchors: Vec::new(),
2012                    mem: "specs".to_string(),
2013                    title: "Referrer Full".to_string(),
2014                    entity_type: "spec".to_string(),
2015                    sections,
2016                    metadata: IndexMap::new(),
2017                    relations: Vec::new(),
2018                    dry_run: false,
2019                },
2020                actor,
2021                Some(&client),
2022                None,
2023            )
2024            .unwrap();
2025
2026        let renamed = engine
2027            .rename_entity(
2028                RenameEntityArgs {
2029                    id: target.id.clone(),
2030                    expected_hash: Some(target.content_hash.clone()),
2031                    new_title: "Renamed Spec".to_string(),
2032                },
2033                actor,
2034                Some(&client),
2035                None,
2036            )
2037            .unwrap();
2038        assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
2039
2040        // The full-id form was retargeted (slug part rewritten, full-id
2041        // form preserved). The old slug must not survive in the
2042        // referrer's on-disk file.
2043        let referrer_path = mem_dir.join(&referrer.file_path);
2044        let body = std::fs::read_to_string(&referrer_path).unwrap();
2045        assert!(
2046            body.contains("[[specs--renamed-spec]]"),
2047            "expected full-id form retargeted to new slug, got:\n{body}"
2048        );
2049        assert!(
2050            !body.contains("target-spec"),
2051            "old slug must not survive in any form, got:\n{body}"
2052        );
2053    }
2054
2055    #[test]
2056    fn rename_entity_rejects_collision_with_existing_id() {
2057        let tmp = TempDir::new().unwrap();
2058        let (mut engine, first) = engine_with_seed(&tmp, "First");
2059        let (actor, client) = cli_actor();
2060        let _ = engine
2061            .create_entity(
2062                empty_create_args("specs", "Second"),
2063                actor,
2064                Some(&client),
2065                None,
2066            )
2067            .unwrap();
2068        // Rename `first` to a title that slugifies to `second`.
2069        let err = engine
2070            .rename_entity(
2071                RenameEntityArgs {
2072                    id: first.id.clone(),
2073                    expected_hash: Some(first.content_hash.clone()),
2074                    new_title: "Second".to_string(),
2075                },
2076                actor,
2077                Some(&client),
2078                None,
2079            )
2080            .unwrap_err();
2081        assert!(matches!(
2082            err,
2083            EngineError::AlreadyExists { ref id, ref existing_title, existing_is_stub: false }
2084                if id == "specs--second" && existing_title == "Second"
2085        ));
2086    }
2087
2088    /// With `test → other` granted, an `IMPLEMENTS` edge from
2089    /// `test--src` to `other--target` is created. Revoking `test →
2090    /// other` (the actual edge direction) must block the rename
2091    /// up-front — pre-fix the gate checked the inverse direction
2092    /// (`other → test`), which was un-granted in both phases, so the
2093    /// rename refused for the wrong reason.
2094    #[test]
2095    fn rename_propagation_gate_checks_actual_edge_direction() {
2096        use crate::engine::error::BlockedReferrer;
2097        use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2098        use indexmap::IndexMap;
2099        use memstead_schema::workspace_config::CrossLinkValue;
2100
2101        let tmp_test = TempDir::new().unwrap();
2102        let tmp_other = TempDir::new().unwrap();
2103        let test_dir = tmp_test.path().to_path_buf();
2104        let other_dir = tmp_other.path().to_path_buf();
2105
2106        // Pretty-print scaffold: `test` and `other` are the
2107        // canonical mem names. Reuse the helper by ignoring the
2108        // returned dirs and overriding the policy explicitly.
2109        let writer_test = FilesystemMemWriter::new(test_dir.clone());
2110        let writer_other = FilesystemMemWriter::new(other_dir.clone());
2111        let mut engine = Engine::from_mounts(vec![
2112            (
2113                folder_mount("test", test_dir.clone()),
2114                Box::new(writer_test) as Box<dyn MemBackend>,
2115            ),
2116            (
2117                folder_mount("other", other_dir.clone()),
2118                Box::new(writer_other) as Box<dyn MemBackend>,
2119            ),
2120        ])
2121        .unwrap();
2122        let (actor, client) = cli_actor();
2123
2124        // Setup policy: only `test → other` granted. Create the edge
2125        // `test--src IMPLEMENTS other--target`.
2126        let mut settings = crate::workspace::WorkspaceSettings::default();
2127        settings.cross_mem_links.insert(
2128            "test".to_string(),
2129            CrossLinkValue::List(vec!["other".to_string()]),
2130        );
2131        engine.set_settings(settings);
2132
2133        let target = engine
2134            .create_entity(
2135                empty_create_args("other", "Target"),
2136                actor,
2137                Some(&client),
2138                None,
2139            )
2140            .unwrap();
2141        let mut src_sections: IndexMap<String, String> = IndexMap::new();
2142        src_sections.insert("identity".to_string(), "source identity".to_string());
2143        src_sections.insert("purpose".to_string(), "source purpose".to_string());
2144        let src = engine
2145            .create_entity(
2146                CreateEntityArgs {
2147                    anchors: Vec::new(),
2148                    mem: "test".to_string(),
2149                    title: "Src".to_string(),
2150                    entity_type: "spec".to_string(),
2151                    sections: src_sections,
2152                    metadata: IndexMap::new(),
2153                    relations: Vec::new(),
2154                    dry_run: false,
2155                },
2156                actor,
2157                Some(&client),
2158                None,
2159            )
2160            .unwrap();
2161        let src = engine
2162            .relate_entity(
2163                RelateEntityArgs {
2164                    source: src.id.clone(),
2165                    rel_type: "IMPLEMENTS".to_string(),
2166                    target: target.id.clone(),
2167                    expected_hash: Some(src.content_hash.clone()),
2168                    remove: false,
2169                    description: None,
2170                    dry_run: false,
2171                },
2172                actor,
2173                Some(&client),
2174                None,
2175            )
2176            .unwrap();
2177        let _ = src; // sink — we won't use the post-relate hash again
2178
2179        // Revoke `test → other`. The post-rename rewrite would
2180        // re-emit the `test → other` edge, which the gate must now
2181        // refuse — pre-fix the inverted check passed because nothing
2182        // ever gated the right direction.
2183        engine.set_settings(crate::workspace::WorkspaceSettings::default());
2184
2185        let err = engine
2186            .rename_entity(
2187                RenameEntityArgs {
2188                    id: target.id.clone(),
2189                    expected_hash: Some(target.content_hash.clone()),
2190                    new_title: "Renamed".to_string(),
2191                },
2192                actor,
2193                Some(&client),
2194                None,
2195            )
2196            .unwrap_err();
2197        match err {
2198            EngineError::RenameBlockedByCrossMemPolicy {
2199                from_mem,
2200                blocked_referrers,
2201            } => {
2202                assert_eq!(from_mem, "other");
2203                assert_eq!(
2204                    blocked_referrers,
2205                    vec![BlockedReferrer {
2206                        from_mem: "test".to_string(),
2207                        to_mem: "other".to_string(),
2208                        count: 1,
2209                    }],
2210                    "blocked_referrers must name the actual edge direction (test → other)",
2211                );
2212            }
2213            other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2214        }
2215        // No write landed in either mem: target file path
2216        // unchanged, the new slug file absent.
2217        assert!(other_dir.join(&target.file_path).exists());
2218        assert!(!other_dir.join("renamed.md").exists());
2219    }
2220
2221    /// Re-granting the actual edge
2222    /// direction lets the same rename succeed. Verifies the gate
2223    /// fires only on the *un-granted* direction.
2224    #[test]
2225    fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2226        use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2227        use indexmap::IndexMap;
2228        use memstead_schema::workspace_config::CrossLinkValue;
2229
2230        let tmp_test = TempDir::new().unwrap();
2231        let tmp_other = TempDir::new().unwrap();
2232        let test_dir = tmp_test.path().to_path_buf();
2233        let other_dir = tmp_other.path().to_path_buf();
2234
2235        let writer_test = FilesystemMemWriter::new(test_dir.clone());
2236        let writer_other = FilesystemMemWriter::new(other_dir.clone());
2237        let mut engine = Engine::from_mounts(vec![
2238            (
2239                folder_mount("test", test_dir),
2240                Box::new(writer_test) as Box<dyn MemBackend>,
2241            ),
2242            (
2243                folder_mount("other", other_dir),
2244                Box::new(writer_other) as Box<dyn MemBackend>,
2245            ),
2246        ])
2247        .unwrap();
2248        let (actor, client) = cli_actor();
2249
2250        let mut settings = crate::workspace::WorkspaceSettings::default();
2251        settings.cross_mem_links.insert(
2252            "test".to_string(),
2253            CrossLinkValue::List(vec!["other".to_string()]),
2254        );
2255        engine.set_settings(settings);
2256
2257        let target = engine
2258            .create_entity(
2259                empty_create_args("other", "Target"),
2260                actor,
2261                Some(&client),
2262                None,
2263            )
2264            .unwrap();
2265        let mut src_sections: IndexMap<String, String> = IndexMap::new();
2266        src_sections.insert("identity".to_string(), "source identity".to_string());
2267        src_sections.insert("purpose".to_string(), "source purpose".to_string());
2268        let src = engine
2269            .create_entity(
2270                CreateEntityArgs {
2271                    anchors: Vec::new(),
2272                    mem: "test".to_string(),
2273                    title: "Src".to_string(),
2274                    entity_type: "spec".to_string(),
2275                    sections: src_sections,
2276                    metadata: IndexMap::new(),
2277                    relations: Vec::new(),
2278                    dry_run: false,
2279                },
2280                actor,
2281                Some(&client),
2282                None,
2283            )
2284            .unwrap();
2285        let _ = engine
2286            .relate_entity(
2287                RelateEntityArgs {
2288                    source: src.id.clone(),
2289                    rel_type: "IMPLEMENTS".to_string(),
2290                    target: target.id.clone(),
2291                    expected_hash: Some(src.content_hash.clone()),
2292                    remove: false,
2293                    description: None,
2294                    dry_run: false,
2295                },
2296                actor,
2297                Some(&client),
2298                None,
2299            )
2300            .unwrap();
2301
2302        // Policy unchanged from setup (`test → other` still granted)
2303        // — rename must succeed and rewrite the cross-mem referrer.
2304        let outcome = engine
2305            .rename_entity(
2306                RenameEntityArgs {
2307                    id: target.id.clone(),
2308                    expected_hash: Some(target.content_hash.clone()),
2309                    new_title: "Renamed".to_string(),
2310                },
2311                actor,
2312                Some(&client),
2313                None,
2314            )
2315            .unwrap();
2316        assert_ne!(outcome.old_id, outcome.new_id);
2317        let renamed = engine
2318            .get_entity(&outcome.new_id)
2319            .expect("renamed entity persists");
2320        assert_eq!(renamed.title, "Renamed");
2321        // Referrer rewritten — IMPLEMENTS edge now points at the new id.
2322        let updated_src = engine.get_entity(&src.id).expect("source persists");
2323        assert!(
2324            updated_src
2325                .relationships
2326                .iter()
2327                .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2328            "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2329        );
2330    }
2331
2332    /// A rename whose target has no
2333    /// cross-mem referrers bypasses the gate entirely. Even with
2334    /// a fully-empty cross-link policy, the rename succeeds.
2335    #[test]
2336    fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2337        let tmp = TempDir::new().unwrap();
2338        let mem_dir = tmp.path().to_path_buf();
2339        let writer = FilesystemMemWriter::new(mem_dir.clone());
2340        let mut engine = Engine::from_mounts(vec![(
2341            folder_mount("specs", mem_dir),
2342            Box::new(writer) as Box<dyn MemBackend>,
2343        )])
2344        .unwrap();
2345        let (actor, client) = cli_actor();
2346        // Default settings: no cross-mem links at all. The
2347        // single-mem rename's referrers (if any) are all same-mem
2348        // and bypass the gate by construction.
2349        let target = engine
2350            .create_entity(
2351                empty_create_args("specs", "Target"),
2352                actor,
2353                Some(&client),
2354                None,
2355            )
2356            .unwrap();
2357        let outcome = engine
2358            .rename_entity(
2359                RenameEntityArgs {
2360                    id: target.id.clone(),
2361                    expected_hash: Some(target.content_hash.clone()),
2362                    new_title: "Renamed Target".to_string(),
2363                },
2364                actor,
2365                Some(&client),
2366                None,
2367            )
2368            .unwrap();
2369        assert_ne!(outcome.old_id, outcome.new_id);
2370    }
2371}