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        // Incremental (flywheel W8/01): the touched set is the OLD id
635        // (its document must leave the index) plus every re-parsed
636        // entity — the renamed entity at its new id and each rewritten
637        // referrer.
638        let mut touched: Vec<crate::EntityId> = parse_results
639            .iter()
640            .map(|pr| pr.entity.id.clone())
641            .collect();
642        touched.push(id.clone());
643        push_entities_into_store(&mut self.store, parse_results, fallback.as_ref(), None);
644        crate::entity::store_builder::remap_alias_target_edge_sources(
645            &mut self.store,
646            &self.schemas,
647        );
648
649        self.invalidate_communities();
650        self.maintain_search_indexes(&touched);
651
652        // `require_notes` provenance nudge — single engine-level
653        // enforcement point. Only reached on the real-rename path; the
654        // slug-noop short-circuit returns early above with an empty
655        // `commit_sha` and never demands a note.
656        if let Some(w) = self.note_missing_warning("rename_entity", note) {
657            outcome_warnings.push(w);
658        }
659
660        Ok(RenameEntityOutcome {
661            old_id: id.clone(),
662            new_id,
663            old_path: old_file_path,
664            new_path: new_file_path,
665            content_hash,
666            commit_sha,
667            warnings: outcome_warnings,
668        })
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use std::path::PathBuf;
675
676    use tempfile::TempDir;
677
678    use crate::backend::MemBackend;
679    use crate::engine::test_helpers::*;
680    use crate::engine::{Engine, EngineError, RenameEntityArgs};
681    use crate::ops::WarningHint;
682    use crate::storage::FilesystemMemWriter;
683
684    #[test]
685    fn rename_moves_entity_anchors_to_new_id() {
686        let tmp = TempDir::new().unwrap();
687        let mem_dir = tmp.path().to_path_buf();
688        let writer = FilesystemMemWriter::new(mem_dir.clone());
689        let mut engine = Engine::from_mounts(vec![(
690            folder_mount("specs", mem_dir.clone()),
691            Box::new(writer) as Box<dyn MemBackend>,
692        )])
693        .unwrap();
694        let (actor, client) = cli_actor();
695        let mut args = empty_create_args("specs", "Old Anchored");
696        args.anchors = vec![crate::anchor::AnchorInput {
697            artifact: Some("src/lib.rs".into()),
698            grain: Some("file".into()),
699            class: Some("anchored".into()),
700            hash: Some("h1".into()),
701            hash_stability: Some("stable".into()),
702            ..Default::default()
703        }];
704        let seeded = engine
705            .create_entity(args, actor, Some(&client), None)
706            .unwrap();
707        let old_id = seeded.id.clone();
708
709        let outcome = engine
710            .rename_entity(
711                RenameEntityArgs {
712                    id: old_id.clone(),
713                    expected_hash: Some(seeded.content_hash.clone()),
714                    new_title: "New Anchored".to_string(),
715                },
716                actor,
717                Some(&client),
718                None,
719            )
720            .unwrap();
721
722        // Zero rows under the old id; all anchors resolve under the new id.
723        assert!(engine.entity_anchors(&old_id).is_empty());
724        assert_eq!(engine.entity_anchors(&outcome.new_id).len(), 1);
725        assert_eq!(
726            engine.anchors_referencing_artifact("src/lib.rs"),
727            vec![(
728                outcome.new_id.clone(),
729                engine.entity_anchors(&outcome.new_id)[0].clone()
730            )]
731        );
732    }
733
734    #[test]
735    fn rename_entity_renames_file_and_id_persists_across_restart() {
736        let tmp = TempDir::new().unwrap();
737        let mem_dir = tmp.path().to_path_buf();
738
739        let (old_id, new_id, new_file) = {
740            let writer = FilesystemMemWriter::new(mem_dir.clone());
741            let mut engine = Engine::from_mounts(vec![(
742                folder_mount("specs", mem_dir.clone()),
743                Box::new(writer) as Box<dyn MemBackend>,
744            )])
745            .unwrap();
746            let (actor, client) = cli_actor();
747            let seeded = engine
748                .create_entity(
749                    empty_create_args("specs", "Old Name"),
750                    actor,
751                    Some(&client),
752                    None,
753                )
754                .unwrap();
755            let outcome = engine
756                .rename_entity(
757                    RenameEntityArgs {
758                        id: seeded.id.clone(),
759                        expected_hash: Some(seeded.content_hash.clone()),
760                        new_title: "New Name".to_string(),
761                    },
762                    actor,
763                    Some(&client),
764                    None,
765                )
766                .unwrap();
767            assert_eq!(outcome.old_id.to_string(), "specs--old-name");
768            assert_eq!(outcome.new_id.to_string(), "specs--new-name");
769            assert_eq!(outcome.new_path, "new-name.md");
770            // Old file gone, new file present.
771            assert!(!mem_dir.join(&outcome.old_path).exists());
772            assert!(mem_dir.join(&outcome.new_path).exists());
773            (outcome.old_id, outcome.new_id, outcome.new_path)
774        };
775
776        // New engine reading the same mem sees only the new id.
777        let writer2 = FilesystemMemWriter::new(mem_dir.clone());
778        let engine2 = Engine::from_mounts(vec![(
779            folder_mount("specs", mem_dir),
780            Box::new(writer2) as Box<dyn MemBackend>,
781        )])
782        .unwrap();
783        assert!(engine2.get_entity(&old_id).is_none());
784        let new_entity = engine2.get_entity(&new_id).expect("new id must persist");
785        assert_eq!(new_entity.title, "New Name");
786        assert_eq!(new_entity.file_path, new_file);
787    }
788
789    #[test]
790    fn rename_entity_returns_typed_warning_on_slug_noop() {
791        let tmp = TempDir::new().unwrap();
792        let (mut engine, seeded) = engine_with_seed(&tmp, "Same Slug");
793        let (actor, client) = cli_actor();
794        let outcome = engine
795            .rename_entity(
796                RenameEntityArgs {
797                    id: seeded.id.clone(),
798                    expected_hash: Some(seeded.content_hash.clone()),
799                    new_title: "Same  Slug".to_string(), // slugifies to same
800                },
801                actor,
802                Some(&client),
803                None,
804            )
805            .unwrap();
806        // Wire-shape parity with full: slug-noop is Ok+warning, not
807        // an error. Old/new IDs are equal; old/new paths are equal;
808        // commit_sha is empty (no disk write); warnings carries the
809        // typed TitleNormalizedToSlugNoop hint.
810        assert_eq!(outcome.old_id, outcome.new_id);
811        assert_eq!(outcome.old_path, outcome.new_path);
812        assert!(outcome.commit_sha.is_empty());
813        assert_eq!(outcome.warnings.len(), 1);
814        assert!(matches!(
815            outcome.warnings[0],
816            WarningHint::TitleNormalizedToSlugNoop { .. }
817        ));
818    }
819
820    #[test]
821    fn rename_entity_returns_commit_sha_on_real_rename() {
822        let tmp = TempDir::new().unwrap();
823        let (mut engine, seeded) = engine_with_seed(&tmp, "Old Name");
824        let (actor, client) = cli_actor();
825        let outcome = engine
826            .rename_entity(
827                RenameEntityArgs {
828                    id: seeded.id.clone(),
829                    expected_hash: Some(seeded.content_hash.clone()),
830                    new_title: "Brand New Name".to_string(),
831                },
832                actor,
833                Some(&client),
834                None,
835            )
836            .unwrap();
837        // Real rename: commit_sha non-empty (folder backend produces
838        // a synthetic CommitId), warnings empty, IDs differ.
839        assert_ne!(outcome.old_id, outcome.new_id);
840        assert!(
841            !outcome.commit_sha.is_empty(),
842            "commit_sha must be populated on a real rename"
843        );
844        assert!(outcome.warnings.is_empty());
845    }
846
847    #[test]
848    fn rename_entity_rewrites_self_references_in_body_and_relationships() {
849        use crate::entity::EntityId;
850        use indexmap::IndexMap;
851
852        let tmp = TempDir::new().unwrap();
853        let mem_dir = tmp.path().to_path_buf();
854        let writer = FilesystemMemWriter::new(mem_dir.clone());
855        let mut engine = Engine::from_mounts(vec![(
856            folder_mount("specs", mem_dir.clone()),
857            Box::new(writer) as Box<dyn MemBackend>,
858        )])
859        .unwrap();
860        let (actor, client) = cli_actor();
861
862        // Create the entity with a body section that contains a
863        // self-reference. The slug is `old-name`; the body literally
864        // names `[[old-name]]`. After rename, both surfaces — the
865        // file on disk and the in-memory entity — must point at the
866        // new slug.
867        let mut sections: IndexMap<String, String> = IndexMap::new();
868        sections.insert("identity".to_string(), "the seed identity".to_string());
869        sections.insert(
870            "purpose".to_string(),
871            "see also [[old-name]] for prior context".to_string(),
872        );
873        // F11: the `[[old-name]]` self-reference body link does NOT
874        // synthesise a self-edge (the alias pass drops vacuous self-edges
875        // and emits `SELF_LINK_IGNORED`); `scan_wikilinks_without_relation`
876        // also skips self-targets, so the unbacked self-link is admitted.
877        // The body link still rewrites on rename — this test pins that the
878        // body follows the slug while no self-relation is ever created.
879        let seeded = engine
880            .create_entity(
881                crate::engine::CreateEntityArgs {
882                    anchors: Vec::new(),
883                    mem: "specs".to_string(),
884                    title: "Old Name".to_string(),
885                    entity_type: "spec".to_string(),
886                    sections,
887                    metadata: IndexMap::new(),
888                    relations: Vec::new(),
889                    dry_run: false,
890                },
891                actor,
892                Some(&client),
893                None,
894            )
895            .unwrap();
896        assert_eq!(seeded.id.to_string(), "specs--old-name");
897        let related = seeded.clone();
898
899        let outcome = engine
900            .rename_entity(
901                RenameEntityArgs {
902                    id: seeded.id.clone(),
903                    expected_hash: Some(related.content_hash.clone()),
904                    new_title: "Brand New Name".to_string(),
905                },
906                actor,
907                Some(&client),
908                None,
909            )
910            .unwrap();
911        assert_eq!(outcome.new_id.to_string(), "specs--brand-new-name");
912
913        // File on disk reflects the rewrite — old slug must not
914        // appear anywhere in the new file's bytes.
915        let new_bytes = std::fs::read_to_string(mem_dir.join(&outcome.new_path)).unwrap();
916        assert!(
917            new_bytes.contains("[[brand-new-name]]"),
918            "expected new slug in body, got:\n{new_bytes}"
919        );
920        assert!(
921            !new_bytes.contains("[[old-name]]"),
922            "old slug must not survive in the rewritten file, got:\n{new_bytes}"
923        );
924
925        // In-memory entity: section body rewritten, relationships
926        // list points at the new id.
927        let in_mem = engine.get_entity(&outcome.new_id).unwrap();
928        assert!(
929            in_mem
930                .sections
931                .get("purpose")
932                .map(|s| s.contains("[[brand-new-name]]"))
933                .unwrap_or(false),
934            "section body must be rewritten in-memory; got {:?}",
935            in_mem.sections.get("purpose")
936        );
937        // F11: no self-relation is ever synthesised — neither to the old
938        // id nor (after the body rewrite) to the new id. The body link
939        // followed the rename, but it produces no self-edge.
940        let new_self_target = EntityId::new("specs", "brand-new-name");
941        assert!(
942            in_mem
943                .relationships
944                .iter()
945                .all(|r| r.target != seeded.id && r.target != new_self_target),
946            "a self-referential body link must produce no self-relation (F11), got: {:?}",
947            in_mem.relationships
948        );
949    }
950
951    /// A
952    /// rename rewrites a referrer's body wiki-link to the new slug — a
953    /// foreign-key change, not a semantic edit — so the referrer's
954    /// `last_modified` staleness clock must NOT reset. Pre-written files
955    /// carry an old `last_modified` (2020-01-01) so the assertion is
956    /// distinctive: after a same-day rename the clock stays at the old
957    /// date (it would jump to today if the re-commit still stamped it),
958    /// while the body link is correctly rewritten.
959    #[test]
960    fn rename_preserves_referrer_last_modified_but_rewrites_link() {
961        let tmp = TempDir::new().unwrap();
962        let mem_dir = tmp.path().to_path_buf();
963
964        std::fs::write(
965            mem_dir.join("target.md"),
966            "---\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",
967        )
968        .unwrap();
969        std::fs::write(
970            mem_dir.join("referrer.md"),
971            "---\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",
972        )
973        .unwrap();
974
975        let writer = FilesystemMemWriter::new(mem_dir.clone());
976        let mut engine = Engine::from_mounts(vec![(
977            folder_mount("specs", mem_dir.clone()),
978            Box::new(writer) as Box<dyn MemBackend>,
979        )])
980        .unwrap();
981        let (actor, client) = cli_actor();
982
983        let target_id = crate::entity::EntityId::new("specs", "target");
984        let target_hash = engine
985            .store()
986            .get(&target_id)
987            .expect("target loaded from disk")
988            .content_hash
989            .clone();
990
991        engine
992            .rename_entity(
993                RenameEntityArgs {
994                    id: target_id,
995                    expected_hash: Some(target_hash),
996                    new_title: "Target Renamed".to_string(),
997                },
998                actor,
999                Some(&client),
1000                None,
1001            )
1002            .expect("rename succeeds");
1003
1004        let referrer_md = std::fs::read_to_string(mem_dir.join("referrer.md")).unwrap();
1005        // Staleness clock preserved — NOT bumped to today.
1006        assert!(
1007            referrer_md.contains("last_modified: 2020-01-01"),
1008            "referrer's last_modified must be preserved across a rename-driven slug rewrite; got:\n{referrer_md}"
1009        );
1010        // Core rename job intact — the body wiki-link points at the new slug.
1011        assert!(
1012            referrer_md.contains("[[target-renamed]]"),
1013            "referrer's body wiki-link must be rewritten to the new slug; got:\n{referrer_md}"
1014        );
1015        assert!(
1016            !referrer_md.contains("[[target]]"),
1017            "old slug must not survive in the referrer body; got:\n{referrer_md}"
1018        );
1019    }
1020
1021    #[test]
1022    fn rename_entity_rewrites_same_mem_referrers_atomically() {
1023        use crate::engine::CreateEntityArgs;
1024        use crate::entity::EntityId;
1025        use indexmap::IndexMap;
1026
1027        let tmp = TempDir::new().unwrap();
1028        let mem_dir = tmp.path().to_path_buf();
1029        let writer = FilesystemMemWriter::new(mem_dir.clone());
1030        let mut engine = Engine::from_mounts(vec![(
1031            folder_mount("specs", mem_dir.clone()),
1032            Box::new(writer) as Box<dyn MemBackend>,
1033        )])
1034        .unwrap();
1035        let (actor, client) = cli_actor();
1036
1037        // Target — the entity that will be renamed.
1038        let target = engine
1039            .create_entity(
1040                empty_create_args("specs", "Target Spec"),
1041                actor,
1042                Some(&client),
1043                None,
1044            )
1045            .unwrap();
1046        assert_eq!(target.id.to_string(), "specs--target-spec");
1047
1048        // Referrer A — explicit relation declared atomically with the
1049        // body wiki-link. Both surfaces must be rewritten.
1050        let mut sections_a: IndexMap<String, String> = IndexMap::new();
1051        sections_a.insert(
1052            "identity".to_string(),
1053            "referrer alpha identity".to_string(),
1054        );
1055        sections_a.insert(
1056            "purpose".to_string(),
1057            "rationale relies on [[target-spec]] for context".to_string(),
1058        );
1059        let referrer_a = engine
1060            .create_entity(
1061                CreateEntityArgs {
1062                    anchors: Vec::new(),
1063                    mem: "specs".to_string(),
1064                    title: "Referrer Alpha".to_string(),
1065                    entity_type: "spec".to_string(),
1066                    sections: sections_a,
1067                    metadata: IndexMap::new(),
1068                    // REFERENCES is engine-emitted from the body wiki-link
1069                    // via the alias-synthesis pass; explicit author is
1070                    // refused under `manual_authoring: forbidden`.
1071                    relations: Vec::new(),
1072                    dry_run: false,
1073                },
1074                actor,
1075                Some(&client),
1076                None,
1077            )
1078            .unwrap();
1079
1080        // Referrer B — second referrer with body wiki-link + atomic
1081        // backing relation. Confirms multi-referrer body rewrites.
1082        let mut sections_b: IndexMap<String, String> = IndexMap::new();
1083        sections_b.insert("identity".to_string(), "referrer beta identity".to_string());
1084        sections_b.insert(
1085            "purpose".to_string(),
1086            "consult [[target-spec]] for the canonical phrasing".to_string(),
1087        );
1088        let referrer_b = engine
1089            .create_entity(
1090                CreateEntityArgs {
1091                    anchors: Vec::new(),
1092                    mem: "specs".to_string(),
1093                    title: "Referrer Bravo".to_string(),
1094                    entity_type: "spec".to_string(),
1095                    sections: sections_b,
1096                    metadata: IndexMap::new(),
1097                    // REFERENCES is engine-emitted from the body wiki-link
1098                    // via the alias-synthesis pass; explicit author is
1099                    // refused under `manual_authoring: forbidden`.
1100                    relations: Vec::new(),
1101                    dry_run: false,
1102                },
1103                actor,
1104                Some(&client),
1105                None,
1106            )
1107            .unwrap();
1108
1109        // Bystander — no reference to the target. Must not be
1110        // touched on disk (its content_hash must be unchanged).
1111        let bystander = engine
1112            .create_entity(
1113                empty_create_args("specs", "Bystander"),
1114                actor,
1115                Some(&client),
1116                None,
1117            )
1118            .unwrap();
1119        let bystander_bytes_before =
1120            std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1121
1122        let renamed = engine
1123            .rename_entity(
1124                RenameEntityArgs {
1125                    id: target.id.clone(),
1126                    expected_hash: Some(target.content_hash.clone()),
1127                    new_title: "Renamed Spec".to_string(),
1128                },
1129                actor,
1130                Some(&client),
1131                None,
1132            )
1133            .unwrap();
1134        assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1135
1136        // Old slug must not survive in any mem file — grep-clean,
1137        // scoped to this single-mem workspace.
1138        for path in std::fs::read_dir(&mem_dir).unwrap().flatten() {
1139            let p = path.path();
1140            if p.extension().and_then(|s| s.to_str()) != Some("md") {
1141                continue;
1142            }
1143            let body = std::fs::read_to_string(&p).unwrap();
1144            assert!(
1145                !body.contains("[[target-spec]]"),
1146                "old slug must not survive in {}, got:\n{body}",
1147                p.display()
1148            );
1149        }
1150
1151        // Referrer A's explicit relation now points at the new id.
1152        let in_mem_a = engine.get_entity(&referrer_a.id).unwrap();
1153        assert!(
1154            in_mem_a
1155                .relationships
1156                .iter()
1157                .any(|r| r.rel_type == "REFERENCES"
1158                    && r.target == EntityId::new("specs", "renamed-spec")),
1159            "expected referrer A's relation to point at renamed-spec, got {:?}",
1160            in_mem_a.relationships
1161        );
1162        assert!(
1163            in_mem_a
1164                .sections
1165                .get("purpose")
1166                .map(|s| s.contains("[[renamed-spec]]"))
1167                .unwrap_or(false),
1168            "referrer A's body must be rewritten"
1169        );
1170
1171        // Referrer B's body is rewritten; it had no explicit
1172        // relation, so the relationships list stays empty.
1173        let in_mem_b = engine.get_entity(&referrer_b.id).unwrap();
1174        assert!(
1175            in_mem_b
1176                .sections
1177                .get("purpose")
1178                .map(|s| s.contains("[[renamed-spec]]"))
1179                .unwrap_or(false),
1180            "referrer B's body must be rewritten"
1181        );
1182
1183        // Bystander untouched — exact byte equality on disk.
1184        let bystander_bytes_after =
1185            std::fs::read_to_string(mem_dir.join(&bystander.file_path)).unwrap();
1186        assert_eq!(
1187            bystander_bytes_before, bystander_bytes_after,
1188            "bystander must not be rewritten"
1189        );
1190    }
1191
1192    /// Two-mem test scaffolding: build an engine with `specs` and
1193    /// `memos` Write mounts, and set `cross_mem_links` so each is
1194    /// permitted to link into the other. Returns the engine, both
1195    /// mem directories, and the actor/client tuple. The test then
1196    /// seeds whatever entities it needs.
1197    fn engine_with_two_mems_and_bidirectional_policy(
1198        specs_dir: PathBuf,
1199        memos_dir: PathBuf,
1200    ) -> Engine {
1201        use memstead_schema::workspace_config::CrossLinkValue;
1202        let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1203        let writer_memos = FilesystemMemWriter::new(memos_dir.clone());
1204        let mut engine = Engine::from_mounts(vec![
1205            (
1206                folder_mount("specs", specs_dir),
1207                Box::new(writer_specs) as Box<dyn MemBackend>,
1208            ),
1209            (
1210                folder_mount("memos", memos_dir),
1211                Box::new(writer_memos) as Box<dyn MemBackend>,
1212            ),
1213        ])
1214        .unwrap();
1215        let mut settings = crate::workspace::WorkspaceSettings::default();
1216        settings.cross_mem_links.insert(
1217            "memos".to_string(),
1218            CrossLinkValue::List(vec!["specs".to_string()]),
1219        );
1220        settings.cross_mem_links.insert(
1221            "specs".to_string(),
1222            CrossLinkValue::List(vec!["memos".to_string()]),
1223        );
1224        engine.set_settings(settings);
1225        engine
1226    }
1227
1228    #[test]
1229    fn rename_entity_rewrites_cross_mem_write_referrer() {
1230        use crate::engine::CreateEntityArgs;
1231        use crate::entity::EntityId;
1232        use indexmap::IndexMap;
1233
1234        let tmp_specs = TempDir::new().unwrap();
1235        let tmp_memos = TempDir::new().unwrap();
1236        let specs_dir = tmp_specs.path().to_path_buf();
1237        let memos_dir = tmp_memos.path().to_path_buf();
1238        let mut engine =
1239            engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1240        let (actor, client) = cli_actor();
1241
1242        // Renaming target lives in `specs`.
1243        let target = engine
1244            .create_entity(
1245                empty_create_args("specs", "Target Spec"),
1246                actor,
1247                Some(&client),
1248                None,
1249            )
1250            .unwrap();
1251
1252        // Cross-mem referrer in `memos` has a body wiki-link in the
1253        // `:` form atomically backed by an explicit cross-mem relation.
1254        // The legacy `--` form is a same-mem nested-prefix drift
1255        // (resolves to `memos--specs--target-spec`); under the alias
1256        // model it cannot be backed and the engine surfaces it as
1257        // `SuspiciousNestedPrefix`, so it stays out of fresh fixtures.
1258        let mut sections: IndexMap<String, String> = IndexMap::new();
1259        sections.insert("claim".to_string(), "the claim".to_string());
1260        sections.insert(
1261            "context".to_string(),
1262            "discussion stems from [[specs:target-spec]]".to_string(),
1263        );
1264        let referrer = engine
1265            .create_entity(
1266                CreateEntityArgs {
1267                    anchors: Vec::new(),
1268                    mem: "memos".to_string(),
1269                    title: "Cross Note".to_string(),
1270                    entity_type: "memo".to_string(),
1271                    sections,
1272                    metadata: IndexMap::new(),
1273                    // REFERENCES is engine-emitted from the body wiki-link
1274                    // via the alias-synthesis pass; explicit author is
1275                    // refused under `manual_authoring: forbidden`.
1276                    relations: Vec::new(),
1277                    dry_run: false,
1278                },
1279                actor,
1280                Some(&client),
1281                None,
1282            )
1283            .unwrap();
1284
1285        // Perform the rename.
1286        let renamed = engine
1287            .rename_entity(
1288                RenameEntityArgs {
1289                    id: target.id.clone(),
1290                    expected_hash: Some(target.content_hash.clone()),
1291                    new_title: "Renamed Spec".to_string(),
1292                },
1293                actor,
1294                Some(&client),
1295                None,
1296            )
1297            .unwrap();
1298        assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1299
1300        // Cross-mem referrer's on-disk file now carries the new
1301        // slug in the colon form and no `target-spec` remnants survive.
1302        let referrer_path = memos_dir.join(&referrer.file_path);
1303        let referrer_bytes = std::fs::read_to_string(&referrer_path).unwrap();
1304        assert!(
1305            referrer_bytes.contains("[[specs:renamed-spec]]"),
1306            "expected colon-form rewrite in referrer body, got:\n{referrer_bytes}"
1307        );
1308        assert!(
1309            !referrer_bytes.contains("target-spec"),
1310            "old slug must not survive in referrer file, got:\n{referrer_bytes}"
1311        );
1312
1313        // In-memory referrer's relationship list points at the new id.
1314        let in_mem = engine.get_entity(&referrer.id).unwrap();
1315        assert!(
1316            in_mem
1317                .relationships
1318                .iter()
1319                .any(|r| r.rel_type == "REFERENCES"
1320                    && r.target == EntityId::new("specs", "renamed-spec")),
1321            "expected cross-mem relation to point at renamed-spec, got {:?}",
1322            in_mem.relationships
1323        );
1324        assert!(
1325            in_mem.relationships.iter().all(|r| r.target != target.id),
1326            "no relationship may still target the old id, got: {:?}",
1327            in_mem.relationships
1328        );
1329    }
1330
1331    /// Wraps a real `MemBackend` and forwards every method
1332    /// verbatim, except `commit_with_expected_parent` returns
1333    /// `BackendError::ParentMismatch` whenever the caller passes a
1334    /// non-`None` `expected_parent`. Models the "sibling writer
1335    /// advanced the head between snapshot and our commit" case
1336    /// without needing a real git-branch repository.
1337    struct DriftingBackend {
1338        inner: Box<dyn MemBackend>,
1339    }
1340    impl DriftingBackend {
1341        fn new(inner: Box<dyn MemBackend>) -> Self {
1342            Self { inner }
1343        }
1344    }
1345    impl crate::backend::MemBackend for DriftingBackend {
1346        fn list_entities(&self) -> Result<Vec<PathBuf>, crate::backend::BackendError> {
1347            self.inner.list_entities()
1348        }
1349        fn read_entity(
1350            &self,
1351            rel: &std::path::Path,
1352        ) -> Result<Option<Vec<u8>>, crate::backend::BackendError> {
1353            self.inner.read_entity(rel)
1354        }
1355        fn write_entity(
1356            &self,
1357            rel: &std::path::Path,
1358            b: &[u8],
1359        ) -> Result<(), crate::backend::BackendError> {
1360            self.inner.write_entity(rel, b)
1361        }
1362        fn delete_entity(&self, rel: &std::path::Path) -> Result<(), crate::backend::BackendError> {
1363            self.inner.delete_entity(rel)
1364        }
1365        fn move_entity(
1366            &self,
1367            f: &std::path::Path,
1368            t: &std::path::Path,
1369        ) -> Result<(), crate::backend::BackendError> {
1370            self.inner.move_entity(f, t)
1371        }
1372        fn commit(
1373            &self,
1374            m: &str,
1375            c: &crate::vcs::CommitContext<'_>,
1376        ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1377            self.inner.commit(m, c)
1378        }
1379        fn commit_with_expected_parent(
1380            &self,
1381            m: &str,
1382            c: &crate::vcs::CommitContext<'_>,
1383            expected_parent: Option<&str>,
1384        ) -> Result<crate::storage::CommitId, crate::backend::BackendError> {
1385            if let Some(expected) = expected_parent {
1386                Err(crate::backend::BackendError::ParentMismatch {
1387                    expected: expected.to_string(),
1388                    actual: "drifted-by-sibling-writer".to_string(),
1389                })
1390            } else {
1391                self.inner.commit(m, c)
1392            }
1393        }
1394        fn append_provenance(
1395            &self,
1396            r: &crate::Provenance,
1397        ) -> Result<(), crate::backend::BackendError> {
1398            self.inner.append_provenance(r)
1399        }
1400        fn read_provenance(
1401            &self,
1402            c: Option<&str>,
1403        ) -> Result<Vec<crate::Provenance>, crate::backend::BackendError> {
1404            self.inner.read_provenance(c)
1405        }
1406        fn current_head(&self) -> Result<Option<String>, crate::backend::BackendError> {
1407            // Return a non-None head so the rename's snapshot is
1408            // populated and the parent-pin path is exercised.
1409            Ok(Some("snapshot-head-sha".to_string()))
1410        }
1411    }
1412
1413    #[test]
1414    fn rename_entity_surfaces_partial_failure_when_peer_mem_drifts() {
1415        use crate::engine::CreateEntityArgs;
1416        use indexmap::IndexMap;
1417        use memstead_schema::workspace_config::CrossLinkValue;
1418
1419        let tmp_specs = TempDir::new().unwrap();
1420        let tmp_memos = TempDir::new().unwrap();
1421        let specs_dir = tmp_specs.path().to_path_buf();
1422        let memos_dir = tmp_memos.path().to_path_buf();
1423
1424        // specs uses a plain filesystem backend; memos uses one
1425        // wrapped in DriftingBackend so its peer-mem commit during
1426        // rename fails with ParentMismatch (the parent-pin tripped
1427        // by a hypothetical sibling writer).
1428        let writer_specs = FilesystemMemWriter::new(specs_dir.clone());
1429        let writer_memos_inner: Box<dyn MemBackend> =
1430            Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1431        let writer_memos = DriftingBackend::new(writer_memos_inner);
1432
1433        let mut engine = Engine::from_mounts(vec![
1434            (
1435                folder_mount("specs", specs_dir.clone()),
1436                Box::new(writer_specs) as Box<dyn MemBackend>,
1437            ),
1438            (
1439                folder_mount("memos", memos_dir.clone()),
1440                Box::new(writer_memos) as Box<dyn MemBackend>,
1441            ),
1442        ])
1443        .unwrap();
1444        let mut settings = crate::workspace::WorkspaceSettings::default();
1445        settings.cross_mem_links.insert(
1446            "memos".to_string(),
1447            CrossLinkValue::List(vec!["specs".to_string()]),
1448        );
1449        settings.cross_mem_links.insert(
1450            "specs".to_string(),
1451            CrossLinkValue::List(vec!["memos".to_string()]),
1452        );
1453        engine.set_settings(settings);
1454
1455        let (actor, client) = cli_actor();
1456        let target = engine
1457            .create_entity(
1458                empty_create_args("specs", "Target Spec"),
1459                actor,
1460                Some(&client),
1461                None,
1462            )
1463            .unwrap();
1464        let mut sections: IndexMap<String, String> = IndexMap::new();
1465        sections.insert("claim".to_string(), "the claim".to_string());
1466        sections.insert(
1467            "context".to_string(),
1468            "see [[specs:target-spec]]".to_string(),
1469        );
1470        let _referrer = engine
1471            .create_entity(
1472                CreateEntityArgs {
1473                    anchors: Vec::new(),
1474                    mem: "memos".to_string(),
1475                    title: "Cross Note".to_string(),
1476                    entity_type: "memo".to_string(),
1477                    sections,
1478                    metadata: IndexMap::new(),
1479                    // REFERENCES is engine-emitted from the body wiki-link
1480                    // via the alias-synthesis pass; explicit author is
1481                    // refused under `manual_authoring: forbidden`.
1482                    relations: Vec::new(),
1483                    dry_run: false,
1484                },
1485                actor,
1486                Some(&client),
1487                None,
1488            )
1489            .unwrap();
1490
1491        let err = engine
1492            .rename_entity(
1493                RenameEntityArgs {
1494                    id: target.id.clone(),
1495                    expected_hash: Some(target.content_hash.clone()),
1496                    new_title: "Renamed Spec".to_string(),
1497                },
1498                actor,
1499                Some(&client),
1500                None,
1501            )
1502            .unwrap_err();
1503        match err {
1504            EngineError::RenamePartialFailure {
1505                committed_mems,
1506                failed_mem,
1507                failure_cause,
1508            } => {
1509                // The renaming entity's own mem committed before
1510                // the peer-mem commit was attempted, so it must be
1511                // listed as already-committed.
1512                assert_eq!(committed_mems, vec!["specs".to_string()]);
1513                assert_eq!(failed_mem, "memos");
1514                assert_eq!(failure_cause, "drift");
1515            }
1516            other => panic!("expected RenamePartialFailure, got {other:?}"),
1517        }
1518        // The renaming entity's own mem has the new file (its
1519        // commit landed) — that's the whole point of the partial-
1520        // failure envelope: source mem is durable, peer is not.
1521        assert!(specs_dir.join("renamed-spec.md").exists());
1522        assert!(!specs_dir.join(&target.file_path).exists());
1523    }
1524
1525    #[test]
1526    fn rename_entity_tags_every_per_mem_commit_with_same_logical_operation_id() {
1527        use crate::backend::MemBackend;
1528        use crate::engine::CreateEntityArgs;
1529        use indexmap::IndexMap;
1530
1531        let tmp_specs = TempDir::new().unwrap();
1532        let tmp_memos = TempDir::new().unwrap();
1533        let specs_dir = tmp_specs.path().to_path_buf();
1534        let memos_dir = tmp_memos.path().to_path_buf();
1535        let mut engine =
1536            engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1537        let (actor, client) = cli_actor();
1538
1539        let target = engine
1540            .create_entity(
1541                empty_create_args("specs", "Target Spec"),
1542                actor,
1543                Some(&client),
1544                None,
1545            )
1546            .unwrap();
1547        let mut sections: IndexMap<String, String> = IndexMap::new();
1548        sections.insert("claim".to_string(), "the claim".to_string());
1549        sections.insert(
1550            "context".to_string(),
1551            "discussion stems from [[specs:target-spec]]".to_string(),
1552        );
1553        let _referrer = engine
1554            .create_entity(
1555                CreateEntityArgs {
1556                    anchors: Vec::new(),
1557                    mem: "memos".to_string(),
1558                    title: "Cross Note".to_string(),
1559                    entity_type: "memo".to_string(),
1560                    sections,
1561                    metadata: IndexMap::new(),
1562                    // REFERENCES is engine-emitted from the body wiki-link
1563                    // via the alias-synthesis pass; explicit author is
1564                    // refused under `manual_authoring: forbidden`.
1565                    relations: Vec::new(),
1566                    dry_run: false,
1567                },
1568                actor,
1569                Some(&client),
1570                None,
1571            )
1572            .unwrap();
1573
1574        let _ = engine
1575            .rename_entity(
1576                RenameEntityArgs {
1577                    id: target.id.clone(),
1578                    expected_hash: Some(target.content_hash.clone()),
1579                    new_title: "Renamed Spec".to_string(),
1580                },
1581                actor,
1582                Some(&client),
1583                None,
1584            )
1585            .unwrap();
1586
1587        // Read provenance from each mem's backend and find the
1588        // rename entries. Both mems must record a Rename entry, and
1589        // both entries must share the same logical_operation_id.
1590        let specs_backend: Box<dyn MemBackend> =
1591            Box::new(FilesystemMemWriter::new(specs_dir.clone()));
1592        let memos_backend: Box<dyn MemBackend> =
1593            Box::new(FilesystemMemWriter::new(memos_dir.clone()));
1594        let specs_provenance = specs_backend.read_provenance(None).unwrap();
1595        let memos_provenance = memos_backend.read_provenance(None).unwrap();
1596
1597        let specs_rename = specs_provenance
1598            .iter()
1599            .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1600            .expect("specs mem must have a rename provenance entry");
1601        let memos_rename = memos_provenance
1602            .iter()
1603            .find(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Rename))
1604            .expect("memos mem must have a rename provenance entry");
1605
1606        let specs_id = specs_rename
1607            .logical_operation_id
1608            .as_deref()
1609            .expect("specs rename entry must carry a logical_operation_id");
1610        let memos_id = memos_rename
1611            .logical_operation_id
1612            .as_deref()
1613            .expect("memos rename entry must carry a logical_operation_id");
1614        assert_eq!(
1615            specs_id, memos_id,
1616            "both per-mem rename commits must share the same logical_operation_id"
1617        );
1618        assert!(
1619            specs_id.starts_with("logop-"),
1620            "logical_operation_id must use the `logop-` prefix the engine mints; got {specs_id}"
1621        );
1622    }
1623
1624    #[test]
1625    fn rename_entity_refuses_when_cross_mem_referrer_blocked_by_policy() {
1626        use crate::engine::CreateEntityArgs;
1627        use indexmap::IndexMap;
1628        use memstead_schema::workspace_config::CrossLinkValue;
1629
1630        let tmp_specs = TempDir::new().unwrap();
1631        let tmp_memos = TempDir::new().unwrap();
1632        let specs_dir = tmp_specs.path().to_path_buf();
1633        let memos_dir = tmp_memos.path().to_path_buf();
1634
1635        // Start with full policy so the create + cross-mem relate
1636        // succeed during setup.
1637        let mut engine =
1638            engine_with_two_mems_and_bidirectional_policy(specs_dir.clone(), memos_dir.clone());
1639        let (actor, client) = cli_actor();
1640
1641        let target = engine
1642            .create_entity(
1643                empty_create_args("specs", "Target Spec"),
1644                actor,
1645                Some(&client),
1646                None,
1647            )
1648            .unwrap();
1649        let mut sections: IndexMap<String, String> = IndexMap::new();
1650        sections.insert("claim".to_string(), "the claim".to_string());
1651        sections.insert(
1652            "context".to_string(),
1653            "see [[specs:target-spec]]".to_string(),
1654        );
1655        let _referrer = engine
1656            .create_entity(
1657                CreateEntityArgs {
1658                    anchors: Vec::new(),
1659                    mem: "memos".to_string(),
1660                    title: "Cross Note".to_string(),
1661                    entity_type: "memo".to_string(),
1662                    sections,
1663                    metadata: IndexMap::new(),
1664                    // REFERENCES is engine-emitted from the body wiki-link
1665                    // via the alias-synthesis pass; explicit author is
1666                    // refused under `manual_authoring: forbidden`.
1667                    relations: Vec::new(),
1668                    dry_run: false,
1669                },
1670                actor,
1671                Some(&client),
1672                None,
1673            )
1674            .unwrap();
1675
1676        // Tighten policy: revoke `memos → specs`, which is the
1677        // direction of the existing referrer edge (`memos--cross-note
1678        // REFERENCES specs--target-spec`). The propagated rewrite
1679        // preserves that direction, so the rename gate must refuse
1680        // up-front with the now-blocked direction named.
1681        let mut settings = crate::workspace::WorkspaceSettings::default();
1682        settings.cross_mem_links.insert(
1683            "specs".to_string(),
1684            CrossLinkValue::List(vec!["memos".to_string()]),
1685        );
1686        // No entry for `memos` → `cross_mem_link_allowed("memos", "specs")` = false.
1687        engine.set_settings(settings);
1688
1689        let err = engine
1690            .rename_entity(
1691                RenameEntityArgs {
1692                    id: target.id.clone(),
1693                    expected_hash: Some(target.content_hash.clone()),
1694                    new_title: "Renamed Spec".to_string(),
1695                },
1696                actor,
1697                Some(&client),
1698                None,
1699            )
1700            .unwrap_err();
1701        match err {
1702            EngineError::RenameBlockedByCrossMemPolicy {
1703                from_mem,
1704                blocked_referrers,
1705            } => {
1706                assert_eq!(from_mem, "specs");
1707                assert_eq!(blocked_referrers.len(), 1);
1708                assert_eq!(blocked_referrers[0].from_mem, "memos");
1709                assert_eq!(blocked_referrers[0].to_mem, "specs");
1710                assert_eq!(blocked_referrers[0].count, 1);
1711            }
1712            other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
1713        }
1714        // Nothing landed: target file is still at the old path, no
1715        // new file was created.
1716        assert!(specs_dir.join(&target.file_path).exists());
1717        assert!(!specs_dir.join("renamed-spec.md").exists());
1718    }
1719
1720    /// Rename target has no Write-mem referrers but is referenced
1721    /// from a ReadOnly archive. The rename rewrites the renaming
1722    /// entity's own mem but cannot reach into the archive — the
1723    /// archive's wiki-link still points at the old slug. To keep
1724    /// `incoming(<new_id>)` aligned with what a fresh boot would
1725    /// produce and surface the dangling reference, the OLD-id store
1726    /// entry is demoted to a stub holding the surviving archive
1727    /// incoming edges, with a `ResidualStubForReadOnlyReferrers`
1728    /// warning on the outcome. Mirrors delete-path's same-shaped
1729    /// demotion.
1730    #[test]
1731    fn rename_entity_demotes_to_stub_when_only_readonly_cross_mem_referrers_remain() {
1732        use crate::engine::test_helpers::{archive_mount, build_archive};
1733        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1734
1735        let tmp = TempDir::new().unwrap();
1736        let writable_dir = tmp.path().join("writable");
1737        std::fs::create_dir_all(&writable_dir).unwrap();
1738        let writer = FilesystemMemWriter::new(writable_dir.clone());
1739
1740        // Archive entity declares an explicit cross-mem relation
1741        // into the writable mem; under the alias model every edge
1742        // originates from `## Relationships`.
1743        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";
1744        let archive_path = build_archive(
1745            tmp.path(),
1746            "archive",
1747            &[("archived-source.md", archive_md.as_bytes())],
1748        );
1749
1750        let folder_mount = Mount {
1751            mem: "specs".to_string(),
1752            schema: Some(crate::engine::test_helpers::pin("default")),
1753            storage: MountStorage::Folder {
1754                path: writable_dir.clone(),
1755            },
1756            capability: MountCapability::Write,
1757            lifecycle: MountLifecycle::Eager,
1758            cross_linkable: true,
1759            migration_target: None,
1760        };
1761        let archive_reader = crate::storage::ArchiveBackend::new(archive_path.clone());
1762        let mut engine = Engine::from_mounts(vec![
1763            (folder_mount, Box::new(writer) as Box<dyn MemBackend>),
1764            (
1765                archive_mount("archive", archive_path.clone()),
1766                Box::new(archive_reader) as Box<dyn MemBackend>,
1767            ),
1768        ])
1769        .unwrap();
1770
1771        let (actor, client) = cli_actor();
1772        let target = engine
1773            .create_entity(
1774                empty_create_args("specs", "Target"),
1775                actor,
1776                Some(&client),
1777                None,
1778            )
1779            .unwrap();
1780
1781        // Sanity check: the archive's wiki-link produces an incoming
1782        // edge on the target.
1783        let archived_source_id = crate::EntityId::new("archive", "archived-source");
1784        let incoming_pre: Vec<_> = engine
1785            .store()
1786            .incoming(&target.id)
1787            .iter()
1788            .map(|e| e.from.clone())
1789            .collect();
1790        assert!(
1791            incoming_pre.contains(&archived_source_id),
1792            "archive wiki-link must produce an incoming edge on target; got {incoming_pre:?}"
1793        );
1794
1795        // Rename. The archive can't be rewritten; engine demotes the
1796        // OLD-id store entry to a stub and emits the warning.
1797        let outcome = engine
1798            .rename_entity(
1799                RenameEntityArgs {
1800                    id: target.id.clone(),
1801                    expected_hash: Some(target.content_hash.clone()),
1802                    new_title: "Renamed Target".to_string(),
1803                },
1804                actor,
1805                Some(&client),
1806                None,
1807            )
1808            .unwrap();
1809        assert_eq!(outcome.new_id.to_string(), "specs--renamed-target");
1810
1811        // New file landed.
1812        assert!(writable_dir.join(&outcome.new_path).exists());
1813        // Old slug demoted to a stub at the original id.
1814        let demoted = engine
1815            .get_entity(&target.id)
1816            .expect("residual stub must remain at old id");
1817        assert!(demoted.stub, "demoted entity must be flagged as stub");
1818        assert!(demoted.entity_type.is_empty());
1819        // Archive's incoming edge still points at the old id (the
1820        // archive markdown wasn't rewritten).
1821        let incoming_old: Vec<_> = engine
1822            .store()
1823            .incoming(&target.id)
1824            .iter()
1825            .map(|e| e.from.clone())
1826            .collect();
1827        assert!(
1828            incoming_old.contains(&archived_source_id),
1829            "archive incoming edge must survive demotion at old id; got {incoming_old:?}"
1830        );
1831        // New id has no incoming edge from the archive (its wiki-link
1832        // points at the old slug, not the new one).
1833        let incoming_new: Vec<_> = engine
1834            .store()
1835            .incoming(&outcome.new_id)
1836            .iter()
1837            .map(|e| e.from.clone())
1838            .collect();
1839        assert!(
1840            !incoming_new.contains(&archived_source_id),
1841            "archive must not be wired to new id (markdown still references old slug); got {incoming_new:?}"
1842        );
1843        // Warning carries the surviving referrer.
1844        let referrers = outcome
1845            .warnings
1846            .iter()
1847            .find_map(|w| match w {
1848                WarningHint::ResidualStubForReadOnlyReferrers {
1849                    id: warn_id,
1850                    referrers,
1851                } => {
1852                    assert_eq!(warn_id, &target.id);
1853                    Some(referrers.clone())
1854                }
1855                _ => None,
1856            })
1857            .expect("ResidualStubForReadOnlyReferrers warning must surface");
1858        assert_eq!(referrers, vec![archived_source_id]);
1859    }
1860
1861    /// A same-mem referrer whose body uses the full-id form
1862    /// `[[<mem>--<slug>]]` to point at the renaming entity must have
1863    /// that token retargeted to the new slug. Pre-fix the rewrite
1864    /// pass only matched short-form `[[<slug>]]`; full-id tokens
1865    /// survived and pointed at the dead id. The new code calls
1866    /// `rewrite_cross_mem_slug` on the same-mem path alongside
1867    /// the bare-slug helper, covering both legal slug-form variants.
1868    #[test]
1869    fn rename_entity_rewrites_full_id_form_body_link_on_same_mem_referrer() {
1870        use crate::engine::CreateEntityArgs;
1871        use indexmap::IndexMap;
1872        let tmp = TempDir::new().unwrap();
1873        let mem_dir = tmp.path().to_path_buf();
1874        let writer = FilesystemMemWriter::new(mem_dir.clone());
1875        let mut engine = Engine::from_mounts(vec![(
1876            folder_mount("specs", mem_dir.clone()),
1877            Box::new(writer) as Box<dyn MemBackend>,
1878        )])
1879        .unwrap();
1880        let (actor, client) = cli_actor();
1881
1882        // Target entity to rename.
1883        let target = engine
1884            .create_entity(
1885                empty_create_args("specs", "Target Spec"),
1886                actor,
1887                Some(&client),
1888                None,
1889            )
1890            .unwrap();
1891        assert_eq!(target.id.to_string(), "specs--target-spec");
1892
1893        // Referrer body uses the full-id form `[[specs--target-spec]]`.
1894        // The body parser admits both short and full-id forms; the
1895        // alias-synthesis pass emits one REFERENCES edge regardless
1896        // of which form the author wrote.
1897        let mut sections: IndexMap<String, String> = IndexMap::new();
1898        sections.insert("identity".to_string(), "referrer identity".to_string());
1899        sections.insert(
1900            "purpose".to_string(),
1901            "see also [[specs--target-spec]] for context".to_string(),
1902        );
1903        let referrer = engine
1904            .create_entity(
1905                CreateEntityArgs {
1906                    anchors: Vec::new(),
1907                    mem: "specs".to_string(),
1908                    title: "Referrer Full".to_string(),
1909                    entity_type: "spec".to_string(),
1910                    sections,
1911                    metadata: IndexMap::new(),
1912                    relations: Vec::new(),
1913                    dry_run: false,
1914                },
1915                actor,
1916                Some(&client),
1917                None,
1918            )
1919            .unwrap();
1920
1921        let renamed = engine
1922            .rename_entity(
1923                RenameEntityArgs {
1924                    id: target.id.clone(),
1925                    expected_hash: Some(target.content_hash.clone()),
1926                    new_title: "Renamed Spec".to_string(),
1927                },
1928                actor,
1929                Some(&client),
1930                None,
1931            )
1932            .unwrap();
1933        assert_eq!(renamed.new_id.to_string(), "specs--renamed-spec");
1934
1935        // The full-id form was retargeted (slug part rewritten, full-id
1936        // form preserved). The old slug must not survive in the
1937        // referrer's on-disk file.
1938        let referrer_path = mem_dir.join(&referrer.file_path);
1939        let body = std::fs::read_to_string(&referrer_path).unwrap();
1940        assert!(
1941            body.contains("[[specs--renamed-spec]]"),
1942            "expected full-id form retargeted to new slug, got:\n{body}"
1943        );
1944        assert!(
1945            !body.contains("target-spec"),
1946            "old slug must not survive in any form, got:\n{body}"
1947        );
1948    }
1949
1950    #[test]
1951    fn rename_entity_rejects_collision_with_existing_id() {
1952        let tmp = TempDir::new().unwrap();
1953        let (mut engine, first) = engine_with_seed(&tmp, "First");
1954        let (actor, client) = cli_actor();
1955        let _ = engine
1956            .create_entity(
1957                empty_create_args("specs", "Second"),
1958                actor,
1959                Some(&client),
1960                None,
1961            )
1962            .unwrap();
1963        // Rename `first` to a title that slugifies to `second`.
1964        let err = engine
1965            .rename_entity(
1966                RenameEntityArgs {
1967                    id: first.id.clone(),
1968                    expected_hash: Some(first.content_hash.clone()),
1969                    new_title: "Second".to_string(),
1970                },
1971                actor,
1972                Some(&client),
1973                None,
1974            )
1975            .unwrap_err();
1976        assert!(matches!(
1977            err,
1978            EngineError::AlreadyExists { ref id, ref existing_title, existing_is_stub: false }
1979                if id == "specs--second" && existing_title == "Second"
1980        ));
1981    }
1982
1983    /// With `test → other` granted, an `IMPLEMENTS` edge from
1984    /// `test--src` to `other--target` is created. Revoking `test →
1985    /// other` (the actual edge direction) must block the rename
1986    /// up-front — pre-fix the gate checked the inverse direction
1987    /// (`other → test`), which was un-granted in both phases, so the
1988    /// rename refused for the wrong reason.
1989    #[test]
1990    fn rename_propagation_gate_checks_actual_edge_direction() {
1991        use crate::engine::error::BlockedReferrer;
1992        use crate::engine::{CreateEntityArgs, RelateEntityArgs};
1993        use indexmap::IndexMap;
1994        use memstead_schema::workspace_config::CrossLinkValue;
1995
1996        let tmp_test = TempDir::new().unwrap();
1997        let tmp_other = TempDir::new().unwrap();
1998        let test_dir = tmp_test.path().to_path_buf();
1999        let other_dir = tmp_other.path().to_path_buf();
2000
2001        // Pretty-print scaffold: `test` and `other` are the
2002        // canonical mem names. Reuse the helper by ignoring the
2003        // returned dirs and overriding the policy explicitly.
2004        let writer_test = FilesystemMemWriter::new(test_dir.clone());
2005        let writer_other = FilesystemMemWriter::new(other_dir.clone());
2006        let mut engine = Engine::from_mounts(vec![
2007            (
2008                folder_mount("test", test_dir.clone()),
2009                Box::new(writer_test) as Box<dyn MemBackend>,
2010            ),
2011            (
2012                folder_mount("other", other_dir.clone()),
2013                Box::new(writer_other) as Box<dyn MemBackend>,
2014            ),
2015        ])
2016        .unwrap();
2017        let (actor, client) = cli_actor();
2018
2019        // Setup policy: only `test → other` granted. Create the edge
2020        // `test--src IMPLEMENTS other--target`.
2021        let mut settings = crate::workspace::WorkspaceSettings::default();
2022        settings.cross_mem_links.insert(
2023            "test".to_string(),
2024            CrossLinkValue::List(vec!["other".to_string()]),
2025        );
2026        engine.set_settings(settings);
2027
2028        let target = engine
2029            .create_entity(
2030                empty_create_args("other", "Target"),
2031                actor,
2032                Some(&client),
2033                None,
2034            )
2035            .unwrap();
2036        let mut src_sections: IndexMap<String, String> = IndexMap::new();
2037        src_sections.insert("identity".to_string(), "source identity".to_string());
2038        src_sections.insert("purpose".to_string(), "source purpose".to_string());
2039        let src = engine
2040            .create_entity(
2041                CreateEntityArgs {
2042                    anchors: Vec::new(),
2043                    mem: "test".to_string(),
2044                    title: "Src".to_string(),
2045                    entity_type: "spec".to_string(),
2046                    sections: src_sections,
2047                    metadata: IndexMap::new(),
2048                    relations: Vec::new(),
2049                    dry_run: false,
2050                },
2051                actor,
2052                Some(&client),
2053                None,
2054            )
2055            .unwrap();
2056        let src = engine
2057            .relate_entity(
2058                RelateEntityArgs {
2059                    source: src.id.clone(),
2060                    rel_type: "IMPLEMENTS".to_string(),
2061                    target: target.id.clone(),
2062                    expected_hash: Some(src.content_hash.clone()),
2063                    remove: false,
2064                    description: None,
2065                    dry_run: false,
2066                },
2067                actor,
2068                Some(&client),
2069                None,
2070            )
2071            .unwrap();
2072        let _ = src; // sink — we won't use the post-relate hash again
2073
2074        // Revoke `test → other`. The post-rename rewrite would
2075        // re-emit the `test → other` edge, which the gate must now
2076        // refuse — pre-fix the inverted check passed because nothing
2077        // ever gated the right direction.
2078        engine.set_settings(crate::workspace::WorkspaceSettings::default());
2079
2080        let err = engine
2081            .rename_entity(
2082                RenameEntityArgs {
2083                    id: target.id.clone(),
2084                    expected_hash: Some(target.content_hash.clone()),
2085                    new_title: "Renamed".to_string(),
2086                },
2087                actor,
2088                Some(&client),
2089                None,
2090            )
2091            .unwrap_err();
2092        match err {
2093            EngineError::RenameBlockedByCrossMemPolicy {
2094                from_mem,
2095                blocked_referrers,
2096            } => {
2097                assert_eq!(from_mem, "other");
2098                assert_eq!(
2099                    blocked_referrers,
2100                    vec![BlockedReferrer {
2101                        from_mem: "test".to_string(),
2102                        to_mem: "other".to_string(),
2103                        count: 1,
2104                    }],
2105                    "blocked_referrers must name the actual edge direction (test → other)",
2106                );
2107            }
2108            other => panic!("expected RenameBlockedByCrossMemPolicy, got {other:?}"),
2109        }
2110        // No write landed in either mem: target file path
2111        // unchanged, the new slug file absent.
2112        assert!(other_dir.join(&target.file_path).exists());
2113        assert!(!other_dir.join("renamed.md").exists());
2114    }
2115
2116    /// Re-granting the actual edge
2117    /// direction lets the same rename succeed. Verifies the gate
2118    /// fires only on the *un-granted* direction.
2119    #[test]
2120    fn rename_propagation_succeeds_when_actual_edge_direction_granted() {
2121        use crate::engine::{CreateEntityArgs, RelateEntityArgs};
2122        use indexmap::IndexMap;
2123        use memstead_schema::workspace_config::CrossLinkValue;
2124
2125        let tmp_test = TempDir::new().unwrap();
2126        let tmp_other = TempDir::new().unwrap();
2127        let test_dir = tmp_test.path().to_path_buf();
2128        let other_dir = tmp_other.path().to_path_buf();
2129
2130        let writer_test = FilesystemMemWriter::new(test_dir.clone());
2131        let writer_other = FilesystemMemWriter::new(other_dir.clone());
2132        let mut engine = Engine::from_mounts(vec![
2133            (
2134                folder_mount("test", test_dir),
2135                Box::new(writer_test) as Box<dyn MemBackend>,
2136            ),
2137            (
2138                folder_mount("other", other_dir),
2139                Box::new(writer_other) as Box<dyn MemBackend>,
2140            ),
2141        ])
2142        .unwrap();
2143        let (actor, client) = cli_actor();
2144
2145        let mut settings = crate::workspace::WorkspaceSettings::default();
2146        settings.cross_mem_links.insert(
2147            "test".to_string(),
2148            CrossLinkValue::List(vec!["other".to_string()]),
2149        );
2150        engine.set_settings(settings);
2151
2152        let target = engine
2153            .create_entity(
2154                empty_create_args("other", "Target"),
2155                actor,
2156                Some(&client),
2157                None,
2158            )
2159            .unwrap();
2160        let mut src_sections: IndexMap<String, String> = IndexMap::new();
2161        src_sections.insert("identity".to_string(), "source identity".to_string());
2162        src_sections.insert("purpose".to_string(), "source purpose".to_string());
2163        let src = engine
2164            .create_entity(
2165                CreateEntityArgs {
2166                    anchors: Vec::new(),
2167                    mem: "test".to_string(),
2168                    title: "Src".to_string(),
2169                    entity_type: "spec".to_string(),
2170                    sections: src_sections,
2171                    metadata: IndexMap::new(),
2172                    relations: Vec::new(),
2173                    dry_run: false,
2174                },
2175                actor,
2176                Some(&client),
2177                None,
2178            )
2179            .unwrap();
2180        let _ = engine
2181            .relate_entity(
2182                RelateEntityArgs {
2183                    source: src.id.clone(),
2184                    rel_type: "IMPLEMENTS".to_string(),
2185                    target: target.id.clone(),
2186                    expected_hash: Some(src.content_hash.clone()),
2187                    remove: false,
2188                    description: None,
2189                    dry_run: false,
2190                },
2191                actor,
2192                Some(&client),
2193                None,
2194            )
2195            .unwrap();
2196
2197        // Policy unchanged from setup (`test → other` still granted)
2198        // — rename must succeed and rewrite the cross-mem referrer.
2199        let outcome = engine
2200            .rename_entity(
2201                RenameEntityArgs {
2202                    id: target.id.clone(),
2203                    expected_hash: Some(target.content_hash.clone()),
2204                    new_title: "Renamed".to_string(),
2205                },
2206                actor,
2207                Some(&client),
2208                None,
2209            )
2210            .unwrap();
2211        assert_ne!(outcome.old_id, outcome.new_id);
2212        let renamed = engine
2213            .get_entity(&outcome.new_id)
2214            .expect("renamed entity persists");
2215        assert_eq!(renamed.title, "Renamed");
2216        // Referrer rewritten — IMPLEMENTS edge now points at the new id.
2217        let updated_src = engine.get_entity(&src.id).expect("source persists");
2218        assert!(
2219            updated_src
2220                .relationships
2221                .iter()
2222                .any(|r| r.rel_type == "IMPLEMENTS" && r.target == outcome.new_id),
2223            "referrer's IMPLEMENTS edge must point at the new id after rewrite"
2224        );
2225    }
2226
2227    /// A rename whose target has no
2228    /// cross-mem referrers bypasses the gate entirely. Even with
2229    /// a fully-empty cross-link policy, the rename succeeds.
2230    #[test]
2231    fn rename_with_no_cross_mem_referrers_succeeds_regardless_of_policy() {
2232        let tmp = TempDir::new().unwrap();
2233        let mem_dir = tmp.path().to_path_buf();
2234        let writer = FilesystemMemWriter::new(mem_dir.clone());
2235        let mut engine = Engine::from_mounts(vec![(
2236            folder_mount("specs", mem_dir),
2237            Box::new(writer) as Box<dyn MemBackend>,
2238        )])
2239        .unwrap();
2240        let (actor, client) = cli_actor();
2241        // Default settings: no cross-mem links at all. The
2242        // single-mem rename's referrers (if any) are all same-mem
2243        // and bypass the gate by construction.
2244        let target = engine
2245            .create_entity(
2246                empty_create_args("specs", "Target"),
2247                actor,
2248                Some(&client),
2249                None,
2250            )
2251            .unwrap();
2252        let outcome = engine
2253            .rename_entity(
2254                RenameEntityArgs {
2255                    id: target.id.clone(),
2256                    expected_hash: Some(target.content_hash.clone()),
2257                    new_title: "Renamed Target".to_string(),
2258                },
2259                actor,
2260                Some(&client),
2261                None,
2262            )
2263            .unwrap();
2264        assert_ne!(outcome.old_id, outcome.new_id);
2265    }
2266}