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                (hook.export)(
786                    gitdir,
787                    branch,
788                    mem_name,
789                    config,
790                    output_path,
791                    workspace_root,
792                    workspace_schemas_dir,
793                    provenance_bytes.as_deref(),
794                )
795                .map_err(EngineError::Backend)
796            }
797            MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
798            // `.mem` export from an in-memory mem lands with the
799            // writable-session-server plan (it needs a backend-level
800            // archive builder); this plan adds the backend, not the
801            // export path, so refuse explicitly rather than silently.
802            MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
803                "export not yet supported for in-memory backend".to_string(),
804            ))),
805        }
806    }
807
808    /// Update a mem's `version` field in its per-mem config and
809    /// persist it through the backend. Backend-symmetric: folder
810    /// backends rewrite `.memstead/config.json`; git-branch backends
811    /// commit `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts
812    /// reject with `BackendError::Sealed`.
813    ///
814    /// Returns the (mem, old_version, new_version) triple so
815    /// callers can surface the change without an extra read. Reads
816    /// the current value from the in-memory `MemConfig` and
817    /// updates it on success, keeping the next call free of a
818    /// stale-version read.
819    ///
820    /// `EngineError::UnknownMem` when the name resolves to no
821    /// mount; `EngineError::ReadOnlyMount` when the mount is sealed
822    /// for writes; `EngineError::InvalidInput` when the mount has no
823    /// loaded `MemConfig` (folder mount with no
824    /// `.memstead/config.json`; the residual missing-config path is
825    /// distinct from the missing-version path). F1.
826    pub fn set_mem_version(
827        &mut self,
828        mem_name: &str,
829        new_version: semver::Version,
830        note: Option<&str>,
831    ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
832        // Resolve the mount up-front so an unknown-mem name refuses
833        // before any drift-probe side effect lands.
834        let mount_idx = self
835            .mounts
836            .iter()
837            .position(|m| m.mount.mem == mem_name)
838            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
839        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
840            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
841        }
842
843        // Probe for concurrent-drift before the write — a sibling
844        // engine that committed between our last snapshot and now
845        // surfaces `MEM_RELOADED` on the response so callers see
846        // the drift without a separate read round-trip. Drift
847        // warnings ride alongside the success outcome; an
848        // unreachable-backend probe collapses to no warnings (the
849        // existing accessor warn-logs internally and skips).
850        let mut warnings = self.reload_if_stale(Some(mem_name));
851        // Provenance nudge — same posture as every other commit-
852        // producing mutation: when `require_notes` is set and no note
853        // was supplied, ride a non-blocking `NOTE_MISSING` warning.
854        // The version bump still commits.
855        if let Some(w) = self.note_missing_warning("set_mem_version", note) {
856            warnings.push(w);
857        }
858
859        let mounted = &mut self.mounts[mount_idx];
860        let mut config = mounted.mem_config.clone().ok_or_else(|| {
861            EngineError::InvalidInput(format!(
862                "mem '{mem_name}' has no loaded MemConfig — \
863                     cannot set version (initialize the mem via `memstead init` \
864                     or `memstead mem create` first)"
865            ))
866        })?;
867        let old_version = config.version.clone();
868        config.version = Some(new_version.clone());
869
870        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
871            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
872        })?;
873        bytes.push(b'\n');
874        mounted.backend.write_mem_config_with_note(&bytes, note)?;
875        mounted.mem_config = Some(config);
876
877        // Refresh the head cursor so the next drift probe doesn't
878        // surface MEM_RELOADED for the commit we just produced
879        // (the git-branch backend's `write_mem_config` writes a
880        // commit on `__MEMSTEAD`; folder backends carry no head and the
881        // refresh is a no-op).
882        let new_head = mounted.backend.current_head().ok().flatten();
883        if let Some(sha) = new_head {
884            mounted.last_known_head = Some(sha);
885        }
886
887        Ok(crate::ops::SetMemVersionOutcome {
888            mem: mem_name.to_string(),
889            old_version,
890            new_version,
891            warnings,
892        })
893    }
894
895    /// Update a mem's `description` field in its per-mem config and
896    /// persist it through the backend — the one-line text mem-archive
897    /// export embeds and the registry card surfaces. `None` clears the
898    /// field. Same backend symmetry, drift probe, and provenance-note
899    /// posture as [`Self::set_mem_version`]; archive mounts reject with
900    /// `BackendError::Sealed`.
901    pub fn set_mem_description(
902        &mut self,
903        mem_name: &str,
904        new_description: Option<String>,
905        note: Option<&str>,
906    ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
907        let mount_idx = self
908            .mounts
909            .iter()
910            .position(|m| m.mount.mem == mem_name)
911            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
912        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
913            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
914        }
915
916        let mut warnings = self.reload_if_stale(Some(mem_name));
917        if let Some(w) = self.note_missing_warning("set_mem_description", note) {
918            warnings.push(w);
919        }
920
921        let mounted = &mut self.mounts[mount_idx];
922        let mut config = mounted.mem_config.clone().ok_or_else(|| {
923            EngineError::InvalidInput(format!(
924                "mem '{mem_name}' has no loaded MemConfig — \
925                     cannot set description (initialize the mem via `memstead init` \
926                     or `memstead mem create` first)"
927            ))
928        })?;
929        let old_description = config.description.clone();
930        config.description = new_description.clone();
931
932        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
933            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
934        })?;
935        bytes.push(b'\n');
936        mounted.backend.write_mem_config_with_note(&bytes, note)?;
937        mounted.mem_config = Some(config);
938
939        let new_head = mounted.backend.current_head().ok().flatten();
940        if let Some(sha) = new_head {
941            mounted.last_known_head = Some(sha);
942        }
943
944        Ok(crate::ops::SetMemDescriptionOutcome {
945            mem: mem_name.to_string(),
946            old_description,
947            new_description,
948            warnings,
949        })
950    }
951
952    /// Set (or clear) one opaque sync-state token in a mem's per-mem
953    /// config and persist it through the backend. The ingest layer calls
954    /// this after a successful pass over a source's changed slice to
955    /// record "the source state the graph was last synced against".
956    ///
957    /// `key` and `token` are both opaque to the engine: the key is
958    /// conventionally `"<ingest>/<facet>"` but the engine treats it as an
959    /// arbitrary string; the token's meaning belongs to the medium-type
960    /// layer (git → commit id, graph → snapshot token, filesystem → a
961    /// JSON-stringified stat digest). The engine never parses either.
962    /// An **empty** `token` removes the key — the surface for clearing a
963    /// baseline (which the next ingest pass re-seeds at the current
964    /// source state).
965    ///
966    /// Backend-symmetric like [`Self::set_mem_version`]: folder backends
967    /// rewrite `.memstead/config.json`; git-branch backends commit
968    /// `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts reject with
969    /// `BackendError::Sealed`.
970    ///
971    /// Returns the (mem, key, previous-token) triple so callers can
972    /// surface the change without an extra read. `EngineError::UnknownMem`
973    /// when the name resolves to no mount; `EngineError::ReadOnlyMount`
974    /// when the mount is sealed for writes; `EngineError::InvalidInput`
975    /// when the mount has no loaded `MemConfig`.
976    pub fn set_mem_sync_state(
977        &mut self,
978        mem_name: &str,
979        key: &str,
980        token: &str,
981        note: Option<&str>,
982    ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
983        // Resolve the mount up-front so an unknown-mem name refuses
984        // before any drift-probe side effect lands.
985        let mount_idx = self
986            .mounts
987            .iter()
988            .position(|m| m.mount.mem == mem_name)
989            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
990        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
991            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
992        }
993
994        // Probe for concurrent-drift before the write — same posture as
995        // every other commit-producing mutation; a sibling engine that
996        // committed since our last snapshot surfaces `MEM_RELOADED`.
997        let mut warnings = self.reload_if_stale(Some(mem_name));
998        if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
999            warnings.push(w);
1000        }
1001
1002        let mounted = &mut self.mounts[mount_idx];
1003        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1004            EngineError::InvalidInput(format!(
1005                "mem '{mem_name}' has no loaded MemConfig — \
1006                 cannot set sync state (initialize the mem via `memstead init` \
1007                 or `memstead mem create` first)"
1008            ))
1009        })?;
1010
1011        // Empty token clears the baseline; otherwise insert/overwrite.
1012        // `removed` distinguishes a no-op clear (key absent) from a real
1013        // one so the outcome is honest.
1014        let removed;
1015        let previous;
1016        if token.is_empty() {
1017            previous = config.sync_state.remove(key);
1018            removed = previous.is_some();
1019        } else {
1020            previous = config.sync_state.insert(key.to_string(), token.to_string());
1021            removed = false;
1022        }
1023
1024        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1025            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1026        })?;
1027        bytes.push(b'\n');
1028        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1029        mounted.mem_config = Some(config);
1030
1031        // Refresh the head cursor so the next drift probe doesn't surface
1032        // MEM_RELOADED for the commit we just produced.
1033        let new_head = mounted.backend.current_head().ok().flatten();
1034        if let Some(sha) = new_head {
1035            mounted.last_known_head = Some(sha);
1036        }
1037
1038        Ok(crate::ops::SetMemSyncStateOutcome {
1039            mem: mem_name.to_string(),
1040            key: key.to_string(),
1041            previous,
1042            removed,
1043            warnings,
1044        })
1045    }
1046
1047    /// Re-read the named mount's backend entities and refresh the
1048    /// in-memory store for that mem. Returns the diff against the
1049    /// pre-reload snapshot — `added` (ids newly present), `removed`
1050    /// (ids no longer present), `changed` (same id, different
1051    /// `content_hash`).
1052    ///
1053    /// Operator-triggered: useful when an external writer modified
1054    /// disk while this engine instance was alive (the lean flavour
1055    /// assumes single-writer; this primitive is the escape hatch when
1056    /// that assumption breaks). On the happy path the diff is empty.
1057    ///
1058    /// Drift detection (whether disk *did* change) is not part of this
1059    /// surface — callers that want to short-circuit on "nothing
1060    /// changed" must compare `added.is_empty() && changed.is_empty()
1061    /// && removed.is_empty()` against the result. Backend-specific
1062    /// drift signals (git HEAD comparison, mtime check) live in the
1063    /// full-flavour engine where they have meaning.
1064    ///
1065    /// Invalidates community + search-index memos on success.
1066    pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
1067        // Per-mem reload is intentionally silent on the engine-
1068        // wide `load_warnings` accumulator — matches full's
1069        // `reload_one_mem`. A LOCAL sink absorbs any warnings
1070        // the parser emits during this reload and is discarded.
1071        // Drift events still surface as `MemReloaded` warnings
1072        // via `reload_if_stale`.
1073        let mut sink: Vec<WarningHint> = Vec::new();
1074        self.reload_one_mem_inner(mem, &mut sink)
1075    }
1076
1077    /// Inner per-mem body shared by [`Self::reload_one_mem`]
1078    /// and [`Self::reload_each_writable_mem`]. The caller passes
1079    /// a warning sink so the workspace-wide reload can forward
1080    /// warnings into `self.load_warnings` while the single-mem
1081    /// path keeps the accumulator pristine.
1082    fn reload_one_mem_inner(
1083        &mut self,
1084        mem: &str,
1085        warnings_sink: &mut Vec<WarningHint>,
1086    ) -> Result<crate::ops::ReloadResult, EngineError> {
1087        // Locate the target mount + schema. Unknown mem short-
1088        // circuits before any store mutation.
1089        let mount_idx = self
1090            .mounts
1091            .iter()
1092            .position(|m| m.mount.mem == mem)
1093            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1094        let schema = self
1095            .schemas
1096            .get(mem)
1097            .cloned()
1098            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
1099
1100        // Snapshot pre-reload (id, content_hash) for this mem.
1101        let pre: HashMap<EntityId, String> = self
1102            .store
1103            .all_entities()
1104            .filter(|e| !e.stub && e.mem == mem)
1105            .map(|e| (e.id.clone(), e.content_hash.clone()))
1106            .collect();
1107        let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
1108
1109        // Walk the backend; surface read-time errors instead of
1110        // mutating the store on a failed reload.
1111        let backend = self.mounts[mount_idx].backend.as_ref();
1112        let (entries, read_errors) = collect_source_entries(backend)?;
1113        let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
1114
1115        // Build the LoadCollector inputs — mem roster + last-
1116        // segment suffixes — so the parser pipeline can emit
1117        // typed drift warnings into the caller's sink.
1118        let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1119        let known_suffixes: Vec<String> = mem_names
1120            .iter()
1121            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
1122            .collect();
1123
1124        // Failure fence above; below this point the store is mutated.
1125        self.store.remove_entities_by_mem(mem);
1126        let fallback = engine_fallback_type();
1127        push_entities_into_store(
1128            &mut self.store,
1129            load_result.entities,
1130            fallback.as_ref(),
1131            Some(crate::entity::store_builder::LoadCollector {
1132                warnings: warnings_sink,
1133                known_suffixes: &known_suffixes,
1134                mem_names: &mem_names,
1135            }),
1136        );
1137        // Re-run parse-time relation validation across the workspace.
1138        // A reload re-parses one mem but the validator's cycle pass
1139        // is global (acyclic-rel-type subgraphs span mems), so the
1140        // scan runs against the whole store. Hand-edits arriving via
1141        // sibling-writer commits get the same gauntlet boot enforces
1142        // (grammar / unknown_rel_type / shape / cycle).
1143        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
1144            .mounts
1145            .iter()
1146            .map(|m| (m.mount.mem.clone(), m.mount.capability))
1147            .collect();
1148        // Restore cross-mem edges that point INTO this mem. The
1149        // removal cascade above dropped their incoming mirrors and the
1150        // re-push only rebuilt edges authored by this mem's own
1151        // entities, so a cross-mem `A→B` would silently vanish from the
1152        // index until a workspace-wide reload. Reconstruct from the
1153        // authoritative source records (in-memory only — no other mem is
1154        // re-read), then let the remap pass below reclassify alias sources.
1155        crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
1156        crate::entity::store_builder::validate_loaded_relations(
1157            &mut self.store,
1158            &self.schemas,
1159            &mount_caps,
1160            warnings_sink,
1161        );
1162        crate::entity::store_builder::remap_alias_target_edge_sources(
1163            &mut self.store,
1164            &self.schemas,
1165        );
1166        // Surface load errors back through the engine's accumulator
1167        // so subsequent `load_errors()` calls reflect the latest read.
1168        // We don't clear pre-existing errors from other mems — only
1169        // append; an external operator that wants a clean slate runs
1170        // a full re-init.
1171        self.load_errors.extend(load_result.errors);
1172
1173        // Diff post-reload against the snapshot.
1174        let mut added: Vec<EntityId> = Vec::new();
1175        let mut changed: Vec<EntityId> = Vec::new();
1176        for entity in self.store.all_entities() {
1177            if entity.stub || entity.mem != mem {
1178                continue;
1179            }
1180            match pre.get(&entity.id) {
1181                None => added.push(entity.id.clone()),
1182                Some(prev_hash) if prev_hash != &entity.content_hash => {
1183                    changed.push(entity.id.clone());
1184                }
1185                Some(_) => {}
1186            }
1187        }
1188        let post_ids: std::collections::HashSet<EntityId> = self
1189            .store
1190            .all_entities()
1191            .filter(|e| !e.stub && e.mem == mem)
1192            .map(|e| e.id.clone())
1193            .collect();
1194        let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
1195        added.sort_by(|a, b| a.0.cmp(&b.0));
1196        changed.sort_by(|a, b| a.0.cmp(&b.0));
1197        removed.sort_by(|a, b| a.0.cmp(&b.0));
1198
1199        self.invalidate_communities();
1200        self.invalidate_search_indexes();
1201
1202        Ok(crate::ops::ReloadResult {
1203            added,
1204            changed,
1205            removed,
1206        })
1207    }
1208
1209    /// Rich-shape variant of [`Self::reload_one_mem`] that returns a
1210    /// [`crate::ops::ReloadReport`] (mem + head_before + head_after +
1211    /// entities_loaded + changed_entity_ids) instead of the slim
1212    /// [`crate::ops::ReloadResult`]. Handler-facing wrapper consumed
1213    /// by the `memstead_reload` MCP tool — the rich shape is the wire
1214    /// contract MCP callers depend on; the slim form stays for
1215    /// programmatic consumers that just want the diff lists.
1216    ///
1217    /// `head_before` is the engine's **prior cursor** for this mem
1218    /// (its cached `last_known_head`), *not* the current on-disk tip:
1219    /// when a sibling has committed since, the tip has already advanced,
1220    /// so reporting it would make the advertised
1221    /// `changes_since(since=head_before)` recipe span an empty range.
1222    /// `head_after` is the freshly-peeled tip from
1223    /// [`crate::backend::MemBackend::current_head`]; the reload also
1224    /// advances the cursor to it, so a follow-up staleness probe does
1225    /// not re-reload the same window. Backends without history (folder,
1226    /// archive) carry no cursor and return `Ok(None)`; both fields fall
1227    /// back to [`crate::ops::EMPTY_TREE_SHA`] for wire-shape stability.
1228    ///
1229    /// `entities_loaded` is the post-reload non-stub count for the
1230    /// mem — same semantic as full's report.
1231    ///
1232    /// `changed_entity_ids` is the union of `added ∪ changed ∪
1233    /// removed` from the underlying [`crate::ops::ReloadResult`]
1234    /// so callers don't have to merge three lists themselves —
1235    /// matches full's bundled wire shape.
1236    pub fn reload_one_mem_report(
1237        &mut self,
1238        mem: &str,
1239    ) -> Result<crate::ops::ReloadReport, EngineError> {
1240        // `head_before` is the engine's PRIOR cursor — the SHA it last
1241        // knew for this mem — not the current (possibly already
1242        // drifted) on-disk tip. Reporting the tip would collapse the
1243        // `changes_since(since=head_before)` range to empty in exactly
1244        // the sibling-drift case the recipe targets. Only history-backed
1245        // mounts (git-branch) carry a git cursor: folder / archive
1246        // backends have no `current_head`, so their `head_before` stays
1247        // the empty-tree sentinel that pairs with the equally-empty
1248        // `head_after` below.
1249        let tracks_head = self
1250            .mounts
1251            .iter()
1252            .find(|m| m.mount.mem == mem)
1253            .and_then(|m| m.backend.current_head().ok().flatten())
1254            .is_some();
1255        let head_before = if tracks_head {
1256            self.mounts
1257                .iter()
1258                .find(|m| m.mount.mem == mem)
1259                .and_then(|m| m.last_known_head.clone())
1260                .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
1261        } else {
1262            crate::ops::EMPTY_TREE_SHA.to_string()
1263        };
1264
1265        let result = self.reload_one_mem(mem)?;
1266
1267        // Capture head_after = the freshly-peeled tip, and advance the
1268        // engine's cursor to it. Without this advance the next
1269        // operation's `reload_if_stale` would compare the stale cursor
1270        // against the same tip and re-reload the identical window,
1271        // re-emitting a spurious `MEM_RELOADED`. Only history-backed
1272        // mounts (current_head → Some) carry a cursor to advance.
1273        let head_after_raw = self
1274            .mounts
1275            .iter()
1276            .find(|m| m.mount.mem == mem)
1277            .and_then(|m| m.backend.current_head().ok().flatten());
1278        if let Some(new_head) = head_after_raw.clone()
1279            && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
1280        {
1281            m.last_known_head = Some(new_head);
1282        }
1283        let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
1284
1285        let entities_loaded = self
1286            .store
1287            .all_entities()
1288            .filter(|e| !e.stub && e.mem == mem)
1289            .count();
1290
1291        // Union of added + changed + removed, sorted lexicographically
1292        // for deterministic wire output. Matches full's "single
1293        // changed_entity_ids list" contract — saves callers from
1294        // merging three slices themselves.
1295        let mut changed_entity_ids: Vec<EntityId> = result
1296            .added
1297            .into_iter()
1298            .chain(result.changed)
1299            .chain(result.removed)
1300            .collect();
1301        changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
1302
1303        Ok(crate::ops::ReloadReport {
1304            mem: mem.to_string(),
1305            head_before,
1306            head_after,
1307            entities_loaded,
1308            changed_entity_ids,
1309        })
1310    }
1311
1312    /// Batched rich-shape variant — returns one
1313    /// [`crate::ops::ReloadReport`] per mounted mem in declaration
1314    /// order. Counterpart to [`Self::reload_each_writable_mem`]
1315    /// (slim) that the `memstead_reload` MCP tool's no-mem path
1316    /// consumes.
1317    ///
1318    /// Also re-reads `.memstead/workspace.toml` and refreshes
1319    /// [`crate::workspace::WorkspaceSettings`] before sweeping the
1320    /// mems — this is the pairing with the CLI's
1321    /// `memstead workspace allow-create / grant-cross-link / set-mutations`
1322    /// family. Without this re-read, a CLI write would land on disk but
1323    /// the running MCP would still serve the engine's boot-time policy
1324    /// snapshot; every subsequent `memstead_mem_create` against the new
1325    /// allowlist would fail with `MEM_PATH_NOT_ALLOWED` until process
1326    /// restart. The workspace-wide form runs the heavier path; the
1327    /// per-mem form (`reload_one_mem_report`) intentionally skips
1328    /// the workspace re-read — content drift doesn't imply policy
1329    /// drift.
1330    ///
1331    /// Reload of `workspace.toml` is best-effort: a missing or
1332    /// unparseable file leaves the existing settings untouched. The
1333    /// per-mem sweep is the primary contract — settings refresh is
1334    /// the additive bonus.
1335    ///
1336    /// First-error-aborts: if any mem's reload fails, the loop
1337    /// stops and the error propagates. Mems reloaded before the
1338    /// failing one are already mutated in the store; the returned
1339    /// error has no rollback. Operators run the per-mem form to
1340    /// retry the failing mem explicitly.
1341    pub fn reload_each_writable_mem_reports(
1342        &mut self,
1343    ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
1344        self.refresh_workspace_settings_if_possible();
1345        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1346        let mut out = Vec::with_capacity(names.len());
1347        for name in names {
1348            let report = self.reload_one_mem_report(&name)?;
1349            out.push(report);
1350        }
1351        Ok(out)
1352    }
1353
1354    /// Best-effort refresh of [`crate::workspace::WorkspaceSettings`]
1355    /// from the workspace's `.memstead/workspace.toml`. Called by the
1356    /// workspace-wide reload sweep so CLI-driven policy edits become
1357    /// visible to a live engine without process restart.
1358    ///
1359    /// Silent no-op when the engine has no `workspace_root` (legacy
1360    /// in-memory constructions) or when the on-disk file is missing /
1361    /// unparseable. The per-mem reload contract stays the canonical
1362    /// failure surface; settings refresh failures are intentionally
1363    /// non-fatal so a malformed workspace.toml doesn't break content
1364    /// drift detection.
1365    fn refresh_workspace_settings_if_possible(&mut self) {
1366        let Some(root) = self.workspace_root.clone() else {
1367            return;
1368        };
1369        let store = crate::workspace_store::FileWorkspaceStore::new();
1370        let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
1371            Ok(w) => w,
1372            Err(_) => return,
1373        };
1374        self.set_settings(workspace.settings);
1375    }
1376
1377    /// Reload every mounted mem in declaration order; returns one
1378    /// `(mem, ReloadResult)` per mount.
1379    ///
1380    /// Failure model is **first-error-aborts**: if any mem's reload
1381    /// fails, the loop stops and the error propagates. Mems reloaded
1382    /// before the failing one are already mutated in the store; the
1383    /// returned error has no rollback. Operators run the per-mem
1384    /// form to retry the failing mem explicitly.
1385    ///
1386    /// Caller-friendly batching wrapper around [`Self::reload_one_mem`];
1387    /// internal cache invalidation happens once per mem (the inner
1388    /// call invalidates) so an N-mem batch invalidates the memos
1389    /// N times. That's wasteful for large workspaces; once the
1390    /// `memstead_reload` MCP handler migrates we can tighten this to one
1391    /// invalidation at the end.
1392    pub fn reload_each_writable_mem(
1393        &mut self,
1394    ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
1395        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
1396        // Workspace-wide reload semantics: take the engine-wide
1397        // sink, clear it, route per-mem inner reloads through it,
1398        // put it back. The result is `self.load_warnings` carries
1399        // every typed drift warning the reload sweep produced (so
1400        // the next `engine.health()` call surfaces them).
1401        let mut sink = std::mem::take(&mut self.load_warnings);
1402        sink.clear();
1403        let mut out = Vec::with_capacity(names.len());
1404        let mut loop_err = None;
1405        for name in names {
1406            match self.reload_one_mem_inner(&name, &mut sink) {
1407                Ok(result) => out.push((name, result)),
1408                Err(e) => {
1409                    loop_err = Some(e);
1410                    break;
1411                }
1412            }
1413        }
1414        self.load_warnings = sink;
1415        if let Some(e) = loop_err {
1416            return Err(e);
1417        }
1418        Ok(out)
1419    }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424
1425    use tempfile::TempDir;
1426
1427    use crate::backend::{BackendError, MemBackend};
1428    use crate::engine::test_helpers::*;
1429    use crate::engine::{Engine, EngineError};
1430    use crate::mem::MemOrigin;
1431    use crate::ops::WarningHint;
1432    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1433
1434    #[test]
1435    fn reload_each_writable_mem_repopulates_load_warnings() {
1436        // Boot with a clean mem, then mid-flight write a file
1437        // with a duplicate heading, then call reload_each_writable_mem.
1438        // The accumulator should pick up the new typed warning.
1439        let tmp = TempDir::new().unwrap();
1440        let mem_dir = tmp.path().to_path_buf();
1441        let writer = FilesystemMemWriter::new(mem_dir.clone());
1442        let mut engine = Engine::from_mounts(vec![(
1443            folder_mount("specs", mem_dir.clone()),
1444            Box::new(writer) as Box<dyn MemBackend>,
1445        )])
1446        .unwrap();
1447        assert!(
1448            engine.load_warnings().is_empty(),
1449            "clean boot has no warnings"
1450        );
1451
1452        // Drop a markdown file with two `## Identity` headings.
1453        let body =
1454            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1455        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1456
1457        engine.reload_each_writable_mem().unwrap();
1458        let warnings = engine.load_warnings();
1459        assert!(
1460            warnings
1461                .iter()
1462                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1463            "workspace-wide reload must repopulate load_warnings: {warnings:?}",
1464        );
1465    }
1466
1467    /// `validate_loaded_relations` runs on the reload path too — a
1468    /// sibling-writer commit that injects a markdown file carrying a
1469    /// schema-undeclared rel-type must surface as a typed
1470    /// `PARSED_RELATION_INVALID` warning after `reload_each_writable_mem`.
1471    /// Without the reload-path wiring this drift would slip past the
1472    /// validator (boot only catches what existed at startup).
1473    #[test]
1474    fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
1475        let tmp = TempDir::new().unwrap();
1476        let mem_dir = tmp.path().to_path_buf();
1477        // Seed a clean target entity at boot.
1478        let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1479        std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
1480        let writer = FilesystemMemWriter::new(mem_dir.clone());
1481        let mut engine = Engine::from_mounts(vec![(
1482            folder_mount("specs", mem_dir.clone()),
1483            Box::new(writer) as Box<dyn MemBackend>,
1484        )])
1485        .unwrap();
1486        // Clean boot — no parse-time relation warnings yet.
1487        assert!(
1488            !engine
1489                .load_warnings()
1490                .iter()
1491                .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
1492            "clean boot must not emit ParsedRelationInvalid; got: {:?}",
1493            engine.load_warnings()
1494        );
1495
1496        // Sibling-writer drops a new file with an unknown rel-type.
1497        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";
1498        std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
1499
1500        engine.reload_each_writable_mem().unwrap();
1501
1502        let invalid: Vec<_> = engine
1503            .load_warnings()
1504            .iter()
1505            .filter_map(|w| match w {
1506                WarningHint::ParsedRelationInvalid {
1507                    rel_type,
1508                    reason,
1509                    origin,
1510                    ..
1511                } => Some((rel_type.clone(), reason.clone(), origin.clone())),
1512                _ => None,
1513            })
1514            .collect();
1515        assert_eq!(
1516            invalid.len(),
1517            1,
1518            "reload must surface the parse-time drift, got: {invalid:?}",
1519        );
1520        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1521        assert_eq!(invalid[0].1, "unknown_rel_type");
1522        assert_eq!(invalid[0].2, "writable");
1523    }
1524
1525    #[test]
1526    fn reload_one_mem_keeps_engine_load_warnings_pristine() {
1527        // Boot with a duplicate-heading file so the accumulator
1528        // starts non-empty. Single-mem reload should NOT clear
1529        // or repopulate the engine-wide accumulator (mirrors full's
1530        // contract: per-mem reload is silent on the engine-wide
1531        // sink). The engine field stays as the boot-time snapshot.
1532        let tmp = TempDir::new().unwrap();
1533        let mem_dir = tmp.path().to_path_buf();
1534        let body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
1535        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1536        let writer = FilesystemMemWriter::new(mem_dir.clone());
1537        let mut engine = Engine::from_mounts(vec![(
1538            folder_mount("specs", mem_dir.clone()),
1539            Box::new(writer) as Box<dyn MemBackend>,
1540        )])
1541        .unwrap();
1542        let pre = engine.load_warnings().to_vec();
1543        assert!(!pre.is_empty(), "boot must populate load_warnings");
1544
1545        engine.reload_one_mem("specs").unwrap();
1546        let post = engine.load_warnings();
1547        // Pristine: same as boot snapshot.
1548        assert_eq!(
1549            post.len(),
1550            pre.len(),
1551            "single-mem reload must not touch sink"
1552        );
1553    }
1554
1555    /// A cross-mem edge `A→B` must survive a
1556    /// per-mem reload of the TARGET mem B. The removal cascade drops
1557    /// B's incoming mirrors (including the cross-mem one sourced from A)
1558    /// and the re-push only rebuilds edges authored by B, so without the
1559    /// reconstruction pass the edge silently vanishes from the in-memory
1560    /// index while staying intact in A's record and on disk — under-
1561    /// reporting topology until a workspace-wide reload heals it.
1562    #[test]
1563    fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
1564        let tmp = TempDir::new().unwrap();
1565        let a_dir = tmp.path().join("a");
1566        let b_dir = tmp.path().join("b");
1567        std::fs::create_dir_all(&a_dir).unwrap();
1568        std::fs::create_dir_all(&b_dir).unwrap();
1569        let a_writer = FilesystemMemWriter::new(a_dir.clone());
1570        let b_writer = FilesystemMemWriter::new(b_dir.clone());
1571        let mut engine = Engine::from_mounts(vec![
1572            (
1573                folder_mount("specs", a_dir),
1574                Box::new(a_writer) as Box<dyn MemBackend>,
1575            ),
1576            (
1577                folder_mount("memos", b_dir),
1578                Box::new(b_writer) as Box<dyn MemBackend>,
1579            ),
1580        ])
1581        .unwrap();
1582
1583        // Grant the cross-mem link specs → memos so the relate lands.
1584        let mut settings = crate::workspace::WorkspaceSettings::default();
1585        settings.cross_mem_links.insert(
1586            "specs".to_string(),
1587            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1588        );
1589        engine.set_settings(settings);
1590
1591        let (actor, client) = cli_actor();
1592        let source = engine
1593            .create_entity(
1594                empty_create_args("specs", "Source"),
1595                actor,
1596                Some(&client),
1597                None,
1598            )
1599            .unwrap();
1600        let target = engine
1601            .create_entity(
1602                empty_create_args("memos", "Target"),
1603                actor,
1604                Some(&client),
1605                None,
1606            )
1607            .unwrap();
1608        engine
1609            .relate_entity(
1610                crate::engine::RelateEntityArgs {
1611                    source: source.id.clone(),
1612                    expected_hash: Some(source.content_hash.clone()),
1613                    rel_type: "USES".to_string(),
1614                    target: target.id.clone(),
1615                    remove: false,
1616                    description: None,
1617                },
1618                actor,
1619                Some(&client),
1620                None,
1621            )
1622            .unwrap();
1623
1624        // (outgoing-present, incoming-present) for the A→B edge.
1625        let has_edge = |e: &Engine| {
1626            let out = e
1627                .store()
1628                .outgoing(&source.id)
1629                .iter()
1630                .any(|edge| edge.target == target.id);
1631            let inc = e
1632                .store()
1633                .incoming(&target.id)
1634                .iter()
1635                .any(|edge| edge.from == source.id);
1636            (out, inc)
1637        };
1638
1639        assert_eq!(
1640            has_edge(&engine),
1641            (true, true),
1642            "edge must be indexed in both directions after relate",
1643        );
1644
1645        // Per-mem reload of the TARGET mem — the bug trigger.
1646        engine.reload_one_mem("memos").unwrap();
1647        assert_eq!(
1648            has_edge(&engine),
1649            (true, true),
1650            "cross-mem edge into B must survive a per-mem reload of B",
1651        );
1652
1653        // Convergence: a workspace-wide reload yields the same incoming
1654        // adjacency for the target — no path-dependent difference.
1655        engine.reload_each_writable_mem().unwrap();
1656        assert_eq!(
1657            has_edge(&engine),
1658            (true, true),
1659            "per-mem and workspace reload converge on the same edge",
1660        );
1661
1662        // Complement: the edge stayed in the source record throughout —
1663        // the bug and the fix are about the index, not the records.
1664        assert!(
1665            engine
1666                .store()
1667                .get(&source.id)
1668                .unwrap()
1669                .relationships
1670                .iter()
1671                .any(|r| r.target == target.id),
1672            "source record must retain the relationship throughout",
1673        );
1674    }
1675
1676    /// A per-mem reload of the SOURCE
1677    /// mem leaves the cross-mem edge intact too — the source's own
1678    /// outgoing edges are rebuilt by the re-push, and the reconstruction
1679    /// pass for the OTHER mem is not needed here. Guards against a fix
1680    /// that fixates on the target case and perturbs the source case.
1681    #[test]
1682    fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
1683        let tmp = TempDir::new().unwrap();
1684        let a_dir = tmp.path().join("a");
1685        let b_dir = tmp.path().join("b");
1686        std::fs::create_dir_all(&a_dir).unwrap();
1687        std::fs::create_dir_all(&b_dir).unwrap();
1688        let a_writer = FilesystemMemWriter::new(a_dir.clone());
1689        let b_writer = FilesystemMemWriter::new(b_dir.clone());
1690        let mut engine = Engine::from_mounts(vec![
1691            (
1692                folder_mount("specs", a_dir),
1693                Box::new(a_writer) as Box<dyn MemBackend>,
1694            ),
1695            (
1696                folder_mount("memos", b_dir),
1697                Box::new(b_writer) as Box<dyn MemBackend>,
1698            ),
1699        ])
1700        .unwrap();
1701        let mut settings = crate::workspace::WorkspaceSettings::default();
1702        settings.cross_mem_links.insert(
1703            "specs".to_string(),
1704            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
1705        );
1706        engine.set_settings(settings);
1707
1708        let (actor, client) = cli_actor();
1709        let source = engine
1710            .create_entity(
1711                empty_create_args("specs", "Source"),
1712                actor,
1713                Some(&client),
1714                None,
1715            )
1716            .unwrap();
1717        let target = engine
1718            .create_entity(
1719                empty_create_args("memos", "Target"),
1720                actor,
1721                Some(&client),
1722                None,
1723            )
1724            .unwrap();
1725        engine
1726            .relate_entity(
1727                crate::engine::RelateEntityArgs {
1728                    source: source.id.clone(),
1729                    expected_hash: Some(source.content_hash.clone()),
1730                    rel_type: "USES".to_string(),
1731                    target: target.id.clone(),
1732                    remove: false,
1733                    description: None,
1734                },
1735                actor,
1736                Some(&client),
1737                None,
1738            )
1739            .unwrap();
1740
1741        engine.reload_one_mem("specs").unwrap();
1742
1743        let out = engine
1744            .store()
1745            .outgoing(&source.id)
1746            .iter()
1747            .any(|edge| edge.target == target.id);
1748        let inc = engine
1749            .store()
1750            .incoming(&target.id)
1751            .iter()
1752            .any(|edge| edge.from == source.id);
1753        assert!(
1754            out && inc,
1755            "outgoing cross-mem edge must survive a source-mem reload"
1756        );
1757    }
1758
1759    #[test]
1760    fn workspace_root_setter_round_trips() {
1761        let tmp = TempDir::new().unwrap();
1762        let mem_dir = tmp.path().to_path_buf();
1763        let writer = FilesystemMemWriter::new(mem_dir.clone());
1764        let mut engine = Engine::from_mounts(vec![(
1765            folder_mount("specs", mem_dir),
1766            Box::new(writer) as Box<dyn MemBackend>,
1767        )])
1768        .unwrap();
1769        let root = tmp.path().to_path_buf();
1770        engine.set_workspace_root(root.clone());
1771        assert_eq!(engine.workspace_root(), Some(root.as_path()));
1772    }
1773
1774    #[test]
1775    fn export_mem_folder_backend_produces_archive() {
1776        // Folder-backed mem with config + one entity. The
1777        // export_mem dispatcher routes to the folder backend's
1778        // override which produces a deterministic .memstead archive.
1779        let tmp = TempDir::new().unwrap();
1780        let mem_dir = tmp.path().join("specs");
1781        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1782        let config_body = r#"{
1783            "format": 1,
1784            "schema": "default@1.0.0",
1785            "version": "1.0.0"
1786        }"#;
1787        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
1788
1789        let writer = FilesystemMemWriter::new(mem_dir.clone());
1790        let engine = Engine::from_mounts(vec![(
1791            folder_mount("specs", mem_dir.clone()),
1792            Box::new(writer) as Box<dyn MemBackend>,
1793        )])
1794        .unwrap();
1795
1796        let archive_path = tmp.path().join("specs.mem");
1797        let result = engine.export_mem("specs", &archive_path).unwrap();
1798        assert!(archive_path.exists(), "archive must exist on disk");
1799        assert!(result.size_bytes > 0);
1800        // entity_count is 0 here (no .md files seeded); the function
1801        // still produces an archive carrying the config + schema.
1802        assert_eq!(result.entity_count, 0);
1803    }
1804
1805    #[test]
1806    fn export_mem_unknown_mem_returns_unknown_mem() {
1807        let tmp = TempDir::new().unwrap();
1808        let mem_dir = tmp.path().to_path_buf();
1809        let writer = FilesystemMemWriter::new(mem_dir.clone());
1810        let engine = Engine::from_mounts(vec![(
1811            folder_mount("specs", mem_dir),
1812            Box::new(writer) as Box<dyn MemBackend>,
1813        )])
1814        .unwrap();
1815        let output = tmp.path().join("out.mem");
1816        let err = engine.export_mem("missing", &output).unwrap_err();
1817        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
1818    }
1819
1820    #[test]
1821    fn export_mem_missing_config_returns_invalid_input() {
1822        // Folder mount with no .memstead/config.json — `mem_config_for`
1823        // returns None and `export_mem` surfaces InvalidInput
1824        // rather than reaching the backend.
1825        let tmp = TempDir::new().unwrap();
1826        let mem_dir = tmp.path().to_path_buf();
1827        let writer = FilesystemMemWriter::new(mem_dir.clone());
1828        let engine = Engine::from_mounts(vec![(
1829            folder_mount("specs", mem_dir),
1830            Box::new(writer) as Box<dyn MemBackend>,
1831        )])
1832        .unwrap();
1833        let output = tmp.path().join("out.mem");
1834        let err = engine.export_mem("specs", &output).unwrap_err();
1835        assert!(matches!(err, EngineError::InvalidInput(_)));
1836    }
1837
1838    #[test]
1839    fn export_mem_archive_backend_returns_sealed() {
1840        // Archive backends are already-an-archive — re-export is
1841        // intentionally rejected via BackendError::Sealed.
1842        let tmp = TempDir::new().unwrap();
1843        let archive_path = build_archive(
1844            tmp.path(),
1845            "ext",
1846            &[(
1847                ".memstead/config.json",
1848                b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
1849            )],
1850        );
1851        let engine = Engine::from_mounts(vec![(
1852            archive_mount("ext", archive_path.clone()),
1853            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1854        )])
1855        .unwrap();
1856        let output = tmp.path().join("out.mem");
1857        let err = engine.export_mem("ext", &output).unwrap_err();
1858        assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
1859    }
1860
1861    #[test]
1862    fn export_markdown_writes_unchanged_files_zero_writes() {
1863        // Seed a folder-backed mem with one entity, then call
1864        // export_markdown. The entity's file already matches the
1865        // generated content (engine wrote it via create_entity), so
1866        // export reports `unchanged: 1, written: 0`.
1867        let tmp = TempDir::new().unwrap();
1868        let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
1869        let result = engine.export_markdown(None, None).unwrap();
1870        assert_eq!(
1871            result.written, 0,
1872            "freshly-created entity's file already matches generated markdown"
1873        );
1874        assert_eq!(
1875            result.unchanged, 1,
1876            "the one seeded entity counts as unchanged"
1877        );
1878        assert!(
1879            result.skipped_mounts.is_empty(),
1880            "folder-only workspace has no skipped mounts"
1881        );
1882    }
1883
1884    #[test]
1885    fn export_markdown_skips_non_folder_mounts() {
1886        // Archive-mounted mem has no working tree — workspace-wide
1887        // export records it under skipped_mounts and reports zero
1888        // writes / zero unchanged for the rest.
1889        let tmp = TempDir::new().unwrap();
1890        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
1891        let engine = Engine::from_mounts(vec![(
1892            archive_mount("ext", archive_path.clone()),
1893            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1894        )])
1895        .unwrap();
1896        let result = engine.export_markdown(None, None).unwrap();
1897        assert_eq!(result.written, 0);
1898        assert_eq!(result.unchanged, 0);
1899        assert_eq!(
1900            result.skipped_mounts.len(),
1901            1,
1902            "archive mount is in the skipped list"
1903        );
1904        let entry = &result.skipped_mounts[0];
1905        assert_eq!(entry.mem, "ext");
1906        assert_eq!(entry.active_backend, "archive");
1907        assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
1908    }
1909
1910    #[test]
1911    fn export_markdown_per_mem_refuses_on_incompatible_backend() {
1912        // Per-mem export against an archive-backed mem returns
1913        // the typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` refusal
1914        // naming the active backend and the supported-backend list.
1915        let tmp = TempDir::new().unwrap();
1916        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
1917        let engine = Engine::from_mounts(vec![(
1918            archive_mount("ext", archive_path.clone()),
1919            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1920        )])
1921        .unwrap();
1922        let err = engine.export_markdown(Some("ext"), None).unwrap_err();
1923        assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
1924        let details = err.details();
1925        assert_eq!(details["mem"], "ext");
1926        assert_eq!(details["active_backend"], "archive");
1927        assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
1928    }
1929
1930    #[test]
1931    fn register_writable_mem_adds_mount_and_router_entry() {
1932        // Start with one mem; register a second at runtime. Both
1933        // should be visible afterwards.
1934        let tmp = TempDir::new().unwrap();
1935        let mem_a = tmp.path().join("a");
1936        std::fs::create_dir_all(&mem_a).unwrap();
1937        let writer_a = FilesystemMemWriter::new(mem_a.clone());
1938
1939        let mut engine = Engine::from_mounts(vec![(
1940            folder_mount("alpha", mem_a),
1941            Box::new(writer_a) as Box<dyn MemBackend>,
1942        )])
1943        .unwrap();
1944        assert!(engine.mem_router().is_writable("alpha"));
1945
1946        let mem_b = tmp.path().join("b");
1947        std::fs::create_dir_all(&mem_b).unwrap();
1948        let writer_b = FilesystemMemWriter::new(mem_b.clone());
1949
1950        engine
1951            .register_writable_mem(
1952                folder_mount("beta", mem_b.clone()),
1953                Box::new(writer_b) as Box<dyn MemBackend>,
1954                MemOrigin::ExplicitToml,
1955            )
1956            .unwrap();
1957
1958        // Both mems are now writable + visible.
1959        assert!(engine.mem_router().is_writable("alpha"));
1960        assert!(engine.mem_router().is_writable("beta"));
1961        assert!(engine.mem_router().is_visible("beta"));
1962
1963        // Mount + schema lookups resolve.
1964        assert!(engine.mount("beta").is_some());
1965        assert!(engine.schemas().contains_key("beta"));
1966
1967        // Folder path surfaces via mem_router.
1968        assert_eq!(
1969            engine.mem_router().dir_for_mem("beta"),
1970            Some(mem_b.as_path()),
1971        );
1972    }
1973
1974    /// Schema-pin authority on the runtime-register path (symmetric with
1975    /// the boot path): a mem registered at runtime resolves its schema
1976    /// from its own config (`software@0.1.0`) even though the mount
1977    /// expects an unresolvable pin — register succeeds, and the
1978    /// disagreement surfaces a `SchemaPinMismatch` warning.
1979    #[test]
1980    fn register_writable_mem_resolves_schema_from_mem_config() {
1981        let tmp = TempDir::new().unwrap();
1982        let mem_a = tmp.path().join("a");
1983        std::fs::create_dir_all(&mem_a).unwrap();
1984        let mut engine = Engine::from_mounts(vec![(
1985            folder_mount("alpha", mem_a.clone()),
1986            Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
1987        )])
1988        .unwrap();
1989
1990        let mem_b = tmp.path().join("b");
1991        std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
1992        std::fs::write(
1993            mem_b.join(".memstead").join("config.json"),
1994            r#"{"schema":"software@0.1.0"}"#,
1995        )
1996        .unwrap();
1997        let mount_b = crate::workspace::Mount {
1998            mem: "beta".to_string(),
1999            schema: Some(memstead_schema::SchemaRef::new(
2000                "totally-not-a-schema",
2001                semver::Version::new(9, 9, 9),
2002            )),
2003            storage: crate::workspace::MountStorage::Folder {
2004                path: mem_b.clone(),
2005            },
2006            capability: crate::workspace::MountCapability::Write,
2007            lifecycle: crate::workspace::MountLifecycle::Eager,
2008            cross_linkable: true,
2009            migration_target: None,
2010        };
2011        engine
2012            .register_writable_mem(
2013                mount_b,
2014                Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
2015                MemOrigin::ExplicitToml,
2016            )
2017            .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
2018
2019        assert!(engine.schemas().contains_key("beta"));
2020        let surfaced = engine.load_warnings().iter().any(|w| {
2021            matches!(
2022                w,
2023                WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
2024                    if mem == "beta"
2025                        && config_pin == "software@0.1.0"
2026                        && mount_pin == "totally-not-a-schema@9.9.9"
2027            )
2028        });
2029        assert!(
2030            surfaced,
2031            "SchemaPinMismatch must surface for beta: {:?}",
2032            engine.load_warnings(),
2033        );
2034    }
2035
2036    #[test]
2037    fn register_writable_mem_rejects_existing_name() {
2038        // Re-registering an already-writable mem must fail with
2039        // MemNameCollision and not mutate the engine.
2040        let tmp = TempDir::new().unwrap();
2041        let mem_a = tmp.path().join("a");
2042        std::fs::create_dir_all(&mem_a).unwrap();
2043        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2044
2045        let mut engine = Engine::from_mounts(vec![(
2046            folder_mount("alpha", mem_a),
2047            Box::new(writer_a) as Box<dyn MemBackend>,
2048        )])
2049        .unwrap();
2050        let mount_count_pre = engine.mounts().len();
2051
2052        let mem_collide = tmp.path().join("alpha-2");
2053        std::fs::create_dir_all(&mem_collide).unwrap();
2054        let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
2055
2056        let err = engine
2057            .register_writable_mem(
2058                folder_mount("alpha", mem_collide),
2059                Box::new(writer_collide) as Box<dyn MemBackend>,
2060                MemOrigin::ExplicitToml,
2061            )
2062            .unwrap_err();
2063        match err {
2064            EngineError::MemNameCollision {
2065                name,
2066                source_origin,
2067            } => {
2068                assert_eq!(name, "alpha");
2069                // post-restructure source_origin references
2070                // `.memstead/workspace.toml`; the assertion stays
2071                // permissive (substring OR non-empty) so the test
2072                // doesn't lock the exact wording.
2073                assert!(
2074                    source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
2075                );
2076            }
2077            other => panic!("expected MemNameCollision, got {other:?}"),
2078        }
2079
2080        // Engine state unchanged.
2081        assert_eq!(engine.mounts().len(), mount_count_pre);
2082    }
2083
2084    #[test]
2085    fn register_writable_mem_loads_entities_into_store() {
2086        // The newly-registered mem's entities should surface in
2087        // the engine's store after registration.
2088        let tmp = TempDir::new().unwrap();
2089        let mem_a = tmp.path().join("a");
2090        std::fs::create_dir_all(&mem_a).unwrap();
2091        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2092
2093        let mut engine = Engine::from_mounts(vec![(
2094            folder_mount("alpha", mem_a),
2095            Box::new(writer_a) as Box<dyn MemBackend>,
2096        )])
2097        .unwrap();
2098        let pre_count = engine.store().all_entities().count();
2099
2100        // Build mem_b with a markdown entity on disk.
2101        let mem_b = tmp.path().join("b");
2102        std::fs::create_dir_all(&mem_b).unwrap();
2103        std::fs::write(
2104            mem_b.join("b1.md"),
2105            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2106        )
2107        .unwrap();
2108        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2109
2110        engine
2111            .register_writable_mem(
2112                folder_mount("beta", mem_b),
2113                Box::new(writer_b) as Box<dyn MemBackend>,
2114                MemOrigin::ExplicitToml,
2115            )
2116            .unwrap();
2117
2118        let post_count = engine.store().all_entities().count();
2119        assert!(post_count > pre_count, "register must load entities");
2120        let beta_count = engine
2121            .store()
2122            .all_entities()
2123            .filter(|e| e.mem == "beta")
2124            .count();
2125        assert_eq!(beta_count, 1);
2126    }
2127
2128    #[test]
2129    fn register_then_unregister_round_trips() {
2130        // End-to-end check: register a mem, then unregister it,
2131        // and confirm the engine returns to the pre-registration
2132        // state.
2133        let tmp = TempDir::new().unwrap();
2134        let mem_a = tmp.path().join("a");
2135        std::fs::create_dir_all(&mem_a).unwrap();
2136        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2137
2138        let mut engine = Engine::from_mounts(vec![(
2139            folder_mount("alpha", mem_a),
2140            Box::new(writer_a) as Box<dyn MemBackend>,
2141        )])
2142        .unwrap();
2143        let pre_mounts = engine.mounts().len();
2144
2145        let mem_b = tmp.path().join("b");
2146        std::fs::create_dir_all(&mem_b).unwrap();
2147        let writer_b = FilesystemMemWriter::new(mem_b);
2148
2149        engine
2150            .register_writable_mem(
2151                folder_mount("beta", tmp.path().join("b")),
2152                Box::new(writer_b) as Box<dyn MemBackend>,
2153                MemOrigin::ExplicitToml,
2154            )
2155            .unwrap();
2156        assert_eq!(engine.mounts().len(), pre_mounts + 1);
2157
2158        let removed = engine.unregister_writable_mem("beta").unwrap();
2159        assert!(removed.is_some());
2160        assert_eq!(engine.mounts().len(), pre_mounts);
2161        assert!(!engine.mem_router().is_writable("beta"));
2162    }
2163
2164    #[test]
2165    fn unregister_writable_mem_returns_false_for_unknown_name() {
2166        // Idempotent contract: repeated calls / unknown names are
2167        // not errors — return false so callers can branch without
2168        // a typed error envelope for the common "already gone" case.
2169        let tmp = TempDir::new().unwrap();
2170        let mem_dir = tmp.path().to_path_buf();
2171        let writer = FilesystemMemWriter::new(mem_dir.clone());
2172        let mut engine = Engine::from_mounts(vec![(
2173            folder_mount("specs", mem_dir),
2174            Box::new(writer) as Box<dyn MemBackend>,
2175        )])
2176        .unwrap();
2177        let removed = engine.unregister_writable_mem("missing").unwrap();
2178        assert!(removed.is_none(), "unknown mem returns Ok(None)");
2179        // The original mem is still present and readable.
2180        assert!(engine.mem_router().is_writable("specs"));
2181    }
2182
2183    #[test]
2184    fn unregister_writable_mem_drops_mount_and_router_entry() {
2185        // Heterogeneous engine: two mounts. Unregister one and
2186        // assert (a) it's gone from the mount list, (b) gone from
2187        // the mem_router's writable set, (c) the OTHER mount is
2188        // untouched.
2189        let tmp = TempDir::new().unwrap();
2190        let mem_a = tmp.path().join("a");
2191        std::fs::create_dir_all(&mem_a).unwrap();
2192        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2193        let mem_b = tmp.path().join("b");
2194        std::fs::create_dir_all(&mem_b).unwrap();
2195        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2196
2197        let mut engine = Engine::from_mounts(vec![
2198            (
2199                folder_mount("alpha", mem_a),
2200                Box::new(writer_a) as Box<dyn MemBackend>,
2201            ),
2202            (
2203                folder_mount("beta", mem_b),
2204                Box::new(writer_b) as Box<dyn MemBackend>,
2205            ),
2206        ])
2207        .unwrap();
2208
2209        let removed = engine.unregister_writable_mem("alpha").unwrap();
2210        assert!(removed.is_some());
2211
2212        // alpha is gone from every surface.
2213        assert!(!engine.mem_router().is_writable("alpha"));
2214        assert!(!engine.mem_router().is_visible("alpha"));
2215        assert!(engine.mount("alpha").is_none());
2216
2217        // beta survives unchanged.
2218        assert!(engine.mem_router().is_writable("beta"));
2219        assert!(engine.mount("beta").is_some());
2220    }
2221
2222    #[test]
2223    fn unregister_writable_mem_drops_entities_for_that_mem_only() {
2224        // Build an engine with two mems, write one entity to each
2225        // backend, build the engine (loads both), unregister one,
2226        // assert the store still has the other mem's entity.
2227        let tmp = TempDir::new().unwrap();
2228        let mem_a = tmp.path().join("a");
2229        std::fs::create_dir_all(&mem_a).unwrap();
2230        std::fs::write(
2231            mem_a.join("a1.md"),
2232            "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
2233        )
2234        .unwrap();
2235        let writer_a = FilesystemMemWriter::new(mem_a.clone());
2236
2237        let mem_b = tmp.path().join("b");
2238        std::fs::create_dir_all(&mem_b).unwrap();
2239        std::fs::write(
2240            mem_b.join("b1.md"),
2241            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
2242        )
2243        .unwrap();
2244        let writer_b = FilesystemMemWriter::new(mem_b.clone());
2245
2246        let mut engine = Engine::from_mounts(vec![
2247            (
2248                folder_mount("alpha", mem_a),
2249                Box::new(writer_a) as Box<dyn MemBackend>,
2250            ),
2251            (
2252                folder_mount("beta", mem_b),
2253                Box::new(writer_b) as Box<dyn MemBackend>,
2254            ),
2255        ])
2256        .unwrap();
2257
2258        let pre_total = engine.store().all_entities().count();
2259        assert!(pre_total >= 2, "both mems must load entities");
2260
2261        engine.unregister_writable_mem("alpha").unwrap();
2262
2263        // alpha's entities are gone.
2264        let alpha_remaining = engine
2265            .store()
2266            .all_entities()
2267            .filter(|e| e.mem == "alpha")
2268            .count();
2269        assert_eq!(alpha_remaining, 0);
2270
2271        // beta's entities survive.
2272        let beta_remaining = engine
2273            .store()
2274            .all_entities()
2275            .filter(|e| e.mem == "beta")
2276            .count();
2277        assert!(beta_remaining > 0, "beta entities must survive");
2278    }
2279    #[test]
2280    fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
2281        let tmp = TempDir::new().unwrap();
2282        let mut engine = build_demo_engine(&tmp);
2283        let result = engine
2284            .reload_one_mem("specs")
2285            .expect("reload on stable disk must succeed");
2286        assert!(result.added.is_empty(), "added: {:?}", result.added);
2287        assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
2288        assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
2289    }
2290
2291    #[test]
2292    fn reload_one_mem_picks_up_external_addition() {
2293        let tmp = TempDir::new().unwrap();
2294        let mut engine = build_demo_engine(&tmp);
2295        // Simulate an external writer dropping a new entity on disk
2296        // without going through the engine.
2297        std::fs::write(
2298            tmp.path().join("external.md"),
2299            "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
2300        )
2301        .unwrap();
2302        let result = engine.reload_one_mem("specs").unwrap();
2303        assert_eq!(
2304            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2305            vec!["specs--external"]
2306        );
2307        assert!(result.changed.is_empty());
2308        assert!(result.removed.is_empty());
2309        // The new entity is now reachable through the engine.
2310        assert!(
2311            engine
2312                .get_entity(&crate::EntityId::new("specs", "external"))
2313                .is_some()
2314        );
2315    }
2316
2317    #[test]
2318    fn reload_one_mem_picks_up_external_removal() {
2319        let tmp = TempDir::new().unwrap();
2320        let mut engine = build_demo_engine(&tmp);
2321        // Lonely Three exists from the demo fixture; remove it
2322        // off-engine and reload.
2323        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2324        let result = engine.reload_one_mem("specs").unwrap();
2325        assert!(result.added.is_empty());
2326        assert!(result.changed.is_empty());
2327        assert_eq!(
2328            result
2329                .removed
2330                .iter()
2331                .map(|i| i.as_ref())
2332                .collect::<Vec<_>>(),
2333            vec!["specs--lonely-three"]
2334        );
2335    }
2336
2337    #[test]
2338    fn reload_one_mem_picks_up_external_change() {
2339        let tmp = TempDir::new().unwrap();
2340        let mut engine = build_demo_engine(&tmp);
2341        // Overwrite an existing entity's content; the new
2342        // `content_hash` must surface in the `changed` diff.
2343        std::fs::write(
2344            tmp.path().join("source-one.md"),
2345            "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
2346        )
2347        .unwrap();
2348        let result = engine.reload_one_mem("specs").unwrap();
2349        assert!(result.added.is_empty());
2350        assert_eq!(
2351            result
2352                .changed
2353                .iter()
2354                .map(|i| i.as_ref())
2355                .collect::<Vec<_>>(),
2356            vec!["specs--source-one"]
2357        );
2358        assert!(result.removed.is_empty());
2359    }
2360
2361    #[test]
2362    fn reload_one_mem_rejects_unknown_mem() {
2363        let tmp = TempDir::new().unwrap();
2364        let mut engine = build_demo_engine(&tmp);
2365        let err = engine.reload_one_mem("nope").unwrap_err();
2366        match err {
2367            EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
2368            other => panic!("expected UnknownMem, got {other:?}"),
2369        }
2370    }
2371
2372    #[test]
2373    fn reload_each_writable_mem_returns_one_entry_per_mount() {
2374        let tmp = TempDir::new().unwrap();
2375        let mut engine = build_demo_engine(&tmp);
2376        let reports = engine
2377            .reload_each_writable_mem()
2378            .expect("batch reload on stable disk must succeed");
2379        assert_eq!(reports.len(), 1);
2380        assert_eq!(reports[0].0, "specs");
2381        assert!(reports[0].1.added.is_empty());
2382        assert!(reports[0].1.changed.is_empty());
2383        assert!(reports[0].1.removed.is_empty());
2384    }
2385
2386    // ---- Engine::settings -------------------------------------------
2387
2388    #[test]
2389    fn settings_default_to_empty_on_fresh_engine() {
2390        let tmp = TempDir::new().unwrap();
2391        let engine = build_demo_engine(&tmp);
2392        let s = engine.settings();
2393        assert!(s.mem_create_rules.is_empty());
2394        assert!(s.mem_delete_rules.is_empty());
2395        assert!(s.cross_mem_links.is_empty());
2396    }
2397
2398    #[test]
2399    fn set_settings_replaces_workspace_policy() {
2400        use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
2401        let tmp = TempDir::new().unwrap();
2402        let mut engine = build_demo_engine(&tmp);
2403        let mut settings = WorkspaceSettings::default();
2404        settings.mem_create_rules.push(CreateRuleSetting {
2405            pattern: "exec-*".to_string(),
2406            schemas: vec!["default@1.0.0".to_string()],
2407            default_cross_links: None,
2408        });
2409        settings.mem_delete_rules.push(DeleteRuleSetting {
2410            pattern: "exec-*".to_string(),
2411        });
2412        engine.set_settings(settings);
2413        assert_eq!(engine.settings().mem_create_rules.len(), 1);
2414        assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
2415        assert_eq!(engine.settings().mem_delete_rules.len(), 1);
2416        assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
2417    }
2418
2419    // ---- Engine::reload_each_writable_mem (continued) -------------
2420
2421    #[test]
2422    fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
2423        let tmp = TempDir::new().unwrap();
2424        let mut engine = build_demo_engine(&tmp);
2425        // Mutate disk: add one entity, remove another, change a third.
2426        std::fs::write(
2427            tmp.path().join("new-via-disk.md"),
2428            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2429        )
2430        .unwrap();
2431        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2432        std::fs::write(
2433            tmp.path().join("source-one.md"),
2434            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2435        )
2436        .unwrap();
2437
2438        let reports = engine.reload_each_writable_mem().unwrap();
2439        assert_eq!(reports.len(), 1);
2440        let (mem, result) = &reports[0];
2441        assert_eq!(mem, "specs");
2442        assert_eq!(
2443            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
2444            vec!["specs--new-via-disk"]
2445        );
2446        assert_eq!(
2447            result
2448                .removed
2449                .iter()
2450                .map(|i| i.as_ref())
2451                .collect::<Vec<_>>(),
2452            vec!["specs--lonely-three"]
2453        );
2454        assert_eq!(
2455            result
2456                .changed
2457                .iter()
2458                .map(|i| i.as_ref())
2459                .collect::<Vec<_>>(),
2460            vec!["specs--source-one"]
2461        );
2462    }
2463
2464    // ---- Engine::reload_one_mem_report (rich-shape wrapper) -------
2465
2466    #[test]
2467    fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
2468        // Folder backend has no current_head (Ok(None)); the wrapper
2469        // falls back to EMPTY_TREE_SHA for both head_before and
2470        // head_after. entities_loaded reflects the post-reload count;
2471        // changed_entity_ids is empty when the disk is unchanged.
2472        let tmp = TempDir::new().unwrap();
2473        let mut engine = build_demo_engine(&tmp);
2474        let report = engine.reload_one_mem_report("specs").unwrap();
2475        assert_eq!(report.mem, "specs");
2476        assert_eq!(report.head_before, crate::ops::EMPTY_TREE_SHA);
2477        assert_eq!(report.head_after, crate::ops::EMPTY_TREE_SHA);
2478        // build_demo_engine seeds 3 entities (Source One, Target Two,
2479        // Lonely Three) — all real, no stubs from those creates.
2480        assert_eq!(report.entities_loaded, 3);
2481        // No external disk changes between init and reload → empty diff.
2482        assert!(report.changed_entity_ids.is_empty());
2483    }
2484
2485    #[test]
2486    fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
2487        // Mutate disk: add one, remove one, change one. The report's
2488        // changed_entity_ids unions the slim ReloadResult's three
2489        // diff lists into a single sorted vec — matches full's
2490        // wire contract.
2491        let tmp = TempDir::new().unwrap();
2492        let mut engine = build_demo_engine(&tmp);
2493        std::fs::write(
2494            tmp.path().join("new-via-disk.md"),
2495            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
2496        )
2497        .unwrap();
2498        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
2499        std::fs::write(
2500            tmp.path().join("source-one.md"),
2501            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
2502        )
2503        .unwrap();
2504
2505        let report = engine.reload_one_mem_report("specs").unwrap();
2506        assert_eq!(report.mem, "specs");
2507        let ids: Vec<&str> = report
2508            .changed_entity_ids
2509            .iter()
2510            .map(|id| id.as_ref())
2511            .collect();
2512        // Sorted lexicographically: lonely-three < new-via-disk < source-one
2513        assert_eq!(
2514            ids,
2515            vec![
2516                "specs--lonely-three",
2517                "specs--new-via-disk",
2518                "specs--source-one",
2519            ]
2520        );
2521    }
2522
2523    #[test]
2524    fn reload_one_mem_report_rejects_unknown_mem() {
2525        let tmp = TempDir::new().unwrap();
2526        let mut engine = build_demo_engine(&tmp);
2527        let err = engine.reload_one_mem_report("missing").unwrap_err();
2528        assert!(matches!(err, EngineError::UnknownMem(_)));
2529    }
2530
2531    #[test]
2532    fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
2533        let tmp = TempDir::new().unwrap();
2534        let mut engine = build_demo_engine(&tmp);
2535        let reports = engine.reload_each_writable_mem_reports().unwrap();
2536        assert_eq!(reports.len(), 1);
2537        assert_eq!(reports[0].mem, "specs");
2538        assert_eq!(reports[0].entities_loaded, 3);
2539    }
2540
2541    /// Workspace-wide reload re-reads `.memstead/workspace.toml` and
2542    /// refreshes [`WorkspaceSettings`]. This is the pairing with the
2543    /// CLI's `memstead workspace allow-create / grant-cross-link /
2544    /// set-mutations` family — without it, a CLI write lands on disk
2545    /// but the running engine keeps serving the boot-time policy
2546    /// snapshot until process restart.
2547    #[test]
2548    fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
2549        let tmp = TempDir::new().unwrap();
2550
2551        // Minimum-viable workspace.toml (no rules) + one writable
2552        // folder-backed mem.
2553        let memstead_dir = tmp.path().join(".memstead");
2554        std::fs::create_dir_all(&memstead_dir).unwrap();
2555        let workspace_toml = memstead_dir.join("workspace.toml");
2556        std::fs::write(
2557            &workspace_toml,
2558            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2559        )
2560        .unwrap();
2561        let mounts_json = memstead_dir.join("state").join("mounts.json");
2562        std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
2563        let mem_dir = tmp.path().join("specs");
2564        std::fs::create_dir_all(&mem_dir).unwrap();
2565        let mounts_body = format!(
2566            r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
2567            mem_dir.display(),
2568        );
2569        std::fs::write(&mounts_json, mounts_body).unwrap();
2570
2571        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2572        assert!(
2573            engine.settings().mem_create_rules.is_empty(),
2574            "boot-time settings carry no create rules"
2575        );
2576
2577        // Simulate an out-of-band CLI write to workspace.toml.
2578        std::fs::write(
2579            &workspace_toml,
2580            "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",
2581        )
2582        .unwrap();
2583
2584        engine.reload_each_writable_mem_reports().unwrap();
2585
2586        let rules = &engine.settings().mem_create_rules;
2587        assert_eq!(
2588            rules.len(),
2589            1,
2590            "workspace-wide reload must refresh the policy"
2591        );
2592        assert_eq!(rules[0].pattern, "exec-*");
2593    }
2594
2595    // ---- Engine::reload_if_stale ------------------------------
2596
2597    // ---- set_mem_schema / dual-pin migration ----
2598
2599    const MIG_TYPE_TAIL: &str = r#"sections:
2600  - key: body
2601    heading: Body
2602    required: true
2603    search_weight: 10.0
2604    catch_all: true
2605    write_rules: []
2606title_weight: 100.0
2607text_fields:
2608  - body
2609hierarchy_relationship: _default
2610propagating_relationships: []
2611updatable_fields: []
2612health_required_fields: []
2613staleness_threshold_days: 90
2614write_rules: []
2615"#;
2616
2617    /// Schema manifest for the migration tests: `name@version` with a
2618    /// `doc` type. `with_status = true` adds a required, no-default
2619    /// enum field `status` — entities created without it are
2620    /// non-conformant against that schema.
2621    fn mig_manifest(name: &str, version: &str) -> String {
2622        format!(
2623            r#"name: {name}
2624version: {version}
2625description: migration test schema
2626when_to_use: tests
2627types:
2628  - doc
2629relationships:
2630  mode: strict
2631  definitions:
2632    - name: USES
2633      description: link
2634      default_weight: 1.0
2635    - name: _default
2636      description: fallback
2637      default_weight: 1.0
2638community:
2639  resolution: 1.0
2640  seed: 42
2641"#
2642        )
2643    }
2644
2645    fn mig_type_yaml(with_status: bool) -> String {
2646        let metadata = if with_status {
2647            "metadata_fields:\n  - key: status\n    description: Lifecycle state\n    field_type: string\n    enum_values:\n      - open\n      - closed\n"
2648        } else {
2649            "metadata_fields: []\n"
2650        };
2651        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
2652    }
2653
2654    fn write_mig_schema(
2655        root: &std::path::Path,
2656        dir: &str,
2657        name: &str,
2658        version: &str,
2659        with_status: bool,
2660    ) {
2661        let d = root.join(dir);
2662        std::fs::create_dir_all(d.join("types")).unwrap();
2663        std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
2664        std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
2665    }
2666
2667    /// Engine with one mem pinned `mig-a@0.1.0` (no required
2668    /// metadata) plus loadable `mig-a@0.2.0` (identical shape) and
2669    /// `mig-b@0.1.0` (required enum `status`) in the workspace
2670    /// schemas dir. Two conformant-under-A entities are created.
2671    fn migration_engine() -> (tempfile::TempDir, Engine) {
2672        let tmp = tempfile::TempDir::new().unwrap();
2673        let schemas_dir = tmp.path().join("schemas");
2674        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2675        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2676        write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
2677        let mem_dir = tmp.path().join("mem");
2678        std::fs::create_dir_all(&mem_dir).unwrap();
2679        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2680        let mut mount = folder_mount("specs", mem_dir);
2681        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2682        let mut engine = Engine::from_mounts_with_schemas_dir(
2683            vec![(
2684                mount,
2685                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2686            )],
2687            Some(&schemas_dir),
2688        )
2689        .unwrap();
2690        for title in ["One", "Two"] {
2691            let mut args = empty_create_args("specs", title);
2692            args.entity_type = "doc".to_string();
2693            args.sections =
2694                indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
2695            engine
2696                .create_entity(args, crate::vcs::Actor::Cli, None, None)
2697                .expect("conformant create under mig-a");
2698        }
2699        (tmp, engine)
2700    }
2701
2702    fn sref(s: &str) -> memstead_schema::SchemaRef {
2703        s.parse().unwrap()
2704    }
2705
2706    #[test]
2707    fn set_schema_noop_on_current_pin() {
2708        let (_tmp, mut engine) = migration_engine();
2709        let out = engine
2710            .set_mem_schema("specs", &sref("mig-a@0.1.0"))
2711            .unwrap();
2712        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
2713        assert_eq!(out.schema_pin, "mig-a@0.1.0");
2714        assert_eq!(out.migration_target, None);
2715        assert!(out.findings.is_empty());
2716    }
2717
2718    #[test]
2719    fn set_schema_switches_immediately_when_integral() {
2720        // Version bump within the same domain; entities conform to
2721        // the identical-shape 0.2.0, so the switch is immediate.
2722        let (_tmp, mut engine) = migration_engine();
2723        let out = engine
2724            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2725            .unwrap();
2726        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2727        assert_eq!(out.schema_pin, "mig-a@0.2.0");
2728        assert_eq!(out.migration_target, None);
2729        assert!(out.findings.is_empty());
2730        assert_eq!(
2731            engine.schema_pin("specs").unwrap().as_display(),
2732            "mig-a@0.2.0"
2733        );
2734        assert!(engine.migration_target("specs").is_none());
2735    }
2736
2737    /// Regression: an atomic switch must persist the new pin into the
2738    /// **authoritative** backend config, not just `mounts.json`. Boot
2739    /// resolution prefers the backend config's pin over `Mount.schema`,
2740    /// so before this fix the switch evaporated on the next process boot
2741    /// for any config-present mem (every `create_mem`-made mem).
2742    #[test]
2743    fn set_schema_switch_persists_pin_into_backend_config() {
2744        let tmp = tempfile::TempDir::new().unwrap();
2745        let schemas_dir = tmp.path().join("schemas");
2746        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
2747        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
2748        let mem_dir = tmp.path().join("mem");
2749        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2750        // Config-present mem: the authoritative pin lives here.
2751        std::fs::write(
2752            mem_dir.join(".memstead").join("config.json"),
2753            br#"{"schema":"mig-a@0.1.0"}"#,
2754        )
2755        .unwrap();
2756        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2757        let mut mount = folder_mount("specs", mem_dir.clone());
2758        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2759        let mut engine = Engine::from_mounts_with_schemas_dir(
2760            vec![(
2761                mount,
2762                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2763            )],
2764            Some(&schemas_dir),
2765        )
2766        .unwrap();
2767
2768        let out = engine
2769            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
2770            .unwrap();
2771        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2772
2773        // The authoritative backend config now carries the new pin —
2774        // otherwise the switch would evaporate on reboot.
2775        let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
2776        let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
2777        assert_eq!(
2778            cfg["schema"], "mig-a@0.2.0",
2779            "atomic switch must update the authoritative backend config"
2780        );
2781    }
2782
2783    #[test]
2784    fn set_schema_unknown_target_refuses_schema_not_found() {
2785        let (_tmp, mut engine) = migration_engine();
2786        let err = engine
2787            .set_mem_schema("specs", &sref("nope@9.9.9"))
2788            .unwrap_err();
2789        assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
2790        // No state change.
2791        assert!(engine.migration_target("specs").is_none());
2792    }
2793
2794    #[test]
2795    fn set_schema_migration_lifecycle_end_to_end() {
2796        let (_tmp, mut engine) = migration_engine();
2797        let target = sref("mig-b@0.1.0");
2798
2799        // 1. Non-integral target → migration starts; pin unchanged.
2800        let out = engine.set_mem_schema("specs", &target).unwrap();
2801        assert_eq!(
2802            out.outcome,
2803            crate::engine::SetSchemaResult::MigrationStarted
2804        );
2805        assert_eq!(out.schema_pin, "mig-a@0.1.0");
2806        assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
2807        assert_eq!(out.findings.len(), 2, "both entities lack `status`");
2808        assert!(
2809            out.findings
2810                .iter()
2811                .all(|f| f.code == "REQUIRED_FIELD_UNSET")
2812        );
2813
2814        // 2. Reads of not-yet-repaired entities stay permissive.
2815        let one = crate::entity::EntityId::new("specs", "one");
2816        assert!(engine.store().get(&one).is_some());
2817
2818        // 3. Re-issue while unrepaired → pending, full remaining set.
2819        let out = engine.set_mem_schema("specs", &target).unwrap();
2820        assert_eq!(
2821            out.outcome,
2822            crate::engine::SetSchemaResult::MigrationPending
2823        );
2824        assert_eq!(out.findings.len(), 2);
2825
2826        // 4. Writes validate against the TARGET: `status` is unknown
2827        //    to the pinned mig-a but declared by mig-b — setting it
2828        //    must commit; an invalid enum value must refuse.
2829        let mut bad = crate::engine::UpdateEntityArgs {
2830            id: one.clone(),
2831            expected_hash: None,
2832            sections: indexmap::IndexMap::new(),
2833            append_sections: indexmap::IndexMap::new(),
2834            patch_sections: indexmap::IndexMap::new(),
2835            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
2836            metadata_unset: Vec::new(),
2837            declare_relations: Vec::new(),
2838            dry_run: false,
2839            relations_unset: Vec::new(),
2840        };
2841        let err = engine
2842            .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
2843            .unwrap_err();
2844        assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
2845        bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
2846        engine
2847            .update_entity(bad, crate::vcs::Actor::Cli, None, None)
2848            .expect("repair write validated against the migration target");
2849
2850        // 5. One entity repaired → still pending, findings shrink.
2851        let out = engine.set_mem_schema("specs", &target).unwrap();
2852        assert_eq!(
2853            out.outcome,
2854            crate::engine::SetSchemaResult::MigrationPending
2855        );
2856        assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
2857
2858        // 6. Repair the second entity, re-issue → atomic switch.
2859        let two = crate::entity::EntityId::new("specs", "two");
2860        let repair = crate::engine::UpdateEntityArgs {
2861            id: two.clone(),
2862            expected_hash: None,
2863            sections: indexmap::IndexMap::new(),
2864            append_sections: indexmap::IndexMap::new(),
2865            patch_sections: indexmap::IndexMap::new(),
2866            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
2867            metadata_unset: Vec::new(),
2868            declare_relations: Vec::new(),
2869            dry_run: false,
2870            relations_unset: Vec::new(),
2871        };
2872        engine
2873            .update_entity(repair, crate::vcs::Actor::Cli, None, None)
2874            .unwrap();
2875        let out = engine.set_mem_schema("specs", &target).unwrap();
2876        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
2877        assert_eq!(out.schema_pin, "mig-b@0.1.0");
2878        assert_eq!(out.migration_target, None);
2879        assert!(out.findings.is_empty());
2880        assert_eq!(
2881            engine.schema_pin("specs").unwrap().as_display(),
2882            "mig-b@0.1.0"
2883        );
2884        assert!(engine.migration_target("specs").is_none());
2885    }
2886
2887    /// During migration every not-yet-repaired entity is
2888    /// non-conformant against the target, so `relations_unset` works
2889    /// on exactly those entities with no mode flag — and the same
2890    /// update can complete the entity's repair.
2891    #[test]
2892    fn relations_unset_works_during_migration_without_mode_flag() {
2893        let (_tmp, mut engine) = migration_engine();
2894        let one = crate::entity::EntityId::new("specs", "one");
2895        let two = crate::entity::EntityId::new("specs", "two");
2896        engine
2897            .relate_entity(
2898                crate::engine::RelateEntityArgs {
2899                    source: one.clone(),
2900                    expected_hash: None,
2901                    rel_type: "USES".to_string(),
2902                    target: two.clone(),
2903                    remove: false,
2904                    description: None,
2905                },
2906                crate::vcs::Actor::Cli,
2907                None,
2908                None,
2909            )
2910            .unwrap();
2911        // Conformant under the pin → the repair gate is shut.
2912        let shut = engine
2913            .update_entity(
2914                crate::engine::UpdateEntityArgs {
2915                    id: one.clone(),
2916                    expected_hash: None,
2917                    sections: indexmap::IndexMap::new(),
2918                    append_sections: indexmap::IndexMap::new(),
2919                    patch_sections: indexmap::IndexMap::new(),
2920                    metadata: indexmap::IndexMap::new(),
2921                    metadata_unset: Vec::new(),
2922                    declare_relations: Vec::new(),
2923                    dry_run: false,
2924                    relations_unset: vec![crate::ops::RelationUnsetArg {
2925                        rel_type: "USES".to_string(),
2926                        target: two.clone(),
2927                    }],
2928                },
2929                crate::vcs::Actor::Cli,
2930                None,
2931                None,
2932            )
2933            .unwrap_err();
2934        assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
2935
2936        // Enter migration → `one` is now non-conformant against the
2937        // target; the same call opens, removes the relation, and the
2938        // bundled `status` set makes the entity integral-against-target.
2939        engine
2940            .set_mem_schema("specs", &sref("mig-b@0.1.0"))
2941            .unwrap();
2942        engine
2943            .update_entity(
2944                crate::engine::UpdateEntityArgs {
2945                    id: one.clone(),
2946                    expected_hash: None,
2947                    sections: indexmap::IndexMap::new(),
2948                    append_sections: indexmap::IndexMap::new(),
2949                    patch_sections: indexmap::IndexMap::new(),
2950                    metadata: indexmap::IndexMap::from_iter([(
2951                        "status".to_string(),
2952                        "open".to_string(),
2953                    )]),
2954                    metadata_unset: Vec::new(),
2955                    declare_relations: Vec::new(),
2956                    dry_run: false,
2957                    relations_unset: vec![crate::ops::RelationUnsetArg {
2958                        rel_type: "USES".to_string(),
2959                        target: two.clone(),
2960                    }],
2961                },
2962                crate::vcs::Actor::Cli,
2963                None,
2964                None,
2965            )
2966            .expect("repair-shaped update lands during migration without a flag");
2967        let entity = engine.store().get(&one).unwrap();
2968        assert!(entity.relationships.is_empty());
2969    }
2970
2971    /// Boot honors a persisted in-flight migration: a mount carrying
2972    /// `migration_target` validates writes against the target from
2973    /// the first call of the new process — the resumability half of
2974    /// the dual-pin contract.
2975    #[test]
2976    fn boot_resumes_dual_pin_validation_against_target() {
2977        let (tmp, engine) = migration_engine();
2978        drop(engine);
2979        let schemas_dir = tmp.path().join("schemas");
2980        let mem_dir = tmp.path().join("mem");
2981        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
2982        let mut mount = folder_mount("specs", mem_dir);
2983        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
2984        mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
2985        let engine = Engine::from_mounts_with_schemas_dir(
2986            vec![(
2987                mount,
2988                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
2989            )],
2990            Some(&schemas_dir),
2991        )
2992        .unwrap();
2993        // Effective validation schema is the target...
2994        let (name, version) = {
2995            let s = engine.schema_for("specs").unwrap();
2996            let (n, v) = s.id();
2997            (n.to_string(), v.to_string())
2998        };
2999        assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
3000        // ...while the settled pin and the in-flight target read back
3001        // distinctly.
3002        assert_eq!(
3003            engine.schema_pin("specs").unwrap().as_display(),
3004            "mig-a@0.1.0"
3005        );
3006        assert_eq!(
3007            engine.migration_target("specs").unwrap().as_display(),
3008            "mig-b@0.1.0"
3009        );
3010    }
3011}