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