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
31/// What [`Engine::stage_sealed_schema`] did.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SchemaStaging {
34    /// The pin already resolves in this workspace — nothing written.
35    /// Re-installing a mem, and installing a second mem that pins the
36    /// same schema, both land here.
37    AlreadyResolvable,
38    /// The package was written into the workspace's local schema
39    /// storage and is resolvable in this process from now on.
40    Staged,
41    /// The archive carries no embedded schema tree. Nothing to stage;
42    /// the pin must resolve on its own or the mount refuses.
43    NoEmbeddedSchema,
44}
45
46impl Engine {
47    /// Replace the workspace-level settings. Called by
48    /// [`Self::from_workspace_root`] (and the full counterpart) after
49    /// reading `.memstead/workspace.toml`. Tests / direct callers leave
50    /// the default empty value in place. Cheap clone — settings
51    /// carry only data shapes (raw rule lists, link policy map),
52    /// no compiled matchers. Invalidates the lazy
53    /// `create_rule_set_memo` so the next synthesis call rebuilds
54    /// from the new policy.
55    pub fn set_settings(&mut self, settings: WorkspaceSettings) {
56        self.settings = settings;
57        self.create_rule_set_memo = OnceCell::new();
58    }
59
60    /// Replace the backend factory. Full consumers call this once at boot
61    /// (`engine_from_workspace_root`) to install
62    /// `memstead_git_branch::storage::instantiate_full_backend` so the engine
63    /// can materialise git-branch backends on top of folder + archive.
64    /// Lean consumers leave the default in place.
65    pub fn set_backend_factory(&mut self, factory: BackendFactory) {
66        self.backend_factory = factory;
67    }
68
69    /// Insert into the engine's schema map, bumping the schemas epoch
70    /// (the second half of the derived-memo key — flywheel W8/01):
71    /// derived structures depend on schemas, so any change here must
72    /// be visible to the memo-invalidation hooks even when the store
73    /// generation did not move.
74    pub(crate) fn schemas_insert(
75        &mut self,
76        mem: String,
77        schema: std::sync::Arc<memstead_schema::Schema>,
78    ) {
79        self.schemas_epoch += 1;
80        self.schemas.insert(mem, schema);
81    }
82
83    /// Remove from the engine's schema map, bumping the schemas epoch
84    /// — see [`Self::schemas_insert`].
85    pub(crate) fn schemas_remove(&mut self, mem: &str) {
86        self.schemas_epoch += 1;
87        self.schemas.remove(mem);
88    }
89
90    /// Install the unmounted-mem storage discovery hook (flywheel
91    /// W7/02). Full boot sets it; without one, writes referencing
92    /// unmounted mems keep the forward-reference mechanic unchanged.
93    pub fn set_unmounted_storage_prober(&mut self, prober: super::UnmountedStorageProber) {
94        self.unmounted_storage_prober = Some(prober);
95    }
96
97    /// Replace the mutation-timestamp clock — the source every
98    /// engine-stamped metadata field (`init_timestamp` /
99    /// `auto_timestamp` schema flags: `created_date`, `last_modified`)
100    /// reads. A testing affordance for suites that assert over
101    /// canonical entity bytes (e.g. cross-surface hash parity):
102    /// pin both engines to the same constant and byte-level
103    /// nondeterminism from wall-clock seconds disappears. Production
104    /// code never calls this — the default installed at construction
105    /// is the system clock, and the stamped format is unchanged
106    /// either way.
107    pub fn set_mutation_clock(&mut self, clock: crate::engine::MutationClock) {
108        self.mutation_clock = clock;
109    }
110
111    /// Set the caller-declared role for subsequent mutations
112    /// (agent-trust plan 13). The surface calls this before every
113    /// mutation with the per-call parameter resolved against its
114    /// session default (per-call wins); `Role::Unspecified` records
115    /// as absence.
116    pub fn set_role(&mut self, role: crate::vcs::Role) {
117        self.current_role = role;
118    }
119
120    /// The currently declared role — what the next mutation records.
121    pub fn current_role(&self) -> crate::vcs::Role {
122        self.current_role
123    }
124
125    /// Current mutation timestamp as the second-granularity ISO form
126    /// the stamping paths write. Reads [`Self::mutation_clock`] — the
127    /// system clock unless a test pinned it.
128    pub(crate) fn now_iso(&self) -> String {
129        crate::engine::mutation::iso_from_system_time((self.mutation_clock)())
130    }
131
132    /// Install the git-branch ops bundle. Full boot
133    /// (`memstead_git_branch::engine_from_workspace_root`) calls this once
134    /// at construction. Lean consumers leave it unset and the
135    /// git-branch dispatch branches collapse to typed errors / empty
136    /// reports — lean has no git-branch mounts.
137    pub fn set_git_branch_ops(&mut self, ops: GitBranchOps) {
138        self.git_branch_ops = Some(ops);
139    }
140
141    /// Install a schema package onto the workspace's git-branch backend —
142    /// the unified `__MEMSTEAD:schemas/<name>@<version>/` ref. `files`
143    /// are `(relative-path, bytes)` pairs (`schema.yaml`,
144    /// `types/<t>.yaml`, optional `mem-template.json`). Returns the
145    /// resulting commit sha; idempotent at the storage layer (an
146    /// identical re-install produces no new commit).
147    ///
148    /// Folder workspaces install schemas by writing under
149    /// `<workspace>/.memstead/schemas/` directly; this is the git-branch
150    /// path, where the engine owns the mem-repo and the write must
151    /// route through it. Errors when no git-branch ops are wired (lean
152    /// flavour) or no git-branch mount exists to resolve the shared
153    /// mem-repo gitdir from. The caller reloads (or restarts) to pick
154    /// the new schema into the resolution catalogue.
155    pub fn install_schema(
156        &self,
157        name: &str,
158        version: &str,
159        files: &[(String, Vec<u8>)],
160    ) -> Result<String, EngineError> {
161        // Validation gate: the engine refuses to seal a package that the
162        // loader would reject or whose section headings cannot round-trip
163        // to their keys. Install time is the last moment the author can
164        // act — sealed schemas keep loading even when a later rule would
165        // refuse them, so nothing invalid may pass this point.
166        Self::validate_schema_package(name, version, files)?;
167        // Resolve the shared mem-repo gitdir: prefer a live git-branch
168        // mount's gitdir (authoritative — that is where the engine reads
169        // schemas from), falling back to the workspace's `mem-repo/.git`
170        // so a schema can be installed into an empty mem-repo *before*
171        // any mem pins it.
172        let gitdir = self
173            .mounts
174            .iter()
175            .find_map(|m| match &m.mount.storage {
176                crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
177                _ => None,
178            })
179            .or_else(|| {
180                self.workspace_root()
181                    .map(|r| r.join("mem-repo").join(".git"))
182            })
183            .ok_or_else(|| {
184                EngineError::Mem(
185                    "schema install requires a mem-repo workspace (no git-branch mount and \
186                     no workspace root to resolve the mem-repo gitdir)"
187                        .to_string(),
188                )
189            })?;
190        let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
191            EngineError::Mem("git-branch ops are not wired on this engine".to_string())
192        })?;
193        // Seal the package AS-GIVEN: the format marker is a generation
194        // claim only the resolver can make (an authored directory source
195        // arrives already stamped; a legacy builtin or sealed source
196        // arrives unmarked, and absence IS its legacy claim). Stamping
197        // here mis-labelled legacy content as current and flipped every
198        // bare field's meaning in the sealed copy — the manufacturing
199        // defect backlog-sweep plan 05 removed.
200        (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)
201    }
202
203    /// Make a **sealed third-party** schema package resolvable in this
204    /// workspace — the install-time half of "a published mem installs
205    /// on the strength of the schema it carries".
206    ///
207    /// The package (package-relative files: `schema.yaml`,
208    /// `types/<t>.yaml`, `schema-format.json`) is written into the
209    /// workspace's local schema storage, the same storage
210    /// [`Self::install_schema`] writes to and the pin resolver reads —
211    /// so the mount that follows resolves without a fourth mechanism
212    /// and a second mem pinning the same schema finds it already
213    /// there. The loaded schema also lands in this engine's live
214    /// catalogue, so the caller mounts in the same process without a
215    /// reload.
216    ///
217    /// Two things it deliberately does NOT do. It does not run the
218    /// authoring gate ([`Self::validate_schema_package`]): these are a
219    /// third party's sealed bytes, the installing user cannot fix
220    /// them, and the archive validator has already admitted them under
221    /// the sealed reading — applying the authoring gate here would
222    /// make an archive that is valid to publish invalid to install.
223    /// And it does not append the format marker: presence of the
224    /// marker IS the package's metadata-polarity generation, so
225    /// injecting one would rewrite the meaning of bytes the publisher
226    /// sealed.
227    ///
228    /// Idempotent: a pin the engine can already resolve returns
229    /// [`SchemaStaging::AlreadyResolvable`] with nothing written.
230    pub fn stage_sealed_schema(
231        &mut self,
232        mem: &str,
233        pin: &memstead_schema::SchemaRef,
234        files: &[(String, Vec<u8>)],
235    ) -> Result<SchemaStaging, EngineError> {
236        let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
237            .workspace_schemas
238            .iter()
239            .chain(self.builtin_schemas.iter())
240            .cloned()
241            .collect();
242        if crate::engine::SchemaResolver::new(&catalogue)
243            .resolve(pin)
244            .is_ok()
245        {
246            return Ok(SchemaStaging::AlreadyResolvable);
247        }
248        if files.is_empty() {
249            // Nothing embedded to stage. The caller's mount attempt
250            // reports the unresolved pin with its own source trail —
251            // that IS the actionable error here.
252            return Ok(SchemaStaging::NoEmbeddedSchema);
253        }
254
255        let unloadable = |reason: String| EngineError::EmbeddedSchemaInvalid {
256            mem: mem.to_string(),
257            pin: pin.as_display(),
258            reason,
259        };
260        // Read it exactly as the local schema source will read it back
261        // — one sealed reader, so admission and re-read cannot diverge.
262        let schema =
263            memstead_schema::load_sealed_package(files).map_err(|e| unloadable(e.to_string()))?;
264        let (name, version) = schema.id();
265        if name != pin.name || version != pin.version {
266            return Err(unloadable(format!(
267                "the package declares '{name}@{version}' but the mem pins {}",
268                pin.as_display()
269            )));
270        }
271
272        self.write_to_local_schema_source(&name, &version.to_string(), files)?;
273        self.workspace_schemas.push(std::sync::Arc::new(schema));
274        Ok(SchemaStaging::Staged)
275    }
276
277    /// Write a schema package into the workspace's local schema
278    /// storage — the git-branch `__MEMSTEAD:schemas/` ref when the
279    /// workspace is mem-repo-shaped. Shares
280    /// [`Self::install_schema`]'s gitdir resolution so authored and
281    /// staged packages land in one place.
282    fn write_to_local_schema_source(
283        &self,
284        name: &str,
285        version: &str,
286        files: &[(String, Vec<u8>)],
287    ) -> Result<(), EngineError> {
288        let gitdir = self
289            .mounts
290            .iter()
291            .find_map(|m| match &m.mount.storage {
292                crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
293                _ => None,
294            })
295            .or_else(|| {
296                self.workspace_root()
297                    .map(|r| r.join("mem-repo").join(".git"))
298            })
299            .filter(|g| g.is_dir())
300            .ok_or_else(|| {
301                EngineError::Mem(
302                    "staging a schema requires a mem-repo workspace — the folder workspace's \
303                     `.memstead/schemas/` is the authoring tier and never holds sealed \
304                     third-party packages"
305                        .to_string(),
306                )
307            })?;
308        let ops = self.git_branch_ops.as_ref().ok_or_else(|| {
309            EngineError::Mem("git-branch ops are not wired on this engine".to_string())
310        })?;
311        (ops.write_schema)(&gitdir, name, version, files).map_err(EngineError::Backend)?;
312        Ok(())
313    }
314
315    /// Validate a schema package's files before they are sealed. Runs
316    /// the full loader (structural + semantic) plus the section-heading
317    /// round-trip gate, and checks the manifest's declared identity
318    /// matches the `(name, version)` the package is being installed
319    /// under — a mismatch would seal the schema under a ref its own
320    /// manifest contradicts.
321    ///
322    /// `pub` so the below-boot install path (memstead-git-branch's
323    /// repair surface) runs the SAME gate as this booted path — the
324    /// two must never fork into separate validation regimes.
325    pub fn validate_schema_package(
326        name: &str,
327        version: &str,
328        files: &[(String, Vec<u8>)],
329    ) -> Result<(), EngineError> {
330        let invalid = |message: String| EngineError::SchemaPackageInvalid {
331            name: name.to_string(),
332            version: version.to_string(),
333            message,
334        };
335        let manifest_yaml = files
336            .iter()
337            .find(|(rel, _)| rel == "schema.yaml")
338            .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
339            .ok_or_else(|| invalid("package has no schema.yaml".to_string()))?;
340        let types: Vec<(String, String)> = files
341            .iter()
342            .filter_map(|(rel, bytes)| {
343                rel.strip_prefix("types/")
344                    .and_then(|f| f.strip_suffix(".yaml"))
345                    .map(|stem| {
346                        (
347                            stem.to_string(),
348                            String::from_utf8_lossy(bytes).into_owned(),
349                        )
350                    })
351            })
352            .collect();
353        // Install validates the CURRENT language (the author acts
354        // now) — the retired `optional:` key refuses here, and absent
355        // required keys mean optional.
356        let schema = memstead_schema::load_schema_from_memory_with_format(
357            &manifest_yaml,
358            &types,
359            memstead_schema::loader::MetadataPolarityFormat::RequiredOptIn,
360        )
361        .map_err(|e| invalid(e.to_string()))?;
362        memstead_schema::check_section_heading_roundtrip(&schema)
363            .map_err(|e| invalid(e.to_string()))?;
364        memstead_schema::check_reserved_metadata_keys(&schema)
365            .map_err(|e| invalid(e.to_string()))?;
366        memstead_schema::check_section_formats(&schema).map_err(|e| invalid(e.to_string()))?;
367        let (declared_name, declared_version) =
368            (schema.manifest.name.as_str(), schema.version.to_string());
369        if declared_name != name || declared_version != version {
370            return Err(invalid(format!(
371                "manifest declares '{declared_name}@{declared_version}' but the package is \
372                 being installed as '{name}@{version}'"
373            )));
374        }
375        Self::validate_schema_exemplars(&std::sync::Arc::new(schema)).map_err(invalid)?;
376        Ok(())
377    }
378
379    /// Validate every type exemplar a schema carries by running it
380    /// through the REAL create validation stage (agent-trust plan 09):
381    /// an in-memory engine is booted with the candidate schema pinned
382    /// on a virtual mem, and each exemplar is submitted as a
383    /// `dry_run` create — the same gates a real write runs (sections,
384    /// metadata + enums, rel-type vocabulary, edge shape, description
385    /// posture), commit-free by construction. Placeholder relation
386    /// targets are bare slugs scoped to the virtual mem, so target
387    /// existence is never checked (an absent target is the legal
388    /// would-be-stub path).
389    ///
390    /// Returns the defect as a message naming the type — the caller
391    /// wraps it in its own typed envelope (`SchemaPackageInvalid` on
392    /// the install path). There is deliberately no warn-and-carry
393    /// mode: a non-conformant exemplar refuses, because the whole
394    /// value of an exemplar is the impossibility of drift.
395    ///
396    /// `pub` so the built-in suite gates every shipped exemplar
397    /// through the SAME validator (a broken built-in exemplar fails
398    /// CI), and the below-boot install path shares the gate via
399    /// [`Self::validate_schema_package`].
400    pub fn validate_schema_exemplars(
401        schema: &std::sync::Arc<memstead_schema::Schema>,
402    ) -> Result<(), String> {
403        let with_exemplars: Vec<&str> = schema
404            .manifest
405            .types
406            .iter()
407            .filter(|t| {
408                schema
409                    .types
410                    .get(t.as_str())
411                    .is_some_and(|td| td.exemplar.is_some())
412            })
413            .map(String::as_str)
414            .collect();
415        if with_exemplars.is_empty() {
416            return Ok(());
417        }
418
419        let (name, version) = schema.id();
420        let mem = "exemplar";
421        let mount = crate::workspace::Mount {
422            mem: mem.to_string(),
423            schema: Some(memstead_schema::SchemaRef::new(name, version)),
424            storage: crate::workspace::MountStorage::InMemory,
425            capability: crate::workspace::MountCapability::Write,
426            lifecycle: crate::workspace::MountLifecycle::Eager,
427            cross_linkable: true,
428            migration_target: None,
429        };
430        let backend = Box::new(crate::storage::InMemoryBackend::new()) as Box<dyn MemBackend>;
431        let mut engine = Engine::from_mounts_with_schemas_dir_and_extra(
432            vec![(mount, backend)],
433            None,
434            vec![schema.clone()],
435        )
436        .map_err(|e| format!("exemplar validation could not boot: {e}"))?;
437
438        for type_name in with_exemplars {
439            let td = schema
440                .types
441                .get(type_name)
442                .expect("filtered on presence above");
443            let ex = td.exemplar.as_ref().expect("filtered on presence above");
444            let mut relations = Vec::with_capacity(ex.relations.len());
445            for r in &ex.relations {
446                if r.to.contains("--") || r.to.trim().is_empty() {
447                    return Err(format!(
448                        "type '{type_name}' exemplar relation target '{}' must be a bare \
449                         placeholder slug (no `--`, non-empty) — exemplars live outside \
450                         any mem",
451                        r.to
452                    ));
453                }
454                relations.push(crate::ops::RelateArg {
455                    target: crate::entity::EntityId::new(mem, &r.to),
456                    rel_type: r.rel_type.clone(),
457                    description: r.description.clone(),
458                });
459            }
460            let args = crate::engine::CreateEntityArgs {
461                anchors: Vec::new(),
462                mem: mem.to_string(),
463                title: ex.title.clone(),
464                entity_type: type_name.to_string(),
465                sections: ex.sections.clone(),
466                metadata: ex.metadata.clone(),
467                relations,
468                dry_run: true,
469            };
470            if let Err(e) = engine.create_entity(args, crate::vcs::Actor::Cli, None, None) {
471                return Err(format!(
472                    "type '{type_name}' exemplar does not conform: [{}] {e}",
473                    e.code()
474                ));
475            }
476        }
477        Ok(())
478    }
479    /// Unregister a writable mem at runtime. Engine-level
480    /// primitive that `memstead_mem_delete` builds on.
481    ///
482    /// Removes the named mount from [`Self::mounts`], drops the
483    /// mem's entities from the store, refreshes the
484    /// [`MemRouterSnapshot`] via `Arc::make_mut` (COW swap so
485    /// readers holding a pre-swap snapshot see the pre-state for
486    /// their lifetime), and invalidates the community + search
487    /// memos. Does NOT touch the backend's on-disk state — the
488    /// caller (`delete_mem` orchestrator) decides whether to
489    /// remove the directory / gitdir after this returns.
490    ///
491    /// Returns `Ok(Some(backend))` when the mem was present and
492    /// unregistered — the caller can drive any backend-specific
493    /// follow-up cleanup (`backend.delete_artifacts()` for the
494    /// mem-repo branch + `__MEMSTEAD` config when `delete_files=true`).
495    /// Returns `Ok(None)` when no mount named the mem (idempotent —
496    /// repeated calls are safe).
497    pub fn unregister_writable_mem(
498        &mut self,
499        mem_name: &str,
500    ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
501        let pos = self.mounts.iter().position(|m| m.mount.mem == mem_name);
502        let Some(idx) = pos else {
503            return Ok(None);
504        };
505
506        // Drop the mount first — releases all engine-side state that
507        // referenced the backend. The `Box<dyn MemBackend>` itself
508        // travels back to the caller so backend-side cleanup
509        // (`delete_artifacts`) can run after the engine snapshot
510        // settled.
511        let mount = self.mounts.remove(idx);
512
513        // Drop the schema entry for this mem (kept in lockstep
514        // with `self.mounts`).
515        self.schemas_remove(&mount.mount.mem);
516
517        // Drop entities. The store's mem index is the
518        // authoritative count; the return value (number of
519        // entities removed) is informational only — the caller
520        // already knows the mem and doesn't need the count.
521        let _removed = self.store.remove_entities_by_mem(mem_name);
522
523        // Purge load-time warnings attributed to the removed mem.
524        // `health()` merges `self.load_warnings` unconditionally, so
525        // a skipped purge leaves phantom warnings citing entities the
526        // store no longer holds — for the whole engine lifetime, since
527        // nothing else clears the accumulator on the MCP path.
528        // Attribution is by SOURCE mem only (`WarningHint::source_mem`):
529        // a warning whose source entity lives in a surviving mem stays
530        // even when its target pointed into the deleted mem — the
531        // invalid row still exists in that survivor's markdown and
532        // remains visible drift (recover-worthy), not stale state.
533        self.load_warnings
534            .retain(|w| w.source_mem() != Some(mem_name));
535
536        // COW snapshot swap on the mem_router. `Arc::make_mut`
537        // clones the inner snapshot when other Arcs exist; if this
538        // is the only handle (typical for the engine's lifetime),
539        // it returns the existing inner directly without cloning.
540        // Readers that captured an `Arc` before this call observe
541        // the pre-swap state — the in-flight handler's
542        // `mem_router()` borrow is unaffected by this mutation.
543        Arc::make_mut(&mut self.mem_router).remove_writable(mem_name);
544
545        // Invalidate dependent memos — community detection + search
546        // indexes were computed over the pre-removal store and are
547        // now stale. Mutation paths already invalidate; this
548        // matches the contract.
549        self.invalidate_communities();
550        self.invalidate_search_indexes();
551
552        Ok(Some(mount.backend))
553    }
554
555    /// Register a read-only mount at runtime — the install path's
556    /// engine primitive. Same registration pipeline as
557    /// [`Self::register_writable_mem`] (collision probe, config read,
558    /// schema resolution, entity load, router swap); the router branch
559    /// lands the mount in the read-only slot for
560    /// `capability: ReadOnly` + `Archive` storage, so `is_writable`
561    /// stays false and `archive_path_for_mem` resolves.
562    pub fn register_read_mount(
563        &mut self,
564        mount: Mount,
565        backend: Box<dyn MemBackend>,
566        origin: MemOrigin,
567    ) -> Result<(), EngineError> {
568        self.register_writable_mem_inner(mount, backend, origin, true)
569    }
570
571    /// Unregister a read-only mount at runtime — the uninstall path's
572    /// engine primitive, mirroring [`Self::unregister_writable_mem`]
573    /// for the read-only slot. Returns `Ok(None)` when the name is
574    /// not a registered read-only mount (writable mems are the
575    /// delete/unregister verbs' business, deliberately not this
576    /// one's). Registration removal only — the backing archive file
577    /// (global cache) is never touched.
578    pub fn unregister_read_mount(
579        &mut self,
580        mem_name: &str,
581    ) -> Result<Option<Box<dyn MemBackend>>, EngineError> {
582        let pos = self.mounts.iter().position(|m| {
583            m.mount.mem == mem_name
584                && m.mount.capability == crate::workspace::MountCapability::ReadOnly
585        });
586        let Some(idx) = pos else {
587            return Ok(None);
588        };
589        let mount = self.mounts.remove(idx);
590        self.schemas_remove(&mount.mount.mem);
591        let _removed = self.store.remove_entities_by_mem(mem_name);
592        self.load_warnings
593            .retain(|w| w.source_mem() != Some(mem_name));
594        Arc::make_mut(&mut self.mem_router).remove_read_only(mem_name);
595        self.invalidate_communities();
596        self.invalidate_search_indexes();
597        Ok(Some(mount.backend))
598    }
599
600    /// Append a typed load-time warning from outside the engine's own
601    /// load pipeline — the boot orchestrators (which live in the full
602    /// crate) use this to surface one-time migrations they perform
603    /// around engine construction.
604    pub fn push_load_warning(&mut self, warning: crate::ops::WarningHint) {
605        self.load_warnings.push(warning);
606    }
607
608    /// Register a writable mem at runtime. Engine-level primitive
609    /// that `memstead_mem_create` builds on.
610    ///
611    /// Steps:
612    /// 1. Name collision probe against the current `mem_router`
613    ///    snapshot. Writable AND read-only entries collide; the
614    ///    error surfaces the colliding source so the orchestrator
615    ///    can render a recovery hint.
616    /// 2. Schema resolution via the built-in catalogue (mirrors
617    ///    [`Self::from_mounts`]; workspace-authored schema
618    ///    resolution lifts later).
619    /// 3. Per-mem config load (folder backends only; git-branch /
620    ///    archive return None — same contract as
621    ///    [`Self::from_mounts`]).
622    /// 4. Entity load via the backend, parse, push into the engine's
623    ///    store with a `LoadCollector` so drift warnings forward to
624    ///    `self.load_warnings`.
625    /// 5. Insert schema into [`Self::schemas`].
626    /// 6. Push the [`MountedBackend`] into [`Self::mounts`].
627    /// 7. COW snapshot swap on [`Self::mem_router`] via
628    ///    `Arc::make_mut` + `add_writable(name, dir, origin, mem_path)`.
629    ///    Folder mounts surface their on-disk path; other backends
630    ///    register with `dir: None` (matches full's contract).
631    ///    `mem_path` carries the create-time organisational `path`
632    ///    component (mirrors `MemCreateParams.path`) — the
633    ///    delete-side lifecycle composer reads it back to rebuild the
634    ///    `<mem_path>/<name>` candidate the create-side composer
635    ///    matched against. Caller threads `None` for flat-layout
636    ///    registrations and `Some(p)` for hierarchical ones.
637    /// 8. Invalidate community + search memos.
638    ///
639    /// Returns `Err(EngineError::MemNameCollision)` when the name
640    /// is already registered. Other failures (schema-not-found,
641    /// backend read errors) propagate as their typed variants. On
642    /// failure no engine mutation happens: every potentially-
643    /// mutating step runs only after the collision probe succeeds,
644    /// and intermediate failures propagate before the mount /
645    /// router are touched.
646    pub fn register_writable_mem(
647        &mut self,
648        mount: Mount,
649        backend: Box<dyn MemBackend>,
650        origin: MemOrigin,
651    ) -> Result<(), EngineError> {
652        self.register_writable_mem_inner(mount, backend, origin, true)
653    }
654
655    /// [`Self::register_writable_mem`] with the workspace-global
656    /// passes (relation validation, alias remap, memo invalidation)
657    /// made optional: `run_global_passes: false` lets a batch caller
658    /// ([`Self::full_refresh`]) attach N mounts and run the global
659    /// passes ONCE afterwards instead of N times under the engine
660    /// lock. A `false` caller MUST run
661    /// [`Self::finish_batched_registrations`] after its loop, or
662    /// loaded relations skip validation and alias edges stay
663    /// unmapped.
664    fn register_writable_mem_inner(
665        &mut self,
666        mount: Mount,
667        backend: Box<dyn MemBackend>,
668        origin: MemOrigin,
669        run_global_passes: bool,
670    ) -> Result<(), EngineError> {
671        // Step 1: name collision probe.
672        if let Some(existing) = self.mem_router.origin_for_mem(&mount.mem) {
673            return Err(EngineError::MemNameCollision {
674                name: mount.mem.clone(),
675                source_origin: existing.render_source(),
676            });
677        }
678        if self.mem_router.archive_path_for_mem(&mount.mem).is_some() {
679            return Err(EngineError::MemNameCollision {
680                name: mount.mem.clone(),
681                source_origin: "attached read mem".to_string(),
682            });
683        }
684
685        // Step 2: per-mem config load via the backend trait. Read
686        // before resolving the schema — the mem's own config carries
687        // the authoritative pin (mirrors the boot path), so a mem
688        // re-registered or mounted from another machine resolves from
689        // its own backend, not this workspace's mount expectation.
690        let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
691            let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
692            memstead_schema::config::parse_mem_config(&value).ok()
693        });
694
695        // Step 3: schema resolution. `MemConfig.schema` is the
696        // authoritative settled pin; `Mount.schema` is the fallback when
697        // the config carries none, and an expectation assertion when it
698        // does — a disagreement surfaces `SchemaPinMismatch` (config
699        // wins, neither silently dropped). Mirrors `from_mounts_inner`.
700        // Resolve against the engine's full loaded catalogue (already-
701        // loaded workspace/local-storage schemas layered over built-ins)
702        // so a mem registered against a backend-installed (e.g.
703        // git-branch `__MEMSTEAD:schemas/` ref) schema resolves.
704        let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
705            self.workspace_schemas().to_vec();
706        builtin_schemas.extend(
707            memstead_schema::builtins::load_builtin_schemas()
708                .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
709        );
710        let config_pin = mem_config.as_ref().and_then(|c| c.schema.as_ref());
711        let mount_pin = mount.schema.as_ref();
712        if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
713            && cfg != mp
714        {
715            self.load_warnings
716                .push(crate::ops::WarningHint::SchemaPinMismatch {
717                    mem: mount.mem.clone(),
718                    config_pin: cfg.as_display(),
719                    mount_pin: mp.as_display(),
720                });
721        }
722        let settled_pin = config_pin.or(mount_pin);
723        let effective_pin = mount
724            .migration_target
725            .as_ref()
726            .or(settled_pin)
727            .ok_or_else(|| EngineError::MemConfigIncomplete {
728                mem: mount.mem.clone(),
729                missing_fields: vec!["schema".to_string()],
730            })?
731            .clone();
732        let schema = crate::engine::SchemaResolver::new(&builtin_schemas)
733            .resolve(&effective_pin)
734            .map_err(|sources| {
735                EngineError::SchemaNotFound {
736                    mem: mount.mem.clone(),
737                    pin: effective_pin.as_display(),
738                    sources,
739                    install_hint: None,
740                }
741                .with_schema_install_probe(self.workspace_root())
742            })?;
743
744        // Step 4: load entities via the backend, push into the
745        // engine's store with a LoadCollector so drift warnings
746        // forward into `self.load_warnings`. Derive the mem
747        // roster + last-segment suffixes from the POST-registration
748        // view (new mem included) so cross-mem references
749        // targeting the new mem resolve correctly during this
750        // load.
751        let (entries, read_errors) = collect_source_entries(backend.as_ref())?;
752        if let Some(w) =
753            super::boot::unbacked_mount_warning(&mount, backend.as_ref(), Some(entries.len()))
754        {
755            self.load_warnings.push(w);
756        }
757        let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
758
759        let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
760        mem_names.push(mount.mem.clone());
761        let known_suffixes: Vec<String> = mem_names
762            .iter()
763            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
764            .collect();
765        let fallback = engine_fallback_type();
766        push_entities_into_store(
767            &mut self.store,
768            load_result.entities,
769            fallback.as_ref(),
770            Some(crate::entity::store_builder::LoadCollector {
771                warnings: &mut self.load_warnings,
772                known_suffixes: &known_suffixes,
773                mem_names: &mem_names,
774            }),
775        );
776        self.load_errors.extend(load_result.errors);
777
778        // Step 5: insert schema (kept in lockstep with `self.mounts`).
779        self.schemas_insert(mount.mem.clone(), schema);
780
781        // Re-run the parse-time relation validator now that the new
782        // mem's schema is in `self.schemas`. Mirrors the boot path
783        // (`Engine::from_mounts_inner`) — hand-edited or externally-
784        // generated markdown in the newly-attached mount goes through
785        // the same gauntlet (grammar / unknown_rel_type / shape /
786        // cycle) and offending relations are dropped with typed
787        // `PARSED_RELATION_INVALID` warnings on `self.load_warnings`.
788        // The newly-pushed mount isn't in `self.mounts` yet (that's
789        // Step 6 below), so build the map from `self.mounts` plus the
790        // about-to-be-attached mount we're still holding.
791        if run_global_passes {
792            let mut mount_caps: std::collections::HashMap<
793                String,
794                crate::workspace::MountCapability,
795            > = self
796                .mounts
797                .iter()
798                .map(|m| (m.mount.mem.clone(), m.mount.capability))
799                .collect();
800            mount_caps.insert(mount.mem.clone(), mount.capability);
801            crate::entity::store_builder::validate_loaded_relations(
802                &mut self.store,
803                &self.schemas,
804                &mount_caps,
805                &mut self.load_warnings,
806            );
807            crate::entity::store_builder::remap_alias_target_edge_sources(
808                &mut self.store,
809                &self.schemas,
810            );
811        }
812
813        // Step 6: push the MountedBackend.
814        let last_known_head = backend.current_head().ok().flatten();
815        let mem_name_for_router = mount.mem.clone();
816        let storage_for_router = mount.storage.clone();
817        let mount_capability_for_router = mount.capability;
818        self.mounts.push(MountedBackend {
819            mount,
820            backend,
821            last_known_head,
822            mem_config,
823            // A runtime-created mem is authored live, not installed from
824            // an archive — it carries no archive-borne provenance payload.
825            archive_provenance: None,
826            // Registered live and loaded in this call — never deferred.
827            deferred: false,
828        });
829
830        // Step 7: COW snapshot swap on mem_router, branched on the
831        // mount's capability — a read-only archive mount registers in
832        // the router's read-only slot (so `is_writable` stays false
833        // and `archive_path_for_mem` resolves), everything else in the
834        // writable slot. Folder mounts surface their on-disk path;
835        // other backends register with `dir: None` (mem-repo-backed
836        // mounts have no working tree).
837        match (&mount_capability_for_router, &storage_for_router) {
838            (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
839                Arc::make_mut(&mut self.mem_router)
840                    .add_read_only(mem_name_for_router, path.clone());
841            }
842            _ => {
843                let dir: Option<PathBuf> = match &storage_for_router {
844                    MountStorage::Folder { path } => Some(path.clone()),
845                    MountStorage::GitBranch { .. }
846                    | MountStorage::Archive { .. }
847                    | MountStorage::InMemory => None,
848                };
849                Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
850            }
851        }
852
853        // Step 8: invalidate dependent memos.
854        if run_global_passes {
855            self.invalidate_communities();
856            self.invalidate_search_indexes();
857        }
858
859        Ok(())
860    }
861
862    /// The batched tail of `register_writable_mem_inner(...,
863    /// run_global_passes: false)`: one workspace-global relation
864    /// validation, one alias remap, one memo invalidation for the
865    /// whole batch of registrations.
866    fn finish_batched_registrations(&mut self) {
867        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
868            .mounts
869            .iter()
870            .map(|m| (m.mount.mem.clone(), m.mount.capability))
871            .collect();
872        crate::entity::store_builder::validate_loaded_relations(
873            &mut self.store,
874            &self.schemas,
875            &mount_caps,
876            &mut self.load_warnings,
877        );
878        crate::entity::store_builder::remap_alias_target_edge_sources(
879            &mut self.store,
880            &self.schemas,
881        );
882        self.invalidate_communities();
883        self.invalidate_search_indexes();
884    }
885
886    /// Additive full refresh — the warm-server half of "restart the
887    /// process": re-scan the schema sources and the mount manifest,
888    /// making newly installed schema versions resolvable and newly
889    /// registered mems usable, WITHOUT applying removals. The
890    /// asymmetry is deliberate and is the whole safety argument:
891    /// adding extends what the in-memory store can answer, while
892    /// removing can strand entities, in-flight handles, and cached
893    /// hashes the process is still serving. Removals are skipped and
894    /// reported; a restart applies them.
895    ///
896    /// Failure model is per-item: a schema source or a mount that
897    /// fails to refresh lands in `failures` and never surfaces as
898    /// newly available; the others proceed. Each mount registration
899    /// is all-or-nothing (every fallible step runs before the store
900    /// is touched), so a failed item leaves no half-updated state.
901    /// The workspace-global passes (relation validation, alias remap,
902    /// memo invalidation) run ONCE per refresh regardless of how many
903    /// mounts attached.
904    ///
905    /// A newly mounted mem starts cold and loads like any other
906    /// mount. Content reload of pre-existing mems is NOT part of this
907    /// method — callers that want both (the `memstead_reload
908    /// full=true` surface) run the existing content-reload sweep
909    /// alongside.
910    pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
911        let started = std::time::Instant::now();
912        let mut report = crate::ops::FullRefreshReport::default();
913
914        let Some(root) = self.workspace_root.clone() else {
915            report.failures.push(crate::ops::RefreshFailure {
916                item: "workspace".to_string(),
917                error: "engine has no workspace root (ad-hoc mount-list construction) — \
918                        nothing to re-scan"
919                    .to_string(),
920            });
921            report.elapsed_ms = started.elapsed().as_millis() as u64;
922            return report;
923        };
924
925        // Workspace policy — same best-effort refresh the
926        // workspace-wide content reload performs.
927        self.refresh_workspace_settings_if_possible();
928
929        // --- Schema sources, additively. ---
930        use crate::schema_source::SchemaSource as _;
931        let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
932        let mut sources_complete = true;
933        match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
934            Ok(mut s) => fresh.append(&mut s),
935            Err(e) => {
936                sources_complete = false;
937                report.failures.push(crate::ops::RefreshFailure {
938                    item: "schema-source:folder".to_string(),
939                    error: e.to_string(),
940                });
941            }
942        }
943        if let Some(ops) = self.git_branch_ops() {
944            match (ops.read_ref_schemas)(&root) {
945                Ok(mut s) => fresh.append(&mut s),
946                Err(e) => {
947                    sources_complete = false;
948                    report.failures.push(crate::ops::RefreshFailure {
949                        item: "schema-source:memstead-ref".to_string(),
950                        error: e.to_string(),
951                    });
952                }
953            }
954        }
955        let key = |s: &memstead_schema::Schema| {
956            let (name, version) = s.id();
957            format!("{name}@{version}")
958        };
959        let existing: std::collections::HashSet<String> =
960            self.workspace_schemas.iter().map(|s| key(s)).collect();
961        let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
962        for schema in fresh {
963            let k = key(&schema);
964            if !existing.contains(&k) && !report.schemas_added.contains(&k) {
965                report.schemas_added.push(k);
966                self.workspace_schemas.push(schema);
967            }
968        }
969        report.schemas_added.sort();
970        // Removal detection is only meaningful when every source was
971        // actually readable — otherwise an unreadable source would
972        // masquerade as a mass removal.
973        if sources_complete {
974            report.schema_removals_skipped = existing
975                .difference(&fresh_keys)
976                .cloned()
977                .collect::<Vec<_>>();
978            report.schema_removals_skipped.sort();
979        }
980
981        // --- Mount manifest, additively. ---
982        let store = crate::workspace_store::FileWorkspaceStore::new();
983        match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
984            Err(e) => {
985                report.failures.push(crate::ops::RefreshFailure {
986                    item: "mount-manifest".to_string(),
987                    error: e.to_string(),
988                });
989            }
990            Ok(workspace) => {
991                let manifest_names: std::collections::HashSet<String> =
992                    workspace.mounts.iter().map(|m| m.mem.clone()).collect();
993                let mut any_mounted = false;
994                for mount in workspace.mounts {
995                    if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
996                        continue;
997                    }
998                    let name = mount.mem.clone();
999                    if mount.capability != crate::workspace::MountCapability::Write {
1000                        report.failures.push(crate::ops::RefreshFailure {
1001                            item: format!("mount:{name}"),
1002                            error: "only writable mounts attach on a warm refresh — \
1003                                    restart the process to attach this mount"
1004                                .to_string(),
1005                        });
1006                        continue;
1007                    }
1008                    let backend = match (self.backend_factory)(&mount) {
1009                        Ok(b) => b,
1010                        Err(e) => {
1011                            report.failures.push(crate::ops::RefreshFailure {
1012                                item: format!("mount:{name}"),
1013                                error: e.to_string(),
1014                            });
1015                            continue;
1016                        }
1017                    };
1018                    match self.register_writable_mem_inner(
1019                        mount,
1020                        backend,
1021                        crate::mem::MemOrigin::ExplicitToml,
1022                        false,
1023                    ) {
1024                        Ok(()) => {
1025                            any_mounted = true;
1026                            report.mems_mounted.push(name);
1027                        }
1028                        Err(e) => report.failures.push(crate::ops::RefreshFailure {
1029                            item: format!("mount:{name}"),
1030                            error: e.to_string(),
1031                        }),
1032                    }
1033                }
1034                // Removals: a currently-mounted WRITABLE mem absent
1035                // from the manifest. Read-only attachments (archive
1036                // read-mems hydrated from per-mem configs) are not
1037                // manifest entries and never count as removals.
1038                report.mem_removals_skipped = self
1039                    .mounts
1040                    .iter()
1041                    .filter(|m| {
1042                        m.mount.capability == crate::workspace::MountCapability::Write
1043                            && !manifest_names.contains(&m.mount.mem)
1044                    })
1045                    .map(|m| m.mount.mem.clone())
1046                    .collect();
1047                report.mem_removals_skipped.sort();
1048                if any_mounted {
1049                    // One workspace-global pass for the whole batch.
1050                    self.finish_batched_registrations();
1051                }
1052            }
1053        }
1054
1055        report.elapsed_ms = started.elapsed().as_millis() as u64;
1056        report
1057    }
1058
1059    /// Override the workspace root after construction. The full
1060    /// boot helper `memstead_git_branch::engine_from_workspace_root`
1061    /// calls this so the engine knows the path even when the boot
1062    /// route runs through the full adapter rather than
1063    /// [`Self::from_workspace_root`].
1064    pub fn set_workspace_root(&mut self, root: PathBuf) {
1065        self.workspace_root = Some(root);
1066    }
1067
1068    /// Persist the engine's current mount list to the workspace
1069    /// store so a freshly-booted sibling process observes the same
1070    /// mem membership. Called by
1071    /// [`crate::mem_management::create_mem`] /
1072    /// [`crate::mem_management::delete_mem`] after the in-memory
1073    /// router mutation lands — without this, the per-mem content
1074    /// (branch + `__MEMSTEAD` config blob, or folder + `.memstead/config.json`)
1075    /// is already on disk, but the next process boot reads an empty
1076    /// `.memstead/state/mounts.json` and the engine starts with zero
1077    /// writable mems.
1078    ///
1079    /// No-op when `workspace_root` is unset (tests / ad-hoc
1080    /// consumers that build the engine directly from a mount list).
1081    /// Production boot paths (`Engine::from_workspace_root` and the
1082    /// full counterpart) always set the root, so the engine-side
1083    /// fix covers every caller — in-process embedders included
1084    /// — by construction.
1085    ///
1086    /// Hardcoded against [`crate::FileWorkspaceStore`] because that
1087    /// is the only V1 adapter; a future SQLite or remote adapter
1088    /// would install through a setter mirroring
1089    /// [`Self::set_backend_factory`].
1090    pub fn persist_state(&self) -> Result<(), EngineError> {
1091        let Some(root) = self.workspace_root.as_ref() else {
1092            return Ok(());
1093        };
1094        use crate::workspace_store::WorkspaceStoreAdapter as _;
1095        let store = crate::FileWorkspaceStore::new();
1096        let map = |e: crate::workspace_store::StoreError| {
1097            EngineError::Mem(format!("persist workspace state: {e}"))
1098        };
1099
1100        // Publish THIS engine's changes, not its whole cached view. An
1101        // earlier version serialized `self.mounts` wholesale, so a
1102        // long-lived process silently dropped every mount a sibling
1103        // process had registered since the cache was taken — the same
1104        // condition the mem-config writers close by re-reading, reaching
1105        // the workspace roster through the one writer they did not cover.
1106        // The delta is computed against `mounts_baseline` (what this
1107        // engine last read or wrote) rather than against a clock: a
1108        // single-writer workspace has an identical on-disk roster, so
1109        // the merge is a no-op there.
1110        // The roster this engine speaks for is the attached mounts PLUS
1111        // the quarantined ones: a quarantined mem's retained Mount is
1112        // what lets `reload` re-attempt the attach after a repair, and
1113        // dropping it from the file is how "degrade, never disappear"
1114        // turns into "disappear". It is also the record the
1115        // quarantine-repair path repins before asking for a state write.
1116        let ours: Vec<crate::workspace::Mount> = self
1117            .mounts
1118            .iter()
1119            .map(|m| m.mount.clone())
1120            .chain(self.quarantined.iter().map(|q| q.mount.clone()))
1121            .collect();
1122
1123        for attempt in 0..8 {
1124            let expected = store.read_state_bytes(root).map_err(map)?;
1125            let on_disk: Vec<crate::workspace::Mount> = match expected.as_deref() {
1126                Some(bytes) => store.parse_state_bytes(root, bytes).map_err(map)?,
1127                None => Vec::new(),
1128            };
1129
1130            let merged = {
1131                let baseline = self.mounts_baseline.borrow();
1132                merge_mount_rosters(&baseline, &ours, on_disk)
1133            };
1134            let workspace = crate::workspace::Workspace {
1135                mounts: merged,
1136                settings: self.settings.clone(),
1137            };
1138
1139            if store
1140                .save_state_cas(root, &workspace, expected.as_deref())
1141                .map_err(map)?
1142            {
1143                *self.mounts_baseline.borrow_mut() = ours;
1144                return Ok(());
1145            }
1146            if attempt == 7 {
1147                return Err(EngineError::Mem(
1148                    "workspace state is being written concurrently: eight compare-and-set \
1149                     attempts all lost the race. Retry, or find the writer that is not \
1150                     backing off."
1151                        .to_string(),
1152                ));
1153            }
1154        }
1155        unreachable!("the loop returns on success and on exhaustion")
1156    }
1157    /// Set a mem's schema pin — the conformance-gated schema-migration
1158    /// trigger. Behaviour per the pinned contract:
1159    ///
1160    /// - requested == current pin → `Noop`, no state change.
1161    /// - requested != pin, mem integral against the target →
1162    ///   atomic switch (`schema_pin = target`, migration state
1163    ///   cleared) in one workspace-store write → `Switched`.
1164    /// - requested != pin, mem NOT integral → enter (or stay in)
1165    ///   dual-pin: `migration_target = target`, writes validate
1166    ///   against the target from this call on, `findings` carries
1167    ///   the non-integral entities → `MigrationStarted`
1168    ///   (first call) / `MigrationPending` (same target re-issued).
1169    /// - re-issued with the in-flight target once every entity is
1170    ///   integral → atomic switch → `Switched`.
1171    ///
1172    /// The trigger is a label change gated by the conformance check —
1173    /// no content hashing. The response hands the agent findings and
1174    /// nothing else (no migration scripts, no hints); each repair
1175    /// write is validated strictly against the target.
1176    pub fn set_mem_schema(
1177        &mut self,
1178        mem: &str,
1179        target: &memstead_schema::SchemaRef,
1180    ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1181        use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1182        // A quarantined mem's set-schema IS the repair path (an
1183        // unresolvable pin is the commonest quarantine cause): repin
1184        // the retained mount, then re-attempt the attach — the same
1185        // in-process recovery `reload` performs after an external
1186        // repair. Ordinary mounts proceed below unchanged.
1187        if self.quarantine_reason(mem).is_some() {
1188            return self.set_schema_on_quarantined(mem, target);
1189        }
1190        let mount_idx = self
1191            .mounts
1192            .iter()
1193            .position(|m| m.mount.mem == mem)
1194            .ok_or_else(|| self.unknown_mem_error(mem))?;
1195        // Capability gate, identical in shape and position to the six
1196        // sibling setters (`set_mem_version` … `set_mem_sync_state`):
1197        // a schema-pin change starts a migration — the one lifecycle
1198        // mutation a read-only mount must be able to refuse like any
1199        // other.
1200        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1201            return Err(EngineError::ReadOnlyMount(mem.to_string()));
1202        }
1203
1204        // The requested target must resolve before anything else —
1205        // an unknown ref is an error, not a migration into nowhere.
1206        let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1207            // The migration resolver consulted workspace-authored
1208            // schemas layered over the built-ins (`resolve_schema_by_ref`).
1209            let consulted: Vec<_> = self
1210                .workspace_schemas
1211                .iter()
1212                .chain(self.builtin_schemas.iter())
1213                .cloned()
1214                .collect();
1215            EngineError::SchemaNotFound {
1216                mem: mem.to_string(),
1217                pin: target.as_display(),
1218                sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1219                    &target.name,
1220                    &target.version,
1221                    &consulted,
1222                ),
1223                install_hint: None,
1224            }
1225            .with_schema_install_probe(self.workspace_root())
1226        })?;
1227
1228        // `Mount.schema` is now the optional assertion; for a mem the
1229        // operator is actively re-pinning it is normally `Some` (and kept
1230        // in sync with the config by the switch below). `<unset>` covers a
1231        // mount that carried no assertion.
1232        let current_pin = self.mounts[mount_idx].mount.schema.clone();
1233        let current_pin_display = current_pin
1234            .as_ref()
1235            .map(|p| p.as_display())
1236            .unwrap_or_else(|| "<unset>".to_string());
1237        let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1238
1239        // The noop question is asked of the pin the engine actually
1240        // serves (the loaded schema, resolved from the authoritative
1241        // backend config at boot), never of the mount's expectation
1242        // alone. With a `SCHEMA_PIN_MISMATCH` (mount says 0.4.0, config
1243        // says 0.1.0) the old comparison against the mount answered
1244        // "noop" for a target of 0.4.0 and the config stayed at 0.1.0
1245        // forever: the one command meant to repair the mismatch could
1246        // not. When the served pin already equals the target and only
1247        // the mount expectation lags, the expectation is aligned in
1248        // place and reported as switched.
1249        let served_pin: Option<memstead_schema::SchemaRef> = self.schemas.get(mem).map(|s| {
1250            let (name, version) = s.id();
1251            memstead_schema::SchemaRef::new(name, version)
1252        });
1253        // During a dual-pin migration the served schema IS the target
1254        // (writes validate against it) while the config still pins the
1255        // old generation, so the shortcut applies only with no migration
1256        // in flight; an in-flight target falls through to the
1257        // conformance gate that completes it.
1258        if served_pin.as_ref() == Some(target) && in_flight.is_none() {
1259            if current_pin.as_ref() == Some(target) {
1260                return Ok(SetSchemaOutcome {
1261                    mem: mem.to_string(),
1262                    schema_pin: current_pin_display,
1263                    migration_target: in_flight.map(|t| t.as_display()),
1264                    outcome: SetSchemaResult::Noop,
1265                    findings: Vec::new(),
1266                });
1267            }
1268            self.mounts[mount_idx].mount.schema = Some(target.clone());
1269            self.mounts[mount_idx].mount.migration_target = None;
1270            self.persist_state()?;
1271            return Ok(SetSchemaOutcome {
1272                mem: mem.to_string(),
1273                schema_pin: target.as_display(),
1274                migration_target: None,
1275                outcome: SetSchemaResult::Switched,
1276                findings: Vec::new(),
1277            });
1278        }
1279
1280        // Conformance gate against the requested target. The full
1281        // integrity definition includes the consistency axis, but the
1282        // schema-switch gate is conformance: consistency breaks are
1283        // schema-independent (they neither block nor are caused by a
1284        // pin change) and keep their always-available repair paths.
1285        let findings = crate::ops::integrity::conformance_findings(
1286            &self.store,
1287            mem,
1288            target_schema.as_ref(),
1289            &self.schemas,
1290        );
1291
1292        if findings.is_empty() {
1293            // Atomic switch. The pin's authoritative home is the backend
1294            // config (boot resolution prefers it over `Mount.schema`), so
1295            // persist there FIRST — if that write fails, every other piece
1296            // of state stays untouched and the switch is a clean no-op.
1297            // Without this the new pin landed only in `mounts.json` and was
1298            // silently reverted on the next process boot for any
1299            // config-present mem.
1300            self.persist_mem_schema_pin(mount_idx, target)?;
1301            self.mounts[mount_idx].mount.schema = Some(target.clone());
1302            self.mounts[mount_idx].mount.migration_target = None;
1303            self.schemas_insert(mem.to_string(), target_schema);
1304            self.invalidate_communities();
1305            // The index field set derives from the pinned schema — the
1306            // schema-switch staleness the whole-map drop used to mask
1307            // (flywheel W8/01, criterion 2): this mem's index must be
1308            // rebuilt against the new field set.
1309            self.invalidate_search_indexes();
1310            self.persist_state()?;
1311            return Ok(SetSchemaOutcome {
1312                mem: mem.to_string(),
1313                schema_pin: target.as_display(),
1314                migration_target: None,
1315                outcome: SetSchemaResult::Switched,
1316                findings: Vec::new(),
1317            });
1318        }
1319
1320        let outcome = if in_flight.as_ref() == Some(target) {
1321            SetSchemaResult::MigrationPending
1322        } else {
1323            SetSchemaResult::MigrationStarted
1324        };
1325        self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1326        // Writes validate against the target from this point on —
1327        // the load-bearing dual-pin semantic.
1328        self.schemas_insert(mem.to_string(), target_schema);
1329        self.invalidate_communities();
1330        // Same field-set dependency as the atomic-switch branch above.
1331        self.invalidate_search_indexes();
1332        self.persist_state()?;
1333        Ok(SetSchemaOutcome {
1334            mem: mem.to_string(),
1335            schema_pin: current_pin_display,
1336            migration_target: Some(target.as_display()),
1337            outcome,
1338            findings,
1339        })
1340    }
1341
1342    /// Persist a mem's new schema pin into the authoritative backend
1343    /// config (`.memstead/config.json` for folder, the `__MEMSTEAD`
1344    /// mem-config blob for git-branch).
1345    ///
1346    /// Boot resolution treats the backend config as the authoritative
1347    /// settled pin and `Mount.schema` (the `mounts.json` copy) as a
1348    /// cross-checked assertion. A schema switch that updated only
1349    /// `mounts.json` would therefore be silently reverted on the next
1350    /// process boot — the config still names the old pin. This keeps the
1351    /// authoritative home in sync at switch time.
1352    ///
1353    /// Value-level field bump: only the `"schema"` string is rewritten;
1354    /// every other config field (`readMems`, write guidance, …) is
1355    /// preserved verbatim. Config-absent mems (no `config.json`) keep
1356    /// `Mount.schema` as their settled pin, so there is nothing to update
1357    /// — a clean no-op.
1358    /// **The one way this engine writes a mem config.** Every config-writing
1359    /// operation goes through it; none serializes its cached struct
1360    /// (consistency-sweep 04/03, criteria 1 and 2).
1361    ///
1362    /// The defect it removes: a long-lived MCP server reads each mem's config
1363    /// once at boot and holds it for days. Eight operations used to clone that
1364    /// cached struct, set one field, and write the whole thing back, so
1365    /// anything a sibling process changed in between was gone. The
1366    /// reload-before-operation invariant did not save them, because the
1367    /// staleness probe watches the entity branch (git-branch) or the change
1368    /// log (folder) and a config-only write advances neither.
1369    ///
1370    /// What this does instead is what the schema-pin writer next door already
1371    /// did: read the config the backend HAS, mutate the one field on that
1372    /// JSON, write it back. A field this call did not set cannot be reverted,
1373    /// because this call never had an opinion about it.
1374    ///
1375    /// `apply` receives the freshly-read config, PARSED. It deliberately does
1376    /// not receive raw JSON: an earlier draft handed out the `Value` and each
1377    /// closure wrote its own key name, which put `review_mark` in the file
1378    /// where the struct reads `reviewMark` and lost the field on the next
1379    /// read. Serde owns the wire names; the closures must not restate them.
1380    /// Unknown fields survive because `MemConfig` flattens them into `extra`.
1381    ///
1382    /// `apply` runs again on a re-read if the file moved between the read and
1383    /// the write, so it must be a pure function of the config it is handed,
1384    /// never of the engine's cache.
1385    ///
1386    /// Returns the parsed new config plus, when the stored config had moved on
1387    /// from what this engine last observed, the fields the intervening writer
1388    /// had changed. The caller rides that on its own response as
1389    /// `CONFIG_WRITE_INTERVENED`.
1390    pub(crate) fn write_mem_config_merged(
1391        &mut self,
1392        mount_idx: usize,
1393        mem_name: &str,
1394        note: Option<&str>,
1395        apply: &dyn Fn(&mut memstead_schema::config::MemConfig),
1396    ) -> Result<(memstead_schema::config::MemConfig, Vec<String>), EngineError> {
1397        let backend = self.mounts[mount_idx].backend.as_ref();
1398        let read = |b: &dyn crate::backend::MemBackend| -> Result<Vec<u8>, EngineError> {
1399            b.read_mem_config()
1400                .map_err(|e| EngineError::Mem(format!("read mem config for update: {e}")))?
1401                .ok_or_else(|| {
1402                    EngineError::InvalidInput(format!(
1403                        "mem '{mem_name}' has no stored MemConfig (initialize the mem via \
1404                         `memstead init` or `memstead mem create` first)"
1405                    ))
1406                })
1407        };
1408
1409        let stored = read(backend)?;
1410        // What the intervening writer changed, if anyone did. Compared against
1411        // the engine's cached copy, never against a clock: criterion 6 forbids
1412        // reacting to cache age, and a single-writer workspace has an
1413        // identical cache, so this is empty there (criterion 4).
1414        let intervened = match self.mounts[mount_idx].mem_config.as_ref() {
1415            Some(cached) => changed_config_fields(cached, &stored),
1416            None => Vec::new(),
1417        };
1418
1419        let render =
1420            |raw: &[u8]| -> Result<(memstead_schema::config::MemConfig, Vec<u8>), EngineError> {
1421                let value: serde_json::Value = serde_json::from_slice(raw)
1422                    .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1423                let mut cfg = memstead_schema::config::parse_mem_config(&value)
1424                    .map_err(|e| EngineError::Mem(format!("parse mem config for update: {e}")))?;
1425                apply(&mut cfg);
1426                let mut bytes = serde_json::to_vec_pretty(&cfg)
1427                    .map_err(|e| EngineError::Mem(format!("serialize mem config: {e}")))?;
1428                bytes.push(b'\n');
1429                Ok((cfg, bytes))
1430            };
1431        let (mut parsed, mut bytes) = render(&stored)?;
1432
1433        // Compare-and-set, done by the backend so the check and the write are
1434        // one step. An earlier draft re-read here and then wrote, which is
1435        // check-then-write and leaves exactly the window criterion 5 names
1436        // open. On a mismatch the loop re-reads, re-applies onto what is
1437        // there, and retries: the intervening writer's change is merged, never
1438        // overwritten. Bounded, because an unbounded retry against a hot
1439        // writer is a hang, and reaching the bound is a real contention
1440        // problem the caller should hear about rather than a state to spin in.
1441        let mut expected = stored;
1442        for attempt in 0..8 {
1443            let wrote = self.mounts[mount_idx].backend.write_mem_config_cas(
1444                Some(&expected),
1445                &bytes,
1446                note,
1447            )?;
1448            if wrote {
1449                break;
1450            }
1451            if attempt == 7 {
1452                return Err(EngineError::Mem(format!(
1453                    "mem '{mem_name}' config is being written concurrently: eight \
1454                     compare-and-set attempts all lost the race. Retry, or find the \
1455                     writer that is not backing off."
1456                )));
1457            }
1458            expected = read(self.mounts[mount_idx].backend.as_ref())?;
1459            let rendered = render(&expected)?;
1460            parsed = rendered.0;
1461            bytes = rendered.1;
1462        }
1463
1464        let mounted = &mut self.mounts[mount_idx];
1465        mounted.mem_config = Some(parsed.clone());
1466        // Refresh the head cursor so the next drift probe does not surface
1467        // MEM_RELOADED for the commit this call just produced.
1468        if let Some(sha) = mounted.backend.current_head().ok().flatten() {
1469            mounted.last_known_head = Some(sha);
1470        }
1471        Ok((parsed, intervened))
1472    }
1473
1474    fn persist_mem_schema_pin(
1475        &mut self,
1476        mount_idx: usize,
1477        target: &memstead_schema::SchemaRef,
1478    ) -> Result<(), EngineError> {
1479        let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1480        // Refresh the cached parsed config so in-session reads observe the
1481        // new pin without a reload.
1482        if let Some(value) = value
1483            && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1484        {
1485            self.mounts[mount_idx].mem_config = Some(cfg);
1486        }
1487        Ok(())
1488    }
1489
1490    /// Regenerate entity markdown files from the in-memory store.
1491    ///
1492    /// Dispatch:
1493    /// - When `mem_filter` is `Some(name)`, only that mem's mount
1494    ///   is considered. If its active backend doesn't support markdown
1495    ///   regeneration in place (today: anything other than
1496    ///   `MountStorage::Folder`), the call refuses with
1497    ///   [`EngineError::MarkdownExportUnsupportedBackend`] carrying
1498    ///   the active backend's id and the supported-backend list.
1499    /// - When `mem_filter` is `None`, every mount is iterated.
1500    ///   Folder mounts regenerate as today; non-folder mounts are
1501    ///   recorded in [`crate::ops::ExportResult::skipped_mounts`] so
1502    ///   the caller can surface the partial-success shape.
1503    ///
1504    /// Per-folder-mount behaviour: iterate the store, regenerate each
1505    /// non-stub entity belonging to the mount's mem, compare to the
1506    /// on-disk file, write if changed.
1507    ///
1508    /// `schema_filter` narrows the per-entity-type subset: when
1509    /// `Some(name)`, only entities whose `entity_type` matches are
1510    /// regenerated. `None` exports every type.
1511    ///
1512    /// Pre-fix this returned
1513    /// `ExportResult { written: 0, unchanged: 0 }` for git-branch /
1514    /// archive mounts — a successful-looking no-op that masked the
1515    /// backend-incompatibility. The typed refusal (per-mem) and the
1516    /// `skipped_mounts` channel (workspace-wide) give the caller an
1517    /// agent-actionable signal in one round-trip.
1518    pub fn export_markdown(
1519        &self,
1520        mem_filter: Option<&str>,
1521        schema_filter: Option<&str>,
1522    ) -> Result<crate::ops::ExportResult, EngineError> {
1523        use crate::workspace::MountStorage;
1524        let fallback = engine_fallback_type();
1525        let supported_backends = vec!["folder".to_string()];
1526
1527        if let Some(name) = mem_filter {
1528            let mount = self
1529                .mounts
1530                .iter()
1531                .find(|m| m.mount.mem == name)
1532                .ok_or_else(|| self.unknown_mem_error(name))?;
1533            if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1534                return Err(EngineError::MarkdownExportUnsupportedBackend {
1535                    mem: name.to_string(),
1536                    active_backend: mount.mount.storage.backend_id().to_string(),
1537                    supported_backends,
1538                });
1539            }
1540        }
1541
1542        let mut total_written = 0;
1543        let mut refused: Vec<crate::ops::RefusedEntity> = Vec::new();
1544        let mut total_unchanged = 0;
1545        let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1546
1547        for mount in &self.mounts {
1548            let mem_name = mount.mount.mem.as_str();
1549            if let Some(filter) = mem_filter
1550                && mem_name != filter
1551            {
1552                continue;
1553            }
1554            let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1555                skipped_mounts.push(crate::ops::SkippedMount {
1556                    mem: mem_name.to_string(),
1557                    active_backend: mount.mount.storage.backend_id().to_string(),
1558                    reason: "backend_does_not_support_markdown_export".to_string(),
1559                });
1560                continue;
1561            };
1562            let schema = match self.schemas.get(mem_name) {
1563                Some(s) => s,
1564                None => continue,
1565            };
1566
1567            for entity in self.store.all_entities() {
1568                if entity.stub || entity.file_path.is_empty() {
1569                    continue;
1570                }
1571                if entity.id.mem() != mem_name {
1572                    continue;
1573                }
1574                if let Some(filter) = schema_filter
1575                    && entity.entity_type != filter
1576                {
1577                    continue;
1578                }
1579                let type_def = schema
1580                    .get_type(&entity.entity_type)
1581                    .unwrap_or_else(|| fallback.clone());
1582                let generated = generate_markdown(entity, type_def.as_ref());
1583
1584                let full_path = mem_dir.join(&entity.file_path);
1585                let needs_write = match std::fs::read_to_string(&full_path) {
1586                    Ok(existing) => existing != generated,
1587                    Err(_) => true,
1588                };
1589                if needs_write {
1590                    match crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref()) {
1591                        Ok(_) => total_written += 1,
1592                        // The one refusal the export must not swallow: writing
1593                        // this entity would bury the sections its open fence
1594                        // absorbed. Name it and carry on — an export that
1595                        // aborted here would strand every other entity.
1596                        Err(e @ crate::entity::writer::WriteError::UnterminatedFence { .. }) => {
1597                            refused.push(crate::ops::RefusedEntity {
1598                                id: entity.id.to_string(),
1599                                reason: "UNTERMINATED_FENCE_IN_STORED_BODY".to_string(),
1600                                detail: e.to_string(),
1601                            });
1602                        }
1603                        Err(_) => {}
1604                    }
1605                } else {
1606                    total_unchanged += 1;
1607                }
1608            }
1609        }
1610
1611        Ok(crate::ops::ExportResult {
1612            refused_entities: refused,
1613            written: total_written,
1614            unchanged: total_unchanged,
1615            skipped_mounts,
1616        })
1617    }
1618
1619    /// Export a mem as a portable `.mem` archive.
1620    ///
1621    /// Dispatch is internal: the engine looks up the mount whose mem
1622    /// name matches and branches on its `MountStorage`. Folder mounts
1623    /// produce a snapshot archive (current `.md` files + config);
1624    /// git-branch mounts invoke the registered [`GitBranchOps::export`]
1625    /// hook to produce a history archive (the per-mem branch tip's
1626    /// tree); archive mounts reject with `BackendError::Sealed`
1627    /// (already-an-archive — no meaningful re-export).
1628    ///
1629    /// The mem's `MemConfig` is looked up via
1630    /// [`Self::mem_config_for`]; unloaded configs (folder mounts
1631    /// without a `.memstead/config.json`, git-branch mounts without a
1632    /// `__MEMSTEAD:mems/<mem>/config.json`) surface as
1633    /// `EngineError::InvalidInput`. Workspace-level schema dir is
1634    /// threaded from `self.settings.schemas_dir` for the
1635    /// schema-source resolution chain.
1636    pub fn export_mem(
1637        &self,
1638        mem_name: &str,
1639        output_path: &std::path::Path,
1640    ) -> Result<crate::ops::MemExportResult, EngineError> {
1641        let mount = self
1642            .mounts
1643            .iter()
1644            .find(|m| m.mount.mem == mem_name)
1645            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1646        let config = self.mem_config_for(mem_name).ok_or_else(|| {
1647            EngineError::InvalidInput(format!(
1648                "mem '{mem_name}' has no loaded MemConfig — cannot export"
1649            ))
1650        })?;
1651        // F1: surface the missing-version case as a typed
1652        // `MEM_CONFIG_INCOMPLETE` envelope with structured recovery
1653        // details, rather than letting it bubble through as the
1654        // backend's `INTERNAL` collapse pointing at the wrong path
1655        // (`.memstead/config.json` is the folder-backend layout — the
1656        // mem-repo backend keeps the blob under `__MEMSTEAD:mems/`).
1657        // The check fires for both backends symmetrically.
1658        if config.version.is_none() {
1659            return Err(EngineError::MemConfigIncomplete {
1660                mem: mem_name.to_string(),
1661                missing_fields: vec!["version".to_string()],
1662            });
1663        }
1664        // Collected before the backend split so both storage flavours report
1665        // it. `install` refuses the archive for each of these; naming them at
1666        // export time is the same courtesy the dangling cross-mem edges get,
1667        // and for the same reason: an operator who learns at install time has
1668        // already shared the archive.
1669        let fenced: Vec<String> = self
1670            .store
1671            .all_entities()
1672            .filter(|e| e.mem == mem_name && !e.stub)
1673            .filter(|e| {
1674                e.sections
1675                    .values()
1676                    .any(|v| crate::markdown::closing_fence_if_unterminated(v.trim()).is_some())
1677            })
1678            .map(|e| e.id.to_string())
1679            .collect();
1680        let workspace_root = self.workspace_root.as_deref();
1681        // Authored schemas live at the fixed `<workspace>/.memstead/schemas/`
1682        // location (the `schemas_dir` key is retired). Absent dir → the
1683        // schema-source chain falls through to cache/built-in, as before.
1684        let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1685        let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1686        let exported = match &mount.mount.storage {
1687            MountStorage::Folder { path } => crate::ops::export::export_mem(
1688                path,
1689                config,
1690                output_path,
1691                workspace_root,
1692                workspace_schemas_dir,
1693            )
1694            .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1695            MountStorage::GitBranch { gitdir, branch } => {
1696                let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1697                    EngineError::Backend(BackendError::Other(
1698                        "git-branch export hook not installed (full flavour not loaded)"
1699                            .to_string(),
1700                    ))
1701                })?;
1702                // Source per-entity provenance from the git-branch mutation
1703                // log (commit trailers) and hand the serialised payload to
1704                // the export hook to embed — symmetric with the bytes path.
1705                let provenance_bytes = mount
1706                    .backend
1707                    .read_provenance(None)
1708                    .ok()
1709                    .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1710                    .and_then(|prov| prov.to_archive_bytes().ok());
1711                // Source the anchors sidecar from the branch tip — symmetric
1712                // with the bytes-export path so the disk `.mem` carries anchors.
1713                let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1714                (hook.export)(
1715                    gitdir,
1716                    branch,
1717                    mem_name,
1718                    config,
1719                    output_path,
1720                    workspace_root,
1721                    workspace_schemas_dir,
1722                    provenance_bytes.as_deref(),
1723                    anchors_bytes.as_deref(),
1724                )
1725                .map_err(EngineError::Backend)
1726            }
1727            MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1728            // `.mem` export from an in-memory mem lands with the
1729            // writable-session-server plan (it needs a backend-level
1730            // archive builder); this plan adds the backend, not the
1731            // export path, so refuse explicitly rather than silently.
1732            MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1733                "export not yet supported for in-memory backend".to_string(),
1734            ))),
1735        };
1736        exported.map(|mut r| {
1737            r.unterminated_fence_entities = fenced;
1738            r
1739        })
1740    }
1741
1742    /// Update a mem's `version` field in its per-mem config and
1743    /// persist it through the backend. Backend-symmetric: folder
1744    /// backends rewrite `.memstead/config.json`; git-branch backends
1745    /// commit `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts
1746    /// reject with `BackendError::Sealed`.
1747    ///
1748    /// Returns the (mem, old_version, new_version) triple so
1749    /// callers can surface the change without an extra read. Reads
1750    /// the current value from the in-memory `MemConfig` and
1751    /// updates it on success, keeping the next call free of a
1752    /// stale-version read.
1753    ///
1754    /// `EngineError::UnknownMem` when the name resolves to no
1755    /// mount; `EngineError::ReadOnlyMount` when the mount is sealed
1756    /// for writes; `EngineError::InvalidInput` when the mount has no
1757    /// loaded `MemConfig` (folder mount with no
1758    /// `.memstead/config.json`; the residual missing-config path is
1759    /// distinct from the missing-version path). F1.
1760    /// Record pipeline-edit provenance through `mem`'s backend — the
1761    /// bridge the pipeline-edit block (outside the engine module) uses
1762    /// to reach a mount's backend. A mem that isn't currently mounted
1763    /// is a successful no-op: pipeline configs may reference unmounted
1764    /// mems, and provenance is recorded against the mounted set.
1765    pub fn record_pipeline_edit_provenance(
1766        &self,
1767        mem: &str,
1768        kind: &str,
1769        edits: &[(String, Option<Vec<u8>>)],
1770        note: Option<&str>,
1771        verb: &str,
1772    ) -> Result<(), crate::backend::BackendError> {
1773        match self.mounts.iter().find(|m| m.mount.mem == mem) {
1774            Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1775            None => Ok(()),
1776        }
1777    }
1778
1779    pub fn set_mem_version(
1780        &mut self,
1781        mem_name: &str,
1782        new_version: semver::Version,
1783        note: Option<&str>,
1784    ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1785        // Resolve the mount up-front so an unknown-mem name refuses
1786        // before any drift-probe side effect lands.
1787        let mount_idx = self
1788            .mounts
1789            .iter()
1790            .position(|m| m.mount.mem == mem_name)
1791            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1792        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1793            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1794        }
1795
1796        // Probe for concurrent-drift before the write — a sibling
1797        // engine that committed between our last snapshot and now
1798        // surfaces `MEM_RELOADED` on the response so callers see
1799        // the drift without a separate read round-trip. Drift
1800        // warnings ride alongside the success outcome; an
1801        // unreachable-backend probe collapses to no warnings (the
1802        // existing accessor warn-logs internally and skips).
1803        let mut warnings = self.reload_if_stale(Some(mem_name));
1804        // Provenance nudge — same posture as every other commit-
1805        // producing mutation: when `require_notes` is set and no note
1806        // was supplied, ride a non-blocking `NOTE_MISSING` warning.
1807        // The version bump still commits.
1808        if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1809            warnings.push(w);
1810        }
1811
1812        // The old value is read from the STORED config, not the cache: the
1813        // cache may be days stale, and reporting a version the file has not
1814        // held since boot would be its own small lie.
1815        let old_version = self.mounts[mount_idx]
1816            .mem_config
1817            .as_ref()
1818            .and_then(|c| c.version.clone());
1819        let target = new_version.clone();
1820        let (_, intervened) = self.write_mem_config_merged(
1821            mount_idx,
1822            mem_name,
1823            note,
1824            &move |c: &mut memstead_schema::config::MemConfig| {
1825                c.version = Some(target.clone());
1826            },
1827        )?;
1828        if !intervened.is_empty() {
1829            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1830                mem: mem_name.to_string(),
1831                fields: intervened,
1832            });
1833        }
1834
1835        Ok(crate::ops::SetMemVersionOutcome {
1836            mem: mem_name.to_string(),
1837            old_version,
1838            new_version,
1839            warnings,
1840        })
1841    }
1842
1843    /// Update a mem's `description` field in its per-mem config and
1844    /// persist it through the backend — the one-line text mem-archive
1845    /// export embeds and the registry card surfaces. `None` clears the
1846    /// field. Same backend symmetry, drift probe, and provenance-note
1847    /// posture as [`Self::set_mem_version`]; archive mounts reject with
1848    /// `BackendError::Sealed`.
1849    pub fn set_mem_description(
1850        &mut self,
1851        mem_name: &str,
1852        new_description: Option<String>,
1853        note: Option<&str>,
1854    ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1855        let mount_idx = self
1856            .mounts
1857            .iter()
1858            .position(|m| m.mount.mem == mem_name)
1859            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1860        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1861            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1862        }
1863
1864        let mut warnings = self.reload_if_stale(Some(mem_name));
1865        if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1866            warnings.push(w);
1867        }
1868
1869        let old_description = self.mounts[mount_idx]
1870            .mem_config
1871            .as_ref()
1872            .and_then(|c| c.description.clone());
1873        let target = new_description.clone();
1874        let (_, intervened) = self.write_mem_config_merged(
1875            mount_idx,
1876            mem_name,
1877            note,
1878            &move |c: &mut memstead_schema::config::MemConfig| {
1879                c.description = target.clone();
1880            },
1881        )?;
1882        if !intervened.is_empty() {
1883            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1884                mem: mem_name.to_string(),
1885                fields: intervened,
1886            });
1887        }
1888
1889        Ok(crate::ops::SetMemDescriptionOutcome {
1890            mem: mem_name.to_string(),
1891            old_description,
1892            new_description,
1893            warnings,
1894        })
1895    }
1896
1897    /// Update a mem's display `title` — free text, NOT identity: the
1898    /// mem name stays the sole handle everywhere. `None` clears it.
1899    /// Mirrors [`Self::set_mem_description`] in backend symmetry,
1900    /// drift probe, and provenance-note posture.
1901    pub fn set_mem_title(
1902        &mut self,
1903        mem_name: &str,
1904        new_title: Option<String>,
1905        note: Option<&str>,
1906    ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1907        let mount_idx = self
1908            .mounts
1909            .iter()
1910            .position(|m| m.mount.mem == mem_name)
1911            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1912        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1913            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1914        }
1915
1916        let mut warnings = self.reload_if_stale(Some(mem_name));
1917        if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1918            warnings.push(w);
1919        }
1920
1921        let old_title = self.mounts[mount_idx]
1922            .mem_config
1923            .as_ref()
1924            .and_then(|c| c.title.clone());
1925        let target = new_title.clone();
1926        let (_, intervened) = self.write_mem_config_merged(
1927            mount_idx,
1928            mem_name,
1929            note,
1930            &move |c: &mut memstead_schema::config::MemConfig| {
1931                c.title = target.clone();
1932            },
1933        )?;
1934        if !intervened.is_empty() {
1935            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1936                mem: mem_name.to_string(),
1937                fields: intervened,
1938            });
1939        }
1940
1941        Ok(crate::ops::SetMemTitleOutcome {
1942            mem: mem_name.to_string(),
1943            old_title,
1944            new_title,
1945            warnings,
1946        })
1947    }
1948
1949    /// Update a mem's `subject` block — scope, method, deliberate
1950    /// exclusions, published verbatim. `None` clears the block AS A
1951    /// UNIT. Mirrors [`Self::set_mem_description`].
1952    pub fn set_mem_subject(
1953        &mut self,
1954        mem_name: &str,
1955        new_subject: Option<memstead_schema::MemSubject>,
1956        note: Option<&str>,
1957    ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1958        let mount_idx = self
1959            .mounts
1960            .iter()
1961            .position(|m| m.mount.mem == mem_name)
1962            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1963        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1964            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1965        }
1966
1967        let mut warnings = self.reload_if_stale(Some(mem_name));
1968        if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1969            warnings.push(w);
1970        }
1971
1972        let old_subject = self.mounts[mount_idx]
1973            .mem_config
1974            .as_ref()
1975            .and_then(|c| c.subject.clone());
1976        let target = new_subject.clone();
1977        let (_, intervened) = self.write_mem_config_merged(
1978            mount_idx,
1979            mem_name,
1980            note,
1981            &move |c: &mut memstead_schema::config::MemConfig| {
1982                c.subject = target.clone();
1983            },
1984        )?;
1985        if !intervened.is_empty() {
1986            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
1987                mem: mem_name.to_string(),
1988                fields: intervened,
1989            });
1990        }
1991
1992        Ok(crate::ops::SetMemSubjectOutcome {
1993            mem: mem_name.to_string(),
1994            old_subject,
1995            new_subject,
1996            warnings,
1997        })
1998    }
1999
2000    /// Mark (or unmark) a mem as **internal** — hidden from the default
2001    /// `memstead_overview` roster and public projections, while remaining a
2002    /// real, schema-validated, diffable mem (inspectable when explicitly
2003    /// scoped by name, and deletable). The ingest process-state redesign
2004    /// (candidate (b)) flags each `ingest/<name>` process mem this way so it
2005    /// does not clutter the roster alongside real content.
2006    ///
2007    /// Stored as the top-level `internal` config field (captured by the
2008    /// flattened `extra` map). Backend-symmetric like
2009    /// [`Self::set_mem_description`]; `EngineError::UnknownMem` /
2010    /// `ReadOnlyMount` / `InvalidInput` on the usual failures.
2011    pub fn set_mem_internal(
2012        &mut self,
2013        mem_name: &str,
2014        internal: bool,
2015        note: Option<&str>,
2016    ) -> Result<crate::ops::SetMemInternalOutcome, EngineError> {
2017        let mount_idx = self
2018            .mounts
2019            .iter()
2020            .position(|m| m.mount.mem == mem_name)
2021            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2022        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2023            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2024        }
2025
2026        let _ = self.reload_if_stale(Some(mem_name));
2027
2028        let (_, intervened) = self.write_mem_config_merged(
2029            mount_idx,
2030            mem_name,
2031            note,
2032            &move |c: &mut memstead_schema::config::MemConfig| {
2033                if internal {
2034                    c.extra
2035                        .insert("internal".to_string(), serde_json::Value::Bool(true));
2036                } else {
2037                    c.extra.remove("internal");
2038                }
2039            },
2040        )?;
2041        // This one used to return a bare `bool` and so had nowhere to put the
2042        // intervention report: it was dropped, not even logged (04/03,
2043        // criterion 3, found by the plan's grade). A writer whose signature
2044        // cannot carry a warning is a writer that silently will not.
2045        let mut warnings = Vec::new();
2046        if !intervened.is_empty() {
2047            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2048                mem: mem_name.to_string(),
2049                fields: intervened,
2050            });
2051        }
2052
2053        Ok(crate::ops::SetMemInternalOutcome {
2054            mem: mem_name.to_string(),
2055            internal,
2056            warnings,
2057        })
2058    }
2059
2060    /// Set (or clear) one opaque sync-state token in a mem's per-mem
2061    /// config and persist it through the backend. The ingest layer calls
2062    /// this after a successful pass over a source's changed slice to
2063    /// record "the source state the graph was last synced against".
2064    ///
2065    /// `key` and `token` are both opaque to the engine: the key is
2066    /// conventionally `"<ingest>/<facet>"` but the engine treats it as an
2067    /// arbitrary string; the token's meaning belongs to the medium-type
2068    /// layer (git → commit id, graph → snapshot token, filesystem → a
2069    /// JSON-stringified stat digest). The engine never parses either.
2070    /// An **empty** `token` removes the key — the surface for clearing a
2071    /// baseline (which the next ingest pass re-seeds at the current
2072    /// source state).
2073    ///
2074    /// Backend-symmetric like [`Self::set_mem_version`]: folder backends
2075    /// rewrite `.memstead/config.json`; git-branch backends commit
2076    /// `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts reject with
2077    /// `BackendError::Sealed`.
2078    ///
2079    /// Returns the (mem, key, previous-token) triple so callers can
2080    /// surface the change without an extra read. `EngineError::UnknownMem`
2081    /// when the name resolves to no mount; `EngineError::ReadOnlyMount`
2082    /// when the mount is sealed for writes; `EngineError::InvalidInput`
2083    /// when the mount has no loaded `MemConfig`.
2084    pub fn set_mem_sync_state(
2085        &mut self,
2086        mem_name: &str,
2087        key: &str,
2088        token: &str,
2089        note: Option<&str>,
2090    ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
2091        // Resolve the mount up-front so an unknown-mem name refuses
2092        // before any drift-probe side effect lands.
2093        let mount_idx = self
2094            .mounts
2095            .iter()
2096            .position(|m| m.mount.mem == mem_name)
2097            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
2098        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
2099            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
2100        }
2101
2102        // Probe for concurrent-drift before the write — same posture as
2103        // every other commit-producing mutation; a sibling engine that
2104        // committed since our last snapshot surfaces `MEM_RELOADED`.
2105        let mut warnings = self.reload_if_stale(Some(mem_name));
2106        if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
2107            warnings.push(w);
2108        }
2109
2110        // `previous` has to come from the config this write actually lands
2111        // on, not from the cache: a sibling ingest pass may have moved the
2112        // very token this call is replacing, and reporting the cached value
2113        // would name a baseline that has not been current since boot. The
2114        // cell is written by each `apply` pass, so after a compare-and-set
2115        // retry it holds what was really overwritten.
2116        let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
2117        let key_owned = key.to_string();
2118        let token_owned = token.to_string();
2119        let (_, intervened) = self.write_mem_config_merged(
2120            mount_idx,
2121            mem_name,
2122            note,
2123            &|c: &mut memstead_schema::config::MemConfig| {
2124                *seen.borrow_mut() = if token_owned.is_empty() {
2125                    c.sync_state.remove(&key_owned)
2126                } else {
2127                    c.sync_state.insert(key_owned.clone(), token_owned.clone())
2128                };
2129            },
2130        )?;
2131        let previous = seen.into_inner();
2132        // `removed` distinguishes a no-op clear (key absent) from a real one
2133        // so the outcome is honest.
2134        let removed = token.is_empty() && previous.is_some();
2135        if !intervened.is_empty() {
2136            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
2137                mem: mem_name.to_string(),
2138                fields: intervened,
2139            });
2140        }
2141
2142        Ok(crate::ops::SetMemSyncStateOutcome {
2143            mem: mem_name.to_string(),
2144            key: key.to_string(),
2145            previous,
2146            removed,
2147            warnings,
2148        })
2149    }
2150
2151    /// Re-read the named mount's backend entities and refresh the
2152    /// in-memory store for that mem. Returns the diff against the
2153    /// pre-reload snapshot — `added` (ids newly present), `removed`
2154    /// (ids no longer present), `changed` (same id, different
2155    /// `content_hash`).
2156    ///
2157    /// Operator-triggered: useful when an external writer modified
2158    /// disk while this engine instance was alive (the lean flavour
2159    /// assumes single-writer; this primitive is the escape hatch when
2160    /// that assumption breaks). On the happy path the diff is empty.
2161    ///
2162    /// Drift detection (whether disk *did* change) is not part of this
2163    /// surface — callers that want to short-circuit on "nothing
2164    /// changed" must compare `added.is_empty() && changed.is_empty()
2165    /// && removed.is_empty()` against the result. Backend-specific
2166    /// drift signals (git HEAD comparison, mtime check) live in the
2167    /// full-flavour engine where they have meaning.
2168    ///
2169    /// Invalidates community + search-index memos on success.
2170    /// Load every DEFERRED (lazy, not-yet-loaded) mem matching `mem`
2171    /// (`None` = all) into the store — the first-read trigger of the
2172    /// lazy-mount lifecycle. Runs before the staleness probes on every
2173    /// operation ([`Self::reload_if_stale`] calls it first), so an
2174    /// operation scoped to one mem loads exactly that mem, and a
2175    /// workspace-scoped operation (search, overview, health) loads
2176    /// whatever it needs to answer over a complete store — a count
2177    /// computed over a partial store is never presented as truth.
2178    ///
2179    /// Loading rides the ordinary per-mem reload (entity walk, store
2180    /// push, the workspace-global validation passes, memo
2181    /// invalidation), so a lazily-loaded mem gets the same refusals and
2182    /// warnings an eager boot produces for the same content — deferral
2183    /// changes WHEN the gauntlet runs, never whether. After each load,
2184    /// pending `SuspiciousNestedPrefix` warnings whose target arrived
2185    /// with this mem are dropped — the same legitimate-cross-mem
2186    /// exemption the eager boot applies once every mount is loaded.
2187    ///
2188    /// A deferred load that FAILS quarantines the mem with the same
2189    /// typed reporting an eager boot failure produces, at this first
2190    /// read: the mount leaves the serving roster, the quarantine roster
2191    /// gains the typed reason, and the existing reattach contract
2192    /// applies. Deferral never converts a load failure into an
2193    /// empty-mem impression.
2194    ///
2195    /// No-op for workspaces without lazy mounts, for already-loaded
2196    /// mems, and for a filter naming no deferred mem.
2197    pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
2198        let pending: Vec<String> = self
2199            .mounts
2200            .iter()
2201            .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
2202            .map(|m| m.mount.mem.clone())
2203            .collect();
2204        for name in pending {
2205            match self.reload_one_mem(&name) {
2206                Ok(_) => {
2207                    if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
2208                        state.deferred = false;
2209                    }
2210                    // Cross-mem links INTO this mem were scanned by the
2211                    // nested-prefix detector against a store that did
2212                    // not yet carry it; now that its entities exist,
2213                    // drop any hit the arrival resolves (mirrors the
2214                    // boot-time retain over the complete store).
2215                    let store = &self.store;
2216                    self.load_warnings.retain(|w| match w {
2217                        crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
2218                            store.get(resolved_id).is_none_or(|e| e.stub)
2219                        }
2220                        _ => true,
2221                    });
2222                }
2223                Err(e) => {
2224                    // Quarantine at the moment of first read — mirror
2225                    // the eager boot failure path byte-for-byte in
2226                    // consequence: out of the serving roster, onto the
2227                    // quarantine roster with the typed reason.
2228                    let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
2229                        continue;
2230                    };
2231                    let removed = self.mounts.remove(idx);
2232                    self.schemas_remove(&name);
2233                    self.quarantined.push(crate::engine::QuarantinedMem {
2234                        mount: removed.mount,
2235                        reason_code: e.code().to_string(),
2236                        reason_message: e.to_string(),
2237                    });
2238                    self.mem_router = std::sync::Arc::new(
2239                        crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2240                    );
2241                    self.invalidate_communities();
2242                    // The schemas epoch just moved (`schemas_remove`),
2243                    // so a filled search memo is stale-keyed — clear it
2244                    // here or the next search trips the memo-key
2245                    // assert (the first W8/01 grade demonstrated
2246                    // exactly that on this branch).
2247                    self.invalidate_search_indexes();
2248                }
2249            }
2250        }
2251    }
2252
2253    pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2254        // Per-mem reload refreshes THIS mem's slice of the engine-wide
2255        // `load_warnings` accumulator: stale boot-time warnings for the
2256        // mem drop, fresh re-parse warnings take their place, other
2257        // mems' entries stay untouched. (The earlier "intentionally
2258        // silent" contract let a reload heal drift on disk while
2259        // `health()` kept reporting the healed warning forever — the
2260        // same class of stale-state lie the mem-delete purge closes.)
2261        //
2262        // The sink is filtered by source-mem attribution before it
2263        // merges: `validate_loaded_relations` scans the whole store, so
2264        // in principle the sink can carry other mems' warnings. In the
2265        // common case those mems' invalid rows were already dropped
2266        // from the in-memory store at their own load, so the filter is
2267        // a no-op guard against cross-mem duplicates, not a routine
2268        // trim. Failure leaves the accumulator untouched (`?` fires
2269        // before the merge), matching the inner fn's no-mutation-on-
2270        // failed-read fence. Drift events still surface as
2271        // `MemReloaded` warnings via `reload_if_stale`.
2272        // A quarantined mem's reload is the way back into service:
2273        // re-attempt the whole attach (backend, schema resolution,
2274        // entity load). On success the roster entry disappears; on
2275        // failure the mem stays quarantined with a refreshed reason.
2276        if self.quarantine_reason(mem).is_some() {
2277            return self.reattach_quarantined_mem(mem);
2278        }
2279        let mut sink: Vec<WarningHint> = Vec::new();
2280        let result = self.reload_one_mem_inner(mem, &mut sink)?;
2281        self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2282        self.load_warnings
2283            .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2284        Ok(result)
2285    }
2286
2287    /// The quarantine branch of [`Self::set_mem_schema`]: repin the
2288    /// retained mount (target must resolve — the same booted resolver;
2289    /// repair never force-writes a pin that resolves nowhere), bump
2290    /// the backend config through the shared value-level writer,
2291    /// persist the mount state, then re-attempt the attach. A reattach
2292    /// that still fails (some second cause) leaves the mem quarantined
2293    /// with its refreshed reason — the pin switch itself is durable
2294    /// either way. The booted path's conformance gate cannot run over
2295    /// an unloaded mem; findings surface on the post-reattach health.
2296    fn set_schema_on_quarantined(
2297        &mut self,
2298        mem: &str,
2299        target: &memstead_schema::SchemaRef,
2300    ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2301        use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2302        // Same target-ref validation as the ordinary branch.
2303        if self.resolve_schema_by_ref(target).is_none() {
2304            let consulted: Vec<_> = self
2305                .workspace_schemas
2306                .iter()
2307                .chain(self.builtin_schemas.iter())
2308                .cloned()
2309                .collect();
2310            return Err(EngineError::SchemaNotFound {
2311                mem: mem.to_string(),
2312                pin: target.as_display(),
2313                sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2314                    &target.name,
2315                    &target.version,
2316                    &consulted,
2317                ),
2318                install_hint: None,
2319            }
2320            .with_schema_install_probe(self.workspace_root()));
2321        }
2322        let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2323            return Err(self.unknown_mem_error(mem));
2324        };
2325        // Repin the retained mount and, where a backend can be
2326        // instantiated, the authoritative backend config (shared
2327        // value-level bump — same writer as the ordinary branch).
2328        self.quarantined[q_idx].mount.schema = Some(target.clone());
2329        self.quarantined[q_idx].mount.migration_target = None;
2330        if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2331            let _ = bump_backend_schema_pin(backend.as_ref(), target);
2332        }
2333        self.persist_state()?;
2334        // Re-attempt the attach. Failure keeps the quarantine (fresh
2335        // reason on the roster) but the pin switch stands — the
2336        // outcome reports the switch, the roster reports any
2337        // remaining cause.
2338        let _ = self.reattach_quarantined_mem(mem);
2339        Ok(SetSchemaOutcome {
2340            mem: mem.to_string(),
2341            schema_pin: target.as_display(),
2342            migration_target: None,
2343            outcome: SetSchemaResult::Switched,
2344            findings: Vec::new(),
2345        })
2346    }
2347
2348    /// Re-attempt the boot-time attach of a quarantined mem —
2349    /// backend instantiation, schema resolution (same resolver and
2350    /// catalogue layering as boot: workspace-authored schemas over
2351    /// built-ins), then a per-mem entity load. Success removes the
2352    /// roster entry and the mem serves again in the same process;
2353    /// any failure keeps (re-)quarantining with the fresh typed
2354    /// reason, so the roster never goes stale against the live state.
2355    fn reattach_quarantined_mem(
2356        &mut self,
2357        mem: &str,
2358    ) -> Result<crate::ops::ReloadResult, EngineError> {
2359        let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2360            return Err(self.unknown_mem_error(mem));
2361        };
2362        let mount = self.quarantined[q_idx].mount.clone();
2363
2364        let requarantine = |this: &mut Self, e: &EngineError| {
2365            this.quarantined[q_idx].reason_code = e.code().to_string();
2366            this.quarantined[q_idx].reason_message = e.to_string();
2367        };
2368
2369        let backend = match (self.backend_factory)(&mount) {
2370            Ok(b) => b,
2371            Err(e) => {
2372                let err = EngineError::Mem(e.to_string());
2373                requarantine(self, &err);
2374                return Err(self.unknown_mem_error(mem));
2375            }
2376        };
2377
2378        // Same config / pin-authority reads as the boot loop.
2379        let last_known_head = backend.current_head().ok().flatten();
2380        let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2381            let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2382            memstead_schema::config::parse_mem_config(&value).ok()
2383        });
2384        let archive_provenance = backend
2385            .read_archive_provenance()
2386            .ok()
2387            .flatten()
2388            .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2389        let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2390        let effective_pin = mount
2391            .migration_target
2392            .clone()
2393            .or(config_pin)
2394            .or(mount.schema.clone());
2395        let Some(effective_pin) = effective_pin else {
2396            let err = EngineError::MemConfigIncomplete {
2397                mem: mem.to_string(),
2398                missing_fields: vec!["schema".to_string()],
2399            };
2400            requarantine(self, &err);
2401            return Err(self.unknown_mem_error(mem));
2402        };
2403        let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2404            .workspace_schemas
2405            .iter()
2406            .chain(self.builtin_schemas.iter())
2407            .cloned()
2408            .collect();
2409        let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2410            Ok(s) => s,
2411            Err(sources) => {
2412                let err = EngineError::SchemaNotFound {
2413                    mem: mem.to_string(),
2414                    pin: effective_pin.as_display(),
2415                    sources,
2416                    install_hint: None,
2417                }
2418                .with_schema_install_probe(self.workspace_root());
2419                requarantine(self, &err);
2420                return Err(self.unknown_mem_error(mem));
2421            }
2422        };
2423
2424        // Attach, then load entities through the ordinary per-mem
2425        // reload. An entity-load failure re-quarantines (the mount is
2426        // detached again) — quarantine is not tolerance.
2427        self.quarantined.remove(q_idx);
2428        self.schemas_insert(mem.to_string(), schema);
2429        self.mounts.push(crate::engine::MountedBackend {
2430            mount,
2431            backend,
2432            last_known_head,
2433            mem_config,
2434            archive_provenance,
2435            // The reattach loads entities immediately below — a
2436            // quarantine return-to-service is never deferred.
2437            deferred: false,
2438        });
2439        self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2440            &self.mounts,
2441        ));
2442        let mut sink: Vec<WarningHint> = Vec::new();
2443        match self.reload_one_mem_inner(mem, &mut sink) {
2444            Ok(result) => {
2445                self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2446                self.load_warnings
2447                    .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2448                self.invalidate_communities();
2449                Ok(result)
2450            }
2451            Err(e) => {
2452                let mount_idx = self.mounts.len() - 1;
2453                let mounted = self.mounts.remove(mount_idx);
2454                self.schemas_remove(mem);
2455                self.mem_router = std::sync::Arc::new(
2456                    crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2457                );
2458                self.quarantined.push(crate::engine::QuarantinedMem {
2459                    mount: mounted.mount,
2460                    reason_code: e.code().to_string(),
2461                    reason_message: e.to_string(),
2462                });
2463                // Same epoch-moved staleness as the deferred-load
2464                // quarantine branch: `schemas_remove` bumped the
2465                // epoch, so both memos must clear.
2466                self.invalidate_communities();
2467                self.invalidate_search_indexes();
2468                Err(e)
2469            }
2470        }
2471    }
2472
2473    /// Inner per-mem body shared by [`Self::reload_one_mem`]
2474    /// and [`Self::reload_each_writable_mem`]. The caller passes
2475    /// a warning sink so the workspace-wide reload can forward
2476    /// warnings into `self.load_warnings` while the single-mem
2477    /// path keeps the accumulator pristine.
2478    fn reload_one_mem_inner(
2479        &mut self,
2480        mem: &str,
2481        warnings_sink: &mut Vec<WarningHint>,
2482    ) -> Result<crate::ops::ReloadResult, EngineError> {
2483        // Locate the target mount + schema. Unknown mem short-
2484        // circuits before any store mutation.
2485        let mount_idx = self
2486            .mounts
2487            .iter()
2488            .position(|m| m.mount.mem == mem)
2489            .ok_or_else(|| self.unknown_mem_error(mem))?;
2490        let schema = self
2491            .schemas
2492            .get(mem)
2493            .cloned()
2494            .ok_or_else(|| self.unknown_mem_error(mem))?;
2495
2496        // Snapshot pre-reload (id, content_hash) for this mem.
2497        let pre: HashMap<EntityId, String> = self
2498            .store
2499            .all_entities()
2500            .filter(|e| !e.stub && e.mem == mem)
2501            .map(|e| (e.id.clone(), e.content_hash.clone()))
2502            .collect();
2503        let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2504
2505        // Walk the backend; surface read-time errors instead of
2506        // mutating the store on a failed reload.
2507        let backend = self.mounts[mount_idx].backend.as_ref();
2508        let (entries, read_errors) = collect_source_entries(backend)?;
2509        // The unbacked-mount probe rides the reload like the other
2510        // load-time warnings: a branch that appeared (or vanished) since
2511        // boot changes the answer, and the sink's per-mem replace below
2512        // drops the boot-time one.
2513        let unbacked = super::boot::unbacked_mount_warning(
2514            &self.mounts[mount_idx].mount,
2515            backend,
2516            Some(entries.len()),
2517        );
2518        let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2519
2520        // Build the LoadCollector inputs — mem roster + last-
2521        // segment suffixes — so the parser pipeline can emit
2522        // typed drift warnings into the caller's sink.
2523        let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2524        let known_suffixes: Vec<String> = mem_names
2525            .iter()
2526            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2527            .collect();
2528
2529        // Failure fence above; below this point the store is mutated.
2530        self.store.remove_entities_by_mem(mem);
2531        if let Some(w) = unbacked {
2532            warnings_sink.push(w);
2533        }
2534        let fallback = engine_fallback_type();
2535        push_entities_into_store(
2536            &mut self.store,
2537            load_result.entities,
2538            fallback.as_ref(),
2539            Some(crate::entity::store_builder::LoadCollector {
2540                warnings: warnings_sink,
2541                known_suffixes: &known_suffixes,
2542                mem_names: &mem_names,
2543            }),
2544        );
2545        // Re-run parse-time relation validation across the workspace.
2546        // A reload re-parses one mem but the validator's cycle pass
2547        // is global (acyclic-rel-type subgraphs span mems), so the
2548        // scan runs against the whole store. Hand-edits arriving via
2549        // sibling-writer commits get the same gauntlet boot enforces
2550        // (grammar / unknown_rel_type / shape / cycle).
2551        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2552            .mounts
2553            .iter()
2554            .map(|m| (m.mount.mem.clone(), m.mount.capability))
2555            .collect();
2556        // Restore cross-mem edges that point INTO this mem. The
2557        // removal cascade above dropped their incoming mirrors and the
2558        // re-push only rebuilt edges authored by this mem's own
2559        // entities, so a cross-mem `A→B` would silently vanish from the
2560        // index until a workspace-wide reload. Reconstruct from the
2561        // authoritative source records (in-memory only — no other mem is
2562        // re-read), then let the remap pass below reclassify alias sources.
2563        crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2564        crate::entity::store_builder::validate_loaded_relations(
2565            &mut self.store,
2566            &self.schemas,
2567            &mount_caps,
2568            warnings_sink,
2569        );
2570        crate::entity::store_builder::remap_alias_target_edge_sources(
2571            &mut self.store,
2572            &self.schemas,
2573        );
2574        // Surface load errors back through the engine's accumulator
2575        // so subsequent `load_errors()` calls reflect the latest read.
2576        // THIS mem's stale entries are replaced, not accumulated — a
2577        // repaired file (e.g. a resolved merge conflict) must stop
2578        // reporting its old refusal, and a re-read of a still-broken
2579        // file must not duplicate its entry. Other mems' entries are
2580        // untouched (their reloads own them). Folder mounts key their
2581        // entries by absolute source path under the mem root; other
2582        // backends have no path-attributable entries to replace.
2583        if let crate::workspace::MountStorage::Folder { path } =
2584            &self.mounts[mount_idx].mount.storage
2585        {
2586            let root = path.clone();
2587            self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2588            // The backend walk yields mem-relative paths while the boot
2589            // loader yields absolute ones — normalize to absolute so the
2590            // replace-on-reload key stays uniform across both origins.
2591            self.load_errors
2592                .extend(load_result.errors.into_iter().map(|(p, m)| {
2593                    let abs = if p.is_relative() { root.join(&p) } else { p };
2594                    (abs, m)
2595                }));
2596        } else {
2597            self.load_errors.extend(load_result.errors);
2598        }
2599
2600        // Refresh the mem's config from the backend too (D13). `sync_state`
2601        // (the projection baselines) and the schema pin / write guidance are
2602        // mem-scoped state that rides the mem branch, so an out-of-band write
2603        // — a sibling `projection advance` / `mem set-sync-state` — must become
2604        // visible after a per-mem reload, not only entity changes. A missing or
2605        // unparseable config leaves the cached value untouched (best-effort:
2606        // the reload never fails on a config read hiccup).
2607        if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2608            && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2609            && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2610        {
2611            self.mounts[mount_idx].mem_config = Some(cfg);
2612        }
2613
2614        // Diff post-reload against the snapshot.
2615        let mut added: Vec<EntityId> = Vec::new();
2616        let mut changed: Vec<EntityId> = Vec::new();
2617        for entity in self.store.all_entities() {
2618            if entity.stub || entity.mem != mem {
2619                continue;
2620            }
2621            match pre.get(&entity.id) {
2622                None => added.push(entity.id.clone()),
2623                Some(prev_hash) if prev_hash != &entity.content_hash => {
2624                    changed.push(entity.id.clone());
2625                }
2626                Some(_) => {}
2627            }
2628        }
2629        let post_ids: std::collections::HashSet<EntityId> = self
2630            .store
2631            .all_entities()
2632            .filter(|e| !e.stub && e.mem == mem)
2633            .map(|e| e.id.clone())
2634            .collect();
2635        let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2636        added.sort_by(|a, b| a.0.cmp(&b.0));
2637        changed.sort_by(|a, b| a.0.cmp(&b.0));
2638        removed.sort_by(|a, b| a.0.cmp(&b.0));
2639
2640        self.invalidate_communities();
2641        self.invalidate_search_indexes();
2642
2643        Ok(crate::ops::ReloadResult {
2644            added,
2645            changed,
2646            removed,
2647        })
2648    }
2649
2650    /// Rich-shape variant of [`Self::reload_one_mem`] that returns a
2651    /// [`crate::ops::ReloadReport`] (mem + head_before + head_after +
2652    /// entities_loaded + changed_entity_ids) instead of the slim
2653    /// [`crate::ops::ReloadResult`]. Handler-facing wrapper consumed
2654    /// by the `memstead_reload` MCP tool — the rich shape is the wire
2655    /// contract MCP callers depend on; the slim form stays for
2656    /// programmatic consumers that just want the diff lists.
2657    ///
2658    /// `head_before` is the engine's **prior cursor** for this mem
2659    /// (its cached `last_known_head`), *not* the current on-disk tip:
2660    /// when a sibling has committed since, the tip has already advanced,
2661    /// so reporting it would make the advertised
2662    /// `changes_since(since=head_before)` recipe span an empty range.
2663    /// `head_after` is the freshly-peeled tip from
2664    /// [`crate::backend::MemBackend::current_head`]; the reload also
2665    /// advances the cursor to it, so a follow-up staleness probe does
2666    /// not re-reload the same window. Backends without history (folder,
2667    /// archive) carry no cursor and return `Ok(None)`; both fields fall
2668    /// back to [`crate::ops::EMPTY_TREE_SHA`] for wire-shape stability.
2669    ///
2670    /// `entities_loaded` is the post-reload non-stub count for the
2671    /// mem — same semantic as full's report.
2672    ///
2673    /// `changed_entity_ids` is the union of `added ∪ changed ∪
2674    /// removed` from the underlying [`crate::ops::ReloadResult`]
2675    /// so callers don't have to merge three lists themselves —
2676    /// matches full's bundled wire shape.
2677    pub fn reload_one_mem_report(
2678        &mut self,
2679        mem: &str,
2680    ) -> Result<crate::ops::ReloadReport, EngineError> {
2681        // `head_before` is the engine's PRIOR cursor — the SHA it last
2682        // knew for this mem — not the current (possibly already
2683        // drifted) on-disk tip. Reporting the tip would collapse the
2684        // `changes_since(since=head_before)` range to empty in exactly
2685        // the sibling-drift case the recipe targets. Only history-backed
2686        // mounts (git-branch) carry a git cursor: folder / archive
2687        // backends have no `current_head`, so their `head_before` stays
2688        // the empty-tree sentinel that pairs with the equally-empty
2689        // `head_after` below.
2690        let tracks_head = self
2691            .mounts
2692            .iter()
2693            .find(|m| m.mount.mem == mem)
2694            .and_then(|m| m.backend.current_head().ok().flatten())
2695            .is_some();
2696        let head_before = if tracks_head {
2697            self.mounts
2698                .iter()
2699                .find(|m| m.mount.mem == mem)
2700                .and_then(|m| m.last_known_head.clone())
2701                .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2702        } else {
2703            crate::ops::EMPTY_TREE_SHA.to_string()
2704        };
2705
2706        let result = self.reload_one_mem(mem)?;
2707
2708        // Capture head_after = the freshly-peeled tip, and advance the
2709        // engine's cursor to it. Without this advance the next
2710        // operation's `reload_if_stale` would compare the stale cursor
2711        // against the same tip and re-reload the identical window,
2712        // re-emitting a spurious `MEM_RELOADED`. Only history-backed
2713        // mounts (current_head → Some) carry a cursor to advance.
2714        let head_after_raw = self
2715            .mounts
2716            .iter()
2717            .find(|m| m.mount.mem == mem)
2718            .and_then(|m| m.backend.current_head().ok().flatten());
2719        if let Some(new_head) = head_after_raw.clone()
2720            && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2721        {
2722            m.last_known_head = Some(new_head);
2723        }
2724        let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2725
2726        let entities_loaded = self
2727            .store
2728            .all_entities()
2729            .filter(|e| !e.stub && e.mem == mem)
2730            .count();
2731
2732        // Union of added + changed + removed, sorted lexicographically
2733        // for deterministic wire output. Matches full's "single
2734        // changed_entity_ids list" contract — saves callers from
2735        // merging three slices themselves.
2736        let mut changed_entity_ids: Vec<EntityId> = result
2737            .added
2738            .into_iter()
2739            .chain(result.changed)
2740            .chain(result.removed)
2741            .collect();
2742        changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2743
2744        Ok(crate::ops::ReloadReport {
2745            mem: mem.to_string(),
2746            head_before,
2747            head_after,
2748            entities_loaded,
2749            changed_entity_ids,
2750        })
2751    }
2752
2753    /// Batched rich-shape variant — returns one
2754    /// [`crate::ops::ReloadReport`] per mounted mem in declaration
2755    /// order. Counterpart to [`Self::reload_each_writable_mem`]
2756    /// (slim) that the `memstead_reload` MCP tool's no-mem path
2757    /// consumes.
2758    ///
2759    /// `load_warnings` semantics ride on the per-mem contract: each
2760    /// [`Self::reload_one_mem`] in the sweep refreshes its own mem's
2761    /// slice of the engine-wide accumulator, so a full sweep leaves
2762    /// the accumulator equivalent to a fresh boot. (Earlier this
2763    /// variant discarded every reload warning while its slim
2764    /// counterpart repopulated — the MCP workspace-wide reload could
2765    /// never clear a stale warning.) On first-error-abort, mems
2766    /// reloaded before the failure carry refreshed slices and the
2767    /// rest keep their boot-time entries — no slice is lost.
2768    ///
2769    /// Also re-reads `.memstead/workspace.toml` and refreshes
2770    /// [`crate::workspace::WorkspaceSettings`] before sweeping the
2771    /// mems — this is the pairing with the CLI's
2772    /// `memstead workspace allow-create / grant-cross-link / set-mutations`
2773    /// family. Without this re-read, a CLI write would land on disk but
2774    /// the running MCP would still serve the engine's boot-time policy
2775    /// snapshot; every subsequent `memstead_mem_create` against the new
2776    /// allowlist would fail with `MEM_PATH_NOT_ALLOWED` until process
2777    /// restart. The workspace-wide form runs the heavier path; the
2778    /// per-mem form (`reload_one_mem_report`) intentionally skips
2779    /// the workspace re-read — content drift doesn't imply policy
2780    /// drift.
2781    ///
2782    /// Reload of `workspace.toml` is best-effort: a missing or
2783    /// unparseable file leaves the existing settings untouched. The
2784    /// per-mem sweep is the primary contract — settings refresh is
2785    /// the additive bonus.
2786    ///
2787    /// First-error-aborts: if any mem's reload fails, the loop
2788    /// stops and the error propagates. Mems reloaded before the
2789    /// failing one are already mutated in the store; the returned
2790    /// error has no rollback. Operators run the per-mem form to
2791    /// retry the failing mem explicitly.
2792    pub fn reload_each_writable_mem_reports(
2793        &mut self,
2794    ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2795        self.refresh_workspace_settings_if_possible();
2796        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2797        let mut out = Vec::with_capacity(names.len());
2798        for name in names {
2799            let report = self.reload_one_mem_report(&name)?;
2800            out.push(report);
2801        }
2802        Ok(out)
2803    }
2804
2805    /// Best-effort refresh of [`crate::workspace::WorkspaceSettings`]
2806    /// from the workspace's `.memstead/workspace.toml`. Called by the
2807    /// workspace-wide reload sweep so CLI-driven policy edits become
2808    /// visible to a live engine without process restart.
2809    ///
2810    /// Silent no-op when the engine has no `workspace_root` (legacy
2811    /// in-memory constructions) or when the on-disk file is missing /
2812    /// unparseable. The per-mem reload contract stays the canonical
2813    /// failure surface; settings refresh failures are intentionally
2814    /// non-fatal so a malformed workspace.toml doesn't break content
2815    /// drift detection.
2816    fn refresh_workspace_settings_if_possible(&mut self) {
2817        let Some(root) = self.workspace_root.clone() else {
2818            return;
2819        };
2820        let store = crate::workspace_store::FileWorkspaceStore::new();
2821        let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2822            Ok(w) => w,
2823            Err(_) => return,
2824        };
2825        self.set_settings(workspace.settings);
2826    }
2827
2828    /// Reload every mounted mem in declaration order; returns one
2829    /// `(mem, ReloadResult)` per mount.
2830    ///
2831    /// Failure model is **first-error-aborts**: if any mem's reload
2832    /// fails, the loop stops and the error propagates. Mems reloaded
2833    /// before the failing one are already mutated in the store; the
2834    /// returned error has no rollback. Operators run the per-mem
2835    /// form to retry the failing mem explicitly.
2836    ///
2837    /// Caller-friendly batching wrapper around [`Self::reload_one_mem`];
2838    /// internal cache invalidation happens once per mem (the inner
2839    /// call invalidates) so an N-mem batch invalidates the memos
2840    /// N times. That's wasteful for large workspaces; once the
2841    /// `memstead_reload` MCP handler migrates we can tighten this to one
2842    /// invalidation at the end.
2843    pub fn reload_each_writable_mem(
2844        &mut self,
2845    ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2846        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2847        // Workspace-wide reload semantics: take the engine-wide
2848        // sink, clear it, route per-mem inner reloads through it,
2849        // put it back. The result is `self.load_warnings` carries
2850        // every typed drift warning the reload sweep produced (so
2851        // the next `engine.health()` call surfaces them).
2852        let mut sink = std::mem::take(&mut self.load_warnings);
2853        sink.clear();
2854        let mut out = Vec::with_capacity(names.len());
2855        let mut loop_err = None;
2856        for name in names {
2857            match self.reload_one_mem_inner(&name, &mut sink) {
2858                Ok(result) => out.push((name, result)),
2859                Err(e) => {
2860                    loop_err = Some(e);
2861                    break;
2862                }
2863            }
2864        }
2865        self.load_warnings = sink;
2866        if let Some(e) = loop_err {
2867            return Err(e);
2868        }
2869        Ok(out)
2870    }
2871}
2872
2873/// Value-level schema-pin bump on a backend's mem config: read the
2874/// config blob, rewrite ONLY the `"schema"` string, write it back —
2875/// every other field (`readMems`, write guidance, sync state, …) is
2876/// preserved verbatim. Config-absent backends (no `config.json`) are a
2877/// clean no-op returning `None`; the caller's `Mount.schema` then
2878/// stays the settled pin. Returns the updated JSON value on a write so
2879/// callers can refresh caches.
2880///
2881/// One shared implementation for the booted path
2882/// (`Engine::persist_mem_schema_pin`) and the below-boot repair path
2883/// (memstead-git-branch) — the two must never fork: a pin written by
2884/// repair must be byte-shaped exactly as one written by the engine.
2885/// Field names on which the stored config differs from `cached`.
2886///
2887/// Compared as JSON so the comparison covers every field the struct models
2888/// without a hand-written field list that a new field would silently escape.
2889/// A parse failure yields no fields rather than a false report: the write
2890/// itself will surface the malformed config.
2891fn changed_config_fields(
2892    cached: &memstead_schema::config::MemConfig,
2893    stored_bytes: &[u8],
2894) -> Vec<String> {
2895    let (Ok(a), Ok(b)) = (
2896        serde_json::to_value(cached),
2897        serde_json::from_slice::<serde_json::Value>(stored_bytes),
2898    ) else {
2899        return Vec::new();
2900    };
2901    let (Some(a), Some(b)) = (a.as_object(), b.as_object()) else {
2902        return Vec::new();
2903    };
2904    let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
2905    keys.extend(b.keys());
2906    keys.into_iter()
2907        .filter(|k| a.get(*k) != b.get(*k))
2908        .map(|k| k.to_string())
2909        .collect()
2910}
2911
2912pub fn bump_backend_schema_pin(
2913    backend: &dyn crate::backend::MemBackend,
2914    target: &memstead_schema::SchemaRef,
2915) -> Result<Option<serde_json::Value>, EngineError> {
2916    let Some(bytes) = backend
2917        .read_mem_config()
2918        .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2919    else {
2920        return Ok(None);
2921    };
2922    let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2923        .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2924    value["schema"] = serde_json::Value::String(target.as_display());
2925    let new_bytes = serde_json::to_vec_pretty(&value)
2926        .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2927    backend
2928        .write_mem_config(&new_bytes)
2929        .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2930    Ok(Some(value))
2931}
2932
2933/// Three-way merge of a mount roster for a state write.
2934///
2935/// `baseline` is what the writing engine last read or wrote, `ours` is
2936/// its roster now, `on_disk` is what the file holds at this instant.
2937/// The result keeps every on-disk mount the writer did not touch (so a
2938/// sibling's registration survives), drops the ones the writer removed
2939/// since its baseline, and applies the ones it added or changed.
2940///
2941/// A mount present in `ours` unchanged since the baseline does NOT
2942/// overwrite the on-disk record of the same name: if a sibling edited
2943/// it and we did not, the sibling's edit is the newer statement about
2944/// it, and republishing our stale copy is exactly the loss this merge
2945/// exists to prevent.
2946fn merge_mount_rosters(
2947    baseline: &[crate::workspace::Mount],
2948    ours: &[crate::workspace::Mount],
2949    on_disk: Vec<crate::workspace::Mount>,
2950) -> Vec<crate::workspace::Mount> {
2951    use std::collections::{HashMap, HashSet};
2952
2953    let ours_names: HashSet<&str> = ours.iter().map(|m| m.mem.as_str()).collect();
2954    let removed_by_us: HashSet<&str> = baseline
2955        .iter()
2956        .map(|m| m.mem.as_str())
2957        .filter(|n| !ours_names.contains(n))
2958        .collect();
2959    let baseline_by_name: HashMap<&str, &crate::workspace::Mount> =
2960        baseline.iter().map(|m| (m.mem.as_str(), m)).collect();
2961
2962    let mut merged: Vec<crate::workspace::Mount> = on_disk
2963        .into_iter()
2964        .filter(|m| !removed_by_us.contains(m.mem.as_str()))
2965        .collect();
2966
2967    for mount in ours {
2968        let untouched_by_us = baseline_by_name
2969            .get(mount.mem.as_str())
2970            .is_some_and(|b| *b == mount);
2971        match merged.iter_mut().find(|d| d.mem == mount.mem) {
2972            Some(slot) => {
2973                if !untouched_by_us {
2974                    *slot = mount.clone();
2975                }
2976            }
2977            None => merged.push(mount.clone()),
2978        }
2979    }
2980    merged
2981}
2982
2983#[cfg(test)]
2984mod tests {
2985
2986    use tempfile::TempDir;
2987
2988    use crate::backend::{BackendError, MemBackend};
2989    use crate::engine::test_helpers::*;
2990    use crate::engine::{Engine, EngineError};
2991    use crate::mem::MemOrigin;
2992    use crate::ops::WarningHint;
2993    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2994
2995    fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2996        let manifest = format!(
2997            r#"name: {manifest_name}
2998version: 1.0.0
2999description: Install-gate test schema
3000when_to_use: Tests
3001types:
3002  - sample
3003relationships:
3004  mode: strict
3005  definitions:
3006    - name: PART_OF
3007      description: hier
3008      default_weight: 3.0
3009    - name: _default
3010      description: fallback
3011      default_weight: 1.0
3012community:
3013  resolution: 1.0
3014  seed: 42
3015"#
3016        );
3017        let type_yaml = format!(
3018            r#"name: sample
3019description: t
3020when_to_use: tests
3021sections:
3022  - key: body
3023    heading: {heading}
3024    required: true
3025    search_weight: 10.0
3026    catch_all: true
3027    write_rules: []
3028metadata_fields: []
3029title_weight: 100.0
3030text_fields:
3031  - body
3032hierarchy_relationship: PART_OF
3033no_self_loop_relationships: []
3034updatable_fields:
3035  - title
3036  - body
3037health_required_fields:
3038  - body
3039staleness_threshold_days: 90
3040write_rules: []
3041"#
3042        );
3043        vec![
3044            ("schema.yaml".to_string(), manifest.into_bytes()),
3045            ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3046        ]
3047    }
3048
3049    /// The install gate accepts a conforming package and refuses one
3050    /// whose section heading cannot round-trip to its key — the last
3051    /// moment the author can act, since sealed schemas keep loading.
3052    #[test]
3053    fn install_gate_refuses_non_roundtrip_heading() {
3054        let ok =
3055            Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
3056        assert!(ok.is_ok(), "conforming package passes: {ok:?}");
3057
3058        let err = Engine::validate_schema_package(
3059            "gate",
3060            "1.0.0",
3061            &schema_package_files("Body Text", "gate"),
3062        )
3063        .expect_err("non-deriving heading must refuse install");
3064        match &err {
3065            EngineError::SchemaPackageInvalid { name, message, .. } => {
3066                assert_eq!(name, "gate");
3067                assert!(
3068                    message.contains("'body'") && message.contains("'Body Text'"),
3069                    "message names the offending tuple: {message}"
3070                );
3071            }
3072            other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3073        }
3074    }
3075
3076    /// Build a package whose single type carries an exemplar assembled
3077    /// from the given pieces — the fixture for the exemplar-gate
3078    /// tests. The type declares a required `body` section, a `status`
3079    /// enum field, PART_OF (unpinned), and REFINES pinned to
3080    /// `source_types: [other]` so a REFINES exemplar edge from
3081    /// `sample` violates shape.
3082    fn exemplar_package_files(
3083        section_key: &str,
3084        status_value: &str,
3085        rel_type: &str,
3086    ) -> Vec<(String, Vec<u8>)> {
3087        let manifest = r#"name: gate
3088version: 1.0.0
3089description: exemplar gate fixture
3090when_to_use: tests
3091types:
3092  - sample
3093  - other
3094relationships:
3095  mode: strict
3096  definitions:
3097    - name: PART_OF
3098      description: hier
3099      default_weight: 3.0
3100    - name: REFINES
3101      description: pinned
3102      default_weight: 1.0
3103      source_types: [other]
3104    - name: _default
3105      description: fallback
3106      default_weight: 1.0
3107community:
3108  resolution: 1.0
3109  seed: 42
3110"#
3111        .to_string();
3112        let type_yaml = format!(
3113            r#"name: sample
3114description: t
3115when_to_use: tests
3116sections:
3117  - key: body
3118    heading: Body
3119    required: true
3120    search_weight: 10.0
3121    catch_all: true
3122    write_rules: []
3123metadata_fields:
3124  - key: status
3125    description: workflow state
3126    field_type: string
3127    enum_values: [draft, final]
3128title_weight: 100.0
3129text_fields:
3130  - body
3131hierarchy_relationship: PART_OF
3132no_self_loop_relationships: []
3133updatable_fields:
3134  - title
3135  - body
3136health_required_fields:
3137  - body
3138staleness_threshold_days: 90
3139write_rules: []
3140exemplar:
3141  title: A Conforming Sample
3142  metadata:
3143    status: "{status_value}"
3144  sections:
3145    {section_key}: "One canonical body paragraph."
3146  relations:
3147    - to: parent-placeholder
3148      type: {rel_type}
3149"#
3150        );
3151        let other_yaml = r#"name: other
3152description: shape-pin partner
3153when_to_use: tests
3154sections:
3155  - key: body
3156    heading: Body
3157    required: true
3158    search_weight: 10.0
3159    catch_all: true
3160    write_rules: []
3161metadata_fields: []
3162title_weight: 100.0
3163text_fields:
3164  - body
3165hierarchy_relationship: PART_OF
3166no_self_loop_relationships: []
3167updatable_fields:
3168  - title
3169  - body
3170health_required_fields:
3171  - body
3172staleness_threshold_days: 90
3173write_rules: []
3174"#
3175        .to_string();
3176        vec![
3177            ("schema.yaml".to_string(), manifest.into_bytes()),
3178            ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
3179            ("types/other.yaml".to_string(), other_yaml.into_bytes()),
3180        ]
3181    }
3182
3183    /// The exemplar gate (agent-trust plan 09): a package whose type
3184    /// carries a CONFORMANT exemplar installs; the same package broken
3185    /// three ways — wrong section key, illegal enum value, relationship
3186    /// shape violation — refuses with a typed error naming the type
3187    /// and the defect. No warn-and-carry path exists: the refusal is
3188    /// `SchemaPackageInvalid`, same as every other install-gate class.
3189    #[test]
3190    fn install_gate_validates_exemplars_through_the_real_create_path() {
3191        // Conformant exemplar → the package installs.
3192        let ok = Engine::validate_schema_package(
3193            "gate",
3194            "1.0.0",
3195            &exemplar_package_files("body", "draft", "PART_OF"),
3196        );
3197        assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
3198
3199        // Variant 1 — wrong section key.
3200        let err = Engine::validate_schema_package(
3201            "gate",
3202            "1.0.0",
3203            &exemplar_package_files("bogus_section", "draft", "PART_OF"),
3204        )
3205        .expect_err("wrong section key must refuse");
3206        match &err {
3207            EngineError::SchemaPackageInvalid { message, .. } => {
3208                assert!(
3209                    message.contains("'sample'") && message.contains("exemplar"),
3210                    "names type and calls out the exemplar: {message}"
3211                );
3212                assert!(
3213                    message.contains("UNKNOWN_SECTION")
3214                        || message.contains("MISSING_REQUIRED_SECTION"),
3215                    "carries the typed defect code: {message}"
3216                );
3217            }
3218            other => panic!("expected SchemaPackageInvalid, got {other:?}"),
3219        }
3220
3221        // Variant 2 — illegal enum value.
3222        let err = Engine::validate_schema_package(
3223            "gate",
3224            "1.0.0",
3225            &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
3226        )
3227        .expect_err("illegal enum value must refuse");
3228        assert!(
3229            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3230                if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
3231            "got {err:?}"
3232        );
3233
3234        // Variant 3 — relationship shape violation (REFINES is pinned
3235        // to source_types [other]; the exemplar's type is `sample`).
3236        let err = Engine::validate_schema_package(
3237            "gate",
3238            "1.0.0",
3239            &exemplar_package_files("body", "draft", "REFINES"),
3240        )
3241        .expect_err("relationship shape violation must refuse");
3242        assert!(
3243            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3244                if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
3245            "got {err:?}"
3246        );
3247    }
3248
3249    /// The worked-example teaching package (`memstead-schema/examples/
3250    /// minimal`) models the exemplar practice — its exemplars validate
3251    /// through the same gate, so the material that teaches schema
3252    /// authoring can never itself teach a non-conformant shape.
3253    #[test]
3254    fn worked_example_package_exemplars_validate() {
3255        let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3256            .join("../memstead-schema/examples/minimal");
3257        let schema = std::sync::Arc::new(
3258            memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
3259        );
3260        assert!(
3261            schema.types.values().all(|td| td.exemplar.is_some()),
3262            "every worked-example type models the exemplar practice"
3263        );
3264        Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
3265    }
3266
3267    /// Every built-in schema's exemplars validate through the SAME
3268    /// gate the install path runs — a built-in exemplar broken by a
3269    /// future edit fails CI here. Completeness rides the same walk:
3270    /// the NEWEST version of every built-in name carries an exemplar
3271    /// on every type (older versions are sealed as shipped and may
3272    /// predate the field).
3273    #[test]
3274    fn builtin_exemplars_validate_through_the_install_gate() {
3275        let schemas = memstead_schema::builtins::load_builtin_schemas()
3276            .expect("built-in schemas always load");
3277        // Validity: every exemplar anywhere in the catalogue conforms.
3278        for schema in &schemas {
3279            if let Err(defect) = Engine::validate_schema_exemplars(schema) {
3280                let (name, version) = schema.id();
3281                panic!("built-in {name}@{version}: {defect}");
3282            }
3283        }
3284        // Completeness: the newest version per name is exemplar-complete.
3285        let mut newest: std::collections::HashMap<
3286            String,
3287            &std::sync::Arc<memstead_schema::Schema>,
3288        > = std::collections::HashMap::new();
3289        for schema in &schemas {
3290            let name = schema.manifest.name.clone();
3291            match newest.get(&name) {
3292                Some(cur) if cur.version >= schema.version => {}
3293                _ => {
3294                    newest.insert(name, schema);
3295                }
3296            }
3297        }
3298        for (name, schema) in &newest {
3299            for (type_name, td) in &schema.types {
3300                assert!(
3301                    td.exemplar.is_some(),
3302                    "built-in {name}@{} type '{type_name}' has no exemplar — the \
3303                     reference schemas model the practice completely",
3304                    schema.version
3305                );
3306            }
3307        }
3308    }
3309
3310    /// Exemplar relation targets are PLACEHOLDERS: a bare slug is
3311    /// legal (target existence is never checked — the absent target
3312    /// is the would-be-stub path), while a mem-prefixed target
3313    /// refuses with the placeholder rule named.
3314    #[test]
3315    fn exemplar_relation_targets_are_bare_placeholder_slugs() {
3316        let mut files = exemplar_package_files("body", "draft", "PART_OF");
3317        let patched = String::from_utf8(files[1].1.clone())
3318            .unwrap()
3319            .replace("to: parent-placeholder", "to: other--real-entity");
3320        files[1].1 = patched.into_bytes();
3321        let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3322            .expect_err("mem-prefixed exemplar target must refuse");
3323        assert!(
3324            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3325                if message.contains("bare") && message.contains("'sample'")),
3326            "got {err:?}"
3327        );
3328    }
3329
3330    /// A manifest whose declared identity contradicts the install ref
3331    /// is refused — the schema would otherwise seal under a ref its
3332    /// own manifest disagrees with.
3333    #[test]
3334    fn install_gate_refuses_manifest_identity_mismatch() {
3335        let err = Engine::validate_schema_package(
3336            "gate",
3337            "1.0.0",
3338            &schema_package_files("Body", "other"),
3339        )
3340        .expect_err("identity mismatch must refuse install");
3341        assert!(
3342            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3343                if message.contains("other@1.0.0")),
3344            "got {err:?}"
3345        );
3346    }
3347
3348    #[test]
3349    fn reload_each_writable_mem_repopulates_load_warnings() {
3350        // Boot with a clean mem, then mid-flight write a file
3351        // with a duplicate heading, then call reload_each_writable_mem.
3352        // The accumulator should pick up the new typed warning.
3353        let tmp = TempDir::new().unwrap();
3354        let mem_dir = tmp.path().to_path_buf();
3355        let writer = FilesystemMemWriter::new(mem_dir.clone());
3356        // Newest default generation so the clean-boot baseline isn't
3357        // tripped by the SCHEMA_GENERATIONS_BEHIND hint.
3358        let mut mount = folder_mount("specs", mem_dir.clone());
3359        mount.schema = Some("default@1.3.0".parse().unwrap());
3360        let mut engine =
3361            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3362        // The only thing a clean, entity-less mount says about itself
3363        // is that it is empty (`MOUNT_UNBACKED` / `empty`).
3364        assert!(
3365            engine
3366                .load_warnings()
3367                .iter()
3368                .all(|w| w.code() == "MOUNT_UNBACKED"),
3369            "clean boot has no warnings beyond the empty-mount one: {:?}",
3370            engine.load_warnings()
3371        );
3372
3373        // Drop a markdown file with two `## Identity` headings.
3374        let body =
3375            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3376        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3377
3378        engine.reload_each_writable_mem().unwrap();
3379        let warnings = engine.load_warnings();
3380        assert!(
3381            warnings
3382                .iter()
3383                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3384            "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3385        );
3386    }
3387
3388    /// `validate_loaded_relations` runs on the reload path too — a
3389    /// sibling-writer commit that injects a markdown file carrying a
3390    /// schema-undeclared rel-type must surface as a typed
3391    /// `PARSED_RELATION_INVALID` warning after `reload_each_writable_mem`.
3392    /// Without the reload-path wiring this drift would slip past the
3393    /// validator (boot only catches what existed at startup).
3394    #[test]
3395    fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3396        let tmp = TempDir::new().unwrap();
3397        let mem_dir = tmp.path().to_path_buf();
3398        // Seed a clean target entity at boot.
3399        let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3400        std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3401        let writer = FilesystemMemWriter::new(mem_dir.clone());
3402        let mut engine = Engine::from_mounts(vec![(
3403            folder_mount("specs", mem_dir.clone()),
3404            Box::new(writer) as Box<dyn MemBackend>,
3405        )])
3406        .unwrap();
3407        // Clean boot — no parse-time relation warnings yet.
3408        assert!(
3409            !engine
3410                .load_warnings()
3411                .iter()
3412                .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3413            "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3414            engine.load_warnings()
3415        );
3416
3417        // Sibling-writer drops a new file with an unknown rel-type.
3418        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";
3419        std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3420
3421        engine.reload_each_writable_mem().unwrap();
3422
3423        let invalid: Vec<_> = engine
3424            .load_warnings()
3425            .iter()
3426            .filter_map(|w| match w {
3427                WarningHint::ParsedRelationInvalid {
3428                    rel_type,
3429                    reason,
3430                    origin,
3431                    ..
3432                } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3433                _ => None,
3434            })
3435            .collect();
3436        assert_eq!(
3437            invalid.len(),
3438            1,
3439            "reload must surface the parse-time drift, got: {invalid:?}",
3440        );
3441        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3442        assert_eq!(invalid[0].1, "unknown_rel_type");
3443        assert_eq!(invalid[0].2, "writable");
3444    }
3445
3446    #[test]
3447    fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3448        // Boot two mems, each with a duplicate-heading file, so the
3449        // accumulator carries one warning per mem. Fix alpha's file on
3450        // disk, reload ONLY alpha: alpha's stale warning must drop
3451        // (reload heals drift — health() must stop reporting it) while
3452        // beta's untouched warning survives (per-mem reload never
3453        // clears other mems' slices).
3454        let tmp = TempDir::new().unwrap();
3455        let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3456        let a_dir = tmp.path().join("a");
3457        std::fs::create_dir_all(&a_dir).unwrap();
3458        std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3459        let b_dir = tmp.path().join("b");
3460        std::fs::create_dir_all(&b_dir).unwrap();
3461        std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3462        let mut engine = Engine::from_mounts(vec![
3463            (
3464                folder_mount("alpha", a_dir.clone()),
3465                Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3466            ),
3467            (
3468                folder_mount("beta", b_dir.clone()),
3469                Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3470            ),
3471        ])
3472        .unwrap();
3473        let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3474        let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3475        assert!(
3476            pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3477            "boot must populate one warning per mem: {pre:?}"
3478        );
3479
3480        // Heal alpha's file on disk, then reload only alpha.
3481        let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3482        std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3483        engine.reload_one_mem("alpha").unwrap();
3484
3485        let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3486        assert!(
3487            !post.contains(&"alpha".to_string()),
3488            "reload must drop the healed mem's stale warning: {post:?}"
3489        );
3490        assert!(
3491            post.contains(&"beta".to_string()),
3492            "reload of alpha must not clear beta's slice: {post:?}"
3493        );
3494    }
3495
3496    #[test]
3497    fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3498        // Two mems, each contributing a boot-time warning. Deleting
3499        // alpha must purge alpha's warnings from the accumulator
3500        // (health() merges it unconditionally — leftovers would cite
3501        // entities the store no longer holds) while beta's survive.
3502        let tmp = TempDir::new().unwrap();
3503        let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3504        let a_dir = tmp.path().join("a");
3505        std::fs::create_dir_all(&a_dir).unwrap();
3506        std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3507        let b_dir = tmp.path().join("b");
3508        std::fs::create_dir_all(&b_dir).unwrap();
3509        std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3510        let mut engine = Engine::from_mounts(vec![
3511            (
3512                folder_mount("alpha", a_dir.clone()),
3513                Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3514            ),
3515            (
3516                folder_mount("beta", b_dir.clone()),
3517                Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3518            ),
3519        ])
3520        .unwrap();
3521        assert!(
3522            engine
3523                .load_warnings()
3524                .iter()
3525                .any(|w| w.source_mem() == Some("alpha")),
3526            "boot must carry alpha-sourced warnings"
3527        );
3528
3529        engine.unregister_writable_mem("alpha").unwrap();
3530
3531        let post = engine.load_warnings();
3532        assert!(
3533            !post.iter().any(|w| w.source_mem() == Some("alpha")),
3534            "delete must purge the removed mem's warnings: {post:?}"
3535        );
3536        assert!(
3537            post.iter().any(|w| w.source_mem() == Some("beta")),
3538            "delete of alpha must keep beta's warnings: {post:?}"
3539        );
3540    }
3541
3542    #[test]
3543    fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3544        // Complement to the purge: a warning SOURCED in a surviving
3545        // mem whose TARGET pointed into the deleted mem must survive.
3546        // The invalid row still exists in the survivor's markdown —
3547        // it is live drift (recover-worthy), not stale state, so
3548        // purging by target would hide a real finding.
3549        let tmp = TempDir::new().unwrap();
3550        let a_dir = tmp.path().join("a");
3551        std::fs::create_dir_all(&a_dir).unwrap();
3552        let source_body = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[beta--b1]]\n";
3553        std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3554        let b_dir = tmp.path().join("b");
3555        std::fs::create_dir_all(&b_dir).unwrap();
3556        let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3557        std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3558        let mut engine = Engine::from_mounts(vec![
3559            (
3560                folder_mount("alpha", a_dir.clone()),
3561                Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3562            ),
3563            (
3564                folder_mount("beta", b_dir.clone()),
3565                Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3566            ),
3567        ])
3568        .unwrap();
3569        let alpha_sourced = |engine: &Engine| {
3570            engine
3571                .load_warnings()
3572                .iter()
3573                .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3574        };
3575        assert!(
3576            alpha_sourced(&engine),
3577            "boot must flag alpha's invalid row: {:?}",
3578            engine.load_warnings()
3579        );
3580
3581        engine.unregister_writable_mem("beta").unwrap();
3582
3583        assert!(
3584            alpha_sourced(&engine),
3585            "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3586            engine.load_warnings()
3587        );
3588    }
3589
3590    /// The `memstead_reload` MCP tool's no-mem path consumes the
3591    /// reports variant — it must refresh `load_warnings` like its slim
3592    /// counterpart, not discard the sweep's warnings. Regression for
3593    /// the split-brain where the slim variant repopulated and the
3594    /// reports variant silently kept the boot-time snapshot forever
3595    /// (observed live 2026-07-11: warnings for deleted mems survived a
3596    /// workspace-wide MCP reload).
3597    #[test]
3598    fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3599        let tmp = TempDir::new().unwrap();
3600        let mem_dir = tmp.path().to_path_buf();
3601        let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3602        std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3603        let writer = FilesystemMemWriter::new(mem_dir.clone());
3604        let mut engine = Engine::from_mounts(vec![(
3605            folder_mount("specs", mem_dir.clone()),
3606            Box::new(writer) as Box<dyn MemBackend>,
3607        )])
3608        .unwrap();
3609        assert!(
3610            !engine.load_warnings().is_empty(),
3611            "boot must populate load_warnings"
3612        );
3613
3614        // Heal the file on disk; the reports sweep must clear the
3615        // stale warning.
3616        let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3617        std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3618        engine.reload_each_writable_mem_reports().unwrap();
3619        assert!(
3620            engine.load_warnings().is_empty(),
3621            "reports sweep must drop healed warnings: {:?}",
3622            engine.load_warnings()
3623        );
3624
3625        // And the inverse: fresh drift surfaces through the same sweep.
3626        std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3627        engine.reload_each_writable_mem_reports().unwrap();
3628        assert!(
3629            engine
3630                .load_warnings()
3631                .iter()
3632                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3633            "reports sweep must surface fresh drift: {:?}",
3634            engine.load_warnings()
3635        );
3636    }
3637
3638    /// A cross-mem edge `A→B` must survive a
3639    /// per-mem reload of the TARGET mem B. The removal cascade drops
3640    /// B's incoming mirrors (including the cross-mem one sourced from A)
3641    /// and the re-push only rebuilds edges authored by B, so without the
3642    /// reconstruction pass the edge silently vanishes from the in-memory
3643    /// index while staying intact in A's record and on disk — under-
3644    /// reporting topology until a workspace-wide reload heals it.
3645    #[test]
3646    fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3647        let tmp = TempDir::new().unwrap();
3648        let a_dir = tmp.path().join("a");
3649        let b_dir = tmp.path().join("b");
3650        std::fs::create_dir_all(&a_dir).unwrap();
3651        std::fs::create_dir_all(&b_dir).unwrap();
3652        let a_writer = FilesystemMemWriter::new(a_dir.clone());
3653        let b_writer = FilesystemMemWriter::new(b_dir.clone());
3654        let mut engine = Engine::from_mounts(vec![
3655            (
3656                folder_mount("specs", a_dir),
3657                Box::new(a_writer) as Box<dyn MemBackend>,
3658            ),
3659            (
3660                folder_mount("memos", b_dir),
3661                Box::new(b_writer) as Box<dyn MemBackend>,
3662            ),
3663        ])
3664        .unwrap();
3665
3666        // Grant the cross-mem link specs → memos so the relate lands.
3667        let mut settings = crate::workspace::WorkspaceSettings::default();
3668        settings.cross_mem_links.insert(
3669            "specs".to_string(),
3670            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3671        );
3672        engine.set_settings(settings);
3673
3674        let (actor, client) = cli_actor();
3675        let source = engine
3676            .create_entity(
3677                empty_create_args("specs", "Source"),
3678                actor,
3679                Some(&client),
3680                None,
3681            )
3682            .unwrap();
3683        let target = engine
3684            .create_entity(
3685                empty_create_args("memos", "Target"),
3686                actor,
3687                Some(&client),
3688                None,
3689            )
3690            .unwrap();
3691        engine
3692            .relate_entity(
3693                crate::engine::RelateEntityArgs {
3694                    source: source.id.clone(),
3695                    expected_hash: Some(source.content_hash.clone()),
3696                    rel_type: "USES".to_string(),
3697                    target: target.id.clone(),
3698                    remove: false,
3699                    description: None,
3700                    dry_run: false,
3701                },
3702                actor,
3703                Some(&client),
3704                None,
3705            )
3706            .unwrap();
3707
3708        // (outgoing-present, incoming-present) for the A→B edge.
3709        let has_edge = |e: &Engine| {
3710            let out = e
3711                .store()
3712                .outgoing(&source.id)
3713                .iter()
3714                .any(|edge| edge.target == target.id);
3715            let inc = e
3716                .store()
3717                .incoming(&target.id)
3718                .iter()
3719                .any(|edge| edge.from == source.id);
3720            (out, inc)
3721        };
3722
3723        assert_eq!(
3724            has_edge(&engine),
3725            (true, true),
3726            "edge must be indexed in both directions after relate",
3727        );
3728
3729        // Per-mem reload of the TARGET mem — the bug trigger.
3730        engine.reload_one_mem("memos").unwrap();
3731        assert_eq!(
3732            has_edge(&engine),
3733            (true, true),
3734            "cross-mem edge into B must survive a per-mem reload of B",
3735        );
3736
3737        // Convergence: a workspace-wide reload yields the same incoming
3738        // adjacency for the target — no path-dependent difference.
3739        engine.reload_each_writable_mem().unwrap();
3740        assert_eq!(
3741            has_edge(&engine),
3742            (true, true),
3743            "per-mem and workspace reload converge on the same edge",
3744        );
3745
3746        // Complement: the edge stayed in the source record throughout —
3747        // the bug and the fix are about the index, not the records.
3748        assert!(
3749            engine
3750                .store()
3751                .get(&source.id)
3752                .unwrap()
3753                .relationships
3754                .iter()
3755                .any(|r| r.target == target.id),
3756            "source record must retain the relationship throughout",
3757        );
3758    }
3759
3760    /// A per-mem reload of the SOURCE
3761    /// mem leaves the cross-mem edge intact too — the source's own
3762    /// outgoing edges are rebuilt by the re-push, and the reconstruction
3763    /// pass for the OTHER mem is not needed here. Guards against a fix
3764    /// that fixates on the target case and perturbs the source case.
3765    #[test]
3766    fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3767        let tmp = TempDir::new().unwrap();
3768        let a_dir = tmp.path().join("a");
3769        let b_dir = tmp.path().join("b");
3770        std::fs::create_dir_all(&a_dir).unwrap();
3771        std::fs::create_dir_all(&b_dir).unwrap();
3772        let a_writer = FilesystemMemWriter::new(a_dir.clone());
3773        let b_writer = FilesystemMemWriter::new(b_dir.clone());
3774        let mut engine = Engine::from_mounts(vec![
3775            (
3776                folder_mount("specs", a_dir),
3777                Box::new(a_writer) as Box<dyn MemBackend>,
3778            ),
3779            (
3780                folder_mount("memos", b_dir),
3781                Box::new(b_writer) as Box<dyn MemBackend>,
3782            ),
3783        ])
3784        .unwrap();
3785        let mut settings = crate::workspace::WorkspaceSettings::default();
3786        settings.cross_mem_links.insert(
3787            "specs".to_string(),
3788            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3789        );
3790        engine.set_settings(settings);
3791
3792        let (actor, client) = cli_actor();
3793        let source = engine
3794            .create_entity(
3795                empty_create_args("specs", "Source"),
3796                actor,
3797                Some(&client),
3798                None,
3799            )
3800            .unwrap();
3801        let target = engine
3802            .create_entity(
3803                empty_create_args("memos", "Target"),
3804                actor,
3805                Some(&client),
3806                None,
3807            )
3808            .unwrap();
3809        engine
3810            .relate_entity(
3811                crate::engine::RelateEntityArgs {
3812                    source: source.id.clone(),
3813                    expected_hash: Some(source.content_hash.clone()),
3814                    rel_type: "USES".to_string(),
3815                    target: target.id.clone(),
3816                    remove: false,
3817                    description: None,
3818                    dry_run: false,
3819                },
3820                actor,
3821                Some(&client),
3822                None,
3823            )
3824            .unwrap();
3825
3826        engine.reload_one_mem("specs").unwrap();
3827
3828        let out = engine
3829            .store()
3830            .outgoing(&source.id)
3831            .iter()
3832            .any(|edge| edge.target == target.id);
3833        let inc = engine
3834            .store()
3835            .incoming(&target.id)
3836            .iter()
3837            .any(|edge| edge.from == source.id);
3838        assert!(
3839            out && inc,
3840            "outgoing cross-mem edge must survive a source-mem reload"
3841        );
3842    }
3843
3844    #[test]
3845    fn workspace_root_setter_round_trips() {
3846        let tmp = TempDir::new().unwrap();
3847        let mem_dir = tmp.path().to_path_buf();
3848        let writer = FilesystemMemWriter::new(mem_dir.clone());
3849        let mut engine = Engine::from_mounts(vec![(
3850            folder_mount("specs", mem_dir),
3851            Box::new(writer) as Box<dyn MemBackend>,
3852        )])
3853        .unwrap();
3854        let root = tmp.path().to_path_buf();
3855        engine.set_workspace_root(root.clone());
3856        assert_eq!(engine.workspace_root(), Some(root.as_path()));
3857    }
3858
3859    #[test]
3860    fn export_mem_folder_backend_produces_archive() {
3861        // Folder-backed mem with config + one entity. The
3862        // export_mem dispatcher routes to the folder backend's
3863        // override which produces a deterministic .memstead archive.
3864        let tmp = TempDir::new().unwrap();
3865        let mem_dir = tmp.path().join("specs");
3866        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3867        let config_body = r#"{
3868            "format": 1,
3869            "schema": "default@1.0.0",
3870            "version": "1.0.0"
3871        }"#;
3872        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3873
3874        let writer = FilesystemMemWriter::new(mem_dir.clone());
3875        let engine = Engine::from_mounts(vec![(
3876            folder_mount("specs", mem_dir.clone()),
3877            Box::new(writer) as Box<dyn MemBackend>,
3878        )])
3879        .unwrap();
3880
3881        let archive_path = tmp.path().join("specs.mem");
3882        let result = engine.export_mem("specs", &archive_path).unwrap();
3883        assert!(archive_path.exists(), "archive must exist on disk");
3884        assert!(result.size_bytes > 0);
3885        // entity_count is 0 here (no .md files seeded); the function
3886        // still produces an archive carrying the config + schema.
3887        assert_eq!(result.entity_count, 0);
3888    }
3889
3890    #[test]
3891    fn export_mem_unknown_mem_returns_unknown_mem() {
3892        let tmp = TempDir::new().unwrap();
3893        let mem_dir = tmp.path().to_path_buf();
3894        let writer = FilesystemMemWriter::new(mem_dir.clone());
3895        let engine = Engine::from_mounts(vec![(
3896            folder_mount("specs", mem_dir),
3897            Box::new(writer) as Box<dyn MemBackend>,
3898        )])
3899        .unwrap();
3900        let output = tmp.path().join("out.mem");
3901        let err = engine.export_mem("missing", &output).unwrap_err();
3902        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3903    }
3904
3905    #[test]
3906    fn export_mem_missing_config_returns_invalid_input() {
3907        // Folder mount with no .memstead/config.json — `mem_config_for`
3908        // returns None and `export_mem` surfaces InvalidInput
3909        // rather than reaching the backend.
3910        let tmp = TempDir::new().unwrap();
3911        let mem_dir = tmp.path().to_path_buf();
3912        let writer = FilesystemMemWriter::new(mem_dir.clone());
3913        let engine = Engine::from_mounts(vec![(
3914            folder_mount("specs", mem_dir),
3915            Box::new(writer) as Box<dyn MemBackend>,
3916        )])
3917        .unwrap();
3918        let output = tmp.path().join("out.mem");
3919        let err = engine.export_mem("specs", &output).unwrap_err();
3920        assert!(matches!(err, EngineError::InvalidInput(_)));
3921    }
3922
3923    #[test]
3924    fn export_mem_archive_backend_returns_sealed() {
3925        // Archive backends are already-an-archive — re-export is
3926        // intentionally rejected via BackendError::Sealed.
3927        let tmp = TempDir::new().unwrap();
3928        let archive_path = build_archive(
3929            tmp.path(),
3930            "ext",
3931            &[(
3932                ".memstead/config.json",
3933                b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3934            )],
3935        );
3936        let engine = Engine::from_mounts(vec![(
3937            archive_mount("ext", archive_path.clone()),
3938            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3939        )])
3940        .unwrap();
3941        let output = tmp.path().join("out.mem");
3942        let err = engine.export_mem("ext", &output).unwrap_err();
3943        assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3944    }
3945
3946    #[test]
3947    fn export_markdown_writes_unchanged_files_zero_writes() {
3948        // Seed a folder-backed mem with one entity, then call
3949        // export_markdown. The entity's file already matches the
3950        // generated content (engine wrote it via create_entity), so
3951        // export reports `unchanged: 1, written: 0`.
3952        let tmp = TempDir::new().unwrap();
3953        let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3954        let result = engine.export_markdown(None, None).unwrap();
3955        assert_eq!(
3956            result.written, 0,
3957            "freshly-created entity's file already matches generated markdown"
3958        );
3959        assert_eq!(
3960            result.unchanged, 1,
3961            "the one seeded entity counts as unchanged"
3962        );
3963        assert!(
3964            result.skipped_mounts.is_empty(),
3965            "folder-only workspace has no skipped mounts"
3966        );
3967    }
3968
3969    #[test]
3970    fn export_markdown_skips_non_folder_mounts() {
3971        // Archive-mounted mem has no working tree — workspace-wide
3972        // export records it under skipped_mounts and reports zero
3973        // writes / zero unchanged for the rest.
3974        let tmp = TempDir::new().unwrap();
3975        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3976        let engine = Engine::from_mounts(vec![(
3977            archive_mount("ext", archive_path.clone()),
3978            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3979        )])
3980        .unwrap();
3981        let result = engine.export_markdown(None, None).unwrap();
3982        assert_eq!(result.written, 0);
3983        assert_eq!(result.unchanged, 0);
3984        assert_eq!(
3985            result.skipped_mounts.len(),
3986            1,
3987            "archive mount is in the skipped list"
3988        );
3989        let entry = &result.skipped_mounts[0];
3990        assert_eq!(entry.mem, "ext");
3991        assert_eq!(entry.active_backend, "archive");
3992        assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3993    }
3994
3995    #[test]
3996    fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3997        // Per-mem export against an archive-backed mem returns
3998        // the typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` refusal
3999        // naming the active backend and the supported-backend list.
4000        let tmp = TempDir::new().unwrap();
4001        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
4002        let engine = Engine::from_mounts(vec![(
4003            archive_mount("ext", archive_path.clone()),
4004            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4005        )])
4006        .unwrap();
4007        let err = engine.export_markdown(Some("ext"), None).unwrap_err();
4008        assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
4009        let details = err.details();
4010        assert_eq!(details["mem"], "ext");
4011        assert_eq!(details["active_backend"], "archive");
4012        assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
4013    }
4014
4015    #[test]
4016    fn register_writable_mem_adds_mount_and_router_entry() {
4017        // Start with one mem; register a second at runtime. Both
4018        // should be visible afterwards.
4019        let tmp = TempDir::new().unwrap();
4020        let mem_a = tmp.path().join("a");
4021        std::fs::create_dir_all(&mem_a).unwrap();
4022        let writer_a = FilesystemMemWriter::new(mem_a.clone());
4023
4024        let mut engine = Engine::from_mounts(vec![(
4025            folder_mount("alpha", mem_a),
4026            Box::new(writer_a) as Box<dyn MemBackend>,
4027        )])
4028        .unwrap();
4029        assert!(engine.mem_router().is_writable("alpha"));
4030
4031        let mem_b = tmp.path().join("b");
4032        std::fs::create_dir_all(&mem_b).unwrap();
4033        let writer_b = FilesystemMemWriter::new(mem_b.clone());
4034
4035        engine
4036            .register_writable_mem(
4037                folder_mount("beta", mem_b.clone()),
4038                Box::new(writer_b) as Box<dyn MemBackend>,
4039                MemOrigin::ExplicitToml,
4040            )
4041            .unwrap();
4042
4043        // Both mems are now writable + visible.
4044        assert!(engine.mem_router().is_writable("alpha"));
4045        assert!(engine.mem_router().is_writable("beta"));
4046        assert!(engine.mem_router().is_visible("beta"));
4047
4048        // Mount + schema lookups resolve.
4049        assert!(engine.mount("beta").is_some());
4050        assert!(engine.schemas().contains_key("beta"));
4051
4052        // Folder path surfaces via mem_router.
4053        assert_eq!(
4054            engine.mem_router().dir_for_mem("beta"),
4055            Some(mem_b.as_path()),
4056        );
4057    }
4058
4059    /// Schema-pin authority on the runtime-register path (symmetric with
4060    /// the boot path): a mem registered at runtime resolves its schema
4061    /// from its own config (`software@0.1.0`) even though the mount
4062    /// expects an unresolvable pin — register succeeds, and the
4063    /// disagreement surfaces a `SchemaPinMismatch` warning.
4064    #[test]
4065    fn register_writable_mem_resolves_schema_from_mem_config() {
4066        let tmp = TempDir::new().unwrap();
4067        let mem_a = tmp.path().join("a");
4068        std::fs::create_dir_all(&mem_a).unwrap();
4069        let mut engine = Engine::from_mounts(vec![(
4070            folder_mount("alpha", mem_a.clone()),
4071            Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
4072        )])
4073        .unwrap();
4074
4075        let mem_b = tmp.path().join("b");
4076        std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
4077        std::fs::write(
4078            mem_b.join(".memstead").join("config.json"),
4079            r#"{"schema":"software@0.1.0"}"#,
4080        )
4081        .unwrap();
4082        let mount_b = crate::workspace::Mount {
4083            mem: "beta".to_string(),
4084            schema: Some(memstead_schema::SchemaRef::new(
4085                "totally-not-a-schema",
4086                semver::Version::new(9, 9, 9),
4087            )),
4088            storage: crate::workspace::MountStorage::Folder {
4089                path: mem_b.clone(),
4090            },
4091            capability: crate::workspace::MountCapability::Write,
4092            lifecycle: crate::workspace::MountLifecycle::Eager,
4093            cross_linkable: true,
4094            migration_target: None,
4095        };
4096        engine
4097            .register_writable_mem(
4098                mount_b,
4099                Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
4100                MemOrigin::ExplicitToml,
4101            )
4102            .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
4103
4104        assert!(engine.schemas().contains_key("beta"));
4105        let surfaced = engine.load_warnings().iter().any(|w| {
4106            matches!(
4107                w,
4108                WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
4109                    if mem == "beta"
4110                        && config_pin == "software@0.1.0"
4111                        && mount_pin == "totally-not-a-schema@9.9.9"
4112            )
4113        });
4114        assert!(
4115            surfaced,
4116            "SchemaPinMismatch must surface for beta: {:?}",
4117            engine.load_warnings(),
4118        );
4119    }
4120
4121    #[test]
4122    fn register_writable_mem_rejects_existing_name() {
4123        // Re-registering an already-writable mem must fail with
4124        // MemNameCollision and not mutate the engine.
4125        let tmp = TempDir::new().unwrap();
4126        let mem_a = tmp.path().join("a");
4127        std::fs::create_dir_all(&mem_a).unwrap();
4128        let writer_a = FilesystemMemWriter::new(mem_a.clone());
4129
4130        let mut engine = Engine::from_mounts(vec![(
4131            folder_mount("alpha", mem_a),
4132            Box::new(writer_a) as Box<dyn MemBackend>,
4133        )])
4134        .unwrap();
4135        let mount_count_pre = engine.mounts().len();
4136
4137        let mem_collide = tmp.path().join("alpha-2");
4138        std::fs::create_dir_all(&mem_collide).unwrap();
4139        let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
4140
4141        let err = engine
4142            .register_writable_mem(
4143                folder_mount("alpha", mem_collide),
4144                Box::new(writer_collide) as Box<dyn MemBackend>,
4145                MemOrigin::ExplicitToml,
4146            )
4147            .unwrap_err();
4148        match err {
4149            EngineError::MemNameCollision {
4150                name,
4151                source_origin,
4152            } => {
4153                assert_eq!(name, "alpha");
4154                // post-restructure source_origin references
4155                // `.memstead/workspace.toml`; the assertion stays
4156                // permissive (substring OR non-empty) so the test
4157                // doesn't lock the exact wording.
4158                assert!(
4159                    source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
4160                );
4161            }
4162            other => panic!("expected MemNameCollision, got {other:?}"),
4163        }
4164
4165        // Engine state unchanged.
4166        assert_eq!(engine.mounts().len(), mount_count_pre);
4167    }
4168
4169    #[test]
4170    fn register_writable_mem_loads_entities_into_store() {
4171        // The newly-registered mem's entities should surface in
4172        // the engine's store after registration.
4173        let tmp = TempDir::new().unwrap();
4174        let mem_a = tmp.path().join("a");
4175        std::fs::create_dir_all(&mem_a).unwrap();
4176        let writer_a = FilesystemMemWriter::new(mem_a.clone());
4177
4178        let mut engine = Engine::from_mounts(vec![(
4179            folder_mount("alpha", mem_a),
4180            Box::new(writer_a) as Box<dyn MemBackend>,
4181        )])
4182        .unwrap();
4183        let pre_count = engine.store().all_entities().count();
4184
4185        // Build mem_b with a markdown entity on disk.
4186        let mem_b = tmp.path().join("b");
4187        std::fs::create_dir_all(&mem_b).unwrap();
4188        std::fs::write(
4189            mem_b.join("b1.md"),
4190            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4191        )
4192        .unwrap();
4193        let writer_b = FilesystemMemWriter::new(mem_b.clone());
4194
4195        engine
4196            .register_writable_mem(
4197                folder_mount("beta", mem_b),
4198                Box::new(writer_b) as Box<dyn MemBackend>,
4199                MemOrigin::ExplicitToml,
4200            )
4201            .unwrap();
4202
4203        let post_count = engine.store().all_entities().count();
4204        assert!(post_count > pre_count, "register must load entities");
4205        let beta_count = engine
4206            .store()
4207            .all_entities()
4208            .filter(|e| e.mem == "beta")
4209            .count();
4210        assert_eq!(beta_count, 1);
4211    }
4212
4213    #[test]
4214    fn register_then_unregister_round_trips() {
4215        // End-to-end check: register a mem, then unregister it,
4216        // and confirm the engine returns to the pre-registration
4217        // state.
4218        let tmp = TempDir::new().unwrap();
4219        let mem_a = tmp.path().join("a");
4220        std::fs::create_dir_all(&mem_a).unwrap();
4221        let writer_a = FilesystemMemWriter::new(mem_a.clone());
4222
4223        let mut engine = Engine::from_mounts(vec![(
4224            folder_mount("alpha", mem_a),
4225            Box::new(writer_a) as Box<dyn MemBackend>,
4226        )])
4227        .unwrap();
4228        let pre_mounts = engine.mounts().len();
4229
4230        let mem_b = tmp.path().join("b");
4231        std::fs::create_dir_all(&mem_b).unwrap();
4232        let writer_b = FilesystemMemWriter::new(mem_b);
4233
4234        engine
4235            .register_writable_mem(
4236                folder_mount("beta", tmp.path().join("b")),
4237                Box::new(writer_b) as Box<dyn MemBackend>,
4238                MemOrigin::ExplicitToml,
4239            )
4240            .unwrap();
4241        assert_eq!(engine.mounts().len(), pre_mounts + 1);
4242
4243        let removed = engine.unregister_writable_mem("beta").unwrap();
4244        assert!(removed.is_some());
4245        assert_eq!(engine.mounts().len(), pre_mounts);
4246        assert!(!engine.mem_router().is_writable("beta"));
4247    }
4248
4249    #[test]
4250    fn unregister_writable_mem_returns_false_for_unknown_name() {
4251        // Idempotent contract: repeated calls / unknown names are
4252        // not errors — return false so callers can branch without
4253        // a typed error envelope for the common "already gone" case.
4254        let tmp = TempDir::new().unwrap();
4255        let mem_dir = tmp.path().to_path_buf();
4256        let writer = FilesystemMemWriter::new(mem_dir.clone());
4257        let mut engine = Engine::from_mounts(vec![(
4258            folder_mount("specs", mem_dir),
4259            Box::new(writer) as Box<dyn MemBackend>,
4260        )])
4261        .unwrap();
4262        let removed = engine.unregister_writable_mem("missing").unwrap();
4263        assert!(removed.is_none(), "unknown mem returns Ok(None)");
4264        // The original mem is still present and readable.
4265        assert!(engine.mem_router().is_writable("specs"));
4266    }
4267
4268    #[test]
4269    fn unregister_writable_mem_drops_mount_and_router_entry() {
4270        // Heterogeneous engine: two mounts. Unregister one and
4271        // assert (a) it's gone from the mount list, (b) gone from
4272        // the mem_router's writable set, (c) the OTHER mount is
4273        // untouched.
4274        let tmp = TempDir::new().unwrap();
4275        let mem_a = tmp.path().join("a");
4276        std::fs::create_dir_all(&mem_a).unwrap();
4277        let writer_a = FilesystemMemWriter::new(mem_a.clone());
4278        let mem_b = tmp.path().join("b");
4279        std::fs::create_dir_all(&mem_b).unwrap();
4280        let writer_b = FilesystemMemWriter::new(mem_b.clone());
4281
4282        let mut engine = Engine::from_mounts(vec![
4283            (
4284                folder_mount("alpha", mem_a),
4285                Box::new(writer_a) as Box<dyn MemBackend>,
4286            ),
4287            (
4288                folder_mount("beta", mem_b),
4289                Box::new(writer_b) as Box<dyn MemBackend>,
4290            ),
4291        ])
4292        .unwrap();
4293
4294        let removed = engine.unregister_writable_mem("alpha").unwrap();
4295        assert!(removed.is_some());
4296
4297        // alpha is gone from every surface.
4298        assert!(!engine.mem_router().is_writable("alpha"));
4299        assert!(!engine.mem_router().is_visible("alpha"));
4300        assert!(engine.mount("alpha").is_none());
4301
4302        // beta survives unchanged.
4303        assert!(engine.mem_router().is_writable("beta"));
4304        assert!(engine.mount("beta").is_some());
4305    }
4306
4307    #[test]
4308    fn unregister_writable_mem_drops_entities_for_that_mem_only() {
4309        // Build an engine with two mems, write one entity to each
4310        // backend, build the engine (loads both), unregister one,
4311        // assert the store still has the other mem's entity.
4312        let tmp = TempDir::new().unwrap();
4313        let mem_a = tmp.path().join("a");
4314        std::fs::create_dir_all(&mem_a).unwrap();
4315        std::fs::write(
4316            mem_a.join("a1.md"),
4317            "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
4318        )
4319        .unwrap();
4320        let writer_a = FilesystemMemWriter::new(mem_a.clone());
4321
4322        let mem_b = tmp.path().join("b");
4323        std::fs::create_dir_all(&mem_b).unwrap();
4324        std::fs::write(
4325            mem_b.join("b1.md"),
4326            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4327        )
4328        .unwrap();
4329        let writer_b = FilesystemMemWriter::new(mem_b.clone());
4330
4331        let mut engine = Engine::from_mounts(vec![
4332            (
4333                folder_mount("alpha", mem_a),
4334                Box::new(writer_a) as Box<dyn MemBackend>,
4335            ),
4336            (
4337                folder_mount("beta", mem_b),
4338                Box::new(writer_b) as Box<dyn MemBackend>,
4339            ),
4340        ])
4341        .unwrap();
4342
4343        let pre_total = engine.store().all_entities().count();
4344        assert!(pre_total >= 2, "both mems must load entities");
4345
4346        engine.unregister_writable_mem("alpha").unwrap();
4347
4348        // alpha's entities are gone.
4349        let alpha_remaining = engine
4350            .store()
4351            .all_entities()
4352            .filter(|e| e.mem == "alpha")
4353            .count();
4354        assert_eq!(alpha_remaining, 0);
4355
4356        // beta's entities survive.
4357        let beta_remaining = engine
4358            .store()
4359            .all_entities()
4360            .filter(|e| e.mem == "beta")
4361            .count();
4362        assert!(beta_remaining > 0, "beta entities must survive");
4363    }
4364    #[test]
4365    fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4366        let tmp = TempDir::new().unwrap();
4367        let mut engine = build_demo_engine(&tmp);
4368        let result = engine
4369            .reload_one_mem("specs")
4370            .expect("reload on stable disk must succeed");
4371        assert!(result.added.is_empty(), "added: {:?}", result.added);
4372        assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4373        assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4374    }
4375
4376    #[test]
4377    fn reload_one_mem_picks_up_external_addition() {
4378        let tmp = TempDir::new().unwrap();
4379        let mut engine = build_demo_engine(&tmp);
4380        // Simulate an external writer dropping a new entity on disk
4381        // without going through the engine.
4382        std::fs::write(
4383            tmp.path().join("external.md"),
4384            "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4385        )
4386        .unwrap();
4387        let result = engine.reload_one_mem("specs").unwrap();
4388        assert_eq!(
4389            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4390            vec!["specs--external"]
4391        );
4392        assert!(result.changed.is_empty());
4393        assert!(result.removed.is_empty());
4394        // The new entity is now reachable through the engine.
4395        assert!(
4396            engine
4397                .get_entity(&crate::EntityId::new("specs", "external"))
4398                .is_some()
4399        );
4400    }
4401
4402    #[test]
4403    fn reload_one_mem_picks_up_external_removal() {
4404        let tmp = TempDir::new().unwrap();
4405        let mut engine = build_demo_engine(&tmp);
4406        // Lonely Three exists from the demo fixture; remove it
4407        // off-engine and reload.
4408        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4409        let result = engine.reload_one_mem("specs").unwrap();
4410        assert!(result.added.is_empty());
4411        assert!(result.changed.is_empty());
4412        assert_eq!(
4413            result
4414                .removed
4415                .iter()
4416                .map(|i| i.as_ref())
4417                .collect::<Vec<_>>(),
4418            vec!["specs--lonely-three"]
4419        );
4420    }
4421
4422    #[test]
4423    fn reload_one_mem_picks_up_external_change() {
4424        let tmp = TempDir::new().unwrap();
4425        let mut engine = build_demo_engine(&tmp);
4426        // Overwrite an existing entity's content; the new
4427        // `content_hash` must surface in the `changed` diff.
4428        std::fs::write(
4429            tmp.path().join("source-one.md"),
4430            "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4431        )
4432        .unwrap();
4433        let result = engine.reload_one_mem("specs").unwrap();
4434        assert!(result.added.is_empty());
4435        assert_eq!(
4436            result
4437                .changed
4438                .iter()
4439                .map(|i| i.as_ref())
4440                .collect::<Vec<_>>(),
4441            vec!["specs--source-one"]
4442        );
4443        assert!(result.removed.is_empty());
4444    }
4445
4446    #[test]
4447    fn reload_one_mem_rejects_unknown_mem() {
4448        let tmp = TempDir::new().unwrap();
4449        let mut engine = build_demo_engine(&tmp);
4450        let err = engine.reload_one_mem("nope").unwrap_err();
4451        match err {
4452            EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4453            other => panic!("expected UnknownMem, got {other:?}"),
4454        }
4455    }
4456
4457    #[test]
4458    fn reload_each_writable_mem_returns_one_entry_per_mount() {
4459        let tmp = TempDir::new().unwrap();
4460        let mut engine = build_demo_engine(&tmp);
4461        let reports = engine
4462            .reload_each_writable_mem()
4463            .expect("batch reload on stable disk must succeed");
4464        assert_eq!(reports.len(), 1);
4465        assert_eq!(reports[0].0, "specs");
4466        assert!(reports[0].1.added.is_empty());
4467        assert!(reports[0].1.changed.is_empty());
4468        assert!(reports[0].1.removed.is_empty());
4469    }
4470
4471    // ---- Engine::settings -------------------------------------------
4472
4473    #[test]
4474    fn settings_default_to_empty_on_fresh_engine() {
4475        let tmp = TempDir::new().unwrap();
4476        let engine = build_demo_engine(&tmp);
4477        let s = engine.settings();
4478        assert!(s.mem_create_rules.is_empty());
4479        assert!(s.mem_delete_rules.is_empty());
4480        assert!(s.cross_mem_links.is_empty());
4481    }
4482
4483    #[test]
4484    fn set_settings_replaces_workspace_policy() {
4485        use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4486        let tmp = TempDir::new().unwrap();
4487        let mut engine = build_demo_engine(&tmp);
4488        let mut settings = WorkspaceSettings::default();
4489        settings.mem_create_rules.push(CreateRuleSetting {
4490            pattern: "exec-*".to_string(),
4491            schemas: vec!["default@1.0.0".to_string()],
4492            default_cross_links: None,
4493        });
4494        settings.mem_delete_rules.push(DeleteRuleSetting {
4495            pattern: "exec-*".to_string(),
4496        });
4497        engine.set_settings(settings);
4498        assert_eq!(engine.settings().mem_create_rules.len(), 1);
4499        assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4500        assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4501        assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4502    }
4503
4504    // ---- Engine::reload_each_writable_mem (continued) -------------
4505
4506    #[test]
4507    fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4508        let tmp = TempDir::new().unwrap();
4509        let mut engine = build_demo_engine(&tmp);
4510        // Mutate disk: add one entity, remove another, change a third.
4511        std::fs::write(
4512            tmp.path().join("new-via-disk.md"),
4513            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4514        )
4515        .unwrap();
4516        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4517        std::fs::write(
4518            tmp.path().join("source-one.md"),
4519            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4520        )
4521        .unwrap();
4522
4523        let reports = engine.reload_each_writable_mem().unwrap();
4524        assert_eq!(reports.len(), 1);
4525        let (mem, result) = &reports[0];
4526        assert_eq!(mem, "specs");
4527        assert_eq!(
4528            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4529            vec!["specs--new-via-disk"]
4530        );
4531        assert_eq!(
4532            result
4533                .removed
4534                .iter()
4535                .map(|i| i.as_ref())
4536                .collect::<Vec<_>>(),
4537            vec!["specs--lonely-three"]
4538        );
4539        assert_eq!(
4540            result
4541                .changed
4542                .iter()
4543                .map(|i| i.as_ref())
4544                .collect::<Vec<_>>(),
4545            vec!["specs--source-one"]
4546        );
4547    }
4548
4549    // ---- Engine::reload_one_mem_report (rich-shape wrapper) -------
4550
4551    #[test]
4552    fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4553        // The folder backend's drift cursor is the changelog's
4554        // last-line timestamp (RFC3339-millis) — the same dialect
4555        // `folder_changes_since` accepts. With the demo engine's
4556        // creates already logged, both heads carry that cursor and,
4557        // with the disk unchanged between init and reload, they are
4558        // equal. entities_loaded reflects the post-reload count;
4559        // changed_entity_ids is empty when the disk is unchanged.
4560        let tmp = TempDir::new().unwrap();
4561        let mut engine = build_demo_engine(&tmp);
4562        let report = engine.reload_one_mem_report("specs").unwrap();
4563        assert_eq!(report.mem, "specs");
4564        assert_eq!(
4565            report.head_before, report.head_after,
4566            "unchanged disk → stable cursor"
4567        );
4568        assert!(
4569            crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4570            "folder heads carry the changelog-ts cursor, got {}",
4571            report.head_after
4572        );
4573        // build_demo_engine seeds 3 entities (Source One, Target Two,
4574        // Lonely Three) — all real, no stubs from those creates.
4575        assert_eq!(report.entities_loaded, 3);
4576        // No external disk changes between init and reload → empty diff.
4577        assert!(report.changed_entity_ids.is_empty());
4578    }
4579
4580    #[test]
4581    fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4582        // Mutate disk: add one, remove one, change one. The report's
4583        // changed_entity_ids unions the slim ReloadResult's three
4584        // diff lists into a single sorted vec — matches full's
4585        // wire contract.
4586        let tmp = TempDir::new().unwrap();
4587        let mut engine = build_demo_engine(&tmp);
4588        std::fs::write(
4589            tmp.path().join("new-via-disk.md"),
4590            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4591        )
4592        .unwrap();
4593        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4594        std::fs::write(
4595            tmp.path().join("source-one.md"),
4596            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4597        )
4598        .unwrap();
4599
4600        let report = engine.reload_one_mem_report("specs").unwrap();
4601        assert_eq!(report.mem, "specs");
4602        let ids: Vec<&str> = report
4603            .changed_entity_ids
4604            .iter()
4605            .map(|id| id.as_ref())
4606            .collect();
4607        // Sorted lexicographically: lonely-three < new-via-disk < source-one
4608        assert_eq!(
4609            ids,
4610            vec![
4611                "specs--lonely-three",
4612                "specs--new-via-disk",
4613                "specs--source-one",
4614            ]
4615        );
4616    }
4617
4618    #[test]
4619    fn reload_one_mem_report_rejects_unknown_mem() {
4620        let tmp = TempDir::new().unwrap();
4621        let mut engine = build_demo_engine(&tmp);
4622        let err = engine.reload_one_mem_report("missing").unwrap_err();
4623        assert!(matches!(err, EngineError::UnknownMem(_)));
4624    }
4625
4626    #[test]
4627    fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4628        let tmp = TempDir::new().unwrap();
4629        let mut engine = build_demo_engine(&tmp);
4630        let reports = engine.reload_each_writable_mem_reports().unwrap();
4631        assert_eq!(reports.len(), 1);
4632        assert_eq!(reports[0].mem, "specs");
4633        assert_eq!(reports[0].entities_loaded, 3);
4634    }
4635
4636    /// Workspace-wide reload re-reads `.memstead/workspace.toml` and
4637    /// refreshes [`WorkspaceSettings`]. This is the pairing with the
4638    /// CLI's `memstead workspace allow-create / grant-cross-link /
4639    /// set-mutations` family — without it, a CLI write lands on disk
4640    /// but the running engine keeps serving the boot-time policy
4641    /// snapshot until process restart.
4642    #[test]
4643    fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4644        let tmp = TempDir::new().unwrap();
4645
4646        // Minimum-viable workspace.toml (no rules) + one writable
4647        // folder-backed mem.
4648        let memstead_dir = tmp.path().join(".memstead");
4649        std::fs::create_dir_all(&memstead_dir).unwrap();
4650        let workspace_toml = memstead_dir.join("workspace.toml");
4651        std::fs::write(
4652            &workspace_toml,
4653            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4654        )
4655        .unwrap();
4656        let mounts_json = memstead_dir.join("state").join("mounts.json");
4657        std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4658        let mem_dir = tmp.path().join("specs");
4659        std::fs::create_dir_all(&mem_dir).unwrap();
4660        let mounts_body = format!(
4661            r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4662            mem_dir.display(),
4663        );
4664        std::fs::write(&mounts_json, mounts_body).unwrap();
4665
4666        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4667        assert!(
4668            engine.settings().mem_create_rules.is_empty(),
4669            "boot-time settings carry no create rules"
4670        );
4671
4672        // Simulate an out-of-band CLI write to workspace.toml.
4673        std::fs::write(
4674            &workspace_toml,
4675            "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",
4676        )
4677        .unwrap();
4678
4679        engine.reload_each_writable_mem_reports().unwrap();
4680
4681        let rules = &engine.settings().mem_create_rules;
4682        assert_eq!(
4683            rules.len(),
4684            1,
4685            "workspace-wide reload must refresh the policy"
4686        );
4687        assert_eq!(rules[0].pattern, "exec-*");
4688    }
4689
4690    // ---- Engine::reload_if_stale ------------------------------
4691
4692    // ---- set_mem_schema / dual-pin migration ----
4693
4694    const MIG_TYPE_TAIL: &str = r#"sections:
4695  - key: body
4696    heading: Body
4697    required: true
4698    search_weight: 10.0
4699    catch_all: true
4700    write_rules: []
4701title_weight: 100.0
4702text_fields:
4703  - body
4704hierarchy_relationship: _default
4705no_self_loop_relationships: []
4706updatable_fields: []
4707health_required_fields: []
4708staleness_threshold_days: 90
4709write_rules: []
4710"#;
4711
4712    /// Schema manifest for the migration tests: `name@version` with a
4713    /// `doc` type. `with_status = true` adds a required, no-default
4714    /// enum field `status` — entities created without it are
4715    /// non-conformant against that schema.
4716    fn mig_manifest(name: &str, version: &str) -> String {
4717        format!(
4718            r#"name: {name}
4719version: {version}
4720description: migration test schema
4721when_to_use: tests
4722types:
4723  - doc
4724relationships:
4725  mode: strict
4726  definitions:
4727    - name: USES
4728      description: link
4729      default_weight: 1.0
4730    - name: _default
4731      description: fallback
4732      default_weight: 1.0
4733community:
4734  resolution: 1.0
4735  seed: 42
4736"#
4737        )
4738    }
4739
4740    fn mig_type_yaml(with_status: bool) -> String {
4741        let metadata = if with_status {
4742            "metadata_fields:\n  - key: status\n    description: Lifecycle state\n    field_type: string\n    required: true\n    enum_values:\n      - open\n      - closed\n"
4743        } else {
4744            "metadata_fields: []\n"
4745        };
4746        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4747    }
4748
4749    fn write_mig_schema(
4750        root: &std::path::Path,
4751        dir: &str,
4752        name: &str,
4753        version: &str,
4754        with_status: bool,
4755    ) {
4756        let d = root.join(dir);
4757        std::fs::create_dir_all(d.join("types")).unwrap();
4758        std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4759        std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4760    }
4761
4762    /// Engine with one mem pinned `mig-a@0.1.0` (no required
4763    /// metadata) plus loadable `mig-a@0.2.0` (identical shape) and
4764    /// `mig-b@0.1.0` (required enum `status`) in the workspace
4765    /// schemas dir. Two conformant-under-A entities are created.
4766    /// The criterion-4 property test (flywheel W8/01): a
4767    /// deterministic, seeded, hand-rolled generator (xorshift64 — the
4768    /// house discipline, no dependency) drives mutation sequences
4769    /// across EVERY kind — create, update, relate, delete, rename,
4770    /// batch update (applied AND refused/rolled-back), reload, and a
4771    /// schema switch — asserting at checkpoints and at sequence end
4772    /// that the maintained derived structures are identical to a
4773    /// from-scratch rebuild over the current store. Coverage is
4774    /// guaranteed by construction (the first pass cycles every kind
4775    /// once before the random tail), and asserted, so a silently
4776    /// narrowed generator fails the suite. A failing sequence
4777    /// reproduces from the seed printed in the panic message alone.
4778    #[test]
4779    fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4780        for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4781            run_mutation_sequence(seed);
4782        }
4783    }
4784
4785    struct Xorshift(u64);
4786    impl Xorshift {
4787        fn next(&mut self) -> u64 {
4788            let mut x = self.0;
4789            x ^= x << 13;
4790            x ^= x >> 7;
4791            x ^= x << 17;
4792            self.0 = x;
4793            x
4794        }
4795        fn pick(&mut self, n: usize) -> usize {
4796            (self.next() % n as u64) as usize
4797        }
4798    }
4799
4800    fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4801        // Search oracle: the maintained per-mem index holds exactly
4802        // the ids a from-scratch build over the current store holds.
4803        let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4804        let live = engine.search_indexes();
4805        let mut live_mems: Vec<&String> = live.keys().collect();
4806        let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4807        live_mems.sort();
4808        fresh_mems.sort();
4809        assert_eq!(
4810            live_mems, fresh_mems,
4811            "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4812        );
4813        for (mem, idx) in live {
4814            let mut got = idx.stored_ids().unwrap();
4815            let mut want = fresh[mem].stored_ids().unwrap();
4816            got.sort();
4817            want.sort();
4818            assert_eq!(
4819                got, want,
4820                "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4821            );
4822        }
4823        // Community oracle: the memoised partition equals a fresh
4824        // detection with the same parameter source (smallest mem name).
4825        let schema = engine
4826            .schemas
4827            .iter()
4828            .min_by(|a, b| a.0.cmp(b.0))
4829            .map(|(_, s)| s.clone())
4830            .expect("schema present");
4831        let weights_schema = schema.clone();
4832        let fresh_partition = crate::graph::community::detect_communities(
4833            engine.store(),
4834            schema.manifest.community.resolution,
4835            schema.manifest.community.seed,
4836            move |rel_type| {
4837                weights_schema
4838                    .manifest
4839                    .relationships
4840                    .definitions
4841                    .iter()
4842                    .find(|d| d.name == rel_type)
4843                    .map(|d| d.default_weight as f64)
4844                    .unwrap_or(1.0)
4845            },
4846        );
4847        assert_eq!(
4848            engine.communities().entity_cluster_map,
4849            fresh_partition.entity_cluster_map,
4850            "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4851        );
4852    }
4853
4854    fn run_mutation_sequence(seed: u64) {
4855        use indexmap::IndexMap;
4856
4857        let (_tmp, mut engine) = migration_engine();
4858        let mut rng = Xorshift(seed);
4859        let mut live: Vec<crate::EntityId> = vec![
4860            crate::EntityId::new("specs", "one"),
4861            crate::EntityId::new("specs", "two"),
4862        ];
4863        let mut counter = 0usize;
4864        let mut kinds_hit: std::collections::HashSet<&'static str> =
4865            std::collections::HashSet::new();
4866
4867        const KINDS: [&str; 7] = [
4868            "create",
4869            "update",
4870            "relate",
4871            "delete",
4872            "rename",
4873            "batch_applied",
4874            "batch_refused",
4875        ];
4876
4877        let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4878            anchors: Vec::new(),
4879            anchors_unset: Vec::new(),
4880            id,
4881            expected_hash: None,
4882            sections: IndexMap::new(),
4883            append_sections: IndexMap::new(),
4884            patch_sections: IndexMap::new(),
4885            metadata: IndexMap::new(),
4886            metadata_unset: Vec::new(),
4887            declare_relations: Vec::new(),
4888            dry_run: false,
4889            relations_unset: Vec::new(),
4890        };
4891
4892        for op_i in 0..30usize {
4893            // First pass cycles every kind once (coverage by
4894            // construction); the tail is seed-driven.
4895            let kind = *KINDS
4896                .get(op_i)
4897                .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4898            match kind {
4899                "create" => {
4900                    counter += 1;
4901                    let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4902                    args.entity_type = "doc".to_string();
4903                    args.sections = IndexMap::from_iter([(
4904                        "body".to_string(),
4905                        format!("generated body {counter}"),
4906                    )]);
4907                    let out = engine
4908                        .create_entity(args, crate::vcs::Actor::Cli, None, None)
4909                        .expect("generated create is conformant");
4910                    live.push(out.id);
4911                    kinds_hit.insert("create");
4912                }
4913                "update" => {
4914                    let id = live[rng.pick(live.len())].clone();
4915                    let mut args = bare_update(id);
4916                    args.append_sections
4917                        .insert("body".to_string(), format!("appended at op {op_i}"));
4918                    engine
4919                        .update_entity(args, crate::vcs::Actor::Cli, None, None)
4920                        .expect("append update is conformant");
4921                    kinds_hit.insert("update");
4922                }
4923                "relate" => {
4924                    if live.len() >= 2 {
4925                        let a = rng.pick(live.len());
4926                        let mut b = rng.pick(live.len());
4927                        if a == b {
4928                            b = (b + 1) % live.len();
4929                        }
4930                        engine
4931                            .relate_entity(
4932                                crate::engine::RelateEntityArgs {
4933                                    source: live[a].clone(),
4934                                    expected_hash: None,
4935                                    rel_type: "USES".to_string(),
4936                                    target: live[b].clone(),
4937                                    remove: false,
4938                                    description: None,
4939                                    dry_run: false,
4940                                },
4941                                crate::vcs::Actor::Cli,
4942                                None,
4943                                None,
4944                            )
4945                            .expect("USES relate is legal under mig-a");
4946                        kinds_hit.insert("relate");
4947                    }
4948                }
4949                "delete" => {
4950                    // Only reference-free entities delete cleanly; keep
4951                    // at least two so relate stays possible.
4952                    if live.len() > 2
4953                        && let Some(pos) = (0..live.len()).find(|&i| {
4954                            engine.store().incoming(&live[i]).is_empty()
4955                                && engine
4956                                    .store()
4957                                    .get(&live[i])
4958                                    .is_some_and(|e| e.relationships.is_empty())
4959                        })
4960                    {
4961                        let id = live.remove(pos);
4962                        engine
4963                            .delete_entity(
4964                                crate::engine::DeleteEntityArgs {
4965                                    id: id.clone(),
4966                                    expected_hash: None,
4967                                },
4968                                crate::vcs::Actor::Cli,
4969                                None,
4970                                None,
4971                            )
4972                            .expect("reference-free delete lands");
4973                        kinds_hit.insert("delete");
4974                    }
4975                }
4976                "rename" => {
4977                    counter += 1;
4978                    let pos = rng.pick(live.len());
4979                    let old = live[pos].clone();
4980                    let out = engine
4981                        .rename_entity(
4982                            crate::engine::RenameEntityArgs {
4983                                id: old,
4984                                new_title: format!("Renamed {counter}"),
4985                                expected_hash: None,
4986                            },
4987                            crate::vcs::Actor::Cli,
4988                            None,
4989                            None,
4990                        )
4991                        .expect("fresh-slug rename lands");
4992                    live[pos] = out.new_id;
4993                    kinds_hit.insert("rename");
4994                }
4995                "batch_applied" => {
4996                    let id_a = live[rng.pick(live.len())].clone();
4997                    let mut a = bare_update(id_a);
4998                    a.append_sections
4999                        .insert("body".to_string(), format!("batch line {op_i}"));
5000                    let result = engine
5001                        .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
5002                        .expect("batch envelope");
5003                    assert!(result.applied, "single-entry append batch applies");
5004                    kinds_hit.insert("batch_applied");
5005                }
5006                "batch_refused" => {
5007                    let id_a = live[rng.pick(live.len())].clone();
5008                    let mut a = bare_update(id_a);
5009                    a.append_sections
5010                        .insert("body".to_string(), "doomed".to_string());
5011                    let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
5012                    let result = engine
5013                        .batch_update(
5014                            vec![(a, None), (missing, None)],
5015                            crate::vcs::Actor::Cli,
5016                            None,
5017                            false,
5018                        )
5019                        .expect("refused batch returns a report-all envelope");
5020                    assert!(!result.applied, "the missing target refuses the batch");
5021                    kinds_hit.insert("batch_refused");
5022                }
5023                _ => unreachable!(),
5024            }
5025
5026            if op_i == 14 {
5027                // The at-least-one schema switch the criterion demands
5028                // (identical field shape, so the index rebuild is
5029                // exercised through the epoch path).
5030                engine
5031                    .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5032                    .expect("integral switch");
5033                kinds_hit.insert("schema_switch");
5034            }
5035            if op_i == 19 {
5036                engine.reload_one_mem("specs").expect("reload lands");
5037                kinds_hit.insert("reload");
5038            }
5039
5040            if op_i % 10 == 9 {
5041                assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
5042            }
5043        }
5044
5045        assert_derived_oracles(&engine, seed, "sequence end");
5046
5047        for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
5048            assert!(
5049                kinds_hit.contains(kind),
5050                "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
5051            );
5052        }
5053    }
5054
5055    fn migration_engine() -> (tempfile::TempDir, Engine) {
5056        let tmp = tempfile::TempDir::new().unwrap();
5057        let schemas_dir = tmp.path().join("schemas");
5058        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5059        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5060        write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
5061        let mem_dir = tmp.path().join("mem");
5062        std::fs::create_dir_all(&mem_dir).unwrap();
5063        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5064        let mut mount = folder_mount("specs", mem_dir);
5065        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5066        let mut engine = Engine::from_mounts_with_schemas_dir(
5067            vec![(
5068                mount,
5069                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5070            )],
5071            Some(&schemas_dir),
5072        )
5073        .unwrap();
5074        for title in ["One", "Two"] {
5075            let mut args = empty_create_args("specs", title);
5076            args.entity_type = "doc".to_string();
5077            args.sections =
5078                indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
5079            engine
5080                .create_entity(args, crate::vcs::Actor::Cli, None, None)
5081                .expect("conformant create under mig-a");
5082        }
5083        (tmp, engine)
5084    }
5085
5086    fn sref(s: &str) -> memstead_schema::SchemaRef {
5087        s.parse().unwrap()
5088    }
5089
5090    /// A `SCHEMA_PIN_MISMATCH` state (the mount expects the target, the
5091    /// served config pins an older generation) is exactly what
5092    /// `set-schema` must repair: the switch persists the config pin and
5093    /// reports `switched`, never `noop`. Complement: with both in
5094    /// agreement the same call is a noop.
5095    #[test]
5096    fn set_schema_repairs_a_mount_expectation_ahead_of_the_served_pin() {
5097        let (_tmp, mut engine) = migration_engine();
5098        // Fabricate the mismatch: the mount expectation says mig-b while
5099        // the engine still serves mig-a from the config.
5100        let idx = engine
5101            .mounts
5102            .iter()
5103            .position(|m| m.mount.mem == "specs")
5104            .unwrap();
5105        engine.mounts[idx].mount.schema = Some(sref("mig-b@0.1.0"));
5106        assert_eq!(engine.schemas.get("specs").unwrap().id().0, "mig-a");
5107
5108        let out = engine
5109            .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5110            .unwrap();
5111        // The fixture's entities are not integral against mig-b, so the
5112        // honest answer is a started migration; the point is that it is
5113        // NOT the noop the stale mount expectation used to produce.
5114        assert_eq!(
5115            out.outcome,
5116            crate::engine::SetSchemaResult::MigrationStarted,
5117            "a served pin behind the target enters the switch path, never a noop: {out:?}"
5118        );
5119        assert!(!out.findings.is_empty());
5120        assert_eq!(
5121            engine.schemas.get("specs").unwrap().id().0,
5122            "mig-b",
5123            "writes now validate against the target"
5124        );
5125
5126        // Complement: served pin and expectation both at the target (no
5127        // migration in flight) is a noop.
5128        let (_tmp2, mut clean) = migration_engine();
5129        let again = clean.set_mem_schema("specs", &sref("mig-a@0.1.0")).unwrap();
5130        assert_eq!(again.outcome, crate::engine::SetSchemaResult::Noop);
5131    }
5132
5133    #[test]
5134    fn set_schema_noop_on_current_pin() {
5135        let (_tmp, mut engine) = migration_engine();
5136        let out = engine
5137            .set_mem_schema("specs", &sref("mig-a@0.1.0"))
5138            .unwrap();
5139        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
5140        assert_eq!(out.schema_pin, "mig-a@0.1.0");
5141        assert_eq!(out.migration_target, None);
5142        assert!(out.findings.is_empty());
5143    }
5144
5145    /// Schema-switch invalidation (flywheel W8/01, criterion 2): a
5146    /// schema switch changes NO store content — the store generation
5147    /// stays put — yet both derived memos depend on the schema
5148    /// (community weights, the index field set), so the switch must
5149    /// clear them. The schemas EPOCH is what carries that dependency
5150    /// into the memo key; without it the generation-checked hooks
5151    /// would keep both memos and serve results computed against the
5152    /// old schema (the staleness the whole-map drop used to mask).
5153    #[test]
5154    fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
5155        let (_tmp, mut engine) = migration_engine();
5156
5157        let _ = engine.communities();
5158        let _ = engine.search_indexes();
5159        assert!(engine.community_memo.get().is_some());
5160        assert!(engine.search_indexes_memo.get().is_some());
5161        let store_gen_before = engine.store().generation();
5162
5163        engine
5164            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5165            .unwrap();
5166
5167        assert_eq!(
5168            engine.store().generation(),
5169            store_gen_before,
5170            "a schema switch mutates no store content"
5171        );
5172        assert!(
5173            engine.community_memo.get().is_none(),
5174            "the community memo must clear on a schema switch (weights derive from the schema)"
5175        );
5176        assert!(
5177            engine.search_indexes_memo.get().is_none(),
5178            "the search memo must clear on a schema switch (the field set derives from the schema)"
5179        );
5180    }
5181
5182    #[test]
5183    fn set_schema_switches_immediately_when_integral() {
5184        // Version bump within the same domain; entities conform to
5185        // the identical-shape 0.2.0, so the switch is immediate.
5186        let (_tmp, mut engine) = migration_engine();
5187        let out = engine
5188            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5189            .unwrap();
5190        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5191        assert_eq!(out.schema_pin, "mig-a@0.2.0");
5192        assert_eq!(out.migration_target, None);
5193        assert!(out.findings.is_empty());
5194        assert_eq!(
5195            engine.schema_pin("specs").unwrap().as_display(),
5196            "mig-a@0.2.0"
5197        );
5198        assert!(engine.migration_target("specs").is_none());
5199    }
5200
5201    /// Regression: an atomic switch must persist the new pin into the
5202    /// **authoritative** backend config, not just `mounts.json`. Boot
5203    /// resolution prefers the backend config's pin over `Mount.schema`,
5204    /// so before this fix the switch evaporated on the next process boot
5205    /// for any config-present mem (every `create_mem`-made mem).
5206    #[test]
5207    fn set_schema_switch_persists_pin_into_backend_config() {
5208        let tmp = tempfile::TempDir::new().unwrap();
5209        let schemas_dir = tmp.path().join("schemas");
5210        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
5211        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
5212        let mem_dir = tmp.path().join("mem");
5213        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
5214        // Config-present mem: the authoritative pin lives here.
5215        std::fs::write(
5216            mem_dir.join(".memstead").join("config.json"),
5217            br#"{"schema":"mig-a@0.1.0"}"#,
5218        )
5219        .unwrap();
5220        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5221        let mut mount = folder_mount("specs", mem_dir.clone());
5222        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5223        let mut engine = Engine::from_mounts_with_schemas_dir(
5224            vec![(
5225                mount,
5226                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5227            )],
5228            Some(&schemas_dir),
5229        )
5230        .unwrap();
5231
5232        let out = engine
5233            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
5234            .unwrap();
5235        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5236
5237        // The authoritative backend config now carries the new pin —
5238        // otherwise the switch would evaporate on reboot.
5239        let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
5240        let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
5241        assert_eq!(
5242            cfg["schema"], "mig-a@0.2.0",
5243            "atomic switch must update the authoritative backend config"
5244        );
5245    }
5246
5247    #[test]
5248    fn set_schema_unknown_target_refuses_schema_not_found() {
5249        let (_tmp, mut engine) = migration_engine();
5250        let err = engine
5251            .set_mem_schema("specs", &sref("nope@9.9.9"))
5252            .unwrap_err();
5253        assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
5254        // No state change.
5255        assert!(engine.migration_target("specs").is_none());
5256    }
5257
5258    #[test]
5259    fn set_schema_migration_lifecycle_end_to_end() {
5260        let (_tmp, mut engine) = migration_engine();
5261        let target = sref("mig-b@0.1.0");
5262
5263        // 1. Non-integral target → migration starts; pin unchanged.
5264        let out = engine.set_mem_schema("specs", &target).unwrap();
5265        assert_eq!(
5266            out.outcome,
5267            crate::engine::SetSchemaResult::MigrationStarted
5268        );
5269        assert_eq!(out.schema_pin, "mig-a@0.1.0");
5270        assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
5271        assert_eq!(out.findings.len(), 2, "both entities lack `status`");
5272        assert!(
5273            out.findings
5274                .iter()
5275                .all(|f| f.code == "REQUIRED_FIELD_UNSET")
5276        );
5277
5278        // 2. Reads of not-yet-repaired entities stay permissive.
5279        let one = crate::entity::EntityId::new("specs", "one");
5280        assert!(engine.store().get(&one).is_some());
5281
5282        // 3. Re-issue while unrepaired → pending, full remaining set.
5283        let out = engine.set_mem_schema("specs", &target).unwrap();
5284        assert_eq!(
5285            out.outcome,
5286            crate::engine::SetSchemaResult::MigrationPending
5287        );
5288        assert_eq!(out.findings.len(), 2);
5289
5290        // 4. Writes validate against the TARGET: `status` is unknown
5291        //    to the pinned mig-a but declared by mig-b — setting it
5292        //    must commit; an invalid enum value must refuse.
5293        let mut bad = crate::engine::UpdateEntityArgs {
5294            anchors: Vec::new(),
5295            id: one.clone(),
5296            expected_hash: None,
5297            sections: indexmap::IndexMap::new(),
5298            append_sections: indexmap::IndexMap::new(),
5299            patch_sections: indexmap::IndexMap::new(),
5300            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
5301            metadata_unset: Vec::new(),
5302            declare_relations: Vec::new(),
5303            dry_run: false,
5304            relations_unset: Vec::new(),
5305            anchors_unset: Vec::new(),
5306        };
5307        let err = engine
5308            .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
5309            .unwrap_err();
5310        assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
5311        bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
5312        engine
5313            .update_entity(bad, crate::vcs::Actor::Cli, None, None)
5314            .expect("repair write validated against the migration target");
5315
5316        // 5. One entity repaired → still pending, findings shrink.
5317        let out = engine.set_mem_schema("specs", &target).unwrap();
5318        assert_eq!(
5319            out.outcome,
5320            crate::engine::SetSchemaResult::MigrationPending
5321        );
5322        assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
5323
5324        // 6. Repair the second entity, re-issue → atomic switch.
5325        let two = crate::entity::EntityId::new("specs", "two");
5326        let repair = crate::engine::UpdateEntityArgs {
5327            anchors: Vec::new(),
5328            id: two.clone(),
5329            expected_hash: None,
5330            sections: indexmap::IndexMap::new(),
5331            append_sections: indexmap::IndexMap::new(),
5332            patch_sections: indexmap::IndexMap::new(),
5333            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
5334            metadata_unset: Vec::new(),
5335            declare_relations: Vec::new(),
5336            dry_run: false,
5337            relations_unset: Vec::new(),
5338            anchors_unset: Vec::new(),
5339        };
5340        engine
5341            .update_entity(repair, crate::vcs::Actor::Cli, None, None)
5342            .unwrap();
5343        let out = engine.set_mem_schema("specs", &target).unwrap();
5344        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
5345        assert_eq!(out.schema_pin, "mig-b@0.1.0");
5346        assert_eq!(out.migration_target, None);
5347        assert!(out.findings.is_empty());
5348        assert_eq!(
5349            engine.schema_pin("specs").unwrap().as_display(),
5350            "mig-b@0.1.0"
5351        );
5352        assert!(engine.migration_target("specs").is_none());
5353    }
5354
5355    /// During migration every not-yet-repaired entity is
5356    /// non-conformant against the target, so `relations_unset` works
5357    /// on exactly those entities with no mode flag — and the same
5358    /// update can complete the entity's repair.
5359    #[test]
5360    fn relations_unset_works_during_migration_without_mode_flag() {
5361        let (_tmp, mut engine) = migration_engine();
5362        let one = crate::entity::EntityId::new("specs", "one");
5363        let two = crate::entity::EntityId::new("specs", "two");
5364        engine
5365            .relate_entity(
5366                crate::engine::RelateEntityArgs {
5367                    source: one.clone(),
5368                    expected_hash: None,
5369                    rel_type: "USES".to_string(),
5370                    target: two.clone(),
5371                    remove: false,
5372                    description: None,
5373                    dry_run: false,
5374                },
5375                crate::vcs::Actor::Cli,
5376                None,
5377                None,
5378            )
5379            .unwrap();
5380        // Conformant under the pin → the repair gate is shut.
5381        let shut = engine
5382            .update_entity(
5383                crate::engine::UpdateEntityArgs {
5384                    anchors: Vec::new(),
5385                    id: one.clone(),
5386                    expected_hash: None,
5387                    sections: indexmap::IndexMap::new(),
5388                    append_sections: indexmap::IndexMap::new(),
5389                    patch_sections: indexmap::IndexMap::new(),
5390                    metadata: indexmap::IndexMap::new(),
5391                    metadata_unset: Vec::new(),
5392                    declare_relations: Vec::new(),
5393                    dry_run: false,
5394                    relations_unset: vec![crate::ops::RelationUnsetArg {
5395                        rel_type: "USES".to_string(),
5396                        target: two.clone(),
5397                    }],
5398                    anchors_unset: Vec::new(),
5399                },
5400                crate::vcs::Actor::Cli,
5401                None,
5402                None,
5403            )
5404            .unwrap_err();
5405        assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5406
5407        // Enter migration → `one` is now non-conformant against the
5408        // target; the same call opens, removes the relation, and the
5409        // bundled `status` set makes the entity integral-against-target.
5410        engine
5411            .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5412            .unwrap();
5413        engine
5414            .update_entity(
5415                crate::engine::UpdateEntityArgs {
5416                    anchors: Vec::new(),
5417                    id: one.clone(),
5418                    expected_hash: None,
5419                    sections: indexmap::IndexMap::new(),
5420                    append_sections: indexmap::IndexMap::new(),
5421                    patch_sections: indexmap::IndexMap::new(),
5422                    metadata: indexmap::IndexMap::from_iter([(
5423                        "status".to_string(),
5424                        "open".to_string(),
5425                    )]),
5426                    metadata_unset: Vec::new(),
5427                    declare_relations: Vec::new(),
5428                    dry_run: false,
5429                    relations_unset: vec![crate::ops::RelationUnsetArg {
5430                        rel_type: "USES".to_string(),
5431                        target: two.clone(),
5432                    }],
5433                    anchors_unset: Vec::new(),
5434                },
5435                crate::vcs::Actor::Cli,
5436                None,
5437                None,
5438            )
5439            .expect("repair-shaped update lands during migration without a flag");
5440        let entity = engine.store().get(&one).unwrap();
5441        assert!(entity.relationships.is_empty());
5442    }
5443
5444    /// Boot honors a persisted in-flight migration: a mount carrying
5445    /// `migration_target` validates writes against the target from
5446    /// the first call of the new process — the resumability half of
5447    /// the dual-pin contract.
5448    #[test]
5449    fn boot_resumes_dual_pin_validation_against_target() {
5450        let (tmp, engine) = migration_engine();
5451        drop(engine);
5452        let schemas_dir = tmp.path().join("schemas");
5453        let mem_dir = tmp.path().join("mem");
5454        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5455        let mut mount = folder_mount("specs", mem_dir);
5456        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5457        mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5458        let engine = Engine::from_mounts_with_schemas_dir(
5459            vec![(
5460                mount,
5461                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5462            )],
5463            Some(&schemas_dir),
5464        )
5465        .unwrap();
5466        // Effective validation schema is the target...
5467        let (name, version) = {
5468            let s = engine.schema_for("specs").unwrap();
5469            let (n, v) = s.id();
5470            (n.to_string(), v.to_string())
5471        };
5472        assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5473        // ...while the settled pin and the in-flight target read back
5474        // distinctly.
5475        assert_eq!(
5476            engine.schema_pin("specs").unwrap().as_display(),
5477            "mig-a@0.1.0"
5478        );
5479        assert_eq!(
5480            engine.migration_target("specs").unwrap().as_display(),
5481            "mig-b@0.1.0"
5482        );
5483    }
5484
5485    /// Every lifecycle setter refuses `READ_ONLY_MOUNT` on a read-only
5486    /// mount — the family, not an instance. `set_mem_schema` was the
5487    /// one ungated sibling (a schema-pin change starts a migration —
5488    /// the last mutation a sealed mount should accept); this test
5489    /// enumerates all seven current setters — extend it when adding an
5490    /// eighth (the enumeration is manual, not reflective). Refusal complement: the same calls succeed (or fail
5491    /// for their own non-capability reasons) against a writable mount —
5492    /// covered by the existing per-setter tests; `set_mem_schema`'s
5493    /// writable-mount behaviour is pinned by the migration tests above.
5494    #[test]
5495    fn every_lifecycle_setter_refuses_on_read_only_mount() {
5496        let tmp = TempDir::new().unwrap();
5497        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5498        let mut engine = Engine::from_mounts(vec![(
5499            archive_mount("ext", archive_path.clone()),
5500            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5501        )])
5502        .unwrap();
5503
5504        let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5505        let attempts: Vec<(&str, EngineError)> = vec![
5506            (
5507                "set_mem_schema",
5508                engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5509            ),
5510            (
5511                "set_mem_version",
5512                engine
5513                    .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5514                    .unwrap_err(),
5515            ),
5516            (
5517                "set_mem_description",
5518                engine
5519                    .set_mem_description("ext", Some("x".into()), None)
5520                    .unwrap_err(),
5521            ),
5522            (
5523                "set_mem_title",
5524                engine
5525                    .set_mem_title("ext", Some("x".into()), None)
5526                    .unwrap_err(),
5527            ),
5528            (
5529                "set_mem_subject",
5530                engine.set_mem_subject("ext", None, None).unwrap_err(),
5531            ),
5532            (
5533                "set_mem_internal",
5534                engine.set_mem_internal("ext", true, None).unwrap_err(),
5535            ),
5536            (
5537                "set_mem_sync_state",
5538                engine
5539                    .set_mem_sync_state("ext", "k", "t", None)
5540                    .unwrap_err(),
5541            ),
5542        ];
5543        for (setter, err) in attempts {
5544            match err {
5545                EngineError::ReadOnlyMount(v) => {
5546                    assert_eq!(v, "ext", "{setter} must name the refused mem")
5547                }
5548                other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5549            }
5550        }
5551    }
5552
5553    /// 04/03, criteria 1 and 2, on the folder backend. Every one of the
5554    /// lifecycle setters, each against a config a sibling moved after boot.
5555    /// The loop is the point: the criterion is the whole set behind one
5556    /// implementation, so a test that exercised one setter would pass while
5557    /// the other six stayed broken.
5558    #[test]
5559    fn no_config_setter_reverts_a_siblings_write() {
5560        type Setter = fn(&mut Engine) -> Result<(), EngineError>;
5561        let setters: Vec<(&str, Setter)> = vec![
5562            ("version", |e| {
5563                e.set_mem_version("specs", semver::Version::new(9, 0, 0), None)
5564                    .map(|_| ())
5565            }),
5566            ("description", |e| {
5567                e.set_mem_description("specs", Some("mine".into()), None)
5568                    .map(|_| ())
5569            }),
5570            ("title", |e| {
5571                e.set_mem_title("specs", Some("Mine".into()), None)
5572                    .map(|_| ())
5573            }),
5574            ("internal", |e| {
5575                e.set_mem_internal("specs", true, None).map(|_| ())
5576            }),
5577            ("sync_state", |e| {
5578                e.set_mem_sync_state("specs", "src/facet", "tok", None)
5579                    .map(|_| ())
5580            }),
5581            // Cleared rather than set: the mark validates against a real
5582            // commit cursor, and the clear path writes config just the same,
5583            // which is what this test is about.
5584            ("review_mark", |e| {
5585                e.set_review_mark("specs", None, None).map(|_| ())
5586            }),
5587        ];
5588
5589        for (name, set) in setters {
5590            let tmp = TempDir::new().unwrap();
5591            let mem_dir = tmp.path().to_path_buf();
5592            let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5593            std::fs::create_dir_all(&meta).unwrap();
5594            let path = meta.join("config.json");
5595            std::fs::write(
5596                &path,
5597                br#"{"schema": "default@1.0.0", "version": "0.1.0"}"#.as_slice(),
5598            )
5599            .unwrap();
5600
5601            let writer = FilesystemMemWriter::new(mem_dir.clone());
5602            let mut engine = Engine::from_mounts(vec![(
5603                folder_mount("specs", mem_dir.clone()),
5604                Box::new(writer) as Box<dyn MemBackend>,
5605            )])
5606            .unwrap();
5607            // The review-mark setter validates its cursor against a real
5608            // entity, so seed one before the sibling write.
5609            engine
5610                .create_entity(
5611                    crate::engine::test_helpers::empty_create_args("specs", "Seed"),
5612                    crate::vcs::Actor::Cli,
5613                    None,
5614                    None,
5615                )
5616                .unwrap();
5617
5618            // A sibling writes a field this engine has never seen.
5619            let mut sibling: memstead_schema::MemConfig =
5620                serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5621            sibling
5622                .extra
5623                .insert("siblingMark".into(), serde_json::json!("kept"));
5624            std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5625
5626            set(&mut engine).unwrap_or_else(|e| panic!("{name} setter failed: {e}"));
5627
5628            let after: memstead_schema::MemConfig =
5629                serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5630            assert_eq!(
5631                after.extra.get("siblingMark"),
5632                Some(&serde_json::json!("kept")),
5633                "the {name} setter reverted a field it never set"
5634            );
5635        }
5636    }
5637
5638    /// Criterion 3, and its complement 4: the intervention is reported on the
5639    /// operation's own response, and only when there was one.
5640    #[test]
5641    fn intervention_is_reported_on_the_response_and_only_when_real() {
5642        let tmp = TempDir::new().unwrap();
5643        let mem_dir = tmp.path().to_path_buf();
5644        let meta = mem_dir.join(memstead_schema::MEM_META_DIR);
5645        std::fs::create_dir_all(&meta).unwrap();
5646        let path = meta.join("config.json");
5647        std::fs::write(&path, br#"{"schema": "default@1.0.0"}"#.as_slice()).unwrap();
5648        let writer = FilesystemMemWriter::new(mem_dir.clone());
5649        let mut engine = Engine::from_mounts(vec![(
5650            folder_mount("specs", mem_dir.clone()),
5651            Box::new(writer) as Box<dyn MemBackend>,
5652        )])
5653        .unwrap();
5654
5655        // Single writer: no report. This is the ordinary path, and a fix that
5656        // cried intervention here would be worse than the bug.
5657        let quiet = engine
5658            .set_mem_description("specs", Some("first".into()), None)
5659            .unwrap();
5660        assert!(
5661            !quiet
5662                .warnings
5663                .iter()
5664                .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
5665            "single-writer workspace must stay silent: {:?}",
5666            quiet.warnings
5667        );
5668
5669        // A sibling intervenes; the next write says so, naming the field.
5670        let mut sibling: memstead_schema::MemConfig =
5671            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5672        sibling.title = Some("theirs".into());
5673        std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
5674
5675        let loud = engine
5676            .set_mem_description("specs", Some("second".into()), None)
5677            .unwrap();
5678        let hint = loud
5679            .warnings
5680            .iter()
5681            .find(|w| w.code() == "CONFIG_WRITE_INTERVENED")
5682            .expect("intervention must be reported on the response");
5683        assert!(
5684            format!("{hint}").contains("title"),
5685            "the report names what they changed: {hint}"
5686        );
5687        // And theirs survived, which is the point of reporting rather than refusing.
5688        let after: memstead_schema::MemConfig =
5689            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5690        assert_eq!(after.title.as_deref(), Some("theirs"));
5691        assert_eq!(after.description.as_deref(), Some("second"));
5692    }
5693}