Skip to main content

memstead_base/engine/
lifecycle.rs

1//! Engine lifecycle — settings/workspace-root setters, runtime
2//! mem add/remove, reload, and export.
3//!
4//! `register_writable_mem` / `unregister_writable_mem` are the
5//! engine-level primitives the `memstead_mem_create` / `memstead_mem_delete`
6//! handlers build on. `reload_one_mem*` re-reads a mount's backend
7//! and refreshes the in-memory store; `reload_each_writable_mem*`
8//! sweeps every writable mount. `export_markdown` regenerates entity
9//! markdown for folder mounts; `export_mem` produces a portable
10//! `.mem` archive via the backend-aware dispatch in
11//! [`crate::ops::export`].
12
13use std::cell::OnceCell;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use crate::backend::{BackendError, MemBackend};
19use crate::engine_fallback_type;
20use crate::entity::EntityId;
21use crate::entity::generator::generate_markdown;
22use crate::entity::loader::parse_entries;
23use crate::entity::store_builder::push_entities_into_store;
24use crate::mem::MemOrigin;
25use crate::ops::WarningHint;
26use crate::workspace::{Mount, MountStorage, WorkspaceSettings};
27
28use super::boot::collect_source_entries;
29use super::{BackendFactory, Engine, EngineError, GitBranchOps, MountedBackend};
30
31impl Engine {
32    /// Replace the workspace-level settings. Called by
33    /// [`Self::from_workspace_root`] (and the full counterpart) after
34    /// reading `.memstead/workspace.toml`. Tests / direct callers leave
35    /// the default empty value in place. Cheap clone — settings
36    /// carry only data shapes (raw rule lists, link policy map),
37    /// no compiled matchers. Invalidates the lazy
38    /// `create_rule_set_memo` so the next synthesis call rebuilds
39    /// from the new policy.
40    pub fn set_settings(&mut self, settings: WorkspaceSettings) {
41        self.settings = settings;
42        self.create_rule_set_memo = OnceCell::new();
43    }
44
45    /// Replace the backend factory. Full consumers call this once at boot
46    /// (`engine_from_workspace_root`) to install
47    /// `memstead_git_branch::storage::instantiate_full_backend` so the engine
48    /// can materialise git-branch backends on top of folder + archive.
49    /// Lean consumers leave the default in place.
50    pub fn set_backend_factory(&mut self, factory: BackendFactory) {
51        self.backend_factory = factory;
52    }
53
54    /// Install the git-branch ops bundle. Full boot
55    /// (`memstead_git_branch::engine_from_workspace_root`) calls this once
56    /// at construction. Lean consumers leave it unset and the
57    /// git-branch dispatch branches collapse to typed errors / empty
58    /// reports — lean has no git-branch mounts.
59    pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
60        self.git_branch_ops = Some(ops);
61    }
62
63    /// Install a schema package onto the workspace's git-branch backend —
64    /// the unified `__MEMSTEAD:schemas/<name>@<version>/` ref. `files`
65    /// are `(relative-path, bytes)` pairs (`schema.yaml`,
66    /// `types/<t>.yaml`, optional `mem-template.json`). Returns the
67    /// resulting commit sha; idempotent at the storage layer (an
68    /// identical re-install produces no new commit).
69    ///
70    /// Folder workspaces install schemas by writing under
71    /// `<workspace>/.memstead/schemas/` directly; this is the git-branch
72    /// path, where the engine owns the mem-repo and the write must
73    /// route through it. Errors when no git-branch ops are wired (lean
74    /// flavour) or no git-branch mount exists to resolve the shared
75    /// mem-repo gitdir from. The caller reloads (or restarts) to pick
76    /// the new schema into the resolution catalogue.
77    pub fn install_schema(
78        &self,
79        name: &str,
80        version: &str,
81        files: &[(String, Vec<u8>)],
82    ) -> Result<String, EngineError> {
83        // Resolve the shared mem-repo gitdir: prefer a live git-branch
84        // mount's gitdir (authoritative — that is where the engine reads
85        // schemas from), falling back to the workspace's `mem-repo/.git`
86        // so a schema can be installed into an empty mem-repo *before*
87        // any mem pins it.
88        let gitdir = self
89            .mounts
90            .iter()
91            .find_map(|m| match &m.mount.storage {
92                crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
93                _ => None,
94            })
95            .or_else(|| {
96                self.workspace_root()
97                    .map(|r| r.join("mem-repo").join(".git"))
98            })
99            .ok_or_else(|| {
100                EngineError::Mem(
101                    "schema install requires a mem-repo workspace (no git-branch mount and \
102                     no workspace root to resolve the mem-repo gitdir)"
103                        .to_string(),
104                )
105            })?;
106        let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
107            EngineError::Mem("git-branch ops are not wired on this engine".to_string())
108        })?;
109        (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)
110    }
111    /// Unregister a writable mem at runtime. Engine-level
112    /// primitive that `memstead_mem_delete` builds on.
113    ///
114    /// Removes the named mount from [`Self::mounts`], drops the
115    /// mem's entities from the store, refreshes the
116    /// [`MemRouterSnapshot`] via `Arc::make_mut` (COW swap so
117    /// readers holding a pre-swap snapshot see the pre-state for
118    /// their lifetime), and invalidates the community + search
119    /// memos. Does NOT touch the backend's on-disk state — the
120    /// caller (`delete_mem` orchestrator) decides whether to
121    /// remove the directory / gitdir after this returns.
122    ///
123    /// Returns `Ok(Some(backend))` when the mem was present and
124    /// unregistered — the caller can drive any backend-specific
125    /// follow-up cleanup (`backend.delete_artifacts()` for the
126    /// mem-repo branch + `__MEMSTEAD` config when `delete_files=true`).
127    /// Returns `Ok(None)` when no mount named the mem (idempotent —
128    /// repeated calls are safe).
129    pub fn unregister_writable_mem(
130        &mut self,
131        mem_name: &str,
132    ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
133        let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
134        let Some(idx) = pos else {
135            return Ok(None);
136        };
137
138        // Drop the mount first — releases all engine-side state that
139        // referenced the backend. The `Box<dyn MemBackend>` itself
140        // travels back to the caller so backend-side cleanup
141        // (`delete_artifacts`) can run after the engine snapshot
142        // settled.
143        let mount = self.mounts.remove(idx);
144
145        // Drop the schema entry for this mem (kept in lockstep
146        // with `self.mounts`).
147        self.schemas.remove(&mount.mount.mem);
148
149        // Drop entities. The store's mem index is the
150        // authoritative count; the return value (number of
151        // entities removed) is informational only — the caller
152        // already knows the mem and doesn't need the count.
153        let _removed = self.store.remove_entities_by_mem(mem_name);
154
155        // COW snapshot swap on the mem_router. `Arc::make_mut`
156        // clones the inner snapshot when other Arcs exist; if this
157        // is the only handle (typical for the engine's lifetime),
158        // it returns the existing inner directly without cloning.
159        // Readers that captured an `Arc` before this call observe
160        // the pre-swap state — the in-flight handler's
161        // `mem_router()` borrow is unaffected by this mutation.
162        Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
163
164        // Invalidate dependent memos — community detection + search
165        // indexes were computed over the pre-removal store and are
166        // now stale. Mutation paths already invalidate; this
167        // matches the contract.
168        self.invalidate_communities();
169        self.invalidate_search_indexes();
170
171        Ok(Some(mount.backend))
172    }
173
174    /// Register a writable mem at runtime. Engine-level primitive
175    /// that `memstead_mem_create` builds on.
176    ///
177    /// Steps:
178    /// 1. Name collision probe against the current `mem_router`
179    ///    snapshot. Writable AND read-only entries collide; the
180    ///    error surfaces the colliding source so the orchestrator
181    ///    can render a recovery hint.
182    /// 2. Schema resolution via the built-in catalogue (mirrors
183    ///    [`Self::from_mounts`]; workspace-authored schema
184    ///    resolution lifts later).
185    /// 3. Per-mem config load (folder backends only; git-branch /
186    ///    archive return None — same contract as
187    ///    [`Self::from_mounts`]).
188    /// 4. Entity load via the backend, parse, push into the engine's
189    ///    store with a `LoadCollector` so drift warnings forward to
190    ///    `self.load_warnings`.
191    /// 5. Insert schema into [`Self::schemas`].
192    /// 6. Push the [`MountedBackend`] into [`Self::mounts`].
193    /// 7. COW snapshot swap on [`Self::mem_router`] via
194    ///    `Arc::make_mut` + `add_writable(name, dir, origin, mem_path)`.
195    ///    Folder mounts surface their on-disk path; other backends
196    ///    register with `dir: None` (matches full's contract).
197    ///    `mem_path` carries the create-time organisational `path`
198    ///    component (mirrors `MemCreateParams.path`) — the
199    ///    delete-side lifecycle composer reads it back to rebuild the
200    ///    `<mem_path>/<name>` candidate the create-side composer
201    ///    matched against. Caller threads `None` for flat-layout
202    ///    registrations and `Some(p)` for hierarchical ones.
203    /// 8. Invalidate community + search memos.
204    ///
205    /// Returns `Err(EngineError::MemNameCollision)` when the name
206    /// is already registered. Other failures (schema-not-found,
207    /// backend read errors) propagate as their typed variants. On
208    /// failure no engine mutation happens: every potentially-
209    /// mutating step runs only after the collision probe succeeds,
210    /// and intermediate failures propagate before the mount /
211    /// router are touched.
212    pub fn register_writable_mem(
213        &mut self,
214        mount: Mount,
215        backend: Box<dyn MemBackend>,
216        origin: MemOrigin,
217    ) -> Result<(), EngineError> {
218        // Step 1: name collision probe.
219        if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
220            return Err(EngineError::MemNameCollision {
221                name: mount.mem.clone(),
222                source_origin: existing.render_source(),
223            });
224        }
225        if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
226            return Err(EngineError::MemNameCollision {
227                name: mount.mem.clone(),
228                source_origin: "attached read mem".to_string(),
229            });
230        }
231
232        // Step 2: per-mem config load via the backend trait. Read
233        // before resolving the schema — the mem's own config carries
234        // the authoritative pin (mirrors the boot path), so a mem
235        // re-registered or mounted from another machine resolves from
236        // its own backend, not this workspace's mount expectation.
237        let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
238            let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
239            memstead_schema::config::parse_mem_config(&value).ok()
240        });
241
242        // Step 3: schema resolution. `MemConfig.schema` is the
243        // authoritative settled pin; `Mount.schema` is the fallback when
244        // the config carries none, and an expectation assertion when it
245        // does — a disagreement surfaces `SchemaPinMismatch` (config
246        // wins, neither silently dropped). Mirrors `from_mounts_inner`.
247        // Resolve against the engine's full loaded catalogue (already-
248        // loaded workspace/local-storage schemas layered over built-ins)
249        // so a mem registered against a backend-installed (e.g.
250        // git-branch `__MEMSTEAD:schemas/` ref) schema resolves.
251        let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
252            self.workspace_schemas().to_vec();
253        builtin_schemas.extend(
254            memstead_schema::builtins::load_builtin_schemas()
255                .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
256        );
257        let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
258        let mount_pin = mount.schema.as_ref();
259        if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
260            && cfg != mp
261        {
262            self.load_warnings
263                .push(crate::ops::WarningHint::SchemaPinMismatch {
264                    mem: mount.mem.clone(),
265                    config_pin: cfg.as_display(),
266                    mount_pin: mp.as_display(),
267                });
268        }
269        let settled_pin = config_pin.or(mount_pin);
270        let effective_pin = mount
271            .migration_target
272            .as_ref()
273            .or(settled_pin)
274            .ok_or_else(|| EngineError::MemConfigIncomplete {
275                mem: mount.mem.clone(),
276                missing_fields: vec!["schema".to_string()],
277            })?
278            .clone();
279        let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
280            .resolve(&effective_pin)
281            .map_err(|sources| EngineError::SchemaNotFound {
282                mem: mount.mem.clone(),
283                pin: effective_pin.as_display(),
284                sources,
285            })?;
286
287        // Step 4: load entities via the backend, push into the
288        // engine's store with a LoadCollector so drift warnings
289        // forward into `self.load_warnings`. Derive the mem
290        // roster + last-segment suffixes from the POST-registration
291        // view (new mem included) so cross-mem references
292        // targeting the new mem resolve correctly during this
293        // load.
294        let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
295        let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
296
297        let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
298        mem_names.push(mount.mem.clone());
299        let known_suffixes: Vec<String> = mem_names
300            .iter()
301            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
302            .collect();
303        let fallback = engine_fallback_type();
304        push_entities_into_store(
305            &mut self.store,
306            load_result.entities,
307            fallback.as_ref(),
308            Some(crate::entity::store_builder::LoadCollector {
309                warnings: &mut self.load_warnings,
310                known_suffixes: &known_suffixes,
311                mem_names: &mem_names,
312            }),
313        );
314        self.load_errors.extend(load_result.errors);
315
316        // Step 5: insert schema (kept in lockstep with `self.mounts`).
317        self.schemas.insert(mount.mem.clone(), schema);
318
319        // Re-run the parse-time relation validator now that the new
320        // mem's schema is in `self.schemas`. Mirrors the boot path
321        // (`Engine::from_mounts_inner`) — hand-edited or externally-
322        // generated markdown in the newly-attached mount goes through
323        // the same gauntlet (grammar / unknown_rel_type / shape /
324        // cycle) and offending relations are dropped with typed
325        // `PARSED_RELATION_INVALID` warnings on `self.load_warnings`.
326        // The newly-pushed mount isn't in `self.mounts` yet (that's
327        // Step 6 below), so build the map from `self.mounts` plus the
328        // about-to-be-attached mount we're still holding.
329        let mut mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
330            self.mounts
331                .iter()
332                .map(|m| (m.mount.mem.clone(), m.mount.capability))
333                .collect();
334        mount_caps.insert(mount.mem.clone(), mount.capability);
335        crate::entity::store_builder::validate_loaded_relations(
336            &mut self.store,
337            &self.schemas,
338            &mount_caps,
339            &mut self.load_warnings,
340        );
341        crate::entity::store_builder::remap_alias_target_edge_sources(
342            &mut self.store,
343            &self.schemas,
344        );
345
346        // Step 6: push the MountedBackend.
347        let last_known_head = backend.current_head().ok().flatten();
348        let mem_name_for_router = mount.mem.clone();
349        let storage_for_router = mount.storage.clone();
350        self.mounts.push(MountedBackend {
351            mount,
352            backend,
353            last_known_head,
354            mem_config,
355            // A runtime-created mem is authored live, not installed from
356            // an archive — it carries no archive-borne provenance payload.
357            archive_provenance: None,
358        });
359
360        // Step 7: COW snapshot swap on mem_router. Folder mounts
361        // surface their on-disk path; other backends register with
362        // `dir: None` (mem-repo-backed mounts have no working tree).
363        let dir: Option<PathBuf> = match &storage_for_router {
364            MountStorage::Folder { path } => Some(path.clone()),
365            MountStorage::GitBranch { .. }
366            | MountStorage::Archive { .. }
367            | MountStorage::InMemory => None,
368        };
369        Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
370
371        // Step 8: invalidate dependent memos.
372        self.invalidate_communities();
373        self.invalidate_search_indexes();
374
375        Ok(())
376    }
377
378    /// Override the workspace root after construction. The full
379    /// boot helper `memstead_git_branch::engine_from_workspace_root`
380    /// calls this so the engine knows the path even when the boot
381    /// route runs through the full adapter rather than
382    /// [`Self::from_workspace_root`].
383    pub fn set_workspace_root(&mut self, root: PathBuf) {
384        self.workspace_root = Some(root);
385    }
386
387    /// Persist the engine's current mount list to the workspace
388    /// store so a freshly-booted sibling process observes the same
389    /// mem membership. Called by
390    /// [`crate::mem_management::create_mem`] /
391    /// [`crate::mem_management::delete_mem`] after the in-memory
392    /// router mutation lands — without this, the per-mem content
393    /// (branch + `__MEMSTEAD` config blob, or folder + `.memstead/config.json`)
394    /// is already on disk, but the next process boot reads an empty
395    /// `.memstead/state/mounts.json` and the engine starts with zero
396    /// writable mems.
397    ///
398    /// No-op when `workspace_root` is unset (tests / ad-hoc
399    /// consumers that build the engine directly from a mount list).
400    /// Production boot paths (`Engine::from_workspace_root` and the
401    /// full counterpart) always set the root, so the engine-side
402    /// fix covers every caller — including the future UniFFI binding
403    /// — by construction.
404    ///
405    /// Hardcoded against [`crate::FileWorkspaceStore`] because that
406    /// is the only V1 adapter; a future SQLite or remote adapter
407    /// would install through a setter mirroring
408    /// [`Self::set_backend_factory`].
409    pub fn persist_state(&self) -> Result<(), EngineError> {
410        let Some(root) = self.workspace_root.as_ref() else {
411            return Ok(());
412        };
413        let workspace = crate::workspace::Workspace {
414            mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
415            settings: self.settings.clone(),
416        };
417        let store = crate::FileWorkspaceStore::new();
418        crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
419            .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
420    }
421    /// Set a mem's schema pin — the conformance-gated schema-migration
422    /// trigger. Behaviour per the pinned contract:
423    ///
424    /// - requested == current pin → `Noop`, no state change.
425    /// - requested != pin, mem integral against the target →
426    ///   atomic switch (`schema_pin = target`, migration state
427    ///   cleared) in one workspace-store write → `Switched`.
428    /// - requested != pin, mem NOT integral → enter (or stay in)
429    ///   dual-pin: `migration_target = target`, writes validate
430    ///   against the target from this call on, `findings` carries
431    ///   the non-integral entities → `MigrationStarted`
432    ///   (first call) / `MigrationPending` (same target re-issued).
433    /// - re-issued with the in-flight target once every entity is
434    ///   integral → atomic switch → `Switched`.
435    ///
436    /// The trigger is a label change gated by the conformance check —
437    /// no content hashing. The response hands the agent findings and
438    /// nothing else (no migration scripts, no hints); each repair
439    /// write is validated strictly against the target.
440    pub fn set_mem_schema(
441        &mut self,
442        mem: &str,
443        target: &memstead_schema::SchemaRef,
444    ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
445        use crate::engine::{SetSchemaOutcome, SetSchemaResult};
446        let mount_idx = self
447            .mounts
448            .iter()
449            .position(|m| m.mount.mem == mem)
450            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
451
452        // The requested target must resolve before anything else —
453        // an unknown ref is an error, not a migration into nowhere.
454        let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
455            // The migration resolver consulted workspace-authored
456            // schemas layered over the built-ins (`resolve_schema_by_ref`).
457            let consulted: Vec<_> = self
458                .workspace_schemas
459                .iter()
460                .chain(self.builtin_schemas.iter())
461                .cloned()
462                .collect();
463            EngineError::SchemaNotFound {
464                mem: mem.to_string(),
465                pin: target.as_display(),
466                sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
467                    &target.name,
468                    &target.version,
469                    &consulted,
470                ),
471            }
472        })?;
473
474        // `Mount.schema` is now the optional assertion; for a mem the
475        // operator is actively re-pinning it is normally `Some` (and kept
476        // in sync with the config by the switch below). `<unset>` covers a
477        // mount that carried no assertion.
478        let current_pin = self.mounts[mount_idx].mount.schema.clone();
479        let current_pin_display = current_pin
480            .as_ref()
481            .map(|p| p.as_display())
482            .unwrap_or_else(|| "<unset>".to_string());
483        let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
484
485        if current_pin.as_ref() == Some(target) {
486            return Ok(SetSchemaOutcome {
487                mem: mem.to_string(),
488                schema_pin: current_pin_display,
489                migration_target: in_flight.map(|t| t.as_display()),
490                outcome: SetSchemaResult::Noop,
491                findings: Vec::new(),
492            });
493        }
494
495        // Conformance gate against the requested target. The full
496        // integrity definition includes the consistency axis, but the
497        // schema-switch gate is conformance: consistency breaks are
498        // schema-independent (they neither block nor are caused by a
499        // pin change) and keep their always-available repair paths.
500        let findings = crate::ops::integrity::conformance_findings(
501            &self.store,
502            mem,
503            target_schema.as_ref(),
504            &self.schemas,
505        );
506
507        if findings.is_empty() {
508            // Atomic switch. The pin's authoritative home is the backend
509            // config (boot resolution prefers it over `Mount.schema`), so
510            // persist there FIRST — if that write fails, every other piece
511            // of state stays untouched and the switch is a clean no-op.
512            // Without this the new pin landed only in `mounts.json` and was
513            // silently reverted on the next process boot for any
514            // config-present mem.
515            self.persist_mem_schema_pin(mount_idx, target)?;
516            self.mounts[mount_idx].mount.schema = Some(target.clone());
517            self.mounts[mount_idx].mount.migration_target = None;
518            self.schemas.insert(mem.to_string(), target_schema);
519            self.invalidate_communities();
520            self.persist_state()?;
521            return Ok(SetSchemaOutcome {
522                mem: mem.to_string(),
523                schema_pin: target.as_display(),
524                migration_target: None,
525                outcome: SetSchemaResult::Switched,
526                findings: Vec::new(),
527            });
528        }
529
530        let outcome = if in_flight.as_ref() == Some(target) {
531            SetSchemaResult::MigrationPending
532        } else {
533            SetSchemaResult::MigrationStarted
534        };
535        self.mounts[mount_idx].mount.migration_target = Some(target.clone());
536        // Writes validate against the target from this point on —
537        // the load-bearing dual-pin semantic.
538        self.schemas.insert(mem.to_string(), target_schema);
539        self.invalidate_communities();
540        self.persist_state()?;
541        Ok(SetSchemaOutcome {
542            mem: mem.to_string(),
543            schema_pin: current_pin_display,
544            migration_target: Some(target.as_display()),
545            outcome,
546            findings,
547        })
548    }
549
550    /// Persist a mem's new schema pin into the authoritative backend
551    /// config (`.memstead/config.json` for folder, the `__MEMSTEAD`
552    /// mem-config blob for git-branch).
553    ///
554    /// Boot resolution treats the backend config as the authoritative
555    /// settled pin and `Mount.schema` (the `mounts.json` copy) as a
556    /// cross-checked assertion. A schema switch that updated only
557    /// `mounts.json` would therefore be silently reverted on the next
558    /// process boot — the config still names the old pin. This keeps the
559    /// authoritative home in sync at switch time.
560    ///
561    /// Value-level field bump: only the `"schema"` string is rewritten;
562    /// every other config field (`readMems`, write guidance, …) is
563    /// preserved verbatim. Config-absent mems (no `config.json`) keep
564    /// `Mount.schema` as their settled pin, so there is nothing to update
565    /// — a clean no-op.
566    fn persist_mem_schema_pin(
567        &mut self,
568        mount_idx: usize,
569        target: &memstead_schema::SchemaRef,
570    ) -> Result<(), EngineError> {
571        let Some(bytes) = self.mounts[mount_idx]
572            .backend
573            .read_mem_config()
574            .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
575        else {
576            return Ok(());
577        };
578        let mut value: serde_json::Value = serde_json::from_slice(&bytes)
579            .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
580        value["schema"] = serde_json::Value::String(target.as_display());
581        let new_bytes = serde_json::to_vec_pretty(&value)
582            .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
583        self.mounts[mount_idx]
584            .backend
585            .write_mem_config(&new_bytes)
586            .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
587        // Refresh the cached parsed config so in-session reads observe the
588        // new pin without a reload.
589        if let Ok(cfg) = memstead_schema::config::parse_mem_config(&value) {
590            self.mounts[mount_idx].mem_config = Some(cfg);
591        }
592        Ok(())
593    }
594
595    /// Regenerate entity markdown files from the in-memory store.
596    ///
597    /// Dispatch:
598    /// - When `mem_filter` is `Some(name)`, only that mem's mount
599    ///   is considered. If its active backend doesn't support markdown
600    ///   regeneration in place (today: anything other than
601    ///   `MountStorage::Folder`), the call refuses with
602    ///   [`EngineError::MarkdownExportUnsupportedBackend`] carrying
603    ///   the active backend's id and the supported-backend list.
604    /// - When `mem_filter` is `None`, every mount is iterated.
605    ///   Folder mounts regenerate as today; non-folder mounts are
606    ///   recorded in [`crate::ops::ExportResult::skipped_mounts`] so
607    ///   the caller can surface the partial-success shape.
608    ///
609    /// Per-folder-mount behaviour: iterate the store, regenerate each
610    /// non-stub entity belonging to the mount's mem, compare to the
611    /// on-disk file, write if changed.
612    ///
613    /// `schema_filter` narrows the per-entity-type subset: when
614    /// `Some(name)`, only entities whose `entity_type` matches are
615    /// regenerated. `None` exports every type.
616    ///
617    /// Pre-fix this returned
618    /// `ExportResult { written: 0, unchanged: 0 }` for git-branch /
619    /// archive mounts — a successful-looking no-op that masked the
620    /// backend-incompatibility. The typed refusal (per-mem) and the
621    /// `skipped_mounts` channel (workspace-wide) give the caller an
622    /// agent-actionable signal in one round-trip.
623    pub fn export_markdown(
624        &self,
625        mem_filter: Option<&str>,
626        schema_filter: Option<&str>,
627    ) -> Result<crate::ops::ExportResult, EngineError> {
628        use crate::workspace::MountStorage;
629        let fallback = engine_fallback_type();
630        let supported_backends = vec!["folder".to_string()];
631
632        if let Some(name) = mem_filter {
633            let mount = self
634                .mounts
635                .iter()
636                .find(|m| m.mount.mem == name)
637                .ok_or_else(|| EngineError::UnknownMem(name.to_string()))?;
638            if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
639                return Err(EngineError::MarkdownExportUnsupportedBackend {
640                    mem: name.to_string(),
641                    active_backend: mount.mount.storage.backend_id().to_string(),
642                    supported_backends,
643                });
644            }
645        }
646
647        let mut total_written = 0;
648        let mut total_unchanged = 0;
649        let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
650
651        for mount in &self.mounts {
652            let mem_name = mount.mount.mem.as_str();
653            if let Some(filter) = mem_filter
654                && mem_name != filter
655            {
656                continue;
657            }
658            let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
659                skipped_mounts.push(crate::ops::SkippedMount {
660                    mem: mem_name.to_string(),
661                    active_backend: mount.mount.storage.backend_id().to_string(),
662                    reason: "backend_does_not_support_markdown_export".to_string(),
663                });
664                continue;
665            };
666            let schema = match self.schemas.get(mem_name) {
667                Some(s) => s,
668                None => continue,
669            };
670
671            for entity in self.store.all_entities() {
672                if entity.stub || entity.file_path.is_empty() {
673                    continue;
674                }
675                if entity.id.mem() != mem_name {
676                    continue;
677                }
678                if let Some(filter) = schema_filter
679                    && entity.entity_type != filter
680                {
681                    continue;
682                }
683                let type_def = schema
684                    .get_type(&entity.entity_type)
685                    .unwrap_or_else(|| fallback.clone());
686                let generated = generate_markdown(entity, type_def.as_ref());
687
688                let full_path = mem_dir.join(&entity.file_path);
689                let needs_write = match std::fs::read_to_string(&full_path) {
690                    Ok(existing) => existing != generated,
691                    Err(_) => true,
692                };
693                if needs_write {
694                    let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
695                    total_written += 1;
696                } else {
697                    total_unchanged += 1;
698                }
699            }
700        }
701
702        Ok(crate::ops::ExportResult {
703            written: total_written,
704            unchanged: total_unchanged,
705            skipped_mounts,
706        })
707    }
708
709    /// Export a mem as a portable `.mem` archive.
710    ///
711    /// Dispatch is internal: the engine looks up the mount whose mem
712    /// name matches and branches on its `MountStorage`. Folder mounts
713    /// produce a snapshot archive (current `.md` files + config);
714    /// git-branch mounts invoke the registered [`GitBranchOps::export`]
715    /// hook to produce a history archive (the per-mem branch tip's
716    /// tree); archive mounts reject with `BackendError::Sealed`
717    /// (already-an-archive — no meaningful re-export).
718    ///
719    /// The mem's `MemConfig` is looked up via
720    /// [`Self::mem_config_for`]; unloaded configs (folder mounts
721    /// without a `.memstead/config.json`, git-branch mounts without a
722    /// `__MEMSTEAD:mems/<mem>/config.json`) surface as
723    /// `EngineError::InvalidInput`. Workspace-level schema dir is
724    /// threaded from `self.settings.schemas_dir` for the
725    /// schema-source resolution chain.
726    pub fn export_mem(
727        &self,
728        mem_name: &str,
729        output_path: &std::path::Path,
730    ) -> Result<crate::ops::MemExportResult, EngineError> {
731        let mount = self
732            .mounts
733            .iter()
734            .find(|m| m.mount.mem == mem_name)
735            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
736        let config = self.mem_config_for(mem_name).ok_or_else(|| {
737            EngineError::InvalidInput(format!(
738                "mem '{mem_name}' has no loaded MemConfig — cannot export"
739            ))
740        })?;
741        // F1: surface the missing-version case as a typed
742        // `MEM_CONFIG_INCOMPLETE` envelope with structured recovery
743        // details, rather than letting it bubble through as the
744        // backend's `INTERNAL` collapse pointing at the wrong path
745        // (`.memstead/config.json` is the folder-backend layout — the
746        // mem-repo backend keeps the blob under `__MEMSTEAD:mems/`).
747        // The check fires for both backends symmetrically.
748        if config.version.is_none() {
749            return Err(EngineError::MemConfigIncomplete {
750                mem: mem_name.to_string(),
751                missing_fields: vec!["version".to_string()],
752            });
753        }
754        let workspace_root = self.workspace_root.as_deref();
755        // Authored schemas live at the fixed `<workspace>/.memstead/schemas/`
756        // location (the `schemas_dir` key is retired). Absent dir → the
757        // schema-source chain falls through to cache/built-in, as before.
758        let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
759        let workspace_schemas_dir = fixed_schemas_dir.as_deref();
760        match &mount.mount.storage {
761            MountStorage::Folder { path } => crate::ops::export::export_mem(
762                path,
763                config,
764                output_path,
765                workspace_root,
766                workspace_schemas_dir,
767            )
768            .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
769            MountStorage::GitBranch { gitdir, branch } => {
770                let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
771                    EngineError::Backend(BackendError::Other(
772                        "git-branch export hook not installed (full flavour not loaded)"
773                            .to_string(),
774                    ))
775                })?;
776                // Source per-entity provenance from the git-branch mutation
777                // log (commit trailers) and hand the serialised payload to
778                // the export hook to embed — symmetric with the bytes path.
779                let provenance_bytes = mount
780                    .backend
781                    .read_provenance(None)
782                    .ok()
783                    .and_then(|records| crate::ops::export::build_archive_provenance(&records))
784                    .and_then(|prov| prov.to_archive_bytes().ok());
785                // Source the anchors sidecar from the branch tip — symmetric
786                // with the bytes-export path so the disk `.mem` carries anchors.
787                let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
788                (hook.export)(
789                    gitdir,
790                    branch,
791                    mem_name,
792                    config,
793                    output_path,
794                    workspace_root,
795                    workspace_schemas_dir,
796                    provenance_bytes.as_deref(),
797                    anchors_bytes.as_deref(),
798                )
799                .map_err(EngineError::Backend)
800            }
801            MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
802            // `.mem` export from an in-memory mem lands with the
803            // writable-session-server plan (it needs a backend-level
804            // archive builder); this plan adds the backend, not the
805            // export path, so refuse explicitly rather than silently.
806            MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
807                "export not yet supported for in-memory backend".to_string(),
808            ))),
809        }
810    }
811
812    /// Update a mem's `version` field in its per-mem config and
813    /// persist it through the backend. Backend-symmetric: folder
814    /// backends rewrite `.memstead/config.json`; git-branch backends
815    /// commit `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts
816    /// reject with `BackendError::Sealed`.
817    ///
818    /// Returns the (mem, old_version, new_version) triple so
819    /// callers can surface the change without an extra read. Reads
820    /// the current value from the in-memory `MemConfig` and
821    /// updates it on success, keeping the next call free of a
822    /// stale-version read.
823    ///
824    /// `EngineError::UnknownMem` when the name resolves to no
825    /// mount; `EngineError::ReadOnlyMount` when the mount is sealed
826    /// for writes; `EngineError::InvalidInput` when the mount has no
827    /// loaded `MemConfig` (folder mount with no
828    /// `.memstead/config.json`; the residual missing-config path is
829    /// distinct from the missing-version path). F1.
830    /// Record pipeline-edit provenance through `mem`'s backend — the
831    /// bridge the pipeline-edit block (outside the engine module) uses
832    /// to reach a mount's backend. A mem that isn't currently mounted
833    /// is a successful no-op: pipeline configs may reference unmounted
834    /// mems, and provenance is recorded against the mounted set.
835    pub fn record_pipeline_edit_provenance(
836        &self,
837        mem: &str,
838        kind: &str,
839        edits: &[(String, Option<Vec<u8>>)],
840        note: Option<&str>,
841        verb: &str,
842    ) -> Result<(), crate::backend::BackendError> {
843        match self.mounts.iter().find(|m| m.mount.mem == mem) {
844            Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
845            None => Ok(()),
846        }
847    }
848
849    pub fn set_mem_version(
850        &mut self,
851        mem_name: &str,
852        new_version: semver::Version,
853        note: Option<&str>,
854    ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
855        // Resolve the mount up-front so an unknown-mem name refuses
856        // before any drift-probe side effect lands.
857        let mount_idx = self
858            .mounts
859            .iter()
860            .position(|m| m.mount.mem == mem_name)
861            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
862        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
863            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
864        }
865
866        // Probe for concurrent-drift before the write — a sibling
867        // engine that committed between our last snapshot and now
868        // surfaces `MEM_RELOADED` on the response so callers see
869        // the drift without a separate read round-trip. Drift
870        // warnings ride alongside the success outcome; an
871        // unreachable-backend probe collapses to no warnings (the
872        // existing accessor warn-logs internally and skips).
873        let mut warnings = self.reload_if_stale(Some(mem_name));
874        // Provenance nudge — same posture as every other commit-
875        // producing mutation: when `require_notes` is set and no note
876        // was supplied, ride a non-blocking `NOTE_MISSING` warning.
877        // The version bump still commits.
878        if let Some(w) = self.note_missing_warning("set_mem_version", note) {
879            warnings.push(w);
880        }
881
882        let mounted = &mut self.mounts[mount_idx];
883        let mut config = mounted.mem_config.clone().ok_or_else(|| {
884            EngineError::InvalidInput(format!(
885                "mem '{mem_name}' has no loaded MemConfig — \
886                     cannot set version (initialize the mem via `memstead init` \
887                     or `memstead mem create` first)"
888            ))
889        })?;
890        let old_version = config.version.clone();
891        config.version = Some(new_version.clone());
892
893        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
894            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
895        })?;
896        bytes.push(b'\n');
897        mounted.backend.write_mem_config_with_note(&bytes, note)?;
898        mounted.mem_config = Some(config);
899
900        // Refresh the head cursor so the next drift probe doesn't
901        // surface MEM_RELOADED for the commit we just produced
902        // (the git-branch backend's `write_mem_config` writes a
903        // commit on `__MEMSTEAD`; folder backends carry no head and the
904        // refresh is a no-op).
905        let new_head = mounted.backend.current_head().ok().flatten();
906        if let Some(sha) = new_head {
907            mounted.last_known_head = Some(sha);
908        }
909
910        Ok(crate::ops::SetMemVersionOutcome {
911            mem: mem_name.to_string(),
912            old_version,
913            new_version,
914            warnings,
915        })
916    }
917
918    /// Update a mem's `description` field in its per-mem config and
919    /// persist it through the backend — the one-line text mem-archive
920    /// export embeds and the registry card surfaces. `None` clears the
921    /// field. Same backend symmetry, drift probe, and provenance-note
922    /// posture as [`Self::set_mem_version`]; archive mounts reject with
923    /// `BackendError::Sealed`.
924    pub fn set_mem_description(
925        &mut self,
926        mem_name: &str,
927        new_description: Option<String>,
928        note: Option<&str>,
929    ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
930        let mount_idx = self
931            .mounts
932            .iter()
933            .position(|m| m.mount.mem == mem_name)
934            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
935        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
936            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
937        }
938
939        let mut warnings = self.reload_if_stale(Some(mem_name));
940        if let Some(w) = self.note_missing_warning("set_mem_description", note) {
941            warnings.push(w);
942        }
943
944        let mounted = &mut self.mounts[mount_idx];
945        let mut config = mounted.mem_config.clone().ok_or_else(|| {
946            EngineError::InvalidInput(format!(
947                "mem '{mem_name}' has no loaded MemConfig — \
948                     cannot set description (initialize the mem via `memstead init` \
949                     or `memstead mem create` first)"
950            ))
951        })?;
952        let old_description = config.description.clone();
953        config.description = new_description.clone();
954
955        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
956            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
957        })?;
958        bytes.push(b'\n');
959        mounted.backend.write_mem_config_with_note(&bytes, note)?;
960        mounted.mem_config = Some(config);
961
962        let new_head = mounted.backend.current_head().ok().flatten();
963        if let Some(sha) = new_head {
964            mounted.last_known_head = Some(sha);
965        }
966
967        Ok(crate::ops::SetMemDescriptionOutcome {
968            mem: mem_name.to_string(),
969            old_description,
970            new_description,
971            warnings,
972        })
973    }
974
975    /// Mark (or unmark) a mem as **internal** — hidden from the default
976    /// `memstead_overview` roster and public projections, while remaining a
977    /// real, schema-validated, diffable mem (inspectable when explicitly
978    /// scoped by name, and deletable). The ingest process-state redesign
979    /// (candidate (b)) flags each `ingest/<name>` process mem this way so it
980    /// does not clutter the roster alongside real content.
981    ///
982    /// Stored as the top-level `internal` config field (captured by the
983    /// flattened `extra` map). Backend-symmetric like
984    /// [`Self::set_mem_description`]; `EngineError::UnknownMem` /
985    /// `ReadOnlyMount` / `InvalidInput` on the usual failures.
986    pub fn set_mem_internal(
987        &mut self,
988        mem_name: &str,
989        internal: bool,
990        note: Option<&str>,
991    ) -> Result<bool, EngineError> {
992        let mount_idx = self
993            .mounts
994            .iter()
995            .position(|m| m.mount.mem == mem_name)
996            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
997        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
998            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
999        }
1000
1001        let _ = self.reload_if_stale(Some(mem_name));
1002
1003        let mounted = &mut self.mounts[mount_idx];
1004        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1005            EngineError::InvalidInput(format!(
1006                "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1007            ))
1008        })?;
1009        if internal {
1010            config
1011                .extra
1012                .insert("internal".to_string(), serde_json::Value::Bool(true));
1013        } else {
1014            config.extra.remove("internal");
1015        }
1016
1017        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1018            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1019        })?;
1020        bytes.push(b'\n');
1021        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1022        mounted.mem_config = Some(config);
1023
1024        let new_head = mounted.backend.current_head().ok().flatten();
1025        if let Some(sha) = new_head {
1026            mounted.last_known_head = Some(sha);
1027        }
1028
1029        Ok(internal)
1030    }
1031
1032    /// Set (or clear) one opaque sync-state token in a mem's per-mem
1033    /// config and persist it through the backend. The ingest layer calls
1034    /// this after a successful pass over a source's changed slice to
1035    /// record "the source state the graph was last synced against".
1036    ///
1037    /// `key` and `token` are both opaque to the engine: the key is
1038    /// conventionally `"<ingest>/<facet>"` but the engine treats it as an
1039    /// arbitrary string; the token's meaning belongs to the medium-type
1040    /// layer (git → commit id, graph → snapshot token, filesystem → a
1041    /// JSON-stringified stat digest). The engine never parses either.
1042    /// An **empty** `token` removes the key — the surface for clearing a
1043    /// baseline (which the next ingest pass re-seeds at the current
1044    /// source state).
1045    ///
1046    /// Backend-symmetric like [`Self::set_mem_version`]: folder backends
1047    /// rewrite `.memstead/config.json`; git-branch backends commit
1048    /// `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts reject with
1049    /// `BackendError::Sealed`.
1050    ///
1051    /// Returns the (mem, key, previous-token) triple so callers can
1052    /// surface the change without an extra read. `EngineError::UnknownMem`
1053    /// when the name resolves to no mount; `EngineError::ReadOnlyMount`
1054    /// when the mount is sealed for writes; `EngineError::InvalidInput`
1055    /// when the mount has no loaded `MemConfig`.
1056    pub fn set_mem_sync_state(
1057        &mut self,
1058        mem_name: &str,
1059        key: &str,
1060        token: &str,
1061        note: Option<&str>,
1062    ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1063        // Resolve the mount up-front so an unknown-mem name refuses
1064        // before any drift-probe side effect lands.
1065        let mount_idx = self
1066            .mounts
1067            .iter()
1068            .position(|m| m.mount.mem == mem_name)
1069            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
1070        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1071            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1072        }
1073
1074        // Probe for concurrent-drift before the write — same posture as
1075        // every other commit-producing mutation; a sibling engine that
1076        // committed since our last snapshot surfaces `MEM_RELOADED`.
1077        let mut warnings = self.reload_if_stale(Some(mem_name));
1078        if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1079            warnings.push(w);
1080        }
1081
1082        let mounted = &mut self.mounts[mount_idx];
1083        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1084            EngineError::InvalidInput(format!(
1085                "mem '{mem_name}' has no loaded MemConfig — \
1086                 cannot set sync state (initialize the mem via `memstead init` \
1087                 or `memstead mem create` first)"
1088            ))
1089        })?;
1090
1091        // Empty token clears the baseline; otherwise insert/overwrite.
1092        // `removed` distinguishes a no-op clear (key absent) from a real
1093        // one so the outcome is honest.
1094        let removed;
1095        let previous;
1096        if token.is_empty() {
1097            previous = config.sync_state.remove(key);
1098            removed = previous.is_some();
1099        } else {
1100            previous = config.sync_state.insert(key.to_string(), token.to_string());
1101            removed = false;
1102        }
1103
1104        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1105            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1106        })?;
1107        bytes.push(b'\n');
1108        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1109        mounted.mem_config = Some(config);
1110
1111        // Refresh the head cursor so the next drift probe doesn't surface
1112        // MEM_RELOADED for the commit we just produced.
1113        let new_head = mounted.backend.current_head().ok().flatten();
1114        if let Some(sha) = new_head {
1115            mounted.last_known_head = Some(sha);
1116        }
1117
1118        Ok(crate::ops::SetMemSyncStateOutcome {
1119            mem: mem_name.to_string(),
1120            key: key.to_string(),
1121            previous,
1122            removed,
1123            warnings,
1124        })
1125    }
1126
1127    /// Re-read the named mount's backend entities and refresh the
1128    /// in-memory store for that mem. Returns the diff against the
1129    /// pre-reload snapshot — `added` (ids newly present), `removed`
1130    /// (ids no longer present), `changed` (same id, different
1131    /// `content_hash`).
1132    ///
1133    /// Operator-triggered: useful when an external writer modified
1134    /// disk while this engine instance was alive (the lean flavour
1135    /// assumes single-writer; this primitive is the escape hatch when
1136    /// that assumption breaks). On the happy path the diff is empty.
1137    ///
1138    /// Drift detection (whether disk *did* change) is not part of this
1139    /// surface — callers that want to short-circuit on "nothing
1140    /// changed" must compare `added.is_empty() && changed.is_empty()
1141    /// && removed.is_empty()` against the result. Backend-specific
1142    /// drift signals (git HEAD comparison, mtime check) live in the
1143    /// full-flavour engine where they have meaning.
1144    ///
1145    /// Invalidates community + search-index memos on success.
1146    pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
1147        // Per-mem reload is intentionally silent on the engine-
1148        // wide `load_warnings` accumulator — matches full's
1149        // `reload_one_mem`. A LOCAL sink absorbs any warnings
1150        // the parser emits during this reload and is discarded.
1151        // Drift events still surface as `MemReloaded` warnings
1152        // via `reload_if_stale`.
1153        let mut sink: Vec<WarningHint> = Vec::new();
1154        self.reload_one_mem_inner(mem, &mut sink)
1155    }
1156
1157    /// Inner per-mem body shared by [`Self::reload_one_mem`]
1158    /// and [`Self::reload_each_writable_mem`]. The caller passes
1159    /// a warning sink so the workspace-wide reload can forward
1160    /// warnings into `self.load_warnings` while the single-mem
1161    /// path keeps the accumulator pristine.
1162    fn reload_one_mem_inner(
1163        &mut self,
1164        mem: &str,
1165        warnings_sink: &mut Vec<WarningHint>,
1166    ) -> Result<crate::ops::ReloadResult, EngineError> {
1167        // Locate the target mount + schema. Unknown mem short-
1168        // circuits before any store mutation.
1169        let mount_idx = self
1170            .mounts
1171            .iter()
1172            .position(|m| m.mount.mem == mem)
1173            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1174        let schema = self
1175            .schemas
1176            .get(mem)
1177            .cloned()
1178            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1179
1180        // Snapshot pre-reload (id, content_hash) for this mem.
1181        let pre: HashMap<EntityId, String> = self
1182            .store
1183            .all_entities()
1184            .filter(|e| !e.stub && e.mem == mem)
1185            .map(|e| (e.id.clone(), e.content_hash.clone()))
1186            .collect();
1187        let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
1188
1189        // Walk the backend; surface read-time errors instead of
1190        // mutating the store on a failed reload.
1191        let backend = self.mounts[mount_idx].backend.as_ref();
1192        let (entries, read_errors) = collect_source_entries(backend)?;
1193        let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
1194
1195        // Build the LoadCollector inputs — mem roster + last-
1196        // segment suffixes — so the parser pipeline can emit
1197        // typed drift warnings into the caller's sink.
1198        let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1199        let known_suffixes: Vec<String> = mem_names
1200            .iter()
1201            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
1202            .collect();
1203
1204        // Failure fence above; below this point the store is mutated.
1205        self.store.remove_entities_by_mem(mem);
1206        let fallback = engine_fallback_type();
1207        push_entities_into_store(
1208            &mut self.store,
1209            load_result.entities,
1210            fallback.as_ref(),
1211            Some(crate::entity::store_builder::LoadCollector {
1212                warnings: warnings_sink,
1213                known_suffixes: &known_suffixes,
1214                mem_names: &mem_names,
1215            }),
1216        );
1217        // Re-run parse-time relation validation across the workspace.
1218        // A reload re-parses one mem but the validator's cycle pass
1219        // is global (acyclic-rel-type subgraphs span mems), so the
1220        // scan runs against the whole store. Hand-edits arriving via
1221        // sibling-writer commits get the same gauntlet boot enforces
1222        // (grammar / unknown_rel_type / shape / cycle).
1223        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
1224            .mounts
1225            .iter()
1226            .map(|m| (m.mount.mem.clone(), m.mount.capability))
1227            .collect();
1228        // Restore cross-mem edges that point INTO this mem. The
1229        // removal cascade above dropped their incoming mirrors and the
1230        // re-push only rebuilt edges authored by this mem's own
1231        // entities, so a cross-mem `A→B` would silently vanish from the
1232        // index until a workspace-wide reload. Reconstruct from the
1233        // authoritative source records (in-memory only — no other mem is
1234        // re-read), then let the remap pass below reclassify alias sources.
1235        crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
1236        crate::entity::store_builder::validate_loaded_relations(
1237            &mut self.store,
1238            &self.schemas,
1239            &mount_caps,
1240            warnings_sink,
1241        );
1242        crate::entity::store_builder::remap_alias_target_edge_sources(
1243            &mut self.store,
1244            &self.schemas,
1245        );
1246        // Surface load errors back through the engine's accumulator
1247        // so subsequent `load_errors()` calls reflect the latest read.
1248        // We don't clear pre-existing errors from other mems — only
1249        // append; an external operator that wants a clean slate runs
1250        // a full re-init.
1251        self.load_errors.extend(load_result.errors);
1252
1253        // Refresh the mem's config from the backend too (D13). `sync_state`
1254        // (the projection baselines) and the schema pin / write guidance are
1255        // mem-scoped state that rides the mem branch, so an out-of-band write
1256        // — a sibling `projection advance` / `mem set-sync-state` — must become
1257        // visible after a per-mem reload, not only entity changes. A missing or
1258        // unparseable config leaves the cached value untouched (best-effort:
1259        // the reload never fails on a config read hiccup).
1260        if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
1261            && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
1262            && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1263        {
1264            self.mounts[mount_idx].mem_config = Some(cfg);
1265        }
1266
1267        // Diff post-reload against the snapshot.
1268        let mut added: Vec<EntityId> = Vec::new();
1269        let mut changed: Vec<EntityId> = Vec::new();
1270        for entity in self.store.all_entities() {
1271            if entity.stub || entity.mem != mem {
1272                continue;
1273            }
1274            match pre.get(&entity.id) {
1275                None => added.push(entity.id.clone()),
1276                Some(prev_hash) if prev_hash != &entity.content_hash => {
1277                    changed.push(entity.id.clone());
1278                }
1279                Some(_) => {}
1280            }
1281        }
1282        let post_ids: std::collections::HashSet<EntityId> = self
1283            .store
1284            .all_entities()
1285            .filter(|e| !e.stub && e.mem == mem)
1286            .map(|e| e.id.clone())
1287            .collect();
1288        let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
1289        added.sort_by(|a, b| a.0.cmp(&b.0));
1290        changed.sort_by(|a, b| a.0.cmp(&b.0));
1291        removed.sort_by(|a, b| a.0.cmp(&b.0));
1292
1293        self.invalidate_communities();
1294        self.invalidate_search_indexes();
1295
1296        Ok(crate::ops::ReloadResult {
1297            added,
1298            changed,
1299            removed,
1300        })
1301    }
1302
1303    /// Rich-shape variant of [`Self::reload_one_mem`] that returns a
1304    /// [`crate::ops::ReloadReport`] (mem + head_before + head_after +
1305    /// entities_loaded + changed_entity_ids) instead of the slim
1306    /// [`crate::ops::ReloadResult`]. Handler-facing wrapper consumed
1307    /// by the `memstead_reload` MCP tool — the rich shape is the wire
1308    /// contract MCP callers depend on; the slim form stays for
1309    /// programmatic consumers that just want the diff lists.
1310    ///
1311    /// `head_before` is the engine's **prior cursor** for this mem
1312    /// (its cached `last_known_head`), *not* the current on-disk tip:
1313    /// when a sibling has committed since, the tip has already advanced,
1314    /// so reporting it would make the advertised
1315    /// `changes_since(since=head_before)` recipe span an empty range.
1316    /// `head_after` is the freshly-peeled tip from
1317    /// [`crate::backend::MemBackend::current_head`]; the reload also
1318    /// advances the cursor to it, so a follow-up staleness probe does
1319    /// not re-reload the same window. Backends without history (folder,
1320    /// archive) carry no cursor and return `Ok(None)`; both fields fall
1321    /// back to [`crate::ops::EMPTY_TREE_SHA`] for wire-shape stability.
1322    ///
1323    /// `entities_loaded` is the post-reload non-stub count for the
1324    /// mem — same semantic as full's report.
1325    ///
1326    /// `changed_entity_ids` is the union of `added ∪ changed ∪
1327    /// removed` from the underlying [`crate::ops::ReloadResult`]
1328    /// so callers don't have to merge three lists themselves —
1329    /// matches full's bundled wire shape.
1330    pub fn reload_one_mem_report(
1331        &mut self,
1332        mem: &str,
1333    ) -> Result<crate::ops::ReloadReport, EngineError> {
1334        // `head_before` is the engine's PRIOR cursor — the SHA it last
1335        // knew for this mem — not the current (possibly already
1336        // drifted) on-disk tip. Reporting the tip would collapse the
1337        // `changes_since(since=head_before)` range to empty in exactly
1338        // the sibling-drift case the recipe targets. Only history-backed
1339        // mounts (git-branch) carry a git cursor: folder / archive
1340        // backends have no `current_head`, so their `head_before` stays
1341        // the empty-tree sentinel that pairs with the equally-empty
1342        // `head_after` below.
1343        let tracks_head = self
1344            .mounts
1345            .iter()
1346            .find(|m| m.mount.mem == mem)
1347            .and_then(|m| m.backend.current_head().ok().flatten())
1348            .is_some();
1349        let head_before = if tracks_head {
1350            self.mounts
1351                .iter()
1352                .find(|m| m.mount.mem == mem)
1353                .and_then(|m| m.last_known_head.clone())
1354                .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
1355        } else {
1356            crate::ops::EMPTY_TREE_SHA.to_string()
1357        };
1358
1359        let result = self.reload_one_mem(mem)?;
1360
1361        // Capture head_after = the freshly-peeled tip, and advance the
1362        // engine's cursor to it. Without this advance the next
1363        // operation's `reload_if_stale` would compare the stale cursor
1364        // against the same tip and re-reload the identical window,
1365        // re-emitting a spurious `MEM_RELOADED`. Only history-backed
1366        // mounts (current_head → Some) carry a cursor to advance.
1367        let head_after_raw = self
1368            .mounts
1369            .iter()
1370            .find(|m| m.mount.mem == mem)
1371            .and_then(|m| m.backend.current_head().ok().flatten());
1372        if let Some(new_head) = head_after_raw.clone()
1373            && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
1374        {
1375            m.last_known_head = Some(new_head);
1376        }
1377        let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
1378
1379        let entities_loaded = self
1380            .store
1381            .all_entities()
1382            .filter(|e| !e.stub && e.mem == mem)
1383            .count();
1384
1385        // Union of added + changed + removed, sorted lexicographically
1386        // for deterministic wire output. Matches full's "single
1387        // changed_entity_ids list" contract — saves callers from
1388        // merging three slices themselves.
1389        let mut changed_entity_ids: Vec<EntityId> = result
1390            .added
1391            .into_iter()
1392            .chain(result.changed)
1393            .chain(result.removed)
1394            .collect();
1395        changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
1396
1397        Ok(crate::ops::ReloadReport {
1398            mem: mem.to_string(),
1399            head_before,
1400            head_after,
1401            entities_loaded,
1402            changed_entity_ids,
1403        })
1404    }
1405
1406    /// Batched rich-shape variant — returns one
1407    /// [`crate::ops::ReloadReport`] per mounted mem in declaration
1408    /// order. Counterpart to [`Self::reload_each_writable_mem`]
1409    /// (slim) that the `memstead_reload` MCP tool's no-mem path
1410    /// consumes.
1411    ///
1412    /// Also re-reads `.memstead/workspace.toml` and refreshes
1413    /// [`crate::workspace::WorkspaceSettings`] before sweeping the
1414    /// mems — this is the pairing with the CLI's
1415    /// `memstead workspace allow-create / grant-cross-link / set-mutations`
1416    /// family. Without this re-read, a CLI write would land on disk but
1417    /// the running MCP would still serve the engine's boot-time policy
1418    /// snapshot; every subsequent `memstead_mem_create` against the new
1419    /// allowlist would fail with `MEM_PATH_NOT_ALLOWED` until process
1420    /// restart. The workspace-wide form runs the heavier path; the
1421    /// per-mem form (`reload_one_mem_report`) intentionally skips
1422    /// the workspace re-read — content drift doesn't imply policy
1423    /// drift.
1424    ///
1425    /// Reload of `workspace.toml` is best-effort: a missing or
1426    /// unparseable file leaves the existing settings untouched. The
1427    /// per-mem sweep is the primary contract — settings refresh is
1428    /// the additive bonus.
1429    ///
1430    /// First-error-aborts: if any mem's reload fails, the loop
1431    /// stops and the error propagates. Mems reloaded before the
1432    /// failing one are already mutated in the store; the returned
1433    /// error has no rollback. Operators run the per-mem form to
1434    /// retry the failing mem explicitly.
1435    pub fn reload_each_writable_mem_reports(
1436        &mut self,
1437    ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
1438        self.refresh_workspace_settings_if_possible();
1439        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1440        let mut out = Vec::with_capacity(names.len());
1441        for name in names {
1442            let report = self.reload_one_mem_report(&name)?;
1443            out.push(report);
1444        }
1445        Ok(out)
1446    }
1447
1448    /// Best-effort refresh of [`crate::workspace::WorkspaceSettings`]
1449    /// from the workspace's `.memstead/workspace.toml`. Called by the
1450    /// workspace-wide reload sweep so CLI-driven policy edits become
1451    /// visible to a live engine without process restart.
1452    ///
1453    /// Silent no-op when the engine has no `workspace_root` (legacy
1454    /// in-memory constructions) or when the on-disk file is missing /
1455    /// unparseable. The per-mem reload contract stays the canonical
1456    /// failure surface; settings refresh failures are intentionally
1457    /// non-fatal so a malformed workspace.toml doesn't break content
1458    /// drift detection.
1459    fn refresh_workspace_settings_if_possible(&mut self) {
1460        let Some(root) = self.workspace_root.clone() else {
1461            return;
1462        };
1463        let store = crate::workspace_store::FileWorkspaceStore::new();
1464        let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
1465            Ok(w) => w,
1466            Err(_) => return,
1467        };
1468        self.set_settings(workspace.settings);
1469    }
1470
1471    /// Reload every mounted mem in declaration order; returns one
1472    /// `(mem, ReloadResult)` per mount.
1473    ///
1474    /// Failure model is **first-error-aborts**: if any mem's reload
1475    /// fails, the loop stops and the error propagates. Mems reloaded
1476    /// before the failing one are already mutated in the store; the
1477    /// returned error has no rollback. Operators run the per-mem
1478    /// form to retry the failing mem explicitly.
1479    ///
1480    /// Caller-friendly batching wrapper around [`Self::reload_one_mem`];
1481    /// internal cache invalidation happens once per mem (the inner
1482    /// call invalidates) so an N-mem batch invalidates the memos
1483    /// N times. That's wasteful for large workspaces; once the
1484    /// `memstead_reload` MCP handler migrates we can tighten this to one
1485    /// invalidation at the end.
1486    pub fn reload_each_writable_mem(
1487        &mut self,
1488    ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
1489        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1490        // Workspace-wide reload semantics: take the engine-wide
1491        // sink, clear it, route per-mem inner reloads through it,
1492        // put it back. The result is `self.load_warnings` carries
1493        // every typed drift warning the reload sweep produced (so
1494        // the next `engine.health()` call surfaces them).
1495        let mut sink = std::mem::take(&mut self.load_warnings);
1496        sink.clear();
1497        let mut out = Vec::with_capacity(names.len());
1498        let mut loop_err = None;
1499        for name in names {
1500            match self.reload_one_mem_inner(&name, &mut sink) {
1501                Ok(result) => out.push((name, result)),
1502                Err(e) => {
1503                    loop_err = Some(e);
1504                    break;
1505                }
1506            }
1507        }
1508        self.load_warnings = sink;
1509        if let Some(e) = loop_err {
1510            return Err(e);
1511        }
1512        Ok(out)
1513    }
1514}
1515
1516#[cfg(test)]
1517mod tests {
1518
1519    use tempfile::TempDir;
1520
1521    use crate::backend::{BackendError, MemBackend};
1522    use crate::engine::test_helpers::*;
1523    use crate::engine::{Engine, EngineError};
1524    use crate::mem::MemOrigin;
1525    use crate::ops::WarningHint;
1526    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1527
1528    #[test]
1529    fn reload_each_writable_mem_repopulates_load_warnings() {
1530        // Boot with a clean mem, then mid-flight write a file
1531        // with a duplicate heading, then call reload_each_writable_mem.
1532        // The accumulator should pick up the new typed warning.
1533        let tmp = TempDir::new().unwrap();
1534        let mem_dir = tmp.path().to_path_buf();
1535        let writer = FilesystemMemWriter::new(mem_dir.clone());
1536        let mut engine = Engine::from_mounts(vec![(
1537            folder_mount("specs", mem_dir.clone()),
1538            Box::new(writer) as Box<dyn MemBackend>,
1539        )])
1540        .unwrap();
1541        assert!(
1542            engine.load_warnings().is_empty(),
1543            "clean boot has no warnings"
1544        );
1545
1546        // Drop a markdown file with two `## Identity` headings.
1547        let body =
1548            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1549        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1550
1551        engine.reload_each_writable_mem().unwrap();
1552        let warnings = engine.load_warnings();
1553        assert!(
1554            warnings
1555                .iter()
1556                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1557            "workspace-wide reload must repopulate load_warnings: {warnings:?}",
1558        );
1559    }
1560
1561    /// `validate_loaded_relations` runs on the reload path too — a
1562    /// sibling-writer commit that injects a markdown file carrying a
1563    /// schema-undeclared rel-type must surface as a typed
1564    /// `PARSED_RELATION_INVALID` warning after `reload_each_writable_mem`.
1565    /// Without the reload-path wiring this drift would slip past the
1566    /// validator (boot only catches what existed at startup).
1567    #[test]
1568    fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
1569        let tmp = TempDir::new().unwrap();
1570        let mem_dir = tmp.path().to_path_buf();
1571        // Seed a clean target entity at boot.
1572        let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1573        std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
1574        let writer = FilesystemMemWriter::new(mem_dir.clone());
1575        let mut engine = Engine::from_mounts(vec![(
1576            folder_mount("specs", mem_dir.clone()),
1577            Box::new(writer) as Box<dyn MemBackend>,
1578        )])
1579        .unwrap();
1580        // Clean boot — no parse-time relation warnings yet.
1581        assert!(
1582            !engine
1583                .load_warnings()
1584                .iter()
1585                .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
1586            "clean boot must not emit ParsedRelationInvalid; got: {:?}",
1587            engine.load_warnings()
1588        );
1589
1590        // Sibling-writer drops a new file with an unknown rel-type.
1591        let drift_body = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1592        std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
1593
1594        engine.reload_each_writable_mem().unwrap();
1595
1596        let invalid: Vec<_> = engine
1597            .load_warnings()
1598            .iter()
1599            .filter_map(|w| match w {
1600                WarningHint::ParsedRelationInvalid {
1601                    rel_type,
1602                    reason,
1603                    origin,
1604                    ..
1605                } => Some((rel_type.clone(), reason.clone(), origin.clone())),
1606                _ => None,
1607            })
1608            .collect();
1609        assert_eq!(
1610            invalid.len(),
1611            1,
1612            "reload must surface the parse-time drift, got: {invalid:?}",
1613        );
1614        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1615        assert_eq!(invalid[0].1, "unknown_rel_type");
1616        assert_eq!(invalid[0].2, "writable");
1617    }
1618
1619    #[test]
1620    fn reload_one_mem_keeps_engine_load_warnings_pristine() {
1621        // Boot with a duplicate-heading file so the accumulator
1622        // starts non-empty. Single-mem reload should NOT clear
1623        // or repopulate the engine-wide accumulator (mirrors full's
1624        // contract: per-mem reload is silent on the engine-wide
1625        // sink). The engine field stays as the boot-time snapshot.
1626        let tmp = TempDir::new().unwrap();
1627        let mem_dir = tmp.path().to_path_buf();
1628        let body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
1629        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1630        let writer = FilesystemMemWriter::new(mem_dir.clone());
1631        let mut engine = Engine::from_mounts(vec![(
1632            folder_mount("specs", mem_dir.clone()),
1633            Box::new(writer) as Box<dyn MemBackend>,
1634        )])
1635        .unwrap();
1636        let pre = engine.load_warnings().to_vec();
1637        assert!(!pre.is_empty(), "boot must populate load_warnings");
1638
1639        engine.reload_one_mem("specs").unwrap();
1640        let post = engine.load_warnings();
1641        // Pristine: same as boot snapshot.
1642        assert_eq!(
1643            post.len(),
1644            pre.len(),
1645            "single-mem reload must not touch sink"
1646        );
1647    }
1648
1649    /// A cross-mem edge `A→B` must survive a
1650    /// per-mem reload of the TARGET mem B. The removal cascade drops
1651    /// B's incoming mirrors (including the cross-mem one sourced from A)
1652    /// and the re-push only rebuilds edges authored by B, so without the
1653    /// reconstruction pass the edge silently vanishes from the in-memory
1654    /// index while staying intact in A's record and on disk — under-
1655    /// reporting topology until a workspace-wide reload heals it.
1656    #[test]
1657    fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
1658        let tmp = TempDir::new().unwrap();
1659        let a_dir = tmp.path().join("a");
1660        let b_dir = tmp.path().join("b");
1661        std::fs::create_dir_all(&a_dir).unwrap();
1662        std::fs::create_dir_all(&b_dir).unwrap();
1663        let a_writer = FilesystemMemWriter::new(a_dir.clone());
1664        let b_writer = FilesystemMemWriter::new(b_dir.clone());
1665        let mut engine = Engine::from_mounts(vec![
1666            (
1667                folder_mount("specs", a_dir),
1668                Box::new(a_writer) as Box<dyn MemBackend>,
1669            ),
1670            (
1671                folder_mount("memos", b_dir),
1672                Box::new(b_writer) as Box<dyn MemBackend>,
1673            ),
1674        ])
1675        .unwrap();
1676
1677        // Grant the cross-mem link specs → memos so the relate lands.
1678        let mut settings = crate::workspace::WorkspaceSettings::default();
1679        settings.cross_mem_links.insert(
1680            "specs".to_string(),
1681            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1682        );
1683        engine.set_settings(settings);
1684
1685        let (actor, client) = cli_actor();
1686        let source = engine
1687            .create_entity(
1688                empty_create_args("specs", "Source"),
1689                actor,
1690                Some(&client),
1691                None,
1692            )
1693            .unwrap();
1694        let target = engine
1695            .create_entity(
1696                empty_create_args("memos", "Target"),
1697                actor,
1698                Some(&client),
1699                None,
1700            )
1701            .unwrap();
1702        engine
1703            .relate_entity(
1704                crate::engine::RelateEntityArgs {
1705                    source: source.id.clone(),
1706                    expected_hash: Some(source.content_hash.clone()),
1707                    rel_type: "USES".to_string(),
1708                    target: target.id.clone(),
1709                    remove: false,
1710                    description: None,
1711                },
1712                actor,
1713                Some(&client),
1714                None,
1715            )
1716            .unwrap();
1717
1718        // (outgoing-present, incoming-present) for the A→B edge.
1719        let has_edge = |e: &Engine| {
1720            let out = e
1721                .store()
1722                .outgoing(&source.id)
1723                .iter()
1724                .any(|edge| edge.target == target.id);
1725            let inc = e
1726                .store()
1727                .incoming(&target.id)
1728                .iter()
1729                .any(|edge| edge.from == source.id);
1730            (out, inc)
1731        };
1732
1733        assert_eq!(
1734            has_edge(&engine),
1735            (true, true),
1736            "edge must be indexed in both directions after relate",
1737        );
1738
1739        // Per-mem reload of the TARGET mem — the bug trigger.
1740        engine.reload_one_mem("memos").unwrap();
1741        assert_eq!(
1742            has_edge(&engine),
1743            (true, true),
1744            "cross-mem edge into B must survive a per-mem reload of B",
1745        );
1746
1747        // Convergence: a workspace-wide reload yields the same incoming
1748        // adjacency for the target — no path-dependent difference.
1749        engine.reload_each_writable_mem().unwrap();
1750        assert_eq!(
1751            has_edge(&engine),
1752            (true, true),
1753            "per-mem and workspace reload converge on the same edge",
1754        );
1755
1756        // Complement: the edge stayed in the source record throughout —
1757        // the bug and the fix are about the index, not the records.
1758        assert!(
1759            engine
1760                .store()
1761                .get(&source.id)
1762                .unwrap()
1763                .relationships
1764                .iter()
1765                .any(|r| r.target == target.id),
1766            "source record must retain the relationship throughout",
1767        );
1768    }
1769
1770    /// A per-mem reload of the SOURCE
1771    /// mem leaves the cross-mem edge intact too — the source's own
1772    /// outgoing edges are rebuilt by the re-push, and the reconstruction
1773    /// pass for the OTHER mem is not needed here. Guards against a fix
1774    /// that fixates on the target case and perturbs the source case.
1775    #[test]
1776    fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
1777        let tmp = TempDir::new().unwrap();
1778        let a_dir = tmp.path().join("a");
1779        let b_dir = tmp.path().join("b");
1780        std::fs::create_dir_all(&a_dir).unwrap();
1781        std::fs::create_dir_all(&b_dir).unwrap();
1782        let a_writer = FilesystemMemWriter::new(a_dir.clone());
1783        let b_writer = FilesystemMemWriter::new(b_dir.clone());
1784        let mut engine = Engine::from_mounts(vec![
1785            (
1786                folder_mount("specs", a_dir),
1787                Box::new(a_writer) as Box<dyn MemBackend>,
1788            ),
1789            (
1790                folder_mount("memos", b_dir),
1791                Box::new(b_writer) as Box<dyn MemBackend>,
1792            ),
1793        ])
1794        .unwrap();
1795        let mut settings = crate::workspace::WorkspaceSettings::default();
1796        settings.cross_mem_links.insert(
1797            "specs".to_string(),
1798            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1799        );
1800        engine.set_settings(settings);
1801
1802        let (actor, client) = cli_actor();
1803        let source = engine
1804            .create_entity(
1805                empty_create_args("specs", "Source"),
1806                actor,
1807                Some(&client),
1808                None,
1809            )
1810            .unwrap();
1811        let target = engine
1812            .create_entity(
1813                empty_create_args("memos", "Target"),
1814                actor,
1815                Some(&client),
1816                None,
1817            )
1818            .unwrap();
1819        engine
1820            .relate_entity(
1821                crate::engine::RelateEntityArgs {
1822                    source: source.id.clone(),
1823                    expected_hash: Some(source.content_hash.clone()),
1824                    rel_type: "USES".to_string(),
1825                    target: target.id.clone(),
1826                    remove: false,
1827                    description: None,
1828                },
1829                actor,
1830                Some(&client),
1831                None,
1832            )
1833            .unwrap();
1834
1835        engine.reload_one_mem("specs").unwrap();
1836
1837        let out = engine
1838            .store()
1839            .outgoing(&source.id)
1840            .iter()
1841            .any(|edge| edge.target == target.id);
1842        let inc = engine
1843            .store()
1844            .incoming(&target.id)
1845            .iter()
1846            .any(|edge| edge.from == source.id);
1847        assert!(
1848            out && inc,
1849            "outgoing cross-mem edge must survive a source-mem reload"
1850        );
1851    }
1852
1853    #[test]
1854    fn workspace_root_setter_round_trips() {
1855        let tmp = TempDir::new().unwrap();
1856        let mem_dir = tmp.path().to_path_buf();
1857        let writer = FilesystemMemWriter::new(mem_dir.clone());
1858        let mut engine = Engine::from_mounts(vec![(
1859            folder_mount("specs", mem_dir),
1860            Box::new(writer) as Box<dyn MemBackend>,
1861        )])
1862        .unwrap();
1863        let root = tmp.path().to_path_buf();
1864        engine.set_workspace_root(root.clone());
1865        assert_eq!(engine.workspace_root(), Some(root.as_path()));
1866    }
1867
1868    #[test]
1869    fn export_mem_folder_backend_produces_archive() {
1870        // Folder-backed mem with config + one entity. The
1871        // export_mem dispatcher routes to the folder backend's
1872        // override which produces a deterministic .memstead archive.
1873        let tmp = TempDir::new().unwrap();
1874        let mem_dir = tmp.path().join("specs");
1875        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1876        let config_body = r#"{
1877            "format": 1,
1878            "schema": "default@1.0.0",
1879            "version": "1.0.0"
1880        }"#;
1881        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
1882
1883        let writer = FilesystemMemWriter::new(mem_dir.clone());
1884        let engine = Engine::from_mounts(vec![(
1885            folder_mount("specs", mem_dir.clone()),
1886            Box::new(writer) as Box<dyn MemBackend>,
1887        )])
1888        .unwrap();
1889
1890        let archive_path = tmp.path().join("specs.mem");
1891        let result = engine.export_mem("specs", &archive_path).unwrap();
1892        assert!(archive_path.exists(), "archive must exist on disk");
1893        assert!(result.size_bytes > 0);
1894        // entity_count is 0 here (no .md files seeded); the function
1895        // still produces an archive carrying the config + schema.
1896        assert_eq!(result.entity_count, 0);
1897    }
1898
1899    #[test]
1900    fn export_mem_unknown_mem_returns_unknown_mem() {
1901        let tmp = TempDir::new().unwrap();
1902        let mem_dir = tmp.path().to_path_buf();
1903        let writer = FilesystemMemWriter::new(mem_dir.clone());
1904        let engine = Engine::from_mounts(vec![(
1905            folder_mount("specs", mem_dir),
1906            Box::new(writer) as Box<dyn MemBackend>,
1907        )])
1908        .unwrap();
1909        let output = tmp.path().join("out.mem");
1910        let err = engine.export_mem("missing", &output).unwrap_err();
1911        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
1912    }
1913
1914    #[test]
1915    fn export_mem_missing_config_returns_invalid_input() {
1916        // Folder mount with no .memstead/config.json — `mem_config_for`
1917        // returns None and `export_mem` surfaces InvalidInput
1918        // rather than reaching the backend.
1919        let tmp = TempDir::new().unwrap();
1920        let mem_dir = tmp.path().to_path_buf();
1921        let writer = FilesystemMemWriter::new(mem_dir.clone());
1922        let engine = Engine::from_mounts(vec![(
1923            folder_mount("specs", mem_dir),
1924            Box::new(writer) as Box<dyn MemBackend>,
1925        )])
1926        .unwrap();
1927        let output = tmp.path().join("out.mem");
1928        let err = engine.export_mem("specs", &output).unwrap_err();
1929        assert!(matches!(err, EngineError::InvalidInput(_)));
1930    }
1931
1932    #[test]
1933    fn export_mem_archive_backend_returns_sealed() {
1934        // Archive backends are already-an-archive — re-export is
1935        // intentionally rejected via BackendError::Sealed.
1936        let tmp = TempDir::new().unwrap();
1937        let archive_path = build_archive(
1938            tmp.path(),
1939            "ext",
1940            &[(
1941                ".memstead/config.json",
1942                b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
1943            )],
1944        );
1945        let engine = Engine::from_mounts(vec![(
1946            archive_mount("ext", archive_path.clone()),
1947            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1948        )])
1949        .unwrap();
1950        let output = tmp.path().join("out.mem");
1951        let err = engine.export_mem("ext", &output).unwrap_err();
1952        assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
1953    }
1954
1955    #[test]
1956    fn export_markdown_writes_unchanged_files_zero_writes() {
1957        // Seed a folder-backed mem with one entity, then call
1958        // export_markdown. The entity's file already matches the
1959        // generated content (engine wrote it via create_entity), so
1960        // export reports `unchanged: 1, written: 0`.
1961        let tmp = TempDir::new().unwrap();
1962        let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
1963        let result = engine.export_markdown(None, None).unwrap();
1964        assert_eq!(
1965            result.written, 0,
1966            "freshly-created entity's file already matches generated markdown"
1967        );
1968        assert_eq!(
1969            result.unchanged, 1,
1970            "the one seeded entity counts as unchanged"
1971        );
1972        assert!(
1973            result.skipped_mounts.is_empty(),
1974            "folder-only workspace has no skipped mounts"
1975        );
1976    }
1977
1978    #[test]
1979    fn export_markdown_skips_non_folder_mounts() {
1980        // Archive-mounted mem has no working tree — workspace-wide
1981        // export records it under skipped_mounts and reports zero
1982        // writes / zero unchanged for the rest.
1983        let tmp = TempDir::new().unwrap();
1984        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
1985        let engine = Engine::from_mounts(vec![(
1986            archive_mount("ext", archive_path.clone()),
1987            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1988        )])
1989        .unwrap();
1990        let result = engine.export_markdown(None, None).unwrap();
1991        assert_eq!(result.written, 0);
1992        assert_eq!(result.unchanged, 0);
1993        assert_eq!(
1994            result.skipped_mounts.len(),
1995            1,
1996            "archive mount is in the skipped list"
1997        );
1998        let entry = &result.skipped_mounts[0];
1999        assert_eq!(entry.mem, "ext");
2000        assert_eq!(entry.active_backend, "archive");
2001        assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
2002    }
2003
2004    #[test]
2005    fn export_markdown_per_mem_refuses_on_incompatible_backend() {
2006        // Per-mem export against an archive-backed mem returns
2007        // the typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` refusal
2008        // naming the active backend and the supported-backend list.
2009        let tmp = TempDir::new().unwrap();
2010        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
2011        let engine = Engine::from_mounts(vec![(
2012            archive_mount("ext", archive_path.clone()),
2013            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2014        )])
2015        .unwrap();
2016        let err = engine.export_markdown(Some("ext"), None).unwrap_err();
2017        assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
2018        let details = err.details();
2019        assert_eq!(details["mem"], "ext");
2020        assert_eq!(details["active_backend"], "archive");
2021        assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
2022    }
2023
2024    #[test]
2025    fn register_writable_mem_adds_mount_and_router_entry() {
2026        // Start with one mem; register a second at runtime. Both
2027        // should be visible afterwards.
2028        let tmp = TempDir::new().unwrap();
2029        let mem_a = tmp.path().join("a");
2030        std::fs::create_dir_all(&mem_a).unwrap();
2031        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2032
2033        let mut engine = Engine::from_mounts(vec![(
2034            folder_mount("alpha", mem_a),
2035            Box::new(writer_a) as Box<dyn MemBackend>,
2036        )])
2037        .unwrap();
2038        assert!(engine.mem_router().is_writable("alpha"));
2039
2040        let mem_b = tmp.path().join("b");
2041        std::fs::create_dir_all(&mem_b).unwrap();
2042        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2043
2044        engine
2045            .register_writable_mem(
2046                folder_mount("beta", mem_b.clone()),
2047                Box::new(writer_b) as Box<dyn MemBackend>,
2048                MemOrigin::ExplicitToml,
2049            )
2050            .unwrap();
2051
2052        // Both mems are now writable + visible.
2053        assert!(engine.mem_router().is_writable("alpha"));
2054        assert!(engine.mem_router().is_writable("beta"));
2055        assert!(engine.mem_router().is_visible("beta"));
2056
2057        // Mount + schema lookups resolve.
2058        assert!(engine.mount("beta").is_some());
2059        assert!(engine.schemas().contains_key("beta"));
2060
2061        // Folder path surfaces via mem_router.
2062        assert_eq!(
2063            engine.mem_router().dir_for_mem("beta"),
2064            Some(mem_b.as_path()),
2065        );
2066    }
2067
2068    /// Schema-pin authority on the runtime-register path (symmetric with
2069    /// the boot path): a mem registered at runtime resolves its schema
2070    /// from its own config (`software@0.1.0`) even though the mount
2071    /// expects an unresolvable pin — register succeeds, and the
2072    /// disagreement surfaces a `SchemaPinMismatch` warning.
2073    #[test]
2074    fn register_writable_mem_resolves_schema_from_mem_config() {
2075        let tmp = TempDir::new().unwrap();
2076        let mem_a = tmp.path().join("a");
2077        std::fs::create_dir_all(&mem_a).unwrap();
2078        let mut engine = Engine::from_mounts(vec![(
2079            folder_mount("alpha", mem_a.clone()),
2080            Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
2081        )])
2082        .unwrap();
2083
2084        let mem_b = tmp.path().join("b");
2085        std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
2086        std::fs::write(
2087            mem_b.join(".memstead").join("config.json"),
2088            r#"{"schema":"software@0.1.0"}"#,
2089        )
2090        .unwrap();
2091        let mount_b = crate::workspace::Mount {
2092            mem: "beta".to_string(),
2093            schema: Some(memstead_schema::SchemaRef::new(
2094                "totally-not-a-schema",
2095                semver::Version::new(9, 9, 9),
2096            )),
2097            storage: crate::workspace::MountStorage::Folder {
2098                path: mem_b.clone(),
2099            },
2100            capability: crate::workspace::MountCapability::Write,
2101            lifecycle: crate::workspace::MountLifecycle::Eager,
2102            cross_linkable: true,
2103            migration_target: None,
2104        };
2105        engine
2106            .register_writable_mem(
2107                mount_b,
2108                Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
2109                MemOrigin::ExplicitToml,
2110            )
2111            .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
2112
2113        assert!(engine.schemas().contains_key("beta"));
2114        let surfaced = engine.load_warnings().iter().any(|w| {
2115            matches!(
2116                w,
2117                WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
2118                    if mem == "beta"
2119                        && config_pin == "software@0.1.0"
2120                        && mount_pin == "totally-not-a-schema@9.9.9"
2121            )
2122        });
2123        assert!(
2124            surfaced,
2125            "SchemaPinMismatch must surface for beta: {:?}",
2126            engine.load_warnings(),
2127        );
2128    }
2129
2130    #[test]
2131    fn register_writable_mem_rejects_existing_name() {
2132        // Re-registering an already-writable mem must fail with
2133        // MemNameCollision and not mutate the engine.
2134        let tmp = TempDir::new().unwrap();
2135        let mem_a = tmp.path().join("a");
2136        std::fs::create_dir_all(&mem_a).unwrap();
2137        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2138
2139        let mut engine = Engine::from_mounts(vec![(
2140            folder_mount("alpha", mem_a),
2141            Box::new(writer_a) as Box<dyn MemBackend>,
2142        )])
2143        .unwrap();
2144        let mount_count_pre = engine.mounts().len();
2145
2146        let mem_collide = tmp.path().join("alpha-2");
2147        std::fs::create_dir_all(&mem_collide).unwrap();
2148        let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
2149
2150        let err = engine
2151            .register_writable_mem(
2152                folder_mount("alpha", mem_collide),
2153                Box::new(writer_collide) as Box<dyn MemBackend>,
2154                MemOrigin::ExplicitToml,
2155            )
2156            .unwrap_err();
2157        match err {
2158            EngineError::MemNameCollision {
2159                name,
2160                source_origin,
2161            } => {
2162                assert_eq!(name, "alpha");
2163                // post-restructure source_origin references
2164                // `.memstead/workspace.toml`; the assertion stays
2165                // permissive (substring OR non-empty) so the test
2166                // doesn't lock the exact wording.
2167                assert!(
2168                    source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
2169                );
2170            }
2171            other => panic!("expected MemNameCollision, got {other:?}"),
2172        }
2173
2174        // Engine state unchanged.
2175        assert_eq!(engine.mounts().len(), mount_count_pre);
2176    }
2177
2178    #[test]
2179    fn register_writable_mem_loads_entities_into_store() {
2180        // The newly-registered mem's entities should surface in
2181        // the engine's store after registration.
2182        let tmp = TempDir::new().unwrap();
2183        let mem_a = tmp.path().join("a");
2184        std::fs::create_dir_all(&mem_a).unwrap();
2185        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2186
2187        let mut engine = Engine::from_mounts(vec![(
2188            folder_mount("alpha", mem_a),
2189            Box::new(writer_a) as Box<dyn MemBackend>,
2190        )])
2191        .unwrap();
2192        let pre_count = engine.store().all_entities().count();
2193
2194        // Build mem_b with a markdown entity on disk.
2195        let mem_b = tmp.path().join("b");
2196        std::fs::create_dir_all(&mem_b).unwrap();
2197        std::fs::write(
2198            mem_b.join("b1.md"),
2199            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2200        )
2201        .unwrap();
2202        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2203
2204        engine
2205            .register_writable_mem(
2206                folder_mount("beta", mem_b),
2207                Box::new(writer_b) as Box<dyn MemBackend>,
2208                MemOrigin::ExplicitToml,
2209            )
2210            .unwrap();
2211
2212        let post_count = engine.store().all_entities().count();
2213        assert!(post_count > pre_count, "register must load entities");
2214        let beta_count = engine
2215            .store()
2216            .all_entities()
2217            .filter(|e| e.mem == "beta")
2218            .count();
2219        assert_eq!(beta_count, 1);
2220    }
2221
2222    #[test]
2223    fn register_then_unregister_round_trips() {
2224        // End-to-end check: register a mem, then unregister it,
2225        // and confirm the engine returns to the pre-registration
2226        // state.
2227        let tmp = TempDir::new().unwrap();
2228        let mem_a = tmp.path().join("a");
2229        std::fs::create_dir_all(&mem_a).unwrap();
2230        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2231
2232        let mut engine = Engine::from_mounts(vec![(
2233            folder_mount("alpha", mem_a),
2234            Box::new(writer_a) as Box<dyn MemBackend>,
2235        )])
2236        .unwrap();
2237        let pre_mounts = engine.mounts().len();
2238
2239        let mem_b = tmp.path().join("b");
2240        std::fs::create_dir_all(&mem_b).unwrap();
2241        let writer_b = FilesystemMemWriter::new(mem_b);
2242
2243        engine
2244            .register_writable_mem(
2245                folder_mount("beta", tmp.path().join("b")),
2246                Box::new(writer_b) as Box<dyn MemBackend>,
2247                MemOrigin::ExplicitToml,
2248            )
2249            .unwrap();
2250        assert_eq!(engine.mounts().len(), pre_mounts + 1);
2251
2252        let removed = engine.unregister_writable_mem("beta").unwrap();
2253        assert!(removed.is_some());
2254        assert_eq!(engine.mounts().len(), pre_mounts);
2255        assert!(!engine.mem_router().is_writable("beta"));
2256    }
2257
2258    #[test]
2259    fn unregister_writable_mem_returns_false_for_unknown_name() {
2260        // Idempotent contract: repeated calls / unknown names are
2261        // not errors — return false so callers can branch without
2262        // a typed error envelope for the common "already gone" case.
2263        let tmp = TempDir::new().unwrap();
2264        let mem_dir = tmp.path().to_path_buf();
2265        let writer = FilesystemMemWriter::new(mem_dir.clone());
2266        let mut engine = Engine::from_mounts(vec![(
2267            folder_mount("specs", mem_dir),
2268            Box::new(writer) as Box<dyn MemBackend>,
2269        )])
2270        .unwrap();
2271        let removed = engine.unregister_writable_mem("missing").unwrap();
2272        assert!(removed.is_none(), "unknown mem returns Ok(None)");
2273        // The original mem is still present and readable.
2274        assert!(engine.mem_router().is_writable("specs"));
2275    }
2276
2277    #[test]
2278    fn unregister_writable_mem_drops_mount_and_router_entry() {
2279        // Heterogeneous engine: two mounts. Unregister one and
2280        // assert (a) it's gone from the mount list, (b) gone from
2281        // the mem_router's writable set, (c) the OTHER mount is
2282        // untouched.
2283        let tmp = TempDir::new().unwrap();
2284        let mem_a = tmp.path().join("a");
2285        std::fs::create_dir_all(&mem_a).unwrap();
2286        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2287        let mem_b = tmp.path().join("b");
2288        std::fs::create_dir_all(&mem_b).unwrap();
2289        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2290
2291        let mut engine = Engine::from_mounts(vec![
2292            (
2293                folder_mount("alpha", mem_a),
2294                Box::new(writer_a) as Box<dyn MemBackend>,
2295            ),
2296            (
2297                folder_mount("beta", mem_b),
2298                Box::new(writer_b) as Box<dyn MemBackend>,
2299            ),
2300        ])
2301        .unwrap();
2302
2303        let removed = engine.unregister_writable_mem("alpha").unwrap();
2304        assert!(removed.is_some());
2305
2306        // alpha is gone from every surface.
2307        assert!(!engine.mem_router().is_writable("alpha"));
2308        assert!(!engine.mem_router().is_visible("alpha"));
2309        assert!(engine.mount("alpha").is_none());
2310
2311        // beta survives unchanged.
2312        assert!(engine.mem_router().is_writable("beta"));
2313        assert!(engine.mount("beta").is_some());
2314    }
2315
2316    #[test]
2317    fn unregister_writable_mem_drops_entities_for_that_mem_only() {
2318        // Build an engine with two mems, write one entity to each
2319        // backend, build the engine (loads both), unregister one,
2320        // assert the store still has the other mem's entity.
2321        let tmp = TempDir::new().unwrap();
2322        let mem_a = tmp.path().join("a");
2323        std::fs::create_dir_all(&mem_a).unwrap();
2324        std::fs::write(
2325            mem_a.join("a1.md"),
2326            "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
2327        )
2328        .unwrap();
2329        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2330
2331        let mem_b = tmp.path().join("b");
2332        std::fs::create_dir_all(&mem_b).unwrap();
2333        std::fs::write(
2334            mem_b.join("b1.md"),
2335            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2336        )
2337        .unwrap();
2338        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2339
2340        let mut engine = Engine::from_mounts(vec![
2341            (
2342                folder_mount("alpha", mem_a),
2343                Box::new(writer_a) as Box<dyn MemBackend>,
2344            ),
2345            (
2346                folder_mount("beta", mem_b),
2347                Box::new(writer_b) as Box<dyn MemBackend>,
2348            ),
2349        ])
2350        .unwrap();
2351
2352        let pre_total = engine.store().all_entities().count();
2353        assert!(pre_total >= 2, "both mems must load entities");
2354
2355        engine.unregister_writable_mem("alpha").unwrap();
2356
2357        // alpha's entities are gone.
2358        let alpha_remaining = engine
2359            .store()
2360            .all_entities()
2361            .filter(|e| e.mem == "alpha")
2362            .count();
2363        assert_eq!(alpha_remaining, 0);
2364
2365        // beta's entities survive.
2366        let beta_remaining = engine
2367            .store()
2368            .all_entities()
2369            .filter(|e| e.mem == "beta")
2370            .count();
2371        assert!(beta_remaining > 0, "beta entities must survive");
2372    }
2373    #[test]
2374    fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
2375        let tmp = TempDir::new().unwrap();
2376        let mut engine = build_demo_engine(&tmp);
2377        let result = engine
2378            .reload_one_mem("specs")
2379            .expect("reload on stable disk must succeed");
2380        assert!(result.added.is_empty(), "added: {:?}", result.added);
2381        assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
2382        assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
2383    }
2384
2385    #[test]
2386    fn reload_one_mem_picks_up_external_addition() {
2387        let tmp = TempDir::new().unwrap();
2388        let mut engine = build_demo_engine(&tmp);
2389        // Simulate an external writer dropping a new entity on disk
2390        // without going through the engine.
2391        std::fs::write(
2392            tmp.path().join("external.md"),
2393            "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
2394        )
2395        .unwrap();
2396        let result = engine.reload_one_mem("specs").unwrap();
2397        assert_eq!(
2398            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2399            vec!["specs--external"]
2400        );
2401        assert!(result.changed.is_empty());
2402        assert!(result.removed.is_empty());
2403        // The new entity is now reachable through the engine.
2404        assert!(
2405            engine
2406                .get_entity(&crate::EntityId::new("specs", "external"))
2407                .is_some()
2408        );
2409    }
2410
2411    #[test]
2412    fn reload_one_mem_picks_up_external_removal() {
2413        let tmp = TempDir::new().unwrap();
2414        let mut engine = build_demo_engine(&tmp);
2415        // Lonely Three exists from the demo fixture; remove it
2416        // off-engine and reload.
2417        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2418        let result = engine.reload_one_mem("specs").unwrap();
2419        assert!(result.added.is_empty());
2420        assert!(result.changed.is_empty());
2421        assert_eq!(
2422            result
2423                .removed
2424                .iter()
2425                .map(|i| i.as_ref())
2426                .collect::<Vec<_>>(),
2427            vec!["specs--lonely-three"]
2428        );
2429    }
2430
2431    #[test]
2432    fn reload_one_mem_picks_up_external_change() {
2433        let tmp = TempDir::new().unwrap();
2434        let mut engine = build_demo_engine(&tmp);
2435        // Overwrite an existing entity's content; the new
2436        // `content_hash` must surface in the `changed` diff.
2437        std::fs::write(
2438            tmp.path().join("source-one.md"),
2439            "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
2440        )
2441        .unwrap();
2442        let result = engine.reload_one_mem("specs").unwrap();
2443        assert!(result.added.is_empty());
2444        assert_eq!(
2445            result
2446                .changed
2447                .iter()
2448                .map(|i| i.as_ref())
2449                .collect::<Vec<_>>(),
2450            vec!["specs--source-one"]
2451        );
2452        assert!(result.removed.is_empty());
2453    }
2454
2455    #[test]
2456    fn reload_one_mem_rejects_unknown_mem() {
2457        let tmp = TempDir::new().unwrap();
2458        let mut engine = build_demo_engine(&tmp);
2459        let err = engine.reload_one_mem("nope").unwrap_err();
2460        match err {
2461            EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
2462            other => panic!("expected UnknownMem, got {other:?}"),
2463        }
2464    }
2465
2466    #[test]
2467    fn reload_each_writable_mem_returns_one_entry_per_mount() {
2468        let tmp = TempDir::new().unwrap();
2469        let mut engine = build_demo_engine(&tmp);
2470        let reports = engine
2471            .reload_each_writable_mem()
2472            .expect("batch reload on stable disk must succeed");
2473        assert_eq!(reports.len(), 1);
2474        assert_eq!(reports[0].0, "specs");
2475        assert!(reports[0].1.added.is_empty());
2476        assert!(reports[0].1.changed.is_empty());
2477        assert!(reports[0].1.removed.is_empty());
2478    }
2479
2480    // ---- Engine::settings -------------------------------------------
2481
2482    #[test]
2483    fn settings_default_to_empty_on_fresh_engine() {
2484        let tmp = TempDir::new().unwrap();
2485        let engine = build_demo_engine(&tmp);
2486        let s = engine.settings();
2487        assert!(s.mem_create_rules.is_empty());
2488        assert!(s.mem_delete_rules.is_empty());
2489        assert!(s.cross_mem_links.is_empty());
2490    }
2491
2492    #[test]
2493    fn set_settings_replaces_workspace_policy() {
2494        use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
2495        let tmp = TempDir::new().unwrap();
2496        let mut engine = build_demo_engine(&tmp);
2497        let mut settings = WorkspaceSettings::default();
2498        settings.mem_create_rules.push(CreateRuleSetting {
2499            pattern: "exec-*".to_string(),
2500            schemas: vec!["default@1.0.0".to_string()],
2501            default_cross_links: None,
2502        });
2503        settings.mem_delete_rules.push(DeleteRuleSetting {
2504            pattern: "exec-*".to_string(),
2505        });
2506        engine.set_settings(settings);
2507        assert_eq!(engine.settings().mem_create_rules.len(), 1);
2508        assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
2509        assert_eq!(engine.settings().mem_delete_rules.len(), 1);
2510        assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
2511    }
2512
2513    // ---- Engine::reload_each_writable_mem (continued) -------------
2514
2515    #[test]
2516    fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
2517        let tmp = TempDir::new().unwrap();
2518        let mut engine = build_demo_engine(&tmp);
2519        // Mutate disk: add one entity, remove another, change a third.
2520        std::fs::write(
2521            tmp.path().join("new-via-disk.md"),
2522            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2523        )
2524        .unwrap();
2525        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2526        std::fs::write(
2527            tmp.path().join("source-one.md"),
2528            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2529        )
2530        .unwrap();
2531
2532        let reports = engine.reload_each_writable_mem().unwrap();
2533        assert_eq!(reports.len(), 1);
2534        let (mem, result) = &reports[0];
2535        assert_eq!(mem, "specs");
2536        assert_eq!(
2537            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2538            vec!["specs--new-via-disk"]
2539        );
2540        assert_eq!(
2541            result
2542                .removed
2543                .iter()
2544                .map(|i| i.as_ref())
2545                .collect::<Vec<_>>(),
2546            vec!["specs--lonely-three"]
2547        );
2548        assert_eq!(
2549            result
2550                .changed
2551                .iter()
2552                .map(|i| i.as_ref())
2553                .collect::<Vec<_>>(),
2554            vec!["specs--source-one"]
2555        );
2556    }
2557
2558    // ---- Engine::reload_one_mem_report (rich-shape wrapper) -------
2559
2560    #[test]
2561    fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
2562        // Folder backend has no current_head (Ok(None)); the wrapper
2563        // falls back to EMPTY_TREE_SHA for both head_before and
2564        // head_after. entities_loaded reflects the post-reload count;
2565        // changed_entity_ids is empty when the disk is unchanged.
2566        let tmp = TempDir::new().unwrap();
2567        let mut engine = build_demo_engine(&tmp);
2568        let report = engine.reload_one_mem_report("specs").unwrap();
2569        assert_eq!(report.mem, "specs");
2570        assert_eq!(report.head_before, crate::ops::EMPTY_TREE_SHA);
2571        assert_eq!(report.head_after, crate::ops::EMPTY_TREE_SHA);
2572        // build_demo_engine seeds 3 entities (Source One, Target Two,
2573        // Lonely Three) — all real, no stubs from those creates.
2574        assert_eq!(report.entities_loaded, 3);
2575        // No external disk changes between init and reload → empty diff.
2576        assert!(report.changed_entity_ids.is_empty());
2577    }
2578
2579    #[test]
2580    fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
2581        // Mutate disk: add one, remove one, change one. The report's
2582        // changed_entity_ids unions the slim ReloadResult's three
2583        // diff lists into a single sorted vec — matches full's
2584        // wire contract.
2585        let tmp = TempDir::new().unwrap();
2586        let mut engine = build_demo_engine(&tmp);
2587        std::fs::write(
2588            tmp.path().join("new-via-disk.md"),
2589            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2590        )
2591        .unwrap();
2592        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2593        std::fs::write(
2594            tmp.path().join("source-one.md"),
2595            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2596        )
2597        .unwrap();
2598
2599        let report = engine.reload_one_mem_report("specs").unwrap();
2600        assert_eq!(report.mem, "specs");
2601        let ids: Vec<&str> = report
2602            .changed_entity_ids
2603            .iter()
2604            .map(|id| id.as_ref())
2605            .collect();
2606        // Sorted lexicographically: lonely-three < new-via-disk < source-one
2607        assert_eq!(
2608            ids,
2609            vec![
2610                "specs--lonely-three",
2611                "specs--new-via-disk",
2612                "specs--source-one",
2613            ]
2614        );
2615    }
2616
2617    #[test]
2618    fn reload_one_mem_report_rejects_unknown_mem() {
2619        let tmp = TempDir::new().unwrap();
2620        let mut engine = build_demo_engine(&tmp);
2621        let err = engine.reload_one_mem_report("missing").unwrap_err();
2622        assert!(matches!(err, EngineError::UnknownMem(_)));
2623    }
2624
2625    #[test]
2626    fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
2627        let tmp = TempDir::new().unwrap();
2628        let mut engine = build_demo_engine(&tmp);
2629        let reports = engine.reload_each_writable_mem_reports().unwrap();
2630        assert_eq!(reports.len(), 1);
2631        assert_eq!(reports[0].mem, "specs");
2632        assert_eq!(reports[0].entities_loaded, 3);
2633    }
2634
2635    /// Workspace-wide reload re-reads `.memstead/workspace.toml` and
2636    /// refreshes [`WorkspaceSettings`]. This is the pairing with the
2637    /// CLI's `memstead workspace allow-create / grant-cross-link /
2638    /// set-mutations` family — without it, a CLI write lands on disk
2639    /// but the running engine keeps serving the boot-time policy
2640    /// snapshot until process restart.
2641    #[test]
2642    fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
2643        let tmp = TempDir::new().unwrap();
2644
2645        // Minimum-viable workspace.toml (no rules) + one writable
2646        // folder-backed mem.
2647        let memstead_dir = tmp.path().join(".memstead");
2648        std::fs::create_dir_all(&memstead_dir).unwrap();
2649        let workspace_toml = memstead_dir.join("workspace.toml");
2650        std::fs::write(
2651            &workspace_toml,
2652            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2653        )
2654        .unwrap();
2655        let mounts_json = memstead_dir.join("state").join("mounts.json");
2656        std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
2657        let mem_dir = tmp.path().join("specs");
2658        std::fs::create_dir_all(&mem_dir).unwrap();
2659        let mounts_body = format!(
2660            r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
2661            mem_dir.display(),
2662        );
2663        std::fs::write(&mounts_json, mounts_body).unwrap();
2664
2665        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2666        assert!(
2667            engine.settings().mem_create_rules.is_empty(),
2668            "boot-time settings carry no create rules"
2669        );
2670
2671        // Simulate an out-of-band CLI write to workspace.toml.
2672        std::fs::write(
2673            &workspace_toml,
2674            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n\n[[mem_management.create]]\npattern = \"exec-*\"\nschemas = [\"default@1.0.0\"]\n",
2675        )
2676        .unwrap();
2677
2678        engine.reload_each_writable_mem_reports().unwrap();
2679
2680        let rules = &engine.settings().mem_create_rules;
2681        assert_eq!(
2682            rules.len(),
2683            1,
2684            "workspace-wide reload must refresh the policy"
2685        );
2686        assert_eq!(rules[0].pattern, "exec-*");
2687    }
2688
2689    // ---- Engine::reload_if_stale ------------------------------
2690
2691    // ---- set_mem_schema / dual-pin migration ----
2692
2693    const MIG_TYPE_TAIL: &str = r#"sections:
2694  - key: body
2695    heading: Body
2696    required: true
2697    search_weight: 10.0
2698    catch_all: true
2699    write_rules: []
2700title_weight: 100.0
2701text_fields:
2702  - body
2703hierarchy_relationship: _default
2704propagating_relationships: []
2705updatable_fields: []
2706health_required_fields: []
2707staleness_threshold_days: 90
2708write_rules: []
2709"#;
2710
2711    /// Schema manifest for the migration tests: `name@version` with a
2712    /// `doc` type. `with_status = true` adds a required, no-default
2713    /// enum field `status` — entities created without it are
2714    /// non-conformant against that schema.
2715    fn mig_manifest(name: &str, version: &str) -> String {
2716        format!(
2717            r#"name: {name}
2718version: {version}
2719description: migration test schema
2720when_to_use: tests
2721types:
2722  - doc
2723relationships:
2724  mode: strict
2725  definitions:
2726    - name: USES
2727      description: link
2728      default_weight: 1.0
2729    - name: _default
2730      description: fallback
2731      default_weight: 1.0
2732community:
2733  resolution: 1.0
2734  seed: 42
2735"#
2736        )
2737    }
2738
2739    fn mig_type_yaml(with_status: bool) -> String {
2740        let metadata = if with_status {
2741            "metadata_fields:\n  - key: status\n    description: Lifecycle state\n    field_type: string\n    enum_values:\n      - open\n      - closed\n"
2742        } else {
2743            "metadata_fields: []\n"
2744        };
2745        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
2746    }
2747
2748    fn write_mig_schema(
2749        root: &std::path::Path,
2750        dir: &str,
2751        name: &str,
2752        version: &str,
2753        with_status: bool,
2754    ) {
2755        let d = root.join(dir);
2756        std::fs::create_dir_all(d.join("types")).unwrap();
2757        std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
2758        std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
2759    }
2760
2761    /// Engine with one mem pinned `mig-a@0.1.0` (no required
2762    /// metadata) plus loadable `mig-a@0.2.0` (identical shape) and
2763    /// `mig-b@0.1.0` (required enum `status`) in the workspace
2764    /// schemas dir. Two conformant-under-A entities are created.
2765    fn migration_engine() -> (tempfile::TempDir, Engine) {
2766        let tmp = tempfile::TempDir::new().unwrap();
2767        let schemas_dir = tmp.path().join("schemas");
2768        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2769        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2770        write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
2771        let mem_dir = tmp.path().join("mem");
2772        std::fs::create_dir_all(&mem_dir).unwrap();
2773        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2774        let mut mount = folder_mount("specs", mem_dir);
2775        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2776        let mut engine = Engine::from_mounts_with_schemas_dir(
2777            vec![(
2778                mount,
2779                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2780            )],
2781            Some(&schemas_dir),
2782        )
2783        .unwrap();
2784        for title in ["One", "Two"] {
2785            let mut args = empty_create_args("specs", title);
2786            args.entity_type = "doc".to_string();
2787            args.sections =
2788                indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
2789            engine
2790                .create_entity(args, crate::vcs::Actor::Cli, None, None)
2791                .expect("conformant create under mig-a");
2792        }
2793        (tmp, engine)
2794    }
2795
2796    fn sref(s: &str) -> memstead_schema::SchemaRef {
2797        s.parse().unwrap()
2798    }
2799
2800    #[test]
2801    fn set_schema_noop_on_current_pin() {
2802        let (_tmp, mut engine) = migration_engine();
2803        let out = engine
2804            .set_mem_schema("specs", &sref("mig-a@0.1.0"))
2805            .unwrap();
2806        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
2807        assert_eq!(out.schema_pin, "mig-a@0.1.0");
2808        assert_eq!(out.migration_target, None);
2809        assert!(out.findings.is_empty());
2810    }
2811
2812    #[test]
2813    fn set_schema_switches_immediately_when_integral() {
2814        // Version bump within the same domain; entities conform to
2815        // the identical-shape 0.2.0, so the switch is immediate.
2816        let (_tmp, mut engine) = migration_engine();
2817        let out = engine
2818            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2819            .unwrap();
2820        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2821        assert_eq!(out.schema_pin, "mig-a@0.2.0");
2822        assert_eq!(out.migration_target, None);
2823        assert!(out.findings.is_empty());
2824        assert_eq!(
2825            engine.schema_pin("specs").unwrap().as_display(),
2826            "mig-a@0.2.0"
2827        );
2828        assert!(engine.migration_target("specs").is_none());
2829    }
2830
2831    /// Regression: an atomic switch must persist the new pin into the
2832    /// **authoritative** backend config, not just `mounts.json`. Boot
2833    /// resolution prefers the backend config's pin over `Mount.schema`,
2834    /// so before this fix the switch evaporated on the next process boot
2835    /// for any config-present mem (every `create_mem`-made mem).
2836    #[test]
2837    fn set_schema_switch_persists_pin_into_backend_config() {
2838        let tmp = tempfile::TempDir::new().unwrap();
2839        let schemas_dir = tmp.path().join("schemas");
2840        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2841        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2842        let mem_dir = tmp.path().join("mem");
2843        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2844        // Config-present mem: the authoritative pin lives here.
2845        std::fs::write(
2846            mem_dir.join(".memstead").join("config.json"),
2847            br#"{"schema":"mig-a@0.1.0"}"#,
2848        )
2849        .unwrap();
2850        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2851        let mut mount = folder_mount("specs", mem_dir.clone());
2852        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2853        let mut engine = Engine::from_mounts_with_schemas_dir(
2854            vec![(
2855                mount,
2856                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2857            )],
2858            Some(&schemas_dir),
2859        )
2860        .unwrap();
2861
2862        let out = engine
2863            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2864            .unwrap();
2865        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2866
2867        // The authoritative backend config now carries the new pin —
2868        // otherwise the switch would evaporate on reboot.
2869        let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
2870        let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
2871        assert_eq!(
2872            cfg["schema"], "mig-a@0.2.0",
2873            "atomic switch must update the authoritative backend config"
2874        );
2875    }
2876
2877    #[test]
2878    fn set_schema_unknown_target_refuses_schema_not_found() {
2879        let (_tmp, mut engine) = migration_engine();
2880        let err = engine
2881            .set_mem_schema("specs", &sref("nope@9.9.9"))
2882            .unwrap_err();
2883        assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
2884        // No state change.
2885        assert!(engine.migration_target("specs").is_none());
2886    }
2887
2888    #[test]
2889    fn set_schema_migration_lifecycle_end_to_end() {
2890        let (_tmp, mut engine) = migration_engine();
2891        let target = sref("mig-b@0.1.0");
2892
2893        // 1. Non-integral target → migration starts; pin unchanged.
2894        let out = engine.set_mem_schema("specs", &target).unwrap();
2895        assert_eq!(
2896            out.outcome,
2897            crate::engine::SetSchemaResult::MigrationStarted
2898        );
2899        assert_eq!(out.schema_pin, "mig-a@0.1.0");
2900        assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
2901        assert_eq!(out.findings.len(), 2, "both entities lack `status`");
2902        assert!(
2903            out.findings
2904                .iter()
2905                .all(|f| f.code == "REQUIRED_FIELD_UNSET")
2906        );
2907
2908        // 2. Reads of not-yet-repaired entities stay permissive.
2909        let one = crate::entity::EntityId::new("specs", "one");
2910        assert!(engine.store().get(&one).is_some());
2911
2912        // 3. Re-issue while unrepaired → pending, full remaining set.
2913        let out = engine.set_mem_schema("specs", &target).unwrap();
2914        assert_eq!(
2915            out.outcome,
2916            crate::engine::SetSchemaResult::MigrationPending
2917        );
2918        assert_eq!(out.findings.len(), 2);
2919
2920        // 4. Writes validate against the TARGET: `status` is unknown
2921        //    to the pinned mig-a but declared by mig-b — setting it
2922        //    must commit; an invalid enum value must refuse.
2923        let mut bad = crate::engine::UpdateEntityArgs {
2924            anchors: Vec::new(),
2925            id: one.clone(),
2926            expected_hash: None,
2927            sections: indexmap::IndexMap::new(),
2928            append_sections: indexmap::IndexMap::new(),
2929            patch_sections: indexmap::IndexMap::new(),
2930            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
2931            metadata_unset: Vec::new(),
2932            declare_relations: Vec::new(),
2933            dry_run: false,
2934            relations_unset: Vec::new(),
2935        };
2936        let err = engine
2937            .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
2938            .unwrap_err();
2939        assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
2940        bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
2941        engine
2942            .update_entity(bad, crate::vcs::Actor::Cli, None, None)
2943            .expect("repair write validated against the migration target");
2944
2945        // 5. One entity repaired → still pending, findings shrink.
2946        let out = engine.set_mem_schema("specs", &target).unwrap();
2947        assert_eq!(
2948            out.outcome,
2949            crate::engine::SetSchemaResult::MigrationPending
2950        );
2951        assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
2952
2953        // 6. Repair the second entity, re-issue → atomic switch.
2954        let two = crate::entity::EntityId::new("specs", "two");
2955        let repair = crate::engine::UpdateEntityArgs {
2956            anchors: Vec::new(),
2957            id: two.clone(),
2958            expected_hash: None,
2959            sections: indexmap::IndexMap::new(),
2960            append_sections: indexmap::IndexMap::new(),
2961            patch_sections: indexmap::IndexMap::new(),
2962            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
2963            metadata_unset: Vec::new(),
2964            declare_relations: Vec::new(),
2965            dry_run: false,
2966            relations_unset: Vec::new(),
2967        };
2968        engine
2969            .update_entity(repair, crate::vcs::Actor::Cli, None, None)
2970            .unwrap();
2971        let out = engine.set_mem_schema("specs", &target).unwrap();
2972        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2973        assert_eq!(out.schema_pin, "mig-b@0.1.0");
2974        assert_eq!(out.migration_target, None);
2975        assert!(out.findings.is_empty());
2976        assert_eq!(
2977            engine.schema_pin("specs").unwrap().as_display(),
2978            "mig-b@0.1.0"
2979        );
2980        assert!(engine.migration_target("specs").is_none());
2981    }
2982
2983    /// During migration every not-yet-repaired entity is
2984    /// non-conformant against the target, so `relations_unset` works
2985    /// on exactly those entities with no mode flag — and the same
2986    /// update can complete the entity's repair.
2987    #[test]
2988    fn relations_unset_works_during_migration_without_mode_flag() {
2989        let (_tmp, mut engine) = migration_engine();
2990        let one = crate::entity::EntityId::new("specs", "one");
2991        let two = crate::entity::EntityId::new("specs", "two");
2992        engine
2993            .relate_entity(
2994                crate::engine::RelateEntityArgs {
2995                    source: one.clone(),
2996                    expected_hash: None,
2997                    rel_type: "USES".to_string(),
2998                    target: two.clone(),
2999                    remove: false,
3000                    description: None,
3001                },
3002                crate::vcs::Actor::Cli,
3003                None,
3004                None,
3005            )
3006            .unwrap();
3007        // Conformant under the pin → the repair gate is shut.
3008        let shut = engine
3009            .update_entity(
3010                crate::engine::UpdateEntityArgs {
3011                    anchors: Vec::new(),
3012                    id: one.clone(),
3013                    expected_hash: None,
3014                    sections: indexmap::IndexMap::new(),
3015                    append_sections: indexmap::IndexMap::new(),
3016                    patch_sections: indexmap::IndexMap::new(),
3017                    metadata: indexmap::IndexMap::new(),
3018                    metadata_unset: Vec::new(),
3019                    declare_relations: Vec::new(),
3020                    dry_run: false,
3021                    relations_unset: vec![crate::ops::RelationUnsetArg {
3022                        rel_type: "USES".to_string(),
3023                        target: two.clone(),
3024                    }],
3025                },
3026                crate::vcs::Actor::Cli,
3027                None,
3028                None,
3029            )
3030            .unwrap_err();
3031        assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
3032
3033        // Enter migration → `one` is now non-conformant against the
3034        // target; the same call opens, removes the relation, and the
3035        // bundled `status` set makes the entity integral-against-target.
3036        engine
3037            .set_mem_schema("specs", &sref("mig-b@0.1.0"))
3038            .unwrap();
3039        engine
3040            .update_entity(
3041                crate::engine::UpdateEntityArgs {
3042                    anchors: Vec::new(),
3043                    id: one.clone(),
3044                    expected_hash: None,
3045                    sections: indexmap::IndexMap::new(),
3046                    append_sections: indexmap::IndexMap::new(),
3047                    patch_sections: indexmap::IndexMap::new(),
3048                    metadata: indexmap::IndexMap::from_iter([(
3049                        "status".to_string(),
3050                        "open".to_string(),
3051                    )]),
3052                    metadata_unset: Vec::new(),
3053                    declare_relations: Vec::new(),
3054                    dry_run: false,
3055                    relations_unset: vec![crate::ops::RelationUnsetArg {
3056                        rel_type: "USES".to_string(),
3057                        target: two.clone(),
3058                    }],
3059                },
3060                crate::vcs::Actor::Cli,
3061                None,
3062                None,
3063            )
3064            .expect("repair-shaped update lands during migration without a flag");
3065        let entity = engine.store().get(&one).unwrap();
3066        assert!(entity.relationships.is_empty());
3067    }
3068
3069    /// Boot honors a persisted in-flight migration: a mount carrying
3070    /// `migration_target` validates writes against the target from
3071    /// the first call of the new process — the resumability half of
3072    /// the dual-pin contract.
3073    #[test]
3074    fn boot_resumes_dual_pin_validation_against_target() {
3075        let (tmp, engine) = migration_engine();
3076        drop(engine);
3077        let schemas_dir = tmp.path().join("schemas");
3078        let mem_dir = tmp.path().join("mem");
3079        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
3080        let mut mount = folder_mount("specs", mem_dir);
3081        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
3082        mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
3083        let engine = Engine::from_mounts_with_schemas_dir(
3084            vec![(
3085                mount,
3086                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
3087            )],
3088            Some(&schemas_dir),
3089        )
3090        .unwrap();
3091        // Effective validation schema is the target...
3092        let (name, version) = {
3093            let s = engine.schema_for("specs").unwrap();
3094            let (n, v) = s.id();
3095            (n.to_string(), v.to_string())
3096        };
3097        assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
3098        // ...while the settled pin and the in-flight target read back
3099        // distinctly.
3100        assert_eq!(
3101            engine.schema_pin("specs").unwrap().as_display(),
3102            "mig-a@0.1.0"
3103        );
3104        assert_eq!(
3105            engine.migration_target("specs").unwrap().as_display(),
3106            "mig-b@0.1.0"
3107        );
3108    }
3109}