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