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