Skip to main content

memstead_engine/
mem_management.rs

1//! Mem-lifecycle orchestrator — full home for the multi-mem create
2//! and delete pipelines. The matcher primitives
3//! ([`memstead_base::CreateRuleSet`], [`memstead_base::DeleteRuleSet`],
4//! [`memstead_base::MatcherSet`]) stay in lean because the lean engine's
5//! `cross_mem_link_allowed` synthesises a [`memstead_base::CreateRuleSet`]
6//! on multi-folder workspaces. Only the lifecycle orchestrators —
7//! `create_mem`, `delete_mem`, their param/response types, the
8//! shared `NOTE_MAX_LEN` cap, and the `validate_mem_path` helper —
9//! live here.
10//!
11//! Functions take `&mut memstead_base::Engine` directly rather than going
12//! through a `FullEngine` wrapper struct: the lean engine is a single
13//! polymorphic `Engine` parameterised by `Box<dyn MemBackend>` and
14//! already carries every state field the orchestrators need
15//! (`mem_router`, `settings`, `backend_factory`, `workspace_root`,
16//! `git_branch_ops`). Full contributes lifecycle as free functions over
17//! that engine; no separate engine type, no policy-provider trait.
18//!
19//! Return type is `Result<_, crate::FullEngineError>`. Lean-side
20//! failures (`InvalidInput`, `UnknownMem`, `SchemaResolverInit`,
21//! `SchemaNotFound`, `MemNameCollision`, `Mem(_)`, `Backend(_)`)
22//! propagate verbatim through `FullEngineError::Lean(_)` via the
23//! `#[from] memstead_base::EngineError` conversion — the `?` operator on
24//! `engine.persist_state()?` and similar lean calls does the wrap
25//! automatically. The four lifecycle-only variants
26//! (`MemPathNotAllowed`, `MemReferencedByPolicy`, `MemSchemaNotAllowed`,
27//! `ConfigAlreadyExists`) are constructed as `FullEngineError::*`
28//! directly; they no longer live in `memstead_base::EngineError`.
29
30use memstead_base::mem_management::{CreateRuleSet, DeleteRuleSet};
31
32use crate::FullEngineError;
33
34/// Note-length cap shared with `memstead_create` / `memstead_update` / full's
35/// lifecycle orchestrators. Mirrors `memstead_git_branch::NOTE_MAX_LEN`.
36pub const NOTE_MAX_LEN: usize = 280;
37
38/// Compose an ISO-8601 UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) for the
39/// current wall clock. Used to stamp the `unregistered_at` tombstone
40/// on `memstead mem unregister`. Hand-rolled to avoid a
41/// chrono / time dependency — the codebase already calculates the
42/// date portion in `memstead_base::entity::generator` via the same
43/// epoch-day algorithm; this adds the time-of-day suffix.
44fn now_iso_utc() -> String {
45    let dur = std::time::SystemTime::now()
46        .duration_since(std::time::UNIX_EPOCH)
47        .unwrap_or_default();
48    let total_secs = dur.as_secs();
49    let days = total_secs / 86_400;
50    let rem = total_secs - days * 86_400;
51    let hours = rem / 3_600;
52    let mins = (rem % 3_600) / 60;
53    let secs = rem % 60;
54    let (year, month, day) = days_to_ymd(days);
55    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{mins:02}:{secs:02}Z")
56}
57
58/// Result shape of the storage-residue probe
59/// `residue_probe_for_workspace` performs at Step 2b of
60/// `create_mem`. `Present` carries the diagnostic payload the
61/// `MEM_STORAGE_RESIDUE_DETECTED` error envelope renders, plus the
62/// parsed existing config (for tombstone + reattach branches).
63enum ResidueProbe {
64    None,
65    Present {
66        branch_ref: String,
67        config_blob: Option<String>,
68        existing_config: Option<Box<memstead_schema::config::MemConfig>>,
69    },
70}
71
72/// Probe the workspace's mem-repo for pre-existing storage at the
73/// composed `branch_full_path`. Returns `None` when the workspace
74/// lacks a mem-repo (folder-only) or when nothing exists at the
75/// path; otherwise returns the residue payload the create-side
76/// orchestrator routes against `(recovery, tombstone)` to pick a
77/// path.
78///
79/// Implementation routes through the engine's installed backend
80/// factory rather than calling `memstead-git-branch` directly — that
81/// keeps `memstead-engine` decoupled from the git-branch crate (the
82/// layer-above-backend posture matches the rest of `mem_management`,
83/// which delegates backend instantiation through the same factory).
84/// `backend.read_mem_config()` for a git-branch mount lifts
85/// `__MEMSTEAD:mems/<branch_full_path>/config.json`'s bytes — present
86/// iff residue exists at this exact path. Folder backends return
87/// `None` here (their residue is `<location>/.memstead/config.json`
88/// which Step 4 below catches separately via `ConfigAlreadyExists`).
89/// Failures from the probe collapse to `None` so the create flow
90/// falls through to its prior behaviour (the seed-commit step's
91/// existing `HashMismatch` is the fallback safety net).
92fn residue_probe_for_workspace(
93    engine: &memstead_base::Engine,
94    workspace_root: Option<&std::path::Path>,
95    branch_full_path: &str,
96    mem_name: &str,
97    canonical_schema_ref: &memstead_schema::SchemaRef,
98) -> ResidueProbe {
99    let Some(root) = workspace_root else {
100        return ResidueProbe::None;
101    };
102    let gitdir = root.join("mem-repo").join(".git");
103    if !gitdir.is_dir() {
104        return ResidueProbe::None;
105    }
106    let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
107    let probe_mount = memstead_base::workspace::Mount {
108        migration_target: None,
109        mem: mem_name.to_string(),
110        schema: Some(canonical_schema_ref.clone()),
111        storage: memstead_base::workspace::MountStorage::GitBranch {
112            gitdir: canonical_gitdir,
113            branch: format!("refs/heads/{branch_full_path}"),
114        },
115        capability: memstead_base::workspace::MountCapability::Write,
116        lifecycle: memstead_base::workspace::MountLifecycle::Eager,
117        cross_linkable: true,
118    };
119    let factory = engine.backend_factory();
120    let backend = match factory(&probe_mount) {
121        Ok(b) => b,
122        Err(_) => return ResidueProbe::None,
123    };
124    let bytes = match backend.read_mem_config() {
125        Ok(Some(b)) => b,
126        Ok(None) | Err(_) => return ResidueProbe::None,
127    };
128    let existing_config = serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes)
129        .ok()
130        .map(Box::new);
131    ResidueProbe::Present {
132        branch_ref: format!("refs/heads/{branch_full_path}"),
133        config_blob: Some(format!("__MEMSTEAD:mems/{branch_full_path}/config.json")),
134        existing_config,
135    }
136}
137
138/// Days-since-epoch → (Y, M, D). Algorithm from
139/// http://howardhinnant.github.io/date_algorithms.html — same one
140/// `memstead_base::entity::generator::days_to_ymd` uses; replicated here
141/// to keep the function private to the orchestrator without
142/// re-exporting from lean.
143fn days_to_ymd(days: u64) -> (u64, u64, u64) {
144    let z = days + 719_468;
145    let era = z / 146_097;
146    let doe = z - era * 146_097;
147    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
148    let y = yoe + era * 400;
149    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
150    let mp = (5 * doy + 2) / 153;
151    let d = doy - (153 * mp + 2) / 5 + 1;
152    let m = if mp < 10 { mp + 3 } else { mp - 9 };
153    let y = if m <= 2 { y + 1 } else { y };
154    (y, m, d)
155}
156
157// ---------------------------------------------------------------------------
158// `memstead_mem_delete` orchestration
159// ---------------------------------------------------------------------------
160
161/// Parameters for [`delete_mem`]. Mirrors the `memstead_mem_delete`
162/// MCP tool's wire shape 1:1, plus a transport-side `operator_mode`
163/// flag the wire shape does not expose.
164#[derive(Debug, Clone)]
165pub struct MemDeleteParams {
166    /// Name of the mem to unregister. Must resolve in the current
167    /// snapshot — unknown names surface as `UnknownMem`.
168    pub name: String,
169    /// When `true`, remove the mem's on-disk directory (folder
170    /// backends only) after unregistering. Default `false` —
171    /// unregister-only.
172    pub delete_files: bool,
173    /// Agent-authored provenance note (≤[`NOTE_MAX_LEN`] chars).
174    pub note: Option<String>,
175    /// Process-scoped operator-mode posture. When `true`, the
176    /// orchestrator skips the `[[mem_management.delete]]` allowlist
177    /// gate. Every other check (input validation, name resolution,
178    /// `MEM_REFERENCED_BY_POLICY`, backend cleanup) runs
179    /// identically — the policy safeguard is now gated by
180    /// `delete_files: true` instead of `!operator_mode`, so the
181    /// CLI's `mem delete` (operator-mode) still hits the refusal
182    /// when cross-mem grants point at the target. Set only by
183    /// transports that established operator intent at boot
184    /// (`memstead-mcp --operator-mode`); never accepted as a wire-shape
185    /// input from agents. Defaults to `false` — agent-mode.
186    pub operator_mode: bool,
187    /// Mem-replacement affordance. When `true`, incoming cross-mem
188    /// edges from surviving Write-Mems do not refuse the delete
189    /// (`MEM_HAS_INCOMING_REFS` is skipped): the referrers' files
190    /// stay untouched, their edges degrade to unresolved stub
191    /// targets in the in-memory index, and a later same-name
192    /// re-creation re-adopts them — the intended flow when a mem is
193    /// re-homed (backend or location change) under a stable name.
194    /// The detached referrers are reported on
195    /// [`MemDeleteResponse::detached_referrers`] so the caller can
196    /// verify re-adoption after the successor mem mounts. Default
197    /// `false` — the refusal stands.
198    pub detach_incoming: bool,
199}
200
201/// Response shape from [`delete_mem`].
202#[derive(Debug, Clone)]
203pub struct MemDeleteResponse {
204    pub name: String,
205    /// Always `true` on successful return — the snapshot swap
206    /// happened.
207    pub deleted_from_router: bool,
208    /// `true` when `delete_files` was `true` AND the directory was
209    /// removed cleanly; `false` otherwise (delete_files false, or
210    /// removal errored, or backend has no on-disk directory).
211    pub files_deleted: bool,
212    /// Non-fatal findings emitted during the disk-cleanup step.
213    /// Populated when `delete_files: true` was requested but
214    /// [`Self::files_deleted`] ended `false` — distinguishes the
215    /// mem-db-no-op case from the rmdir-failure case so an agent
216    /// reading `files_deleted: false` doesn't trigger redundant
217    /// cleanup attempts. Empty when nothing surprised the operation
218    /// (e.g. `delete_files: false`, or `delete_files: true` and rmdir
219    /// succeeded).
220    pub warnings: Vec<memstead_base::ops::WarningHint>,
221    /// Dangling `[cross_mem_links]` grants scrubbed from
222    /// `.memstead/workspace.toml` on a destructive delete. Surfacing
223    /// the scrub here gives the agent a one-round-trip view of every
224    /// policy side effect. Only dangling cross-link grants are scrubbed
225    /// (and reported here); the `[[mem_management.*]]` allowlist rules
226    /// are preserved, so a later re-create of the same name needs no
227    /// fresh `allow-create`. Empty `[]` when no cross-link grant named
228    /// the deleted mem.
229    pub allowlist_entries_removed: Vec<AllowlistEntryRemoved>,
230    /// Write-Mem referrers whose cross-mem edges into the deleted mem
231    /// were deliberately left dangling under
232    /// [`MemDeleteParams::detach_incoming`] — one entry per source
233    /// entity, `rel_types` aggregating every detached edge type. The
234    /// referrers' files are untouched; their edges resolve to stubs
235    /// until a same-name re-creation re-adopts them. Always empty
236    /// when `detach_incoming` was `false` (the refusal fires
237    /// instead).
238    pub detached_referrers: Vec<memstead_base::ReferrerInfo>,
239}
240
241/// One scrubbed `.memstead/workspace.toml` entry surfaced on
242/// [`MemDeleteResponse::allowlist_entries_removed`]. Only dangling
243/// `[cross_mem_links]` grants are scrubbed, so `table` is always
244/// `"cross_mem_links"` and `from` / `to` name the directionality the
245/// grant established (`from` is the table key, `to` is the array
246/// element or wildcard). The `pattern` field is retained on the stable
247/// response shape but is no longer populated — the
248/// `[[mem_management.*]]` allowlist rules are preserved across a
249/// delete and therefore never reported here.
250#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
251pub struct AllowlistEntryRemoved {
252    /// Section in `.memstead/workspace.toml` the scrubbed entry came
253    /// from. Always `"cross_mem_links"` — the only class scrubbed on
254    /// delete.
255    pub table: String,
256    /// Retained on the response shape for stability but never
257    /// populated since the `[[mem_management.*]]` allowlist rules
258    /// are preserved across a delete. Always `None`.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub pattern: Option<String>,
261    /// Cross-link source mem — the grant's table key.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub from: Option<String>,
264    /// Cross-link target — the deleted mem when scrubbed from a
265    /// peer's list, or `"*"` when a wildcard grant got dropped.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub to: Option<String>,
268}
269
270/// Unregister a writable mem at runtime. The unified-engine
271/// counterpart to full's `memstead_git_branch::mem_management::delete_mem`.
272///
273/// Ordering guarantees mirror full's:
274/// 1. Pre-mutation checks (input validation, name resolution,
275///    allowlist match). Any failure here leaves the engine untouched
276///    and performs zero filesystem writes.
277/// 2. Router unregister (snapshot swap via
278///    [`memstead_base::Engine::unregister_writable_mem`]). After this the
279///    mem is no longer visible to readers. The unregister hands
280///    back the backend handle so step 3 can drive backend-side
281///    cleanup without re-resolving the mount.
282/// 3. Optional disk delete. A failure here is non-fatal: the mem
283///    is already unregistered, the leftover artifacts are a
284///    follow-up concern, and the response reports `files_deleted: false`
285///    with a typed `MEM_FILES_NOT_DELETED` warning naming what
286///    survived.
287///    - Folder mount: `remove_dir_all(location)` removes the mem
288///      directory. `backend.delete_artifacts()` is a no-op (folder
289///      backends keep the default impl).
290///    - Mem-db-backed (git-branch) mount: `backend.delete_artifacts()`
291///      drops `refs/heads/<branch_leaf>` and prunes
292///      `__MEMSTEAD:mems/<branch_leaf>/config.json`. There is no
293///      on-disk directory to rmdir.
294///
295///    `files_deleted: true` reflects "every backend-visible artifact
296///    for this mem has been removed" — the wire shape is unchanged
297///    but the semantic now covers both backends symmetrically.
298///
299/// Operator-mode bypass. When [`MemDeleteParams::operator_mode`] is
300/// `true`, Step 2 (`[[mem_management.delete]]` allowlist match) is
301/// skipped. All other steps run identically — including input
302/// validation, name resolution, the policy safeguard, router
303/// unregister, backend cleanup, and persistence. The flag is set by
304/// the transport that established operator intent at boot (today,
305/// `memstead-mcp --operator-mode`) and is not exposed as a wire-shape
306/// input.
307///
308/// Policy safeguard. Step 3 (`MEM_REFERENCED_BY_POLICY`) fires when
309/// `delete_files: true` AND another writable mem has a
310/// `cross_mem_links` grant pointing into the target. The gating is
311/// independent of `operator_mode`: storage destruction would orphan
312/// the grant, so the refusal is a hard stop until the grant is
313/// revoked. When `delete_files: false` (router-only unregister), the
314/// storage survives and the grant remains valid against it — the
315/// check is skipped. This matches the verb split exposed at the CLI
316/// layer (`mem unregister` is the verb that produces `delete_files:
317/// false`; `mem delete` is the verb that produces `delete_files:
318/// true`).
319///
320/// Hierarchical candidate composition is symmetric with
321/// `create_mem`: the router records the create-time `path` on
322/// each writable entry, and Step 2 below reads it back via
323/// [`memstead_base::MemRouterSnapshot::mem_path_for_mem`] to assemble
324/// the same `<mem_path>/<name>` (or bare `<name>`) string the
325/// create-side composer matched against.
326pub fn delete_mem(
327    engine: &mut memstead_base::Engine,
328    params: MemDeleteParams,
329) -> Result<MemDeleteResponse, FullEngineError> {
330    // ---- Step 0: input validation ----
331    if let Some(note) = params.note.as_deref()
332        && note.chars().count() > NOTE_MAX_LEN
333    {
334        return Err(memstead_base::EngineError::InvalidInput(format!(
335            "note exceeds {NOTE_MAX_LEN} characters"
336        ))
337        .into());
338    }
339
340    // Populated only under `detach_incoming: true` — see Step 3a.
341    let mut detached_referrers: Vec<memstead_base::ReferrerInfo> = Vec::new();
342
343    // ---- Step 1: resolve name ----
344    if !engine.mem_router().is_writable(&params.name) {
345        return Err(memstead_base::EngineError::UnknownMem(params.name.clone()).into());
346    }
347
348    // ---- Step 2: allowlist match ----
349    // Snapshot the on-disk dir (folder mounts only) and the
350    // create-time hierarchical `mem_path`. The lifecycle candidate
351    // composes as `<mem_path>/<name>` when a path was recorded at
352    // registration, falling back to the bare `<name>` for flat-layout
353    // mems — the same shape `create_mem` matched against the rule
354    // list. Symmetric on the engine side; closes the asymmetry that
355    // previously left hierarchical runtime mems un-deletable.
356    //
357    // Operator-mode skips the allowlist match entirely. The mem_dir
358    // is still resolved because Step 5 needs it for the rmdir step.
359    let mem_dir: Option<std::path::PathBuf> = engine
360        .mem_router()
361        .dir_for_mem(&params.name)
362        .map(|p| p.to_path_buf());
363
364    if !params.operator_mode {
365        // Hierarchical paths are first-class mem identifiers.
366        // `params.name` is already the full path (e.g.
367        // `team/sub-mem`) — no separate `mem_path` composition
368        // step needed. The delete-side lifecycle candidate IS the
369        // mem name.
370        let attempted = mem_dir
371            .clone()
372            .unwrap_or_else(|| std::path::PathBuf::from(format!("(mem: {})", params.name)));
373        let candidate: String = params.name.clone();
374
375        let delete_rule_set = DeleteRuleSet::new(engine.settings().mem_delete_rules.clone())
376            .map_err(|e| {
377                memstead_base::EngineError::InvalidInput(format!("mem_delete_rules: {e}"))
378            })?;
379        let patterns_for_errors: Vec<String> = delete_rule_set.patterns();
380
381        if delete_rule_set.is_empty() {
382            return Err(FullEngineError::MemPathNotAllowed {
383                attempted,
384                candidate,
385                patterns: patterns_for_errors,
386                reason: "no_allowlist_configured",
387                policy_table: "mem_management.delete",
388            });
389        }
390        if delete_rule_set
391            .first_match(std::path::Path::new(&candidate))
392            .is_none()
393        {
394            return Err(FullEngineError::MemPathNotAllowed {
395                attempted,
396                candidate,
397                patterns: patterns_for_errors,
398                reason: "no_match",
399                policy_table: "mem_management.delete",
400            });
401        }
402    }
403
404    // ---- Step 3: MEM_REFERENCED_BY_POLICY check ----
405    // Walk the workspace's `cross_mem_links` setting and refuse to
406    // delete a mem that any other visible writable mem is
407    // permitted to link into. Reads the workspace-level policy
408    // directly rather than per-mem `effective_cross_links` (the
409    // per-mem projection that composes workspace policy with
410    // create-rule defaults) — for workspaces that only configure
411    // links via the workspace-level `[cross_mem_links]` section
412    // (the common case), the two are equivalent.
413    //
414    // The check gates on `delete_files: true` rather than
415    // `!operator_mode`. The
416    // safeguard protects against orphaning a grant by destroying the
417    // storage it relies on; when `delete_files: false` (router-only
418    // unregister), the storage survives so grants remain valid and
419    // re-activate on re-init. The new CLI verb `memstead mem
420    // unregister` maps to this branch unconditionally; the CLI verb
421    // `memstead mem delete` maps to the storage-destruction branch
422    // where the check fires regardless of `operator_mode` (the
423    // operator is presumed to have surveyed the link graph, but a
424    // hard stop forces an explicit revoke-first flow).
425    if params.delete_files {
426        use memstead_schema::workspace_config::CrossLinkValue;
427        let mut referring_mems: Vec<String> = engine
428            .settings()
429            .cross_mem_links
430            .iter()
431            .filter_map(|(referring, policy)| {
432                if referring == &params.name {
433                    return None;
434                }
435                match policy {
436                    CrossLinkValue::List(targets) => {
437                        if targets.iter().any(|t| t == &params.name) {
438                            Some(referring.clone())
439                        } else {
440                            None
441                        }
442                    }
443                    _ => None,
444                }
445            })
446            .collect();
447        referring_mems.sort();
448        referring_mems.dedup();
449        if !referring_mems.is_empty() {
450            return Err(FullEngineError::MemReferencedByPolicy {
451                name: params.name,
452                referring_mems,
453            });
454        }
455    }
456
457    // ---- Step 3a: MEM_HAS_INCOMING_REFS check ----
458    // The policy check above closes the workspace-policy axis ("is this
459    // mem still grant-pointed-at?") but not the edge-graph axis
460    // ("does any actual entity still point at this mem's entities?").
461    // Revoking a grant is independent of removing the edges. Without
462    // this step, a mem whose grant was revoked but whose surviving
463    // Write-Mem peers still carry `DEPENDS_ON → target_mem--*`
464    // edges deletes cleanly, leaving dangling cross-mem edges that
465    // resolve to nothing.
466    //
467    // The scan walks every entity in the doomed mem, collects each
468    // entity's incoming edges, partitions out same-mem and ReadOnly-
469    // mount referrers, and groups the remaining Write-Mem referrers
470    // by source-entity id (one [`memstead_base::ReferrerInfo`] per source,
471    // `rel_types` aggregating every offending edge type). Same shape
472    // as entity-level `HasIncomingRefs` — see [`memstead_base::EngineError::MemHasIncomingRefs`]
473    // for the refusal-with-recovery contract. Fires regardless of
474    // `delete_files` because a router-only unregister with stale edges
475    // is just as broken as a storage-destruction with stale edges —
476    // either way, surviving entities point at a mem the engine no
477    // longer routes to.
478    {
479        use std::collections::BTreeSet;
480
481        // Source-id is grouped via a `Vec` of `(EntityId, BTreeSet<rel_type>)`
482        // pairs keyed by the id's string form — EntityId itself isn't
483        // `Ord` (no full lexical ordering defined), but its string
484        // form sorts deterministically and is what the wire envelope
485        // serialises anyway.
486        let store = engine.store();
487        let mut by_source: std::collections::BTreeMap<
488            String,
489            (memstead_base::EntityId, BTreeSet<String>),
490        > = std::collections::BTreeMap::new();
491        let doomed_mem = params.name.as_str();
492        for entity in store.all_entities() {
493            if entity.mem != doomed_mem {
494                continue;
495            }
496            for in_edge in store.incoming(&entity.id) {
497                if in_edge.from.mem() == doomed_mem {
498                    continue;
499                }
500                // ReadOnly-mount referrers are partitioned out — they
501                // route through the residual-stub demotion path on
502                // the destructive mutation, same as entity-level
503                // HasIncomingRefs. Use the router's capability lookup
504                // to decide; mounts the router doesn't know about
505                // (shouldn't happen post-construction) are treated as
506                // Write to be conservative.
507                let is_writable = engine.mem_router().is_writable(in_edge.from.mem());
508                if !is_writable {
509                    continue;
510                }
511                by_source
512                    .entry(in_edge.from.to_string())
513                    .or_insert_with(|| (in_edge.from.clone(), BTreeSet::new()))
514                    .1
515                    .insert(in_edge.rel_type.clone());
516            }
517        }
518
519        if !by_source.is_empty() {
520            let referrers: Vec<memstead_base::ReferrerInfo> = by_source
521                .into_values()
522                .map(|(from, rel_types)| memstead_base::ReferrerInfo {
523                    from_id: from.to_string(),
524                    rel_types: rel_types.into_iter().collect(),
525                    mem: from.mem().to_string(),
526                })
527                .collect();
528            if params.detach_incoming {
529                // Mem-replacement affordance: the caller intends a
530                // same-name re-creation, so the referrers' edges stay
531                // in their files and degrade to stubs until the
532                // successor mem re-adopts them. Report the detached
533                // set so the caller can verify re-adoption.
534                detached_referrers = referrers;
535            } else {
536                return Err(memstead_base::EngineError::MemHasIncomingRefs {
537                    mem: params.name,
538                    referrers,
539                }
540                .into());
541            }
542        }
543    }
544
545    // ---- Step 4: router unregister ----
546    // Returns the backend handle so step 5 can drive backend-side
547    // cleanup without re-resolving the mount through the router
548    // (which has already lost the entry by the time we get here).
549    let removed_backend = engine.unregister_writable_mem(&params.name)?;
550    let backend =
551        removed_backend.expect("mem_router().is_writable check above guarantees a present mount");
552
553    // ---- Step 4b: tombstone write (unregister-only path) ----
554    // When the operator asked for
555    // router-only removal (`delete_files: false`), stamp the surviving
556    // config blob with an `unregistered_at` ISO-8601 marker so a
557    // subsequent `memstead mem init <same-name>` can recognize the
558    // residue as deliberate operator state (zero-friction reattach
559    // path) versus crash residue (refuse with
560    // `MEM_STORAGE_RESIDUE_DETECTED`).
561    //
562    // Failures here are non-fatal — the unregister has already
563    // committed, and a missing tombstone only downgrades the
564    // re-init flow (operator must pass `--reattach` explicitly).
565    // The warning surfaces the missed write so the operator can
566    // intervene if needed.
567    if !params.delete_files {
568        match backend.read_mem_config() {
569            Ok(Some(bytes)) => {
570                match serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes) {
571                    Ok(mut cfg) => {
572                        cfg.unregistered_at = Some(now_iso_utc());
573                        match serde_json::to_vec_pretty(&cfg) {
574                            Ok(mut new_bytes) => {
575                                new_bytes.push(b'\n');
576                                if let Err(e) = backend.write_mem_config(&new_bytes) {
577                                    tracing::warn!(
578                                        mem = %params.name,
579                                        error = %e,
580                                        "delete_mem: unregister succeeded but tombstone \
581                                         write failed — re-init will require an explicit \
582                                         --reattach flag"
583                                    );
584                                }
585                            }
586                            Err(e) => tracing::warn!(
587                                mem = %params.name,
588                                error = %e,
589                                "delete_mem: tombstone serialize failed",
590                            ),
591                        }
592                    }
593                    Err(e) => tracing::warn!(
594                        mem = %params.name,
595                        error = %e,
596                        "delete_mem: tombstone-write skipped — config blob did not \
597                         parse as MemConfig",
598                    ),
599                }
600            }
601            Ok(None) => {
602                // No on-disk config blob (folder backend with no
603                // `.memstead/config.json`, or git-branch mount whose
604                // `__MEMSTEAD:mems/.../config.json` was never written).
605                // Nothing to stamp.
606            }
607            Err(e) => tracing::warn!(
608                mem = %params.name,
609                error = %e,
610                "delete_mem: tombstone-read skipped — backend read_mem_config errored",
611            ),
612        }
613    }
614
615    // ---- Step 5: optional disk delete ----
616    // `delete_files: true` runs BOTH halves of the symmetric cleanup:
617    //   1. Backend-side `delete_artifacts()`. Folder + archive
618    //      backends keep the default no-op. The git-branch backend
619    //      drops `refs/heads/<branch_leaf>` and prunes
620    //      `__MEMSTEAD:mems/<branch_leaf>/config.json` in a single
621    //      ref-edit transaction.
622    //   2. Folder-direct `remove_dir_all(location)` when the mount
623    //      registered an on-disk directory (folder backends only).
624    //      Git-branch backends register `dir: None` so this branch
625    //      is skipped — the backend step above handled their state.
626    // Any sub-step failing leaves the operation in a documented
627    // partial state: the mem is already unregistered, `files_deleted`
628    // ends `false`, and per-failure `MEM_FILES_NOT_DELETED` warnings
629    // name the surviving artifact(s). `delete_files: false` returns
630    // `files_deleted: false` silently (the archive-workflow contract).
631    let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
632    let files_deleted = if params.delete_files {
633        let backend_ok = match backend.delete_artifacts() {
634            Ok(()) => true,
635            Err(e) => {
636                tracing::warn!(
637                    mem = %params.name,
638                    error = %e,
639                    "delete_mem: unregister succeeded but backend artifact \
640                     cleanup failed — leaving leftover refs / tree entries \
641                     for explicit cleanup"
642                );
643                warnings.push(memstead_base::ops::WarningHint::MemFilesNotDeleted {
644                    mem: params.name.clone(),
645                    reason: "backend_prune_failed".into(),
646                    path: None,
647                    error: Some(e.to_string()),
648                });
649                false
650            }
651        };
652        let dir_ok = match mem_dir.as_ref() {
653            Some(dir) => match std::fs::remove_dir_all(dir) {
654                Ok(()) => true,
655                Err(e) => {
656                    tracing::warn!(
657                        mem = %params.name,
658                        path = %dir.display(),
659                        error = %e,
660                        "delete_mem: unregister succeeded but rmdir failed — \
661                         leaving leftover files for explicit cleanup"
662                    );
663                    warnings.push(memstead_base::ops::WarningHint::MemFilesNotDeleted {
664                        mem: params.name.clone(),
665                        reason: "rmdir_failed".into(),
666                        path: Some(dir.display().to_string()),
667                        error: Some(e.to_string()),
668                    });
669                    false
670                }
671            },
672            // No on-disk directory to rmdir — mem-db-backed mount.
673            // The backend step above carried the cleanup; no warning
674            // here since the absence of a directory is the documented
675            // shape, not a partial-state signal.
676            None => true,
677        };
678        backend_ok && dir_ok
679    } else {
680        false
681    };
682
683    // Symmetric persistence with `create_mem`: write the post-
684    // unregister mount manifest so a sibling process boots without
685    // the deleted mem. If the workspace_root is unset (tests /
686    // ad-hoc consumers) the call is a no-op.
687    engine.persist_state()?;
688
689    // ---- Step 6: policy scrub on destructive delete ----
690    // When both refusal gates admitted and the destructive delete
691    // committed, scrub
692    // `.memstead/workspace.toml` of the now-dangling `[cross_mem_links]`
693    // grants naming the deleted mem — its own key plus every peer's
694    // allowlist value. The `[[mem_management.create|delete]]`
695    // allowlist rules are deliberately preserved (forward-looking
696    // permissions for the name, not references to the gone instance),
697    // so a later `mem init <same name>` needs no fresh allow-create.
698    // Refresh the engine's in-memory settings from the freshly-edited
699    // workspace.toml so a follow-up `memstead_mem_create` against the
700    // same name doesn't trip a stale grant, and `workspace show`
701    // agrees with the on-disk file. Skipped for router-only unregister
702    // (`delete_files: false`): the storage and grants survive
703    // together, set to re-activate on a future reattach.
704    let mut allowlist_entries_removed: Vec<AllowlistEntryRemoved> = Vec::new();
705    if params.delete_files
706        && let Some(root) = engine.workspace_root().map(|p| p.to_path_buf())
707    {
708        // Scrub failures are non-fatal but surfaced as warnings —
709        // the delete itself committed, and dangling policy entries
710        // would only cost reload-time `UNKNOWN_MEM` checks. Wrap
711        // the typed enum in a warning code the agent can branch on.
712        match crate::workspace_config_edit::scrub_policy_for_deleted_mem(&root, &params.name) {
713            Err(e) => {
714                tracing::warn!(
715                    mem = %params.name,
716                    error = %e,
717                    "delete_mem: destructive delete committed but policy \
718                     scrub failed — `.memstead/workspace.toml` may still \
719                     reference the deleted mem"
720                );
721            }
722            Ok(scrubbed) => {
723                // Lift scrubbed entries into the response envelope
724                // so the agent doesn't have to re-read
725                // `workspace show` to learn the side effects of
726                // the delete.
727                allowlist_entries_removed = scrubbed
728                    .into_iter()
729                    .map(|e| match e {
730                        crate::workspace_config_edit::ScrubbedEntry::CrossLink { from, to } => {
731                            AllowlistEntryRemoved {
732                                table: "cross_mem_links".to_string(),
733                                pattern: None,
734                                from: Some(from),
735                                to: Some(to),
736                            }
737                        }
738                    })
739                    .collect();
740                // Refresh the in-memory settings so the scrub takes
741                // effect without a full reload. Best-effort: missing or
742                // unparseable file leaves the existing in-memory
743                // settings untouched (the scrub already succeeded; the
744                // pre-scrub settings were strictly more permissive).
745                let store = memstead_base::workspace_store::FileWorkspaceStore::new();
746                if let Ok(ws) = <memstead_base::workspace_store::FileWorkspaceStore as memstead_base::workspace_store::WorkspaceStoreAdapter>::load(
747                        &store,
748                        &root,
749                    ) {
750                        engine.set_settings(ws.settings);
751                    }
752            }
753        }
754    }
755
756    // `require_notes` provenance nudge — inherited from the engine's
757    // single enforcement point (see `create_mem`).
758    if let Some(w) = engine.note_missing_warning("delete_mem", params.note.as_deref()) {
759        warnings.push(w);
760    }
761
762    Ok(MemDeleteResponse {
763        name: params.name,
764        deleted_from_router: true,
765        files_deleted,
766        warnings,
767        allowlist_entries_removed,
768        detached_referrers,
769    })
770}
771
772// ---------------------------------------------------------------------------
773// `memstead_mem_create` orchestration
774// ---------------------------------------------------------------------------
775
776/// Explicit storage-backend override for [`create_mem`]. The default
777/// (`MemCreateParams.storage: None`) keeps the workspace-shape
778/// heuristic: git-branch when `<workspace_root>/mem-repo/.git/`
779/// exists, folder otherwise. Passing `Some(_)` pins the backend
780/// regardless of workspace shape — the mount loader and runtime
781/// already handle mixed-backend workspaces (per-mount backend
782/// dispatch), so a folder mem can live beside git-branch mems.
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum StorageKind {
785    /// Plain-markdown folder mount — the mem's files live at
786    /// `location`, visible in the outer tree.
787    Folder,
788    /// Per-mem branch in the workspace's `mem-repo/.git/`. Requires
789    /// a mem-repo workspace; refused with `InvalidInput` otherwise.
790    GitBranch,
791}
792
793/// Parameters for [`create_mem`]. Mirrors the `memstead_mem_create`
794/// MCP tool's wire shape 1:1.
795///
796/// Hierarchical paths are first-class mem identifiers — there is no
797/// separate `path` field; `name` carries the full path
798/// (e.g. `"team/sub-mem"`) directly, validated via
799/// [`memstead_base::entity::id::validate_mem_name_grammar`]. The
800/// branch ref composes as `refs/heads/<name>` and the `__MEMSTEAD`
801/// config blob as `__MEMSTEAD:mems/<name>/config.json` with no extra
802/// composition step.
803#[derive(Debug, Clone)]
804pub struct MemCreateParams {
805    /// Name of the new mem — the full hierarchical identifier
806    /// (e.g. `"sub-mem"` for flat layouts or `"team/sub-mem"`
807    /// for hierarchical layouts). Must be unique across every
808    /// visible mem in the current snapshot, match the basename of
809    /// `location` (folder backend identity invariant on the
810    /// trailing path segment), and satisfy the mem-name grammar
811    /// (`[a-z0-9-]+(/[a-z0-9-]+)*`).
812    pub name: String,
813    /// Target location. Absolute or workspace-relative.
814    /// Canonicalized inside the orchestrator before the allowlist
815    /// check.
816    pub location: std::path::PathBuf,
817    /// Schema pin for the new mem (`name@x.y.z`).
818    pub schema_ref: memstead_schema::SchemaRef,
819    /// Optional vcs config override (signing keys, identity hints).
820    /// Persisted into the per-mem config blob alongside `schema`
821    /// and `name`. Most callers pass `None` — defaults come from
822    /// the workspace's `.memstead/workspace.toml`. Mirrors full's
823    /// `memstead_git_branch::MemCreateParams.vcs`.
824    pub vcs: Option<memstead_schema::VcsConfig>,
825    /// Agent-authored provenance note (≤[`NOTE_MAX_LEN`] chars).
826    pub note: Option<String>,
827    /// Process-scoped operator-mode posture. When `true`, the
828    /// orchestrator skips the `[[mem_management.create]]` allowlist
829    /// gate and the matched-rule schema gate it derives — every other
830    /// check (input validation, schema canonicalisation, basename
831    /// invariant, name collision, backend instantiation) runs
832    /// identically. Set only by transports that established operator
833    /// intent at boot (`memstead-mcp --operator-mode`); never accepted as
834    /// a wire-shape input from agents. Defaults to `false` —
835    /// agent-mode.
836    pub operator_mode: bool,
837    /// Explicit recovery action for
838    /// the case where the storage already carries residue for the
839    /// composed branch path. `None` is the default — the engine
840    /// then routes by tombstone presence: residue with an
841    /// `unregistered_at` tombstone (deliberate operator state from
842    /// `memstead mem unregister`) defaults to [`RecoveryAction::Reattach`],
843    /// residue without a tombstone refuses with
844    /// `MEM_STORAGE_RESIDUE_DETECTED`. Setting an explicit value
845    /// overrides the tombstone-driven default. A bare `mem init`
846    /// against a name with no residue at all ignores this field
847    /// (the happy path is unchanged).
848    pub recovery: Option<crate::RecoveryAction>,
849    /// Optional opaque per-instance writing guidance, persisted
850    /// verbatim into the new mem's config (`writeGuidance`) in the
851    /// seed commit. The engine never inspects the map's contents
852    /// (schema-strictness D8 — `writeGuidance` is client-owned
853    /// vocabulary); a client that read a schema package's
854    /// `mem-template.json` fills the instance keys and passes them
855    /// here. Empty map (the default) seeds no guidance, identical to
856    /// pre-parameter behaviour.
857    pub write_guidance: std::collections::HashMap<String, serde_json::Value>,
858    /// Explicit storage-backend override. `None` (default) keeps the
859    /// workspace-shape heuristic — git-branch when
860    /// `<workspace_root>/mem-repo/.git/` exists, folder otherwise.
861    /// `Some(StorageKind::Folder)` forces a folder mount even in a
862    /// mem-repo workspace: the mem's markdown files live at
863    /// `location`, visible in the outer tree.
864    /// `Some(StorageKind::GitBranch)` in a workspace WITHOUT
865    /// `mem-repo/.git/` refuses with `EngineError::InvalidInput` —
866    /// there is no gitdir to host the branch.
867    pub storage: Option<StorageKind>,
868    /// Caller category for the seed commit's provenance trailer.
869    /// Transports pass their own: MCP `Actor::Agent`, the CLI
870    /// `Actor::Cli`, application embedders (UniFFI, HTTP) `Actor::App`.
871    /// Pre-parameter behaviour hardcoded `Agent` for every transport —
872    /// the macOS app's mem creations were misattributed as agent
873    /// writes.
874    pub actor: memstead_base::vcs::Actor,
875    /// Client identity paired with `actor` — renders `name@version`
876    /// in the seed commit's `Client:` trailer and derives its author,
877    /// exactly as entity mutations do.
878    pub client: Option<memstead_base::vcs::ClientId>,
879}
880
881/// Response shape from [`create_mem`].
882#[derive(Debug, Clone)]
883pub struct MemCreateResponse {
884    pub name: String,
885    pub location: std::path::PathBuf,
886    pub schema_ref: memstead_schema::SchemaRef,
887    /// Seed-commit cursor. Folder backends produce a synthetic id
888    /// (UNIX-nanos + counter, hex per the trait's contract);
889    /// git-branch backends produce a real 40-char hex sha. Either
890    /// way, the cursor is non-empty — agents poll
891    /// `memstead_changes_since` against it without branching on
892    /// backend type. Empty string (`""`) signals the reattach branch
893    /// was taken — pair with the `MEM_REATTACHED_AFTER_UNREGISTER`
894    /// warning surfaced via [`Self::warnings`] for full context.
895    pub seed_commit_sha: String,
896    /// Non-fatal findings emitted during the create / reattach
897    /// pipeline. Today populated only by the reattach branch with a
898    /// `MEM_REATTACHED_AFTER_UNREGISTER` warning carrying
899    /// `{mem, unregistered_at}`. Fresh-create
900    /// (residue absent or force-overwrite branch taken) leaves this
901    /// empty.
902    pub warnings: Vec<memstead_base::ops::WarningHint>,
903}
904
905/// Classify a structurally-invalid mem name into the typed
906/// `FullEngineError::InvalidMemName.reason` discriminator. Returns
907/// `None` when the name passes the structural check and the caller
908/// should proceed to the regex-level grammar check + allowlist gate.
909///
910/// Discriminator vocabulary:
911/// - `empty` — `params.name == ""`.
912/// - `whitespace` — input contains any ASCII whitespace, or is non-
913///   empty but trims to empty.
914/// - `reserved_prefix` — any path segment starts with `__` (the
915///   reserved prefix the engine uses for `__MEMSTEAD` registry refs and
916///   similar). Caught early so the operator sees the intent rather
917///   than a regex no-match.
918/// - `invalid_char` — fallback for anything else the grammar rejects
919///   (non-printable, non-ASCII letters, reserved characters).
920fn classify_invalid_mem_name(name: &str) -> Option<&'static str> {
921    if name.is_empty() {
922        return Some("empty");
923    }
924    if name.chars().any(char::is_whitespace) {
925        return Some("whitespace");
926    }
927    if name.split('/').any(|seg| seg.starts_with("__")) {
928        return Some("reserved_prefix");
929    }
930    None
931}
932
933/// Create a new writable mem at runtime. Unified counterpart to
934/// full's `memstead_git_branch::mem_management::create_mem`. Routes
935/// through the engine's installed [`memstead_base::BackendFactory`] so the
936/// same call site materialises folder, archive, or git-branch
937/// backends transparently — production full consumers install
938/// `memstead_git_branch::storage::instantiate_full_backend` at boot via
939/// `engine_from_workspace_root`.
940///
941/// Pipeline:
942/// 0. Input validation (note length, optional `path` segments).
943///    - 0b. Schema canonicalization against the built-in catalogue.
944///      Workspace-authored schemas are resolved by the workspace's
945///      schemas_dir, not yet here.
946/// 1. Canonicalize location against `engine.workspace_root()`
947///    (relative paths) or take absolute paths as-is.
948///    - 1a. Allowlist match against the composed candidate
949///      (`<path>/<name>` when `path` is `Some`, else `<name>`).
950///    - 1b. Schema gate against matched rule's `schemas` list. `["*"]`
951///      wildcard admits any schema.
952///    - 1c. Basename invariant: `params.name` MUST equal the canonical
953///      location's basename.
954/// 2. Name collision probe against the current mem_router
955///    snapshot. The rich tree-walk collision detector (with
956///    `colliding_paths` envelope payload) is full-only; unified
957///    surfaces collisions through the snapshot probe with the
958///    same `EngineError::MemNameCollision` discriminant.
959/// 3. Build [`memstead_schema::config::MemConfig`] bytes.
960///    - 3b. Pick the storage variant: `params.storage` when set
961///      (explicit [`StorageKind`] override — folder mems beside
962///      git-branch mems in one workspace), else by workspace shape
963///      (git-branch when `<workspace_root>/mem-repo/.git/` exists,
964///      folder otherwise). The branch leaf composes as
965///      `<path>/<name>`.
966/// 4. Write `<location>/.memstead/config.json` for folder mounts
967///    only. Git-branch mounts skip the on-disk write — the
968///    per-mem config travels in the workspace's `__MEMSTEAD` registry
969///    ref.
970/// 5. Materialise the backend via the engine's
971///    [`memstead_base::BackendFactory`], commit the seed (real sha for
972///    git-branch, synthetic id for folder), and register via
973///    [`memstead_base::Engine::register_writable_mem`] with
974///    [`memstead_base::MemOrigin::RuntimeCreated`].
975pub fn create_mem(
976    engine: &mut memstead_base::Engine,
977    mut params: MemCreateParams,
978) -> Result<MemCreateResponse, FullEngineError> {
979    use std::path::Path;
980
981    // ---- Step 0: input validation ----
982    if let Some(note) = params.note.as_deref()
983        && note.chars().count() > NOTE_MAX_LEN
984    {
985        return Err(memstead_base::EngineError::InvalidInput(format!(
986            "note exceeds {NOTE_MAX_LEN} characters"
987        ))
988        .into());
989    }
990    // Hierarchical paths are first-class. `params.name` carries the
991    // full path (e.g. `"team/sub-mem"`); the grammar validator
992    // accepts both flat and hierarchical forms and refuses
993    // malformations (leading / trailing / double slashes, segments
994    // outside `[a-z0-9-]+`).
995    //
996    // Structural-failure modes get typed reasons before the allowlist
997    // check fires, so the four distinct shapes (empty / whitespace /
998    // invalid-char / reserved-prefix) stay distinguishable from a
999    // legitimate authorisation refusal rather than collapsing into the
1000    // post-allowlist `MEM_PATH_NOT_ALLOWED (no_match)` envelope.
1001    if let Some(reason) = classify_invalid_mem_name(&params.name) {
1002        return Err(FullEngineError::InvalidMemName {
1003            name: params.name.clone(),
1004            reason,
1005        });
1006    }
1007    // Grammar check (regex-level shape) for anything the structural
1008    // classifier did not catch. The grammar refusals shouldn't fire
1009    // here in practice — `classify_invalid_mem_name` already covers
1010    // every concrete malformation. But keep the call as a defense in
1011    // depth in case the grammar tightens later; route through the
1012    // typed `invalid_char` reason so the wire shape stays consistent.
1013    if memstead_base::entity::id::validate_mem_name_grammar(&params.name).is_err() {
1014        return Err(FullEngineError::InvalidMemName {
1015            name: params.name.clone(),
1016            reason: "invalid_char",
1017        });
1018    }
1019
1020    // ---- Step 0b: schema canonicalization ----
1021    // Resolve the agent-supplied schema pin against the engine's full
1022    // loaded catalogue: workspace-authored schemas from the backend's
1023    // local storage (folder `.memstead/schemas/` or the git-branch
1024    // `__MEMSTEAD:schemas/` ref) layered over the built-ins. A mem can
1025    // therefore pin a schema installed onto the backend via
1026    // `memstead schema install`, not just a built-in.
1027    let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
1028        engine.workspace_schemas().to_vec();
1029    builtin_schemas.extend_from_slice(engine.builtin_schemas());
1030    let resolved_schema = memstead_base::engine::SchemaResolver::new(&builtin_schemas)
1031        .resolve(&params.schema_ref)
1032        .map_err(|sources| {
1033            memstead_base::EngineError::SchemaNotFound {
1034                mem: params.name.clone(),
1035                pin: params.schema_ref.to_string(),
1036                sources,
1037                install_hint: None,
1038            }
1039            .with_schema_install_probe(engine.workspace_root())
1040        })?;
1041    let canonical_schema_ref = memstead_schema::SchemaRef::new(
1042        resolved_schema.manifest.name.clone(),
1043        resolved_schema.version.clone(),
1044    );
1045    params.schema_ref = canonical_schema_ref.clone();
1046
1047    // ---- Step 1: canonicalize location ----
1048    let workspace_root = engine.workspace_root().map(|p| p.to_path_buf());
1049    let absolute = if params.location.is_absolute() {
1050        params.location.clone()
1051    } else if let Some(root) = workspace_root.as_ref() {
1052        root.join(&params.location)
1053    } else {
1054        // No workspace_root set (tests, ad-hoc consumers). Treat
1055        // relative as relative-to-CWD by canonicalising directly;
1056        // the basename invariant + allowlist still apply.
1057        params.location.clone()
1058    };
1059    let canonical = canonicalize_maybe_missing(&absolute);
1060    // The mount record keeps the caller's *expressed* anchoring: a
1061    // relative `location` stays the lexical workspace-root join (no
1062    // `..` resolution, no symlink normalisation), so
1063    // `relativize_mount_path`'s lexical strip_prefix recovers the
1064    // same relative form at serialisation time — an out-of-root
1065    // location like `../public/engineering` lands in `mounts.json`
1066    // as that relative path and survives cloning the tree to a
1067    // different absolute prefix. An absolute `location` stays
1068    // absolute — machine-pinned by expression. Every validation
1069    // below (basename invariant, outside-workspace check, residue
1070    // probes) still runs on `canonical`. Without a workspace_root
1071    // there is nothing to anchor portability against, so the
1072    // canonical form is the honest record.
1073    let mount_path = if workspace_root.is_some() {
1074        absolute.clone()
1075    } else {
1076        canonical.clone()
1077    };
1078
1079    // ---- Step 1a: allowlist match ----
1080    // Compose the allowlist candidate from the optional `<path>`
1081    // plus `<name>`. Flat layout (path = None) candidate is just
1082    // `<name>`; hierarchical layout candidate is `<path>/<name>`
1083    // (used by the rule lookup and surfaced in the
1084    // `MEM_PATH_NOT_ALLOWED` envelope's `details.candidate` field).
1085    //
1086    // Operator-mode bypasses Step 1a and Step 1b entirely. The
1087    // outside-workspace check below also folds in — operators
1088    // typically rebuild from scratch and may place a mem outside
1089    // any allowlist'd region. Every safety-shaped check (schema
1090    // canonicalisation, basename invariant, name collision) stays
1091    // unconditional.
1092    // Hierarchical paths are first-class. The allowlist candidate IS
1093    // the mem name (no `<path>/<name>` composition step —
1094    // `params.name` already carries the full path).
1095    let candidate: String = params.name.clone();
1096
1097    if !params.operator_mode {
1098        let create_rule_set = CreateRuleSet::new(engine.settings().mem_create_rules.clone())
1099            .map_err(|e| {
1100                memstead_base::EngineError::InvalidInput(format!("mem_create_rules: {e}"))
1101            })?;
1102        let patterns_for_errors: Vec<String> = create_rule_set.patterns();
1103
1104        if create_rule_set.is_empty() {
1105            return Err(FullEngineError::MemPathNotAllowed {
1106                attempted: canonical.clone(),
1107                candidate,
1108                patterns: patterns_for_errors,
1109                reason: "no_allowlist_configured",
1110                policy_table: "mem_management.create",
1111            });
1112        }
1113        let matched_rule = match create_rule_set.first_match(Path::new(&candidate)) {
1114            Some(r) => r.clone(),
1115            None => {
1116                return Err(FullEngineError::MemPathNotAllowed {
1117                    attempted: canonical.clone(),
1118                    candidate,
1119                    patterns: patterns_for_errors,
1120                    reason: "no_match",
1121                    policy_table: "mem_management.create",
1122                });
1123            }
1124        };
1125
1126        // Outside-workspace check (skipped when no workspace_root is
1127        // set — tests / ad-hoc).
1128        if let Some(root) = workspace_root.as_ref()
1129            && canonical.strip_prefix(root).is_err()
1130        {
1131            return Err(FullEngineError::MemPathNotAllowed {
1132                attempted: canonical.clone(),
1133                candidate,
1134                patterns: patterns_for_errors,
1135                reason: "outside_workspace",
1136                policy_table: "mem_management.create",
1137            });
1138        }
1139
1140        // ---- Step 1b: schema gate ----
1141        let schema_wildcard = matched_rule
1142            .schemas
1143            .iter()
1144            .any(|s| s == memstead_base::SCHEMA_WILDCARD);
1145        if !schema_wildcard {
1146            let requested_canonical = canonical_schema_ref.to_string();
1147            let mut allowed_canonical: Vec<String> = Vec::with_capacity(matched_rule.schemas.len());
1148            let mut allowed = false;
1149            for raw in &matched_rule.schemas {
1150                let parsed: memstead_schema::SchemaRef = match raw.parse() {
1151                    Ok(r) => r,
1152                    Err(_) => {
1153                        return Err(memstead_base::EngineError::InvalidInput(format!(
1154                            "[mem_management] rule {:?}: schema entry {:?} is not a valid `name@version` pin",
1155                            matched_rule.pattern, raw,
1156                        ))
1157                        .into());
1158                    }
1159                };
1160                let resolved = memstead_base::engine::SchemaResolver::new(&builtin_schemas)
1161                    .resolve(&parsed)
1162                    .map_err(|sources| {
1163                        memstead_base::EngineError::SchemaNotFound {
1164                            mem: params.name.clone(),
1165                            pin: parsed.to_string(),
1166                            sources,
1167                            install_hint: None,
1168                        }
1169                        .with_schema_install_probe(engine.workspace_root())
1170                    })?;
1171                let canon_str = memstead_schema::SchemaRef::new(
1172                    resolved.manifest.name.clone(),
1173                    resolved.version.clone(),
1174                )
1175                .to_string();
1176                if canon_str == requested_canonical {
1177                    allowed = true;
1178                }
1179                allowed_canonical.push(canon_str);
1180            }
1181            if !allowed {
1182                return Err(FullEngineError::MemSchemaNotAllowed {
1183                    candidate,
1184                    matched_pattern: matched_rule.pattern.clone(),
1185                    requested_schema: requested_canonical,
1186                    allowed_schemas: allowed_canonical,
1187                });
1188            }
1189        }
1190    }
1191
1192    // ---- Step 1c: basename invariant ----
1193    // Enforced for folder creates only, because the equivalent
1194    // invariant is implicit on the git-branch path: `params.name` IS
1195    // the branch identifier, and `params.location` is ignored at
1196    // runtime (the mem has no on-disk identity beyond the gitdir).
1197    //
1198    // Mem names accept hierarchical paths (`team/sub-mem`). The
1199    // on-disk basename matches the LAST segment of the path —
1200    // folder-backed
1201    // hierarchical mems register under `<location>/sub-mem`
1202    // even when their identity is `team/sub-mem`.
1203    let workspace_has_mem_repo = workspace_root
1204        .as_ref()
1205        .map(|root| root.join("mem-repo").join(".git").is_dir())
1206        .unwrap_or(false);
1207    // Resolve the effective storage kind once: the explicit override
1208    // wins; `None` keeps the workspace-shape heuristic (behaviour-
1209    // preserving for existing callers). An explicit git-branch
1210    // request without a mem-repo has no gitdir to host the branch —
1211    // typed refusal rather than a downstream instantiate failure.
1212    let storage_kind = match params.storage {
1213        Some(k) => k,
1214        None => {
1215            if workspace_has_mem_repo {
1216                StorageKind::GitBranch
1217            } else {
1218                StorageKind::Folder
1219            }
1220        }
1221    };
1222    if storage_kind == StorageKind::GitBranch && !workspace_has_mem_repo {
1223        return Err(memstead_base::EngineError::InvalidInput(
1224            "storage: git-branch requires a mem-repo workspace \
1225             (<workspace_root>/mem-repo/.git/ not found) — omit the \
1226             override or pass storage: folder"
1227                .to_string(),
1228        )
1229        .into());
1230    }
1231    if storage_kind == StorageKind::Folder {
1232        let target_basename = canonical
1233            .file_name()
1234            .and_then(|n| n.to_str())
1235            .unwrap_or("")
1236            .to_string();
1237        let name_leaf = params
1238            .name
1239            .rsplit('/')
1240            .next()
1241            .unwrap_or(params.name.as_str());
1242        if target_basename != name_leaf {
1243            return Err(memstead_base::EngineError::InvalidInput(format!(
1244                "mem name '{}' (leaf '{}') does not match the basename '{}' of the canonical location '{}' \
1245                 — rename either side so the registered identity's leaf matches the on-disk basename",
1246                params.name,
1247                name_leaf,
1248                target_basename,
1249                canonical.display()
1250            ))
1251            .into());
1252        }
1253    }
1254
1255    // ---- Step 2: name collision probe (snapshot only) ----
1256    if let Some(existing) = engine.mem_router().origin_for_mem(&params.name) {
1257        return Err(memstead_base::EngineError::MemNameCollision {
1258            name: params.name,
1259            source_origin: existing.render_source(),
1260        }
1261        .into());
1262    }
1263    if engine
1264        .mem_router()
1265        .archive_path_for_mem(&params.name)
1266        .is_some()
1267    {
1268        return Err(memstead_base::EngineError::MemNameCollision {
1269            name: params.name,
1270            source_origin: "attached read mem".to_string(),
1271        }
1272        .into());
1273    }
1274
1275    // ---- Step 2b: storage residue probe (mem-repo only) ----
1276    // A name absent from the in-memory router can still have storage
1277    // residue — a per-mem content branch +
1278    // `__MEMSTEAD:mems/<branch_leaf>/config.json` blob surviving a
1279    // `memstead mem unregister` (deliberate operator state), a crash
1280    // mid-create, or a partially-failed delete. Without this probe the
1281    // seed-commit step would silently re-attach (resurrecting deleted
1282    // entities) or fail with a low-level `HashMismatch` carrying no
1283    // useful recovery context. The probe here is path-aware: the
1284    // composed `branch_leaf`
1285    // (`<mem_path>/<name>` for hierarchical, bare `<name>` for
1286    // flat) is the exact branch ref to inspect — `find_branches_by_leaf`
1287    // is too permissive (would match `other-team/<name>` for
1288    // `team/<name>`).
1289    //
1290    // Folder creates don't have a branch-residue concept (even in a
1291    // mem-repo workspace where the explicit override forces folder);
1292    // their analogous probe is "does `<location>/.memstead/config.json`
1293    // already exist?" — which Step 4 below already enforces via
1294    // `ConfigAlreadyExists`. The residue refusal is git-branch-only.
1295    // Hierarchical paths are first-class: the composed branch path IS
1296    // the mem name
1297    // (`params.name` carries the full `team/sub-mem` form
1298    // directly). Bound to a local for readability and to match the
1299    // reattach + force-overwrite arm shapes that still need a
1300    // `&str` reference.
1301    let composed_branch_leaf = params.name.clone();
1302    let residue_probe = if storage_kind == StorageKind::Folder {
1303        ResidueProbe::None
1304    } else {
1305        residue_probe_for_workspace(
1306            engine,
1307            workspace_root.as_deref(),
1308            &composed_branch_leaf,
1309            &params.name,
1310            &canonical_schema_ref,
1311        )
1312    };
1313    // The match discriminates the residue routes: `None` / fresh-create
1314    // and `ForceOverwrite` fall through to Step 3 below; `Reattach`
1315    // early-returns with the warning surfaced via the response's
1316    // `warnings` field. The match value itself is unused once those
1317    // branches have taken effect — the warning emission lives on the
1318    // response, not the discarded binding.
1319    let _: Option<memstead_base::ops::WarningHint> = match residue_probe {
1320        ResidueProbe::None => None,
1321        ResidueProbe::Present {
1322            branch_ref,
1323            config_blob,
1324            existing_config,
1325        } => {
1326            let tombstone = existing_config
1327                .as_ref()
1328                .and_then(|c| c.unregistered_at.clone());
1329            let effective_action = params
1330                .recovery
1331                .or_else(|| tombstone.as_ref().map(|_| crate::RecoveryAction::Reattach));
1332            match effective_action {
1333                None => {
1334                    return Err(FullEngineError::MemStorageResidueDetected {
1335                        branch_ref,
1336                        config_blob,
1337                        entity_count: 0,
1338                    });
1339                }
1340                Some(crate::RecoveryAction::HardCleanupFirst) => {
1341                    return Err(FullEngineError::MemStorageResidueDetected {
1342                        branch_ref,
1343                        config_blob,
1344                        entity_count: 0,
1345                    });
1346                }
1347                Some(crate::RecoveryAction::ForceOverwrite) => {
1348                    // Force-overwrite — prune the residual branch and
1349                    // `__MEMSTEAD` config blob in one ref-edit
1350                    // transaction, then fall through to Steps 3-5
1351                    // (normal create path). The match arm yields
1352                    // `None` so no warning rides on the response;
1353                    // the prior entities are gone by design.
1354                    let workspace_root_ref = workspace_root.as_ref().ok_or_else(|| {
1355                        memstead_base::EngineError::InvalidInput(
1356                            "force_overwrite requires a workspace_root \
1357                                 to locate mem-repo/.git/"
1358                                .to_string(),
1359                        )
1360                    })?;
1361                    let gitdir = workspace_root_ref.join("mem-repo").join(".git");
1362                    let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
1363                    let ops = engine.git_branch_ops().ok_or_else(|| {
1364                        memstead_base::EngineError::InvalidInput(
1365                            "force_overwrite requires the git-branch ops \
1366                             bundle (full boot only) — folder workspaces \
1367                             have no branch residue to prune"
1368                                .to_string(),
1369                        )
1370                    })?;
1371                    (ops.prune_residue)(&canonical_gitdir, &composed_branch_leaf).map_err(|e| {
1372                        memstead_base::EngineError::Mem(format!("force_overwrite prune: {e}"))
1373                    })?;
1374                    // Fall through to Step 3 — the residue is gone,
1375                    // create proceeds normally and the fresh seed
1376                    // commit is the new branch tip.
1377                    None
1378                }
1379                Some(crate::RecoveryAction::Reattach) => {
1380                    // Reattach path — register the existing branch
1381                    // as a fresh writable mount, skip the seed
1382                    // commit (the branch already carries history),
1383                    // clear the tombstone if present, surface the
1384                    // audit warning. Falls out below via early
1385                    // return so steps 3-5 stay aligned with the
1386                    // fresh-create path.
1387                    let workspace_root_ref = workspace_root.as_ref().ok_or_else(|| {
1388                        memstead_base::EngineError::InvalidInput(
1389                            "reattach requires a workspace_root \
1390                                 to locate mem-repo/.git/"
1391                                .to_string(),
1392                        )
1393                    })?;
1394                    let gitdir = workspace_root_ref.join("mem-repo").join(".git");
1395                    let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
1396                    let mount = memstead_base::workspace::Mount {
1397                        migration_target: None,
1398                        mem: params.name.clone(),
1399                        schema: Some(canonical_schema_ref.clone()),
1400                        storage: memstead_base::workspace::MountStorage::GitBranch {
1401                            gitdir: canonical_gitdir,
1402                            branch: format!("refs/heads/{composed_branch_leaf}"),
1403                        },
1404                        capability: memstead_base::workspace::MountCapability::Write,
1405                        lifecycle: memstead_base::workspace::MountLifecycle::Eager,
1406                        cross_linkable: true,
1407                    };
1408                    let factory = engine.backend_factory();
1409                    let backend = factory(&mount).map_err(|e| {
1410                        memstead_base::EngineError::Mem(format!(
1411                            "reattach backend instantiate: {e}"
1412                        ))
1413                    })?;
1414                    // Clear the tombstone if one was present, so a
1415                    // future drift probe doesn't re-trigger the
1416                    // reattach branch.
1417                    if let Some(cfg) = existing_config.as_ref()
1418                        && cfg.unregistered_at.is_some()
1419                    {
1420                        let mut updated = cfg.clone();
1421                        updated.unregistered_at = None;
1422                        if let Ok(mut bytes) = serde_json::to_vec_pretty(&updated) {
1423                            bytes.push(b'\n');
1424                            if let Err(e) = backend.write_mem_config(&bytes) {
1425                                tracing::warn!(
1426                                    mem = %params.name,
1427                                    error = %e,
1428                                    "reattach: tombstone clear failed — \
1429                                     the marker survives; a re-unregister will \
1430                                     overwrite it",
1431                                );
1432                            }
1433                        }
1434                    }
1435                    let origin = memstead_base::MemOrigin::RuntimeCreated {
1436                        at: std::time::SystemTime::now(),
1437                        by_tool: "memstead_mem_create (reattach)",
1438                    };
1439                    engine.register_writable_mem(mount, backend, origin)?;
1440                    engine.persist_state()?;
1441                    // Re-derive every writable mem's incoming-edge slice
1442                    // so other mems' relationships pointing at the
1443                    // reattaching mem land in the in-memory edge index.
1444                    // Rebuilding only from the reattaching mem's own
1445                    // outgoing edges would leave `memstead_health`
1446                    // undercounting cross-mem edges visible via
1447                    // `memstead_entity` on the on-disk markdown.
1448                    // Reuses the existing workspace-wide reload path
1449                    // (`memstead_reload` no-arg) — schema rebuild + per-mem
1450                    // bodies + read-mem re-attach + workspace.toml
1451                    // re-read. Reports are dropped; the side effect is
1452                    // the edge index re-derivation.
1453                    let _ = engine.reload_each_writable_mem_reports()?;
1454                    let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
1455                    if let Some(ts) = tombstone {
1456                        warnings.push(
1457                            memstead_base::ops::WarningHint::MemReattachedAfterUnregister {
1458                                mem: params.name.clone(),
1459                                unregistered_at: ts,
1460                            },
1461                        );
1462                    }
1463                    // Early return — the reattach path has no seed
1464                    // commit, no .memstead/config.json write. The branch
1465                    // tip stays as the prior session left it.
1466                    return Ok(MemCreateResponse {
1467                        name: params.name,
1468                        location: canonical,
1469                        schema_ref: canonical_schema_ref,
1470                        seed_commit_sha: String::new(),
1471                        warnings,
1472                    });
1473                }
1474            }
1475        }
1476    };
1477    // ---- Step 3: build MemConfig bytes ----
1478    // F1: every mem carries a populated `version` from creation
1479    // onward — `0.1.0` is the engine default; operators bump via
1480    // `memstead mem set-version` before publishing. Without this seed,
1481    // the export path hits the residual `MEM_CONFIG_INCOMPLETE` /
1482    // pre-fix `INTERNAL` collapse on the first archive attempt.
1483    let mem_config = memstead_schema::config::MemConfig {
1484        review_mark: None,
1485        mutation_stamp: None,
1486        name: None,
1487        title: None,
1488        subject: None,
1489        version: Some(semver::Version::new(0, 1, 0)),
1490        description: None,
1491        authors: None,
1492        schema: Some(canonical_schema_ref.clone()),
1493        write_guidance: params.write_guidance.clone(),
1494        process_mem: None,
1495        rules: None,
1496        publish: None,
1497        language: None,
1498        read_mems: Default::default(),
1499        community: None,
1500        vcs: params.vcs.clone(),
1501        unregistered_at: None,
1502        sync_state: Default::default(),
1503        extra: Default::default(),
1504    };
1505    let config_bytes = serde_json::to_vec_pretty(&mem_config).map_err(|e| {
1506        memstead_base::EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1507    })?;
1508
1509    // ---- Step 3b: pick storage variant ----
1510    // `storage_kind` was resolved at Step 1c: the explicit
1511    // `params.storage` override when set, else the workspace-shape
1512    // heuristic (git-branch when `<workspace_root>/mem-repo/.git/`
1513    // exists, folder otherwise — gix-free, so the heuristic works in
1514    // lean builds, which never have a mem-repo).
1515    //
1516    // The git-branch storage requires the engine to have the full
1517    // backend factory installed (`engine_from_workspace_root` does
1518    // this at boot). When the factory is the default lean one, the
1519    // factory call below returns
1520    // [`memstead_base::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature`]
1521    // — wrapped as `EngineError::Mem` in the seed-commit step.
1522    // The branch leaf IS `params.name` — no separate composition step.
1523    // Hierarchical identity lives directly in the mem name.
1524    let branch_leaf = params.name.clone();
1525    let storage = match storage_kind {
1526        StorageKind::GitBranch => {
1527            // Step 1c refused git-branch without a mem-repo, and the
1528            // mem-repo probe requires a workspace_root — this arm
1529            // always has one. The `ok_or_else` is defence in depth.
1530            let root = workspace_root.as_ref().ok_or_else(|| {
1531                memstead_base::EngineError::InvalidInput(
1532                    "storage: git-branch requires a workspace_root \
1533                     to locate mem-repo/.git/"
1534                        .to_string(),
1535                )
1536            })?;
1537            let probe = root.join("mem-repo").join(".git");
1538            let gitdir = probe.canonicalize().unwrap_or(probe);
1539            memstead_base::workspace::MountStorage::GitBranch {
1540                gitdir,
1541                branch: format!("refs/heads/{branch_leaf}"),
1542            }
1543        }
1544        StorageKind::Folder => memstead_base::workspace::MountStorage::Folder {
1545            path: mount_path.clone(),
1546        },
1547    };
1548    let is_git_branch = matches!(
1549        storage,
1550        memstead_base::workspace::MountStorage::GitBranch { .. }
1551    );
1552
1553    // ---- Step 4: write .memstead/config.json ----
1554    // Folder path: write the config blob to disk before instantiating
1555    // the backend so the post-register `read_mem_config()` sees it.
1556    // Git-branch path: skip the on-disk write — the per-mem config
1557    // travels in the workspace's `__MEMSTEAD` registry ref, not on disk.
1558    // The seed commit on the per-mem branch may also include a
1559    // `.memstead/config.json` blob for parity with folder backends; this
1560    // can be added later as an additive piece without changing the
1561    // wire shape.
1562    if !is_git_branch {
1563        std::fs::create_dir_all(&canonical).map_err(|e| {
1564            memstead_base::EngineError::Mem(format!("create_dir_all {}: {e}", canonical.display()))
1565        })?;
1566        let memstead_dir = canonical.join(memstead_base::MEM_META_DIR);
1567        std::fs::create_dir_all(&memstead_dir).map_err(|e| {
1568            memstead_base::EngineError::Mem(format!(
1569                "create_dir_all {}: {e}",
1570                memstead_dir.display()
1571            ))
1572        })?;
1573        let config_path = memstead_dir.join("config.json");
1574        if config_path.exists() {
1575            return Err(FullEngineError::ConfigAlreadyExists { path: config_path });
1576        }
1577        std::fs::write(&config_path, &config_bytes).map_err(|e| {
1578            memstead_base::EngineError::Mem(format!("write {}: {e}", config_path.display()))
1579        })?;
1580    }
1581
1582    // ---- Step 5: instantiate backend + seed commit + register ----
1583    // Backend instantiation routes through `engine.backend_factory()`.
1584    // Full consumers install
1585    // `memstead_git_branch::storage::instantiate_full_backend` at boot so
1586    // the same call site produces git-branch backends when the mount
1587    // declares one (Step 3b above picks the variant by workspace
1588    // shape).
1589    //
1590    // Produce a real seed commit via the backend's commit method
1591    // before registering. Folder backends emit a synthetic id
1592    // (UNIX-nanos + counter, hex per the trait's contract);
1593    // git-branch backends produce real 40-char shas. Either way the
1594    // response carries a non-empty cursor.
1595    let mount = memstead_base::workspace::Mount {
1596        mem: params.name.clone(),
1597        schema: Some(canonical_schema_ref.clone()),
1598        storage,
1599        capability: memstead_base::workspace::MountCapability::Write,
1600        lifecycle: memstead_base::workspace::MountLifecycle::Eager,
1601        cross_linkable: true,
1602        migration_target: None,
1603    };
1604    let factory = engine.backend_factory();
1605    let backend = factory(&mount)
1606        .map_err(|e| memstead_base::EngineError::Mem(format!("instantiate backend: {e}")))?;
1607    let seed_ctx = memstead_base::vcs::CommitContext {
1608        actor: params.actor,
1609        client: params.client.clone(),
1610        tool: Some("memstead_mem_create"),
1611        note: params.note.clone(),
1612        role: Default::default(),
1613        logical_operation_id: None,
1614        entity_ids: None,
1615    };
1616    // For git-branch mounts, write the per-mem config blob to the
1617    // workspace's `__MEMSTEAD` ref before sealing the per-mem branch.
1618    // Folder mounts already wrote the config to disk in Step 4;
1619    // calling `write_mem_config` again would be redundant for
1620    // folder.
1621    if is_git_branch {
1622        backend
1623            .write_mem_config(&config_bytes)
1624            .map_err(|e| memstead_base::EngineError::Mem(format!("write mem config: {e}")))?;
1625    }
1626    let seed_commit_sha = backend
1627        .commit(&format!("memstead: create mem {}", params.name), &seed_ctx)
1628        .map_err(|e| memstead_base::EngineError::Mem(format!("seed commit: {e}")))?;
1629    let origin = memstead_base::MemOrigin::RuntimeCreated {
1630        at: std::time::SystemTime::now(),
1631        by_tool: "memstead_mem_create",
1632    };
1633    // Hierarchical identity lives in `mount.mem` directly — there is
1634    // exactly one identifier (the full path), with no separate
1635    // `params.path` plumbed into the router.
1636    engine.register_writable_mem(mount, backend, origin)?;
1637
1638    // Persist the updated mount list to the workspace store. Without
1639    // this, the per-mem branch + `__MEMSTEAD` config (or folder +
1640    // `.memstead/config.json`) lives on disk but the next CLI / MCP
1641    // process boots with an empty mount manifest — `unknown mem`
1642    // on every follow-up call. Engine-side rather than orchestrator-
1643    // side so every caller (MCP, UniFFI, in-process embedding)
1644    // inherits persistence by construction.
1645    engine.persist_state()?;
1646
1647    // `require_notes` provenance nudge — inherited from the engine's
1648    // single enforcement point so mem lifecycle matches entity
1649    // mutations (no second, drift-prone implementation on the MCP
1650    // transport). The seed commit landed above; a noteless create
1651    // surfaces the warning without blocking.
1652    let note_warning = engine.note_missing_warning("create_mem", params.note.as_deref());
1653    let mut warnings: Vec<memstead_base::ops::WarningHint> = note_warning.into_iter().collect();
1654    // Folder storage has no version control: say at creation what
1655    // provenance means there (changelog ledger, placeholder SHAs,
1656    // durability tied to the surrounding repo). A warning, never a
1657    // refusal — folder mems are a supported storage class. Git-branch
1658    // mounts carry real commits and get no notice.
1659    if storage_kind == StorageKind::Folder {
1660        warnings.push(memstead_base::ops::WarningHint::FolderMemProvenance {
1661            mem: params.name.clone(),
1662        });
1663    }
1664    Ok(MemCreateResponse {
1665        name: params.name,
1666        location: canonical,
1667        schema_ref: canonical_schema_ref,
1668        seed_commit_sha,
1669        warnings,
1670    })
1671}
1672
1673/// Canonicalize a path that may or may not yet exist. Walks up
1674/// until the first existing ancestor, canonicalizes that, and
1675/// appends the tail — preserving the original segment order. Falls
1676/// back to the input when every ancestor is unavailable.
1677///
1678/// Mirrors full's `canonicalize_maybe_missing` in
1679/// `memstead_git_branch::mem_management::create`. Lifted into memstead-engine
1680/// so the unified create orchestrator doesn't reach back to
1681/// memstead-git-branch.
1682fn canonicalize_maybe_missing(path: &std::path::Path) -> std::path::PathBuf {
1683    if let Ok(c) = path.canonicalize() {
1684        return c;
1685    }
1686    let mut tail: Vec<std::ffi::OsString> = Vec::new();
1687    let mut cursor: &std::path::Path = path;
1688    loop {
1689        if let Ok(c) = cursor.canonicalize() {
1690            let mut out = c;
1691            for seg in tail.iter().rev() {
1692                out.push(seg);
1693            }
1694            return out;
1695        }
1696        match cursor.file_name() {
1697            Some(name) => {
1698                tail.push(name.to_os_string());
1699                match cursor.parent() {
1700                    Some(parent) => cursor = parent,
1701                    None => return path.to_path_buf(),
1702                }
1703            }
1704            None => return path.to_path_buf(),
1705        }
1706    }
1707}
1708
1709// ---------------------------------------------------------------------------
1710// Mem rename
1711// ---------------------------------------------------------------------------
1712
1713/// Parameters for [`rename_mem`].
1714#[derive(Debug, Clone)]
1715pub struct MemRenameParams {
1716    /// Current mem name (full hierarchical identifier).
1717    pub old: String,
1718    /// Target mem name. Must satisfy the mem-name grammar and not be
1719    /// registered.
1720    pub new: String,
1721    /// When `true`, both allowlist gates (delete for `old`, create for
1722    /// `new`) are skipped — same posture as `create_mem` / `delete_mem`.
1723    pub operator_mode: bool,
1724    /// Agent-authored provenance note (≤[`NOTE_MAX_LEN`] chars),
1725    /// carried on every commit the rename produces.
1726    pub note: Option<String>,
1727}
1728
1729/// Response of [`rename_mem`].
1730#[derive(Debug, Clone)]
1731pub struct MemRenameResponse {
1732    pub old: String,
1733    pub new: String,
1734    /// Mems whose entity files were rewritten by the reference sweep,
1735    /// in commit order.
1736    pub rewritten_mems: Vec<String>,
1737    /// `true` when the call completed a previously interrupted rename
1738    /// (the old name was already gone, the new mount present): only
1739    /// the idempotent halves ran (reference sweep, grants, binding /
1740    /// findings relocation).
1741    pub resumed: bool,
1742    pub warnings: Vec<memstead_base::ops::WarningHint>,
1743}
1744
1745/// Rename a mem: `old` → `new`, complete across every surface that
1746/// carries the name.
1747///
1748/// Entity ids are derived from `(mount name, file path)`, so the mem's
1749/// own entities re-id automatically once the mount is renamed; the
1750/// orchestrator's job is everything textual and structural around
1751/// that:
1752///
1753/// 1. **Reference sweep** ([`memstead_base::Engine::rewrite_mem_references`]):
1754///    every `<old>--<slug>` / `<old>:<slug>` wiki-link and
1755///    Relationships entry in every writable mem (peers and the renamed
1756///    mem's own full-id self-references), plus the anchors-sidecar
1757///    keys — one commit per affected mem, all tagged with one
1758///    `logical_operation_id`.
1759/// 2. **Sync-state keys** in the mem's config
1760///    (`<old>/<binding>/<source>#…` → `<new>/…`), written to the
1761///    backend before the identity flip so the updated blob travels
1762///    with it.
1763/// 3. **Storage identity flip**: git-branch mounts move
1764///    `refs/heads/<old>` to `refs/heads/<new>` at the same tip
1765///    (history preserved) and relocate the `__MEMSTEAD:mems/` config
1766///    blob in one ref transaction; folder mounts keep their directory
1767///    (the mount name, not the path, is the identity).
1768/// 4. **Router / mounts**: the mount re-registers under the new name
1769///    and `mounts.json` is persisted.
1770/// 5. **Workspace grants**: `[cross_mem_links]` keys and named values
1771///    carrying `old` are rewritten to `new` in `workspace.toml`.
1772/// 6. **Binding + findings stores**:
1773///    `.memstead/projections/<old>/` and
1774///    `.memstead/state/findings/<old>/` move to `<new>/`, and each
1775///    relocated binding's `destination_mem` field is rewritten.
1776///
1777/// **Refusal atomicity:** every refusal (unknown mem, read-only mount,
1778/// grammar, collision, allowlists) fires before the first write — a
1779/// refused call leaves the workspace byte-identical.
1780///
1781/// **Interruption:** the sweep commits per-mem; a crash mid-sweep
1782/// leaves stale `<old>--` references that surface as stubs in health,
1783/// and re-issuing the same `rename_mem` completes the operation. When
1784/// the identity flip has already happened (old gone, new present) the
1785/// call runs in *resumption mode*: only the idempotent halves execute.
1786///
1787/// **In-process caveat:** grants rewritten on disk (step 5) are not
1788/// reflected into the already-loaded engine's settings — the CLI's
1789/// one-shot process model makes this invisible; a long-lived embedder
1790/// must re-boot after a rename.
1791pub fn rename_mem(
1792    engine: &mut memstead_base::Engine,
1793    params: MemRenameParams,
1794) -> Result<MemRenameResponse, FullEngineError> {
1795    // ---- Step 0: input validation (no writes past this block) ----
1796    if let Some(note) = params.note.as_deref()
1797        && note.chars().count() > NOTE_MAX_LEN
1798    {
1799        return Err(memstead_base::EngineError::InvalidInput(format!(
1800            "note exceeds {NOTE_MAX_LEN} characters"
1801        ))
1802        .into());
1803    }
1804    if params.old == params.new {
1805        return Err(memstead_base::EngineError::InvalidInput(
1806            "rename source and target are the same name".to_string(),
1807        )
1808        .into());
1809    }
1810    if let Some(reason) = classify_invalid_mem_name(&params.new) {
1811        return Err(FullEngineError::InvalidMemName {
1812            name: params.new.clone(),
1813            reason,
1814        });
1815    }
1816    if memstead_base::entity::id::validate_mem_name_grammar(&params.new).is_err() {
1817        return Err(FullEngineError::InvalidMemName {
1818            name: params.new.clone(),
1819            reason: "invalid_char",
1820        });
1821    }
1822
1823    // ---- Step 1: mode resolution ----
1824    let old_mount = engine.mount(&params.old).cloned();
1825    let new_mount_present = engine.mount(&params.new).is_some();
1826    let resumed = match (&old_mount, new_mount_present) {
1827        (Some(_), true) => {
1828            return Err(memstead_base::EngineError::MemNameCollision {
1829                name: params.new.clone(),
1830                source_origin: "registered mount".to_string(),
1831            }
1832            .into());
1833        }
1834        (Some(m), false) => {
1835            if m.capability != memstead_base::MountCapability::Write {
1836                return Err(memstead_base::EngineError::ReadOnlyMount(params.old.clone()).into());
1837            }
1838            false
1839        }
1840        (None, true) => {
1841            // The identity flip already happened — resumption mode.
1842            // The new mount must be writable (a rename never produces
1843            // a read-only mount, so anything else is a name clash
1844            // with an installed read mem, not a resumable rename).
1845            if !engine.mem_router().is_writable(&params.new) {
1846                return Err(memstead_base::EngineError::UnknownMem(params.old.clone()).into());
1847            }
1848            true
1849        }
1850        (None, false) => {
1851            return Err(memstead_base::EngineError::UnknownMem(params.old.clone()).into());
1852        }
1853    };
1854
1855    // ---- Step 2: allowlist gates (normal mode, agent posture) ----
1856    // Resumption mode skips them: the flip that created the current
1857    // state already passed both gates, and the old name can no longer
1858    // match anything.
1859    if !resumed && !params.operator_mode {
1860        let attempted = std::path::PathBuf::from(format!("(mem: {})", params.old));
1861
1862        let delete_rule_set = DeleteRuleSet::new(engine.settings().mem_delete_rules.clone())
1863            .map_err(|e| {
1864                memstead_base::EngineError::InvalidInput(format!("mem_delete_rules: {e}"))
1865            })?;
1866        let delete_patterns: Vec<String> = delete_rule_set.patterns();
1867        if delete_rule_set.is_empty()
1868            || delete_rule_set
1869                .first_match(std::path::Path::new(&params.old))
1870                .is_none()
1871        {
1872            let reason = if delete_rule_set.is_empty() {
1873                "no_allowlist_configured"
1874            } else {
1875                "no_match"
1876            };
1877            return Err(FullEngineError::MemPathNotAllowed {
1878                attempted,
1879                candidate: params.old.clone(),
1880                patterns: delete_patterns,
1881                reason,
1882                policy_table: "mem_management.delete",
1883            });
1884        }
1885
1886        let create_rule_set = CreateRuleSet::new(engine.settings().mem_create_rules.clone())
1887            .map_err(|e| {
1888                memstead_base::EngineError::InvalidInput(format!("mem_create_rules: {e}"))
1889            })?;
1890        let create_patterns: Vec<String> = create_rule_set.patterns();
1891        let matched_rule = if create_rule_set.is_empty() {
1892            None
1893        } else {
1894            create_rule_set
1895                .first_match(std::path::Path::new(&params.new))
1896                .cloned()
1897        };
1898        let Some(matched_rule) = matched_rule else {
1899            let reason = if create_rule_set.is_empty() {
1900                "no_allowlist_configured"
1901            } else {
1902                "no_match"
1903            };
1904            return Err(FullEngineError::MemPathNotAllowed {
1905                attempted: std::path::PathBuf::from(format!("(mem: {})", params.new)),
1906                candidate: params.new.clone(),
1907                patterns: create_patterns,
1908                reason,
1909                policy_table: "mem_management.create",
1910            });
1911        };
1912
1913        // Schema gate: the pin is unchanged by a rename, so the
1914        // matched create rule's schema list is checked against the
1915        // mem's EXISTING pin (config pin first, mount assertion as
1916        // fallback). A mem with no discoverable pin passes only a
1917        // wildcard rule — refusing there would make unpinned mems
1918        // unrenamable for a reason the operator can't see.
1919        let schema_wildcard = matched_rule
1920            .schemas
1921            .iter()
1922            .any(|s| s == memstead_base::SCHEMA_WILDCARD);
1923        if !schema_wildcard {
1924            let existing_pin: Option<String> = engine
1925                .mem_configs_named()
1926                .find(|(name, _)| *name == params.old)
1927                .and_then(|(_, c)| c.schema.as_ref().map(|s| s.to_string()))
1928                .or_else(|| {
1929                    old_mount
1930                        .as_ref()
1931                        .and_then(|m| m.schema.as_ref().map(|s| s.to_string()))
1932                });
1933            let allowed = existing_pin
1934                .as_deref()
1935                .is_some_and(|pin| matched_rule.schemas.iter().any(|s| s == pin));
1936            if !allowed {
1937                return Err(FullEngineError::MemSchemaNotAllowed {
1938                    candidate: params.new.clone(),
1939                    matched_pattern: matched_rule.pattern.clone(),
1940                    requested_schema: existing_pin.unwrap_or_else(|| "(no pin)".to_string()),
1941                    allowed_schemas: matched_rule.schemas.clone(),
1942                });
1943            }
1944        }
1945    }
1946
1947    // ---- Step 3: reference sweep (idempotent; both modes) ----
1948    let sweep = engine
1949        .rewrite_mem_references(&params.old, &params.new, params.note.as_deref())
1950        .map_err(FullEngineError::from)?;
1951
1952    // ---- Steps 4-6: identity flip (normal mode only) ----
1953    if !resumed {
1954        let mount = old_mount.expect("normal mode implies the old mount is present");
1955
1956        // Step 4: sync-state keys embed the mem name
1957        // (`<mem>/<binding>/<source>#…`) — rewrite them on the old
1958        // backend so the updated config blob travels with the flip.
1959        {
1960            // No public live-backend accessor exists; a throwaway
1961            // backend handle from the factory reads and writes the
1962            // same storage the mounted one does (git-branch handles
1963            // are cheap ref wrappers, folder handles are paths).
1964            let factory = engine.backend_factory();
1965            let backend_ref = factory(&mount).map_err(|e| {
1966                memstead_base::EngineError::Mem(format!("instantiate backend: {e}"))
1967            })?;
1968            let config_bytes = backend_ref
1969                .read_mem_config()
1970                .map_err(|e| memstead_base::EngineError::Mem(format!("read mem config: {e}")))?;
1971            if let Some(bytes) = config_bytes
1972                && let Ok(mut cfg) =
1973                    serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes)
1974            {
1975                let old_prefix = format!("{}/", params.old);
1976                let new_prefix = format!("{}/", params.new);
1977                let mut changed = false;
1978                let rewritten: std::collections::BTreeMap<String, String> = cfg
1979                    .sync_state
1980                    .into_iter()
1981                    .map(|(k, v)| match k.strip_prefix(&old_prefix) {
1982                        Some(rest) => {
1983                            changed = true;
1984                            (format!("{new_prefix}{rest}"), v)
1985                        }
1986                        None => (k, v),
1987                    })
1988                    .collect();
1989                cfg.sync_state = rewritten;
1990                if changed {
1991                    let mut out = serde_json::to_vec_pretty(&cfg).map_err(|e| {
1992                        memstead_base::EngineError::Mem(format!("serialise mem config: {e}"))
1993                    })?;
1994                    out.push(b'\n');
1995                    backend_ref.write_mem_config(&out).map_err(|e| {
1996                        memstead_base::EngineError::Mem(format!("write mem config: {e}"))
1997                    })?;
1998                }
1999            }
2000        }
2001
2002        // Step 5: storage identity flip.
2003        let new_storage = match &mount.storage {
2004            memstead_base::MountStorage::GitBranch { gitdir, branch } => {
2005                let ops = engine.git_branch_ops().ok_or_else(|| {
2006                    memstead_base::EngineError::InvalidInput(
2007                        "mem rename on a git-branch mount requires the git-branch ops \
2008                         bundle (full boot only)"
2009                            .to_string(),
2010                    )
2011                })?;
2012                let canonical_gitdir = gitdir.canonicalize().unwrap_or_else(|_| gitdir.clone());
2013                // Mount records may carry the branch as a bare leaf or
2014                // as the full `refs/heads/<leaf>` form — the backend
2015                // instantiation tolerates both. Normalise to the leaf
2016                // for the storage call and write the new record in the
2017                // same form the old one used.
2018                let had_prefix = branch.starts_with("refs/heads/");
2019                let old_leaf = branch.strip_prefix("refs/heads/").unwrap_or(branch);
2020                (ops.rename_mem_storage)(&canonical_gitdir, old_leaf, &params.new)
2021                    .map_err(|e| memstead_base::EngineError::Mem(format!("storage rename: {e}")))?;
2022                let new_branch = if had_prefix {
2023                    format!("refs/heads/{}", params.new)
2024                } else {
2025                    params.new.clone()
2026                };
2027                memstead_base::MountStorage::GitBranch {
2028                    gitdir: gitdir.clone(),
2029                    branch: new_branch,
2030                }
2031            }
2032            other => other.clone(),
2033        };
2034
2035        // Step 6: re-register under the new name and persist.
2036        engine
2037            .unregister_writable_mem(&params.old)
2038            .map_err(FullEngineError::from)?;
2039        let new_mount = memstead_base::Mount {
2040            mem: params.new.clone(),
2041            schema: mount.schema.clone(),
2042            storage: new_storage,
2043            capability: mount.capability,
2044            lifecycle: mount.lifecycle,
2045            cross_linkable: mount.cross_linkable,
2046            migration_target: mount.migration_target.clone(),
2047        };
2048        let factory = engine.backend_factory();
2049        let backend = factory(&new_mount)
2050            .map_err(|e| memstead_base::EngineError::Mem(format!("instantiate backend: {e}")))?;
2051        let origin = memstead_base::MemOrigin::RuntimeCreated {
2052            at: std::time::SystemTime::now(),
2053            by_tool: "memstead mem rename",
2054        };
2055        engine
2056            .register_writable_mem(new_mount, backend, origin)
2057            .map_err(FullEngineError::from)?;
2058        engine.persist_state().map_err(FullEngineError::from)?;
2059    }
2060
2061    // ---- Step 7: workspace grants (idempotent; both modes) ----
2062    if let Some(root) = engine.workspace_root().map(|p| p.to_path_buf()) {
2063        crate::workspace_config_edit::rename_mem_in_cross_links(&root, &params.old, &params.new)
2064            .map_err(|e| memstead_base::EngineError::Mem(format!("grants rewrite: {e}")))?;
2065
2066        // ---- Step 8: binding + findings stores (idempotent) ----
2067        let store_dir = root.join(memstead_base::WORKSPACE_STORE_DIR);
2068        let projections_old = store_dir.join("projections").join(&params.old);
2069        let projections_new = store_dir.join("projections").join(&params.new);
2070        if projections_old.is_dir() && !projections_new.exists() {
2071            std::fs::rename(&projections_old, &projections_new).map_err(|e| {
2072                memstead_base::EngineError::Mem(format!("move projections dir: {e}"))
2073            })?;
2074        }
2075        if projections_new.is_dir() {
2076            for entry in std::fs::read_dir(&projections_new)
2077                .map_err(|e| memstead_base::EngineError::Mem(format!("read projections: {e}")))?
2078            {
2079                let path = entry
2080                    .map_err(|e| memstead_base::EngineError::Mem(format!("read projections: {e}")))?
2081                    .path();
2082                if path.extension().and_then(|e| e.to_str()) != Some("json") {
2083                    continue;
2084                }
2085                let Ok(text) = std::fs::read_to_string(&path) else {
2086                    continue;
2087                };
2088                let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&text) else {
2089                    continue;
2090                };
2091                if doc.get("destination_mem").and_then(|v| v.as_str()) == Some(params.old.as_str())
2092                {
2093                    doc["destination_mem"] = serde_json::Value::String(params.new.clone());
2094                    let mut out = serde_json::to_string_pretty(&doc).unwrap_or(text);
2095                    out.push('\n');
2096                    std::fs::write(&path, out).map_err(|e| {
2097                        memstead_base::EngineError::Mem(format!("rewrite binding: {e}"))
2098                    })?;
2099                }
2100            }
2101        }
2102        let findings_old = store_dir.join("state").join("findings").join(&params.old);
2103        let findings_new = store_dir.join("state").join("findings").join(&params.new);
2104        if findings_old.is_dir() && !findings_new.exists() {
2105            std::fs::rename(&findings_old, &findings_new)
2106                .map_err(|e| memstead_base::EngineError::Mem(format!("move findings dir: {e}")))?;
2107        }
2108    }
2109
2110    let note_warning = engine.note_missing_warning("rename_mem", params.note.as_deref());
2111    Ok(MemRenameResponse {
2112        old: params.old,
2113        new: params.new,
2114        rewritten_mems: sweep.rewritten_mems,
2115        resumed,
2116        warnings: note_warning.into_iter().collect(),
2117    })
2118}