Skip to main content

memstead_base/engine/mutation/
rename.rs

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