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