Skip to main content

memstead_base/engine/mutation/
rename.rs

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