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}
188
189/// Response shape from [`delete_mem`].
190#[derive(Debug, Clone)]
191pub struct MemDeleteResponse {
192    pub name: String,
193    /// Always `true` on successful return — the snapshot swap
194    /// happened.
195    pub deleted_from_router: bool,
196    /// `true` when `delete_files` was `true` AND the directory was
197    /// removed cleanly; `false` otherwise (delete_files false, or
198    /// removal errored, or backend has no on-disk directory).
199    pub files_deleted: bool,
200    /// Non-fatal findings emitted during the disk-cleanup step.
201    /// Populated when `delete_files: true` was requested but
202    /// [`Self::files_deleted`] ended `false` — distinguishes the
203    /// mem-db-no-op case from the rmdir-failure case so an agent
204    /// reading `files_deleted: false` doesn't trigger redundant
205    /// cleanup attempts. Empty when nothing surprised the operation
206    /// (e.g. `delete_files: false`, or `delete_files: true` and rmdir
207    /// succeeded).
208    pub warnings: Vec<memstead_base::ops::WarningHint>,
209    /// Dangling `[cross_mem_links]` grants scrubbed from
210    /// `.memstead/workspace.toml` on a destructive delete. Surfacing
211    /// the scrub here gives the agent a one-round-trip view of every
212    /// policy side effect. Only dangling cross-link grants are scrubbed
213    /// (and reported here); the `[[mem_management.*]]` allowlist rules
214    /// are preserved, so a later re-create of the same name needs no
215    /// fresh `allow-create`. Empty `[]` when no cross-link grant named
216    /// the deleted mem.
217    pub allowlist_entries_removed: Vec<AllowlistEntryRemoved>,
218}
219
220/// One scrubbed `.memstead/workspace.toml` entry surfaced on
221/// [`MemDeleteResponse::allowlist_entries_removed`]. Only dangling
222/// `[cross_mem_links]` grants are scrubbed, so `table` is always
223/// `"cross_mem_links"` and `from` / `to` name the directionality the
224/// grant established (`from` is the table key, `to` is the array
225/// element or wildcard). The `pattern` field is retained on the stable
226/// response shape but is no longer populated — the
227/// `[[mem_management.*]]` allowlist rules are preserved across a
228/// delete and therefore never reported here.
229#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
230pub struct AllowlistEntryRemoved {
231    /// Section in `.memstead/workspace.toml` the scrubbed entry came
232    /// from. Always `"cross_mem_links"` — the only class scrubbed on
233    /// delete.
234    pub table: String,
235    /// Retained on the response shape for stability but never
236    /// populated since the `[[mem_management.*]]` allowlist rules
237    /// are preserved across a delete. Always `None`.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub pattern: Option<String>,
240    /// Cross-link source mem — the grant's table key.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub from: Option<String>,
243    /// Cross-link target — the deleted mem when scrubbed from a
244    /// peer's list, or `"*"` when a wildcard grant got dropped.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub to: Option<String>,
247}
248
249/// Unregister a writable mem at runtime. The unified-engine
250/// counterpart to full's `memstead_git_branch::mem_management::delete_mem`.
251///
252/// Ordering guarantees mirror full's:
253/// 1. Pre-mutation checks (input validation, name resolution,
254///    allowlist match). Any failure here leaves the engine untouched
255///    and performs zero filesystem writes.
256/// 2. Router unregister (snapshot swap via
257///    [`memstead_base::Engine::unregister_writable_mem`]). After this the
258///    mem is no longer visible to readers. The unregister hands
259///    back the backend handle so step 3 can drive backend-side
260///    cleanup without re-resolving the mount.
261/// 3. Optional disk delete. A failure here is non-fatal: the mem
262///    is already unregistered, the leftover artifacts are a
263///    follow-up concern, and the response reports `files_deleted: false`
264///    with a typed `MEM_FILES_NOT_DELETED` warning naming what
265///    survived.
266///    - Folder mount: `remove_dir_all(location)` removes the mem
267///      directory. `backend.delete_artifacts()` is a no-op (folder
268///      backends keep the default impl).
269///    - Mem-db-backed (git-branch) mount: `backend.delete_artifacts()`
270///      drops `refs/heads/<branch_leaf>` and prunes
271///      `__MEMSTEAD:mems/<branch_leaf>/config.json`. There is no
272///      on-disk directory to rmdir.
273///
274///    `files_deleted: true` reflects "every backend-visible artifact
275///    for this mem has been removed" — the wire shape is unchanged
276///    but the semantic now covers both backends symmetrically.
277///
278/// Operator-mode bypass. When [`MemDeleteParams::operator_mode`] is
279/// `true`, Step 2 (`[[mem_management.delete]]` allowlist match) is
280/// skipped. All other steps run identically — including input
281/// validation, name resolution, the policy safeguard, router
282/// unregister, backend cleanup, and persistence. The flag is set by
283/// the transport that established operator intent at boot (today,
284/// `memstead-mcp --operator-mode`) and is not exposed as a wire-shape
285/// input.
286///
287/// Policy safeguard. Step 3 (`MEM_REFERENCED_BY_POLICY`) fires when
288/// `delete_files: true` AND another writable mem has a
289/// `cross_mem_links` grant pointing into the target. The gating is
290/// independent of `operator_mode`: storage destruction would orphan
291/// the grant, so the refusal is a hard stop until the grant is
292/// revoked. When `delete_files: false` (router-only unregister), the
293/// storage survives and the grant remains valid against it — the
294/// check is skipped. This matches the verb split exposed at the CLI
295/// layer (`mem unregister` is the verb that produces `delete_files:
296/// false`; `mem delete` is the verb that produces `delete_files:
297/// true`).
298///
299/// Hierarchical candidate composition is symmetric with
300/// `create_mem`: the router records the create-time `path` on
301/// each writable entry, and Step 2 below reads it back via
302/// [`memstead_base::MemRouterSnapshot::mem_path_for_mem`] to assemble
303/// the same `<mem_path>/<name>` (or bare `<name>`) string the
304/// create-side composer matched against.
305pub fn delete_mem(
306    engine: &mut memstead_base::Engine,
307    params: MemDeleteParams,
308) -> Result<MemDeleteResponse, FullEngineError> {
309    // ---- Step 0: input validation ----
310    if let Some(note) = params.note.as_deref()
311        && note.chars().count() > NOTE_MAX_LEN
312    {
313        return Err(memstead_base::EngineError::InvalidInput(format!(
314            "note exceeds {NOTE_MAX_LEN} characters"
315        ))
316        .into());
317    }
318
319    // ---- Step 1: resolve name ----
320    if !engine.mem_router().is_writable(&params.name) {
321        return Err(memstead_base::EngineError::UnknownMem(params.name.clone()).into());
322    }
323
324    // ---- Step 2: allowlist match ----
325    // Snapshot the on-disk dir (folder mounts only) and the
326    // create-time hierarchical `mem_path`. The lifecycle candidate
327    // composes as `<mem_path>/<name>` when a path was recorded at
328    // registration, falling back to the bare `<name>` for flat-layout
329    // mems — the same shape `create_mem` matched against the rule
330    // list. Symmetric on the engine side; closes the asymmetry that
331    // previously left hierarchical runtime mems un-deletable.
332    //
333    // Operator-mode skips the allowlist match entirely. The mem_dir
334    // is still resolved because Step 5 needs it for the rmdir step.
335    let mem_dir: Option<std::path::PathBuf> = engine
336        .mem_router()
337        .dir_for_mem(&params.name)
338        .map(|p| p.to_path_buf());
339
340    if !params.operator_mode {
341        // Hierarchical paths are first-class mem identifiers.
342        // `params.name` is already the full path (e.g.
343        // `team/sub-mem`) — no separate `mem_path` composition
344        // step needed. The delete-side lifecycle candidate IS the
345        // mem name.
346        let attempted = mem_dir
347            .clone()
348            .unwrap_or_else(|| std::path::PathBuf::from(format!("(mem: {})", params.name)));
349        let candidate: String = params.name.clone();
350
351        let delete_rule_set = DeleteRuleSet::new(engine.settings().mem_delete_rules.clone())
352            .map_err(|e| {
353                memstead_base::EngineError::InvalidInput(format!("mem_delete_rules: {e}"))
354            })?;
355        let patterns_for_errors: Vec<String> = delete_rule_set.patterns();
356
357        if delete_rule_set.is_empty() {
358            return Err(FullEngineError::MemPathNotAllowed {
359                attempted,
360                candidate,
361                patterns: patterns_for_errors,
362                reason: "no_allowlist_configured",
363                policy_table: "mem_management.delete",
364            });
365        }
366        if delete_rule_set
367            .first_match(std::path::Path::new(&candidate))
368            .is_none()
369        {
370            return Err(FullEngineError::MemPathNotAllowed {
371                attempted,
372                candidate,
373                patterns: patterns_for_errors,
374                reason: "no_match",
375                policy_table: "mem_management.delete",
376            });
377        }
378    }
379
380    // ---- Step 3: MEM_REFERENCED_BY_POLICY check ----
381    // Walk the workspace's `cross_mem_links` setting and refuse to
382    // delete a mem that any other visible writable mem is
383    // permitted to link into. Reads the workspace-level policy
384    // directly rather than per-mem `effective_cross_links` (the
385    // per-mem projection that composes workspace policy with
386    // create-rule defaults) — for workspaces that only configure
387    // links via the workspace-level `[cross_mem_links]` section
388    // (the common case), the two are equivalent.
389    //
390    // The check gates on `delete_files: true` rather than
391    // `!operator_mode`. The
392    // safeguard protects against orphaning a grant by destroying the
393    // storage it relies on; when `delete_files: false` (router-only
394    // unregister), the storage survives so grants remain valid and
395    // re-activate on re-init. The new CLI verb `memstead mem
396    // unregister` maps to this branch unconditionally; the CLI verb
397    // `memstead mem delete` maps to the storage-destruction branch
398    // where the check fires regardless of `operator_mode` (the
399    // operator is presumed to have surveyed the link graph, but a
400    // hard stop forces an explicit revoke-first flow).
401    if params.delete_files {
402        use memstead_schema::workspace_config::CrossLinkValue;
403        let mut referring_mems: Vec<String> = engine
404            .settings()
405            .cross_mem_links
406            .iter()
407            .filter_map(|(referring, policy)| {
408                if referring == &params.name {
409                    return None;
410                }
411                match policy {
412                    CrossLinkValue::List(targets) => {
413                        if targets.iter().any(|t| t == &params.name) {
414                            Some(referring.clone())
415                        } else {
416                            None
417                        }
418                    }
419                    _ => None,
420                }
421            })
422            .collect();
423        referring_mems.sort();
424        referring_mems.dedup();
425        if !referring_mems.is_empty() {
426            return Err(FullEngineError::MemReferencedByPolicy {
427                name: params.name,
428                referring_mems,
429            });
430        }
431    }
432
433    // ---- Step 3a: MEM_HAS_INCOMING_REFS check ----
434    // The policy check above closes the workspace-policy axis ("is this
435    // mem still grant-pointed-at?") but not the edge-graph axis
436    // ("does any actual entity still point at this mem's entities?").
437    // Revoking a grant is independent of removing the edges. Without
438    // this step, a mem whose grant was revoked but whose surviving
439    // Write-Mem peers still carry `DEPENDS_ON → target_mem--*`
440    // edges deletes cleanly, leaving dangling cross-mem edges that
441    // resolve to nothing.
442    //
443    // The scan walks every entity in the doomed mem, collects each
444    // entity's incoming edges, partitions out same-mem and ReadOnly-
445    // mount referrers, and groups the remaining Write-Mem referrers
446    // by source-entity id (one [`memstead_base::ReferrerInfo`] per source,
447    // `rel_types` aggregating every offending edge type). Same shape
448    // as entity-level `HasIncomingRefs` — see [`memstead_base::EngineError::MemHasIncomingRefs`]
449    // for the refusal-with-recovery contract. Fires regardless of
450    // `delete_files` because a router-only unregister with stale edges
451    // is just as broken as a storage-destruction with stale edges —
452    // either way, surviving entities point at a mem the engine no
453    // longer routes to.
454    {
455        use std::collections::BTreeSet;
456
457        // Source-id is grouped via a `Vec` of `(EntityId, BTreeSet<rel_type>)`
458        // pairs keyed by the id's string form — EntityId itself isn't
459        // `Ord` (no full lexical ordering defined), but its string
460        // form sorts deterministically and is what the wire envelope
461        // serialises anyway.
462        let store = engine.store();
463        let mut by_source: std::collections::BTreeMap<
464            String,
465            (memstead_base::EntityId, BTreeSet<String>),
466        > = std::collections::BTreeMap::new();
467        let doomed_mem = params.name.as_str();
468        for entity in store.all_entities() {
469            if entity.mem != doomed_mem {
470                continue;
471            }
472            for in_edge in store.incoming(&entity.id) {
473                if in_edge.from.mem() == doomed_mem {
474                    continue;
475                }
476                // ReadOnly-mount referrers are partitioned out — they
477                // route through the residual-stub demotion path on
478                // the destructive mutation, same as entity-level
479                // HasIncomingRefs. Use the router's capability lookup
480                // to decide; mounts the router doesn't know about
481                // (shouldn't happen post-construction) are treated as
482                // Write to be conservative.
483                let is_writable = engine.mem_router().is_writable(in_edge.from.mem());
484                if !is_writable {
485                    continue;
486                }
487                by_source
488                    .entry(in_edge.from.to_string())
489                    .or_insert_with(|| (in_edge.from.clone(), BTreeSet::new()))
490                    .1
491                    .insert(in_edge.rel_type.clone());
492            }
493        }
494
495        if !by_source.is_empty() {
496            let referrers: Vec<memstead_base::ReferrerInfo> = by_source
497                .into_values()
498                .map(|(from, rel_types)| memstead_base::ReferrerInfo {
499                    from_id: from.to_string(),
500                    rel_types: rel_types.into_iter().collect(),
501                    mem: from.mem().to_string(),
502                })
503                .collect();
504            return Err(memstead_base::EngineError::MemHasIncomingRefs {
505                mem: params.name,
506                referrers,
507            }
508            .into());
509        }
510    }
511
512    // ---- Step 4: router unregister ----
513    // Returns the backend handle so step 5 can drive backend-side
514    // cleanup without re-resolving the mount through the router
515    // (which has already lost the entry by the time we get here).
516    let removed_backend = engine.unregister_writable_mem(&params.name)?;
517    let backend =
518        removed_backend.expect("mem_router().is_writable check above guarantees a present mount");
519
520    // ---- Step 4b: tombstone write (unregister-only path) ----
521    // When the operator asked for
522    // router-only removal (`delete_files: false`), stamp the surviving
523    // config blob with an `unregistered_at` ISO-8601 marker so a
524    // subsequent `memstead mem init <same-name>` can recognize the
525    // residue as deliberate operator state (zero-friction reattach
526    // path) versus crash residue (refuse with
527    // `MEM_STORAGE_RESIDUE_DETECTED`).
528    //
529    // Failures here are non-fatal — the unregister has already
530    // committed, and a missing tombstone only downgrades the
531    // re-init flow (operator must pass `--reattach` explicitly).
532    // The warning surfaces the missed write so the operator can
533    // intervene if needed.
534    if !params.delete_files {
535        match backend.read_mem_config() {
536            Ok(Some(bytes)) => {
537                match serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes) {
538                    Ok(mut cfg) => {
539                        cfg.unregistered_at = Some(now_iso_utc());
540                        match serde_json::to_vec_pretty(&cfg) {
541                            Ok(mut new_bytes) => {
542                                new_bytes.push(b'\n');
543                                if let Err(e) = backend.write_mem_config(&new_bytes) {
544                                    tracing::warn!(
545                                        mem = %params.name,
546                                        error = %e,
547                                        "delete_mem: unregister succeeded but tombstone \
548                                         write failed — re-init will require an explicit \
549                                         --reattach flag"
550                                    );
551                                }
552                            }
553                            Err(e) => tracing::warn!(
554                                mem = %params.name,
555                                error = %e,
556                                "delete_mem: tombstone serialize failed",
557                            ),
558                        }
559                    }
560                    Err(e) => tracing::warn!(
561                        mem = %params.name,
562                        error = %e,
563                        "delete_mem: tombstone-write skipped — config blob did not \
564                         parse as MemConfig",
565                    ),
566                }
567            }
568            Ok(None) => {
569                // No on-disk config blob (folder backend with no
570                // `.memstead/config.json`, or git-branch mount whose
571                // `__MEMSTEAD:mems/.../config.json` was never written).
572                // Nothing to stamp.
573            }
574            Err(e) => tracing::warn!(
575                mem = %params.name,
576                error = %e,
577                "delete_mem: tombstone-read skipped — backend read_mem_config errored",
578            ),
579        }
580    }
581
582    // ---- Step 5: optional disk delete ----
583    // `delete_files: true` runs BOTH halves of the symmetric cleanup:
584    //   1. Backend-side `delete_artifacts()`. Folder + archive
585    //      backends keep the default no-op. The git-branch backend
586    //      drops `refs/heads/<branch_leaf>` and prunes
587    //      `__MEMSTEAD:mems/<branch_leaf>/config.json` in a single
588    //      ref-edit transaction.
589    //   2. Folder-direct `remove_dir_all(location)` when the mount
590    //      registered an on-disk directory (folder backends only).
591    //      Git-branch backends register `dir: None` so this branch
592    //      is skipped — the backend step above handled their state.
593    // Any sub-step failing leaves the operation in a documented
594    // partial state: the mem is already unregistered, `files_deleted`
595    // ends `false`, and per-failure `MEM_FILES_NOT_DELETED` warnings
596    // name the surviving artifact(s). `delete_files: false` returns
597    // `files_deleted: false` silently (the archive-workflow contract).
598    let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
599    let files_deleted = if params.delete_files {
600        let backend_ok = match backend.delete_artifacts() {
601            Ok(()) => true,
602            Err(e) => {
603                tracing::warn!(
604                    mem = %params.name,
605                    error = %e,
606                    "delete_mem: unregister succeeded but backend artifact \
607                     cleanup failed — leaving leftover refs / tree entries \
608                     for explicit cleanup"
609                );
610                warnings.push(memstead_base::ops::WarningHint::MemFilesNotDeleted {
611                    mem: params.name.clone(),
612                    reason: "backend_prune_failed".into(),
613                    path: None,
614                    error: Some(e.to_string()),
615                });
616                false
617            }
618        };
619        let dir_ok = match mem_dir.as_ref() {
620            Some(dir) => match std::fs::remove_dir_all(dir) {
621                Ok(()) => true,
622                Err(e) => {
623                    tracing::warn!(
624                        mem = %params.name,
625                        path = %dir.display(),
626                        error = %e,
627                        "delete_mem: unregister succeeded but rmdir failed — \
628                         leaving leftover files for explicit cleanup"
629                    );
630                    warnings.push(memstead_base::ops::WarningHint::MemFilesNotDeleted {
631                        mem: params.name.clone(),
632                        reason: "rmdir_failed".into(),
633                        path: Some(dir.display().to_string()),
634                        error: Some(e.to_string()),
635                    });
636                    false
637                }
638            },
639            // No on-disk directory to rmdir — mem-db-backed mount.
640            // The backend step above carried the cleanup; no warning
641            // here since the absence of a directory is the documented
642            // shape, not a partial-state signal.
643            None => true,
644        };
645        backend_ok && dir_ok
646    } else {
647        false
648    };
649
650    // Symmetric persistence with `create_mem`: write the post-
651    // unregister mount manifest so a sibling process boots without
652    // the deleted mem. If the workspace_root is unset (tests /
653    // ad-hoc consumers) the call is a no-op.
654    engine.persist_state()?;
655
656    // ---- Step 6: policy scrub on destructive delete ----
657    // When both refusal gates admitted and the destructive delete
658    // committed, scrub
659    // `.memstead/workspace.toml` of the now-dangling `[cross_mem_links]`
660    // grants naming the deleted mem — its own key plus every peer's
661    // allowlist value. The `[[mem_management.create|delete]]`
662    // allowlist rules are deliberately preserved (forward-looking
663    // permissions for the name, not references to the gone instance),
664    // so a later `mem init <same name>` needs no fresh allow-create.
665    // Refresh the engine's in-memory settings from the freshly-edited
666    // workspace.toml so a follow-up `memstead_mem_create` against the
667    // same name doesn't trip a stale grant, and `workspace show`
668    // agrees with the on-disk file. Skipped for router-only unregister
669    // (`delete_files: false`): the storage and grants survive
670    // together, set to re-activate on a future reattach.
671    let mut allowlist_entries_removed: Vec<AllowlistEntryRemoved> = Vec::new();
672    if params.delete_files
673        && let Some(root) = engine.workspace_root().map(|p| p.to_path_buf())
674    {
675        // Scrub failures are non-fatal but surfaced as warnings —
676        // the delete itself committed, and dangling policy entries
677        // would only cost reload-time `UNKNOWN_MEM` checks. Wrap
678        // the typed enum in a warning code the agent can branch on.
679        match crate::workspace_config_edit::scrub_policy_for_deleted_mem(&root, &params.name) {
680            Err(e) => {
681                tracing::warn!(
682                    mem = %params.name,
683                    error = %e,
684                    "delete_mem: destructive delete committed but policy \
685                     scrub failed — `.memstead/workspace.toml` may still \
686                     reference the deleted mem"
687                );
688            }
689            Ok(scrubbed) => {
690                // Lift scrubbed entries into the response envelope
691                // so the agent doesn't have to re-read
692                // `workspace show` to learn the side effects of
693                // the delete.
694                allowlist_entries_removed = scrubbed
695                    .into_iter()
696                    .map(|e| match e {
697                        crate::workspace_config_edit::ScrubbedEntry::CrossLink { from, to } => {
698                            AllowlistEntryRemoved {
699                                table: "cross_mem_links".to_string(),
700                                pattern: None,
701                                from: Some(from),
702                                to: Some(to),
703                            }
704                        }
705                    })
706                    .collect();
707                // Refresh the in-memory settings so the scrub takes
708                // effect without a full reload. Best-effort: missing or
709                // unparseable file leaves the existing in-memory
710                // settings untouched (the scrub already succeeded; the
711                // pre-scrub settings were strictly more permissive).
712                let store = memstead_base::workspace_store::FileWorkspaceStore::new();
713                if let Ok(ws) = <memstead_base::workspace_store::FileWorkspaceStore as memstead_base::workspace_store::WorkspaceStoreAdapter>::load(
714                        &store,
715                        &root,
716                    ) {
717                        engine.set_settings(ws.settings);
718                    }
719            }
720        }
721    }
722
723    // `require_notes` provenance nudge — inherited from the engine's
724    // single enforcement point (see `create_mem`).
725    if let Some(w) = engine.note_missing_warning("delete_mem", params.note.as_deref()) {
726        warnings.push(w);
727    }
728
729    Ok(MemDeleteResponse {
730        name: params.name,
731        deleted_from_router: true,
732        files_deleted,
733        warnings,
734        allowlist_entries_removed,
735    })
736}
737
738// ---------------------------------------------------------------------------
739// `memstead_mem_create` orchestration
740// ---------------------------------------------------------------------------
741
742/// Parameters for [`create_mem`]. Mirrors the `memstead_mem_create`
743/// MCP tool's wire shape 1:1.
744///
745/// Hierarchical paths are first-class mem identifiers — there is no
746/// separate `path` field; `name` carries the full path
747/// (e.g. `"team/sub-mem"`) directly, validated via
748/// [`memstead_base::entity::id::validate_mem_name_grammar`]. The
749/// branch ref composes as `refs/heads/<name>` and the `__MEMSTEAD`
750/// config blob as `__MEMSTEAD:mems/<name>/config.json` with no extra
751/// composition step.
752#[derive(Debug, Clone)]
753pub struct MemCreateParams {
754    /// Name of the new mem — the full hierarchical identifier
755    /// (e.g. `"sub-mem"` for flat layouts or `"team/sub-mem"`
756    /// for hierarchical layouts). Must be unique across every
757    /// visible mem in the current snapshot, match the basename of
758    /// `location` (folder backend identity invariant on the
759    /// trailing path segment), and satisfy the mem-name grammar
760    /// (`[a-z0-9-]+(/[a-z0-9-]+)*`).
761    pub name: String,
762    /// Target location. Absolute or workspace-relative.
763    /// Canonicalized inside the orchestrator before the allowlist
764    /// check.
765    pub location: std::path::PathBuf,
766    /// Schema pin for the new mem (`name@x.y.z`).
767    pub schema_ref: memstead_schema::SchemaRef,
768    /// Optional vcs config override (signing keys, identity hints).
769    /// Persisted into the per-mem config blob alongside `schema`
770    /// and `name`. Most callers pass `None` — defaults come from
771    /// the workspace's `.memstead/workspace.toml`. Mirrors full's
772    /// `memstead_git_branch::MemCreateParams.vcs`.
773    pub vcs: Option<memstead_schema::VcsConfig>,
774    /// Agent-authored provenance note (≤[`NOTE_MAX_LEN`] chars).
775    pub note: Option<String>,
776    /// Process-scoped operator-mode posture. When `true`, the
777    /// orchestrator skips the `[[mem_management.create]]` allowlist
778    /// gate and the matched-rule schema gate it derives — every other
779    /// check (input validation, schema canonicalisation, basename
780    /// invariant, name collision, backend instantiation) runs
781    /// identically. Set only by transports that established operator
782    /// intent at boot (`memstead-mcp --operator-mode`); never accepted as
783    /// a wire-shape input from agents. Defaults to `false` —
784    /// agent-mode.
785    pub operator_mode: bool,
786    /// Explicit recovery action for
787    /// the case where the storage already carries residue for the
788    /// composed branch path. `None` is the default — the engine
789    /// then routes by tombstone presence: residue with an
790    /// `unregistered_at` tombstone (deliberate operator state from
791    /// `memstead mem unregister`) defaults to [`RecoveryAction::Reattach`],
792    /// residue without a tombstone refuses with
793    /// `MEM_STORAGE_RESIDUE_DETECTED`. Setting an explicit value
794    /// overrides the tombstone-driven default. A bare `mem init`
795    /// against a name with no residue at all ignores this field
796    /// (the happy path is unchanged).
797    pub recovery: Option<crate::RecoveryAction>,
798    /// Optional opaque per-instance writing guidance, persisted
799    /// verbatim into the new mem's config (`writeGuidance`) in the
800    /// seed commit. The engine never inspects the map's contents
801    /// (schema-strictness D8 — `writeGuidance` is client-owned
802    /// vocabulary); a client that read a schema package's
803    /// `mem-template.json` fills the instance keys and passes them
804    /// here. Empty map (the default) seeds no guidance, identical to
805    /// pre-parameter behaviour.
806    pub write_guidance: std::collections::HashMap<String, serde_json::Value>,
807}
808
809/// Response shape from [`create_mem`].
810#[derive(Debug, Clone)]
811pub struct MemCreateResponse {
812    pub name: String,
813    pub location: std::path::PathBuf,
814    pub schema_ref: memstead_schema::SchemaRef,
815    /// Seed-commit cursor. Folder backends produce a synthetic id
816    /// (UNIX-nanos + counter, hex per the trait's contract);
817    /// git-branch backends produce a real 40-char hex sha. Either
818    /// way, the cursor is non-empty — agents poll
819    /// `memstead_changes_since` against it without branching on
820    /// backend type. Empty string (`""`) signals the reattach branch
821    /// was taken — pair with the `MEM_REATTACHED_AFTER_UNREGISTER`
822    /// warning surfaced via [`Self::warnings`] for full context.
823    pub seed_commit_sha: String,
824    /// Non-fatal findings emitted during the create / reattach
825    /// pipeline. Today populated only by the reattach branch with a
826    /// `MEM_REATTACHED_AFTER_UNREGISTER` warning carrying
827    /// `{mem, unregistered_at}`. Fresh-create
828    /// (residue absent or force-overwrite branch taken) leaves this
829    /// empty.
830    pub warnings: Vec<memstead_base::ops::WarningHint>,
831}
832
833/// Classify a structurally-invalid mem name into the typed
834/// `FullEngineError::InvalidMemName.reason` discriminator. Returns
835/// `None` when the name passes the structural check and the caller
836/// should proceed to the regex-level grammar check + allowlist gate.
837///
838/// Discriminator vocabulary:
839/// - `empty` — `params.name == ""`.
840/// - `whitespace` — input contains any ASCII whitespace, or is non-
841///   empty but trims to empty.
842/// - `reserved_prefix` — any path segment starts with `__` (the
843///   reserved prefix the engine uses for `__MEMSTEAD` registry refs and
844///   similar). Caught early so the operator sees the intent rather
845///   than a regex no-match.
846/// - `invalid_char` — fallback for anything else the grammar rejects
847///   (non-printable, non-ASCII letters, reserved characters).
848fn classify_invalid_mem_name(name: &str) -> Option<&'static str> {
849    if name.is_empty() {
850        return Some("empty");
851    }
852    if name.chars().any(char::is_whitespace) {
853        return Some("whitespace");
854    }
855    if name.split('/').any(|seg| seg.starts_with("__")) {
856        return Some("reserved_prefix");
857    }
858    None
859}
860
861/// Create a new writable mem at runtime. Unified counterpart to
862/// full's `memstead_git_branch::mem_management::create_mem`. Routes
863/// through the engine's installed [`memstead_base::BackendFactory`] so the
864/// same call site materialises folder, archive, or git-branch
865/// backends transparently — production full consumers install
866/// `memstead_git_branch::storage::instantiate_full_backend` at boot via
867/// `engine_from_workspace_root`.
868///
869/// Pipeline:
870/// 0. Input validation (note length, optional `path` segments).
871///    - 0b. Schema canonicalization against the built-in catalogue.
872///      Workspace-authored schemas are resolved by the workspace's
873///      schemas_dir, not yet here.
874/// 1. Canonicalize location against `engine.workspace_root()`
875///    (relative paths) or take absolute paths as-is.
876///    - 1a. Allowlist match against the composed candidate
877///      (`<path>/<name>` when `path` is `Some`, else `<name>`).
878///    - 1b. Schema gate against matched rule's `schemas` list. `["*"]`
879///      wildcard admits any schema.
880///    - 1c. Basename invariant: `params.name` MUST equal the canonical
881///      location's basename.
882/// 2. Name collision probe against the current mem_router
883///    snapshot. The rich tree-walk collision detector (with
884///    `colliding_paths` envelope payload) is full-only; unified
885///    surfaces collisions through the snapshot probe with the
886///    same `EngineError::MemNameCollision` discriminant.
887/// 3. Build [`memstead_schema::config::MemConfig`] bytes.
888///    - 3b. Pick the storage variant by workspace shape: git-branch
889///      when `<workspace_root>/mem-repo/.git/` exists; folder
890///      otherwise. The branch leaf composes as `<path>/<name>`.
891/// 4. Write `<location>/.memstead/config.json` for folder mounts
892///    only. Git-branch mounts skip the on-disk write — the
893///    per-mem config travels in the workspace's `__MEMSTEAD` registry
894///    ref.
895/// 5. Materialise the backend via the engine's
896///    [`memstead_base::BackendFactory`], commit the seed (real sha for
897///    git-branch, synthetic id for folder), and register via
898///    [`memstead_base::Engine::register_writable_mem`] with
899///    [`memstead_base::MemOrigin::RuntimeCreated`].
900pub fn create_mem(
901    engine: &mut memstead_base::Engine,
902    mut params: MemCreateParams,
903) -> Result<MemCreateResponse, FullEngineError> {
904    use std::path::Path;
905
906    // ---- Step 0: input validation ----
907    if let Some(note) = params.note.as_deref()
908        && note.chars().count() > NOTE_MAX_LEN
909    {
910        return Err(memstead_base::EngineError::InvalidInput(format!(
911            "note exceeds {NOTE_MAX_LEN} characters"
912        ))
913        .into());
914    }
915    // Hierarchical paths are first-class. `params.name` carries the
916    // full path (e.g. `"team/sub-mem"`); the grammar validator
917    // accepts both flat and hierarchical forms and refuses
918    // malformations (leading / trailing / double slashes, segments
919    // outside `[a-z0-9-]+`).
920    //
921    // Structural-failure modes get typed reasons before the allowlist
922    // check fires, so the four distinct shapes (empty / whitespace /
923    // invalid-char / reserved-prefix) stay distinguishable from a
924    // legitimate authorisation refusal rather than collapsing into the
925    // post-allowlist `MEM_PATH_NOT_ALLOWED (no_match)` envelope.
926    if let Some(reason) = classify_invalid_mem_name(&params.name) {
927        return Err(FullEngineError::InvalidMemName {
928            name: params.name.clone(),
929            reason,
930        });
931    }
932    // Grammar check (regex-level shape) for anything the structural
933    // classifier did not catch. The grammar refusals shouldn't fire
934    // here in practice — `classify_invalid_mem_name` already covers
935    // every concrete malformation. But keep the call as a defense in
936    // depth in case the grammar tightens later; route through the
937    // typed `invalid_char` reason so the wire shape stays consistent.
938    if memstead_base::entity::id::validate_mem_name_grammar(&params.name).is_err() {
939        return Err(FullEngineError::InvalidMemName {
940            name: params.name.clone(),
941            reason: "invalid_char",
942        });
943    }
944
945    // ---- Step 0b: schema canonicalization ----
946    // Resolve the agent-supplied schema pin against the engine's full
947    // loaded catalogue: workspace-authored schemas from the backend's
948    // local storage (folder `.memstead/schemas/` or the git-branch
949    // `__MEMSTEAD:schemas/` ref) layered over the built-ins. A mem can
950    // therefore pin a schema installed onto the backend via
951    // `memstead schema install`, not just a built-in.
952    let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
953        engine.workspace_schemas().to_vec();
954    builtin_schemas.extend_from_slice(engine.builtin_schemas());
955    let resolved_schema = memstead_base::engine::SchemaResolver::new(&builtin_schemas)
956        .resolve(&params.schema_ref)
957        .map_err(|sources| memstead_base::EngineError::SchemaNotFound {
958            mem: params.name.clone(),
959            pin: params.schema_ref.to_string(),
960            sources,
961        })?;
962    let canonical_schema_ref = memstead_schema::SchemaRef::new(
963        resolved_schema.manifest.name.clone(),
964        resolved_schema.version.clone(),
965    );
966    params.schema_ref = canonical_schema_ref.clone();
967
968    // ---- Step 1: canonicalize location ----
969    let workspace_root = engine.workspace_root().map(|p| p.to_path_buf());
970    let absolute = if params.location.is_absolute() {
971        params.location.clone()
972    } else if let Some(root) = workspace_root.as_ref() {
973        root.join(&params.location)
974    } else {
975        // No workspace_root set (tests, ad-hoc consumers). Treat
976        // relative as relative-to-CWD by canonicalising directly;
977        // the basename invariant + allowlist still apply.
978        params.location.clone()
979    };
980    let canonical = canonicalize_maybe_missing(&absolute);
981
982    // ---- Step 1a: allowlist match ----
983    // Compose the allowlist candidate from the optional `<path>`
984    // plus `<name>`. Flat layout (path = None) candidate is just
985    // `<name>`; hierarchical layout candidate is `<path>/<name>`
986    // (used by the rule lookup and surfaced in the
987    // `MEM_PATH_NOT_ALLOWED` envelope's `details.candidate` field).
988    //
989    // Operator-mode bypasses Step 1a and Step 1b entirely. The
990    // outside-workspace check below also folds in — operators
991    // typically rebuild from scratch and may place a mem outside
992    // any allowlist'd region. Every safety-shaped check (schema
993    // canonicalisation, basename invariant, name collision) stays
994    // unconditional.
995    // Hierarchical paths are first-class. The allowlist candidate IS
996    // the mem name (no `<path>/<name>` composition step —
997    // `params.name` already carries the full path).
998    let candidate: String = params.name.clone();
999
1000    if !params.operator_mode {
1001        let create_rule_set = CreateRuleSet::new(engine.settings().mem_create_rules.clone())
1002            .map_err(|e| {
1003                memstead_base::EngineError::InvalidInput(format!("mem_create_rules: {e}"))
1004            })?;
1005        let patterns_for_errors: Vec<String> = create_rule_set.patterns();
1006
1007        if create_rule_set.is_empty() {
1008            return Err(FullEngineError::MemPathNotAllowed {
1009                attempted: canonical.clone(),
1010                candidate,
1011                patterns: patterns_for_errors,
1012                reason: "no_allowlist_configured",
1013                policy_table: "mem_management.create",
1014            });
1015        }
1016        let matched_rule = match create_rule_set.first_match(Path::new(&candidate)) {
1017            Some(r) => r.clone(),
1018            None => {
1019                return Err(FullEngineError::MemPathNotAllowed {
1020                    attempted: canonical.clone(),
1021                    candidate,
1022                    patterns: patterns_for_errors,
1023                    reason: "no_match",
1024                    policy_table: "mem_management.create",
1025                });
1026            }
1027        };
1028
1029        // Outside-workspace check (skipped when no workspace_root is
1030        // set — tests / ad-hoc).
1031        if let Some(root) = workspace_root.as_ref()
1032            && canonical.strip_prefix(root).is_err()
1033        {
1034            return Err(FullEngineError::MemPathNotAllowed {
1035                attempted: canonical.clone(),
1036                candidate,
1037                patterns: patterns_for_errors,
1038                reason: "outside_workspace",
1039                policy_table: "mem_management.create",
1040            });
1041        }
1042
1043        // ---- Step 1b: schema gate ----
1044        let schema_wildcard = matched_rule
1045            .schemas
1046            .iter()
1047            .any(|s| s == memstead_base::SCHEMA_WILDCARD);
1048        if !schema_wildcard {
1049            let requested_canonical = canonical_schema_ref.to_string();
1050            let mut allowed_canonical: Vec<String> = Vec::with_capacity(matched_rule.schemas.len());
1051            let mut allowed = false;
1052            for raw in &matched_rule.schemas {
1053                let parsed: memstead_schema::SchemaRef = match raw.parse() {
1054                    Ok(r) => r,
1055                    Err(_) => {
1056                        return Err(memstead_base::EngineError::InvalidInput(format!(
1057                            "[mem_management] rule {:?}: schema entry {:?} is not a valid `name@version` pin",
1058                            matched_rule.pattern, raw,
1059                        ))
1060                        .into());
1061                    }
1062                };
1063                let resolved = memstead_base::engine::SchemaResolver::new(&builtin_schemas)
1064                    .resolve(&parsed)
1065                    .map_err(|sources| memstead_base::EngineError::SchemaNotFound {
1066                        mem: params.name.clone(),
1067                        pin: parsed.to_string(),
1068                        sources,
1069                    })?;
1070                let canon_str = memstead_schema::SchemaRef::new(
1071                    resolved.manifest.name.clone(),
1072                    resolved.version.clone(),
1073                )
1074                .to_string();
1075                if canon_str == requested_canonical {
1076                    allowed = true;
1077                }
1078                allowed_canonical.push(canon_str);
1079            }
1080            if !allowed {
1081                return Err(FullEngineError::MemSchemaNotAllowed {
1082                    candidate,
1083                    matched_pattern: matched_rule.pattern.clone(),
1084                    requested_schema: requested_canonical,
1085                    allowed_schemas: allowed_canonical,
1086                });
1087            }
1088        }
1089    }
1090
1091    // ---- Step 1c: basename invariant ----
1092    // Skipped on the git-branch path (mem-repo workspaces) because
1093    // the equivalent invariant is implicit there: `params.name` IS
1094    // the branch identifier, and `params.location` is ignored at
1095    // runtime (the mem has no on-disk identity beyond the gitdir).
1096    //
1097    // Mem names accept hierarchical paths (`team/sub-mem`). The
1098    // on-disk basename matches the LAST segment of the path —
1099    // folder-backed
1100    // hierarchical mems register under `<location>/sub-mem`
1101    // even when their identity is `team/sub-mem`.
1102    let workspace_has_mem_repo = workspace_root
1103        .as_ref()
1104        .map(|root| root.join("mem-repo").join(".git").is_dir())
1105        .unwrap_or(false);
1106    if !workspace_has_mem_repo {
1107        let target_basename = canonical
1108            .file_name()
1109            .and_then(|n| n.to_str())
1110            .unwrap_or("")
1111            .to_string();
1112        let name_leaf = params
1113            .name
1114            .rsplit('/')
1115            .next()
1116            .unwrap_or(params.name.as_str());
1117        if target_basename != name_leaf {
1118            return Err(memstead_base::EngineError::InvalidInput(format!(
1119                "mem name '{}' (leaf '{}') does not match the basename '{}' of the canonical location '{}' \
1120                 — rename either side so the registered identity's leaf matches the on-disk basename",
1121                params.name,
1122                name_leaf,
1123                target_basename,
1124                canonical.display()
1125            ))
1126            .into());
1127        }
1128    }
1129
1130    // ---- Step 2: name collision probe (snapshot only) ----
1131    if let Some(existing) = engine.mem_router().origin_for_mem(&params.name) {
1132        return Err(memstead_base::EngineError::MemNameCollision {
1133            name: params.name,
1134            source_origin: existing.render_source(),
1135        }
1136        .into());
1137    }
1138    if engine
1139        .mem_router()
1140        .archive_path_for_mem(&params.name)
1141        .is_some()
1142    {
1143        return Err(memstead_base::EngineError::MemNameCollision {
1144            name: params.name,
1145            source_origin: "attached read mem".to_string(),
1146        }
1147        .into());
1148    }
1149
1150    // ---- Step 2b: storage residue probe (mem-repo only) ----
1151    // A name absent from the in-memory router can still have storage
1152    // residue — a per-mem content branch +
1153    // `__MEMSTEAD:mems/<branch_leaf>/config.json` blob surviving a
1154    // `memstead mem unregister` (deliberate operator state), a crash
1155    // mid-create, or a partially-failed delete. Without this probe the
1156    // seed-commit step would silently re-attach (resurrecting deleted
1157    // entities) or fail with a low-level `HashMismatch` carrying no
1158    // useful recovery context. The probe here is path-aware: the
1159    // composed `branch_leaf`
1160    // (`<mem_path>/<name>` for hierarchical, bare `<name>` for
1161    // flat) is the exact branch ref to inspect — `find_branches_by_leaf`
1162    // is too permissive (would match `other-team/<name>` for
1163    // `team/<name>`).
1164    //
1165    // Folder-backed workspaces don't have a branch-residue concept;
1166    // their analogous probe is "does `<location>/.memstead/config.json`
1167    // already exist?" — which Step 4 below already enforces via
1168    // `ConfigAlreadyExists`. The residue refusal is git-branch-only.
1169    // Hierarchical paths are first-class: the composed branch path IS
1170    // the mem name
1171    // (`params.name` carries the full `team/sub-mem` form
1172    // directly). Bound to a local for readability and to match the
1173    // reattach + force-overwrite arm shapes that still need a
1174    // `&str` reference.
1175    let composed_branch_leaf = params.name.clone();
1176    let residue_probe = residue_probe_for_workspace(
1177        engine,
1178        workspace_root.as_deref(),
1179        &composed_branch_leaf,
1180        &params.name,
1181        &canonical_schema_ref,
1182    );
1183    // The match discriminates the residue routes: `None` / fresh-create
1184    // and `ForceOverwrite` fall through to Step 3 below; `Reattach`
1185    // early-returns with the warning surfaced via the response's
1186    // `warnings` field. The match value itself is unused once those
1187    // branches have taken effect — the warning emission lives on the
1188    // response, not the discarded binding.
1189    let _: Option<memstead_base::ops::WarningHint> = match residue_probe {
1190        ResidueProbe::None => None,
1191        ResidueProbe::Present {
1192            branch_ref,
1193            config_blob,
1194            existing_config,
1195        } => {
1196            let tombstone = existing_config
1197                .as_ref()
1198                .and_then(|c| c.unregistered_at.clone());
1199            let effective_action = params
1200                .recovery
1201                .or_else(|| tombstone.as_ref().map(|_| crate::RecoveryAction::Reattach));
1202            match effective_action {
1203                None => {
1204                    return Err(FullEngineError::MemStorageResidueDetected {
1205                        branch_ref,
1206                        config_blob,
1207                        entity_count: 0,
1208                    });
1209                }
1210                Some(crate::RecoveryAction::HardCleanupFirst) => {
1211                    return Err(FullEngineError::MemStorageResidueDetected {
1212                        branch_ref,
1213                        config_blob,
1214                        entity_count: 0,
1215                    });
1216                }
1217                Some(crate::RecoveryAction::ForceOverwrite) => {
1218                    // Force-overwrite — prune the residual branch and
1219                    // `__MEMSTEAD` config blob in one ref-edit
1220                    // transaction, then fall through to Steps 3-5
1221                    // (normal create path). The match arm yields
1222                    // `None` so no warning rides on the response;
1223                    // the prior entities are gone by design.
1224                    let workspace_root_ref = workspace_root.as_ref().ok_or_else(|| {
1225                        memstead_base::EngineError::InvalidInput(
1226                            "force_overwrite requires a workspace_root \
1227                                 to locate mem-repo/.git/"
1228                                .to_string(),
1229                        )
1230                    })?;
1231                    let gitdir = workspace_root_ref.join("mem-repo").join(".git");
1232                    let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
1233                    let ops = engine.git_branch_ops().ok_or_else(|| {
1234                        memstead_base::EngineError::InvalidInput(
1235                            "force_overwrite requires the git-branch ops \
1236                             bundle (full boot only) — folder workspaces \
1237                             have no branch residue to prune"
1238                                .to_string(),
1239                        )
1240                    })?;
1241                    (ops.prune_residue)(&canonical_gitdir, &composed_branch_leaf).map_err(|e| {
1242                        memstead_base::EngineError::Mem(format!("force_overwrite prune: {e}"))
1243                    })?;
1244                    // Fall through to Step 3 — the residue is gone,
1245                    // create proceeds normally and the fresh seed
1246                    // commit is the new branch tip.
1247                    None
1248                }
1249                Some(crate::RecoveryAction::Reattach) => {
1250                    // Reattach path — register the existing branch
1251                    // as a fresh writable mount, skip the seed
1252                    // commit (the branch already carries history),
1253                    // clear the tombstone if present, surface the
1254                    // audit warning. Falls out below via early
1255                    // return so steps 3-5 stay aligned with the
1256                    // fresh-create path.
1257                    let workspace_root_ref = workspace_root.as_ref().ok_or_else(|| {
1258                        memstead_base::EngineError::InvalidInput(
1259                            "reattach requires a workspace_root \
1260                                 to locate mem-repo/.git/"
1261                                .to_string(),
1262                        )
1263                    })?;
1264                    let gitdir = workspace_root_ref.join("mem-repo").join(".git");
1265                    let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
1266                    let mount = memstead_base::workspace::Mount {
1267                        migration_target: None,
1268                        mem: params.name.clone(),
1269                        schema: Some(canonical_schema_ref.clone()),
1270                        storage: memstead_base::workspace::MountStorage::GitBranch {
1271                            gitdir: canonical_gitdir,
1272                            branch: format!("refs/heads/{composed_branch_leaf}"),
1273                        },
1274                        capability: memstead_base::workspace::MountCapability::Write,
1275                        lifecycle: memstead_base::workspace::MountLifecycle::Eager,
1276                        cross_linkable: true,
1277                    };
1278                    let factory = engine.backend_factory();
1279                    let backend = factory(&mount).map_err(|e| {
1280                        memstead_base::EngineError::Mem(format!(
1281                            "reattach backend instantiate: {e}"
1282                        ))
1283                    })?;
1284                    // Clear the tombstone if one was present, so a
1285                    // future drift probe doesn't re-trigger the
1286                    // reattach branch.
1287                    if let Some(cfg) = existing_config.as_ref()
1288                        && cfg.unregistered_at.is_some()
1289                    {
1290                        let mut updated = cfg.clone();
1291                        updated.unregistered_at = None;
1292                        if let Ok(mut bytes) = serde_json::to_vec_pretty(&updated) {
1293                            bytes.push(b'\n');
1294                            if let Err(e) = backend.write_mem_config(&bytes) {
1295                                tracing::warn!(
1296                                    mem = %params.name,
1297                                    error = %e,
1298                                    "reattach: tombstone clear failed — \
1299                                     the marker survives; a re-unregister will \
1300                                     overwrite it",
1301                                );
1302                            }
1303                        }
1304                    }
1305                    let origin = memstead_base::MemOrigin::RuntimeCreated {
1306                        at: std::time::SystemTime::now(),
1307                        by_tool: "memstead_mem_create (reattach)",
1308                    };
1309                    engine.register_writable_mem(mount, backend, origin)?;
1310                    engine.persist_state()?;
1311                    // Re-derive every writable mem's incoming-edge slice
1312                    // so other mems' relationships pointing at the
1313                    // reattaching mem land in the in-memory edge index.
1314                    // Rebuilding only from the reattaching mem's own
1315                    // outgoing edges would leave `memstead_health`
1316                    // undercounting cross-mem edges visible via
1317                    // `memstead_entity` on the on-disk markdown.
1318                    // Reuses the existing workspace-wide reload path
1319                    // (`memstead_reload` no-arg) — schema rebuild + per-mem
1320                    // bodies + read-mem re-attach + workspace.toml
1321                    // re-read. Reports are dropped; the side effect is
1322                    // the edge index re-derivation.
1323                    let _ = engine.reload_each_writable_mem_reports()?;
1324                    let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
1325                    if let Some(ts) = tombstone {
1326                        warnings.push(
1327                            memstead_base::ops::WarningHint::MemReattachedAfterUnregister {
1328                                mem: params.name.clone(),
1329                                unregistered_at: ts,
1330                            },
1331                        );
1332                    }
1333                    // Early return — the reattach path has no seed
1334                    // commit, no .memstead/config.json write. The branch
1335                    // tip stays as the prior session left it.
1336                    return Ok(MemCreateResponse {
1337                        name: params.name,
1338                        location: canonical,
1339                        schema_ref: canonical_schema_ref,
1340                        seed_commit_sha: String::new(),
1341                        warnings,
1342                    });
1343                }
1344            }
1345        }
1346    };
1347    // ---- Step 3: build MemConfig bytes ----
1348    // F1: every mem carries a populated `version` from creation
1349    // onward — `0.1.0` is the engine default; operators bump via
1350    // `memstead mem set-version` before publishing. Without this seed,
1351    // the export path hits the residual `MEM_CONFIG_INCOMPLETE` /
1352    // pre-fix `INTERNAL` collapse on the first archive attempt.
1353    let mem_config = memstead_schema::config::MemConfig {
1354        name: None,
1355        version: Some(semver::Version::new(0, 1, 0)),
1356        description: None,
1357        authors: None,
1358        schema: Some(canonical_schema_ref.clone()),
1359        write_guidance: params.write_guidance.clone(),
1360        rules: None,
1361        publish: None,
1362        language: None,
1363        read_mems: Default::default(),
1364        community: None,
1365        vcs: params.vcs.clone(),
1366        unregistered_at: None,
1367        sync_state: Default::default(),
1368        extra: Default::default(),
1369    };
1370    let config_bytes = serde_json::to_vec_pretty(&mem_config).map_err(|e| {
1371        memstead_base::EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1372    })?;
1373
1374    // ---- Step 3b: pick storage variant ----
1375    // Workspace-shape heuristic. When
1376    // `<workspace_root>/mem-repo/.git/` exists, the new mem gets
1377    // a git-branch mount; otherwise folder. The probe is gix-free so
1378    // the heuristic works in lean builds (lean builds never have a
1379    // mem-repo, so the probe always returns false there).
1380    //
1381    // The git-branch storage requires the engine to have the full
1382    // backend factory installed (`engine_from_workspace_root` does
1383    // this at boot). When the factory is the default lean one, the
1384    // factory call below returns
1385    // [`memstead_base::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature`]
1386    // — wrapped as `EngineError::Mem` in the seed-commit step.
1387    // The branch leaf IS `params.name` — no separate composition step.
1388    // Hierarchical identity lives directly in the mem name.
1389    let branch_leaf = params.name.clone();
1390    let storage = if let Some(root) = workspace_root.as_ref() {
1391        let probe = root.join("mem-repo").join(".git");
1392        if probe.is_dir() {
1393            let gitdir = probe.canonicalize().unwrap_or(probe);
1394            memstead_base::workspace::MountStorage::GitBranch {
1395                gitdir,
1396                branch: format!("refs/heads/{branch_leaf}"),
1397            }
1398        } else {
1399            memstead_base::workspace::MountStorage::Folder {
1400                path: canonical.clone(),
1401            }
1402        }
1403    } else {
1404        memstead_base::workspace::MountStorage::Folder {
1405            path: canonical.clone(),
1406        }
1407    };
1408    let is_git_branch = matches!(
1409        storage,
1410        memstead_base::workspace::MountStorage::GitBranch { .. }
1411    );
1412
1413    // ---- Step 4: write .memstead/config.json ----
1414    // Folder path: write the config blob to disk before instantiating
1415    // the backend so the post-register `read_mem_config()` sees it.
1416    // Git-branch path: skip the on-disk write — the per-mem config
1417    // travels in the workspace's `__MEMSTEAD` registry ref, not on disk.
1418    // The seed commit on the per-mem branch may also include a
1419    // `.memstead/config.json` blob for parity with folder backends; this
1420    // can be added later as an additive piece without changing the
1421    // wire shape.
1422    if !is_git_branch {
1423        std::fs::create_dir_all(&canonical).map_err(|e| {
1424            memstead_base::EngineError::Mem(format!("create_dir_all {}: {e}", canonical.display()))
1425        })?;
1426        let memstead_dir = canonical.join(memstead_base::MEM_META_DIR);
1427        std::fs::create_dir_all(&memstead_dir).map_err(|e| {
1428            memstead_base::EngineError::Mem(format!(
1429                "create_dir_all {}: {e}",
1430                memstead_dir.display()
1431            ))
1432        })?;
1433        let config_path = memstead_dir.join("config.json");
1434        if config_path.exists() {
1435            return Err(FullEngineError::ConfigAlreadyExists { path: config_path });
1436        }
1437        std::fs::write(&config_path, &config_bytes).map_err(|e| {
1438            memstead_base::EngineError::Mem(format!("write {}: {e}", config_path.display()))
1439        })?;
1440    }
1441
1442    // ---- Step 5: instantiate backend + seed commit + register ----
1443    // Backend instantiation routes through `engine.backend_factory()`.
1444    // Full consumers install
1445    // `memstead_git_branch::storage::instantiate_full_backend` at boot so
1446    // the same call site produces git-branch backends when the mount
1447    // declares one (Step 3b above picks the variant by workspace
1448    // shape).
1449    //
1450    // Produce a real seed commit via the backend's commit method
1451    // before registering. Folder backends emit a synthetic id
1452    // (UNIX-nanos + counter, hex per the trait's contract);
1453    // git-branch backends produce real 40-char shas. Either way the
1454    // response carries a non-empty cursor.
1455    let mount = memstead_base::workspace::Mount {
1456        mem: params.name.clone(),
1457        schema: Some(canonical_schema_ref.clone()),
1458        storage,
1459        capability: memstead_base::workspace::MountCapability::Write,
1460        lifecycle: memstead_base::workspace::MountLifecycle::Eager,
1461        cross_linkable: true,
1462        migration_target: None,
1463    };
1464    let factory = engine.backend_factory();
1465    let backend = factory(&mount)
1466        .map_err(|e| memstead_base::EngineError::Mem(format!("instantiate backend: {e}")))?;
1467    let seed_ctx = memstead_base::vcs::CommitContext {
1468        actor: memstead_base::vcs::Actor::Agent,
1469        client: None,
1470        tool: Some("memstead_mem_create"),
1471        note: params.note.clone(),
1472        logical_operation_id: None,
1473        entity_ids: None,
1474    };
1475    // For git-branch mounts, write the per-mem config blob to the
1476    // workspace's `__MEMSTEAD` ref before sealing the per-mem branch.
1477    // Folder mounts already wrote the config to disk in Step 4;
1478    // calling `write_mem_config` again would be redundant for
1479    // folder.
1480    if is_git_branch {
1481        backend
1482            .write_mem_config(&config_bytes)
1483            .map_err(|e| memstead_base::EngineError::Mem(format!("write mem config: {e}")))?;
1484    }
1485    let seed_commit_sha = backend
1486        .commit(&format!("memstead: create mem {}", params.name), &seed_ctx)
1487        .map_err(|e| memstead_base::EngineError::Mem(format!("seed commit: {e}")))?;
1488    let origin = memstead_base::MemOrigin::RuntimeCreated {
1489        at: std::time::SystemTime::now(),
1490        by_tool: "memstead_mem_create",
1491    };
1492    // Hierarchical identity lives in `mount.mem` directly — there is
1493    // exactly one identifier (the full path), with no separate
1494    // `params.path` plumbed into the router.
1495    engine.register_writable_mem(mount, backend, origin)?;
1496
1497    // Persist the updated mount list to the workspace store. Without
1498    // this, the per-mem branch + `__MEMSTEAD` config (or folder +
1499    // `.memstead/config.json`) lives on disk but the next CLI / MCP
1500    // process boots with an empty mount manifest — `unknown mem`
1501    // on every follow-up call. Engine-side rather than orchestrator-
1502    // side so every caller (MCP, UniFFI, in-process embedding)
1503    // inherits persistence by construction.
1504    engine.persist_state()?;
1505
1506    // `require_notes` provenance nudge — inherited from the engine's
1507    // single enforcement point so mem lifecycle matches entity
1508    // mutations (no second, drift-prone implementation on the MCP
1509    // transport). The seed commit landed above; a noteless create
1510    // surfaces the warning without blocking.
1511    let note_warning = engine.note_missing_warning("create_mem", params.note.as_deref());
1512    Ok(MemCreateResponse {
1513        name: params.name,
1514        location: canonical,
1515        schema_ref: canonical_schema_ref,
1516        seed_commit_sha,
1517        warnings: note_warning.into_iter().collect(),
1518    })
1519}
1520
1521/// Canonicalize a path that may or may not yet exist. Walks up
1522/// until the first existing ancestor, canonicalizes that, and
1523/// appends the tail — preserving the original segment order. Falls
1524/// back to the input when every ancestor is unavailable.
1525///
1526/// Mirrors full's `canonicalize_maybe_missing` in
1527/// `memstead_git_branch::mem_management::create`. Lifted into memstead-engine
1528/// so the unified create orchestrator doesn't reach back to
1529/// memstead-git-branch.
1530fn canonicalize_maybe_missing(path: &std::path::Path) -> std::path::PathBuf {
1531    if let Ok(c) = path.canonicalize() {
1532        return c;
1533    }
1534    let mut tail: Vec<std::ffi::OsString> = Vec::new();
1535    let mut cursor: &std::path::Path = path;
1536    loop {
1537        if let Ok(c) = cursor.canonicalize() {
1538            let mut out = c;
1539            for seg in tail.iter().rev() {
1540                out.push(seg);
1541            }
1542            return out;
1543        }
1544        match cursor.file_name() {
1545            Some(name) => {
1546                tail.push(name.to_os_string());
1547                match cursor.parent() {
1548                    Some(parent) => cursor = parent,
1549                    None => return path.to_path_buf(),
1550                }
1551            }
1552            None => return path.to_path_buf(),
1553        }
1554    }
1555}