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