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                    to: 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        let load_result = parse_entries(entries, read_errors, &mount.mem, schema.as_ref());
753
754        let mut mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
755        mem_names.push(mount.mem.clone());
756        let known_suffixes: Vec<String> = mem_names
757            .iter()
758            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
759            .collect();
760        let fallback = engine_fallback_type();
761        push_entities_into_store(
762            &mut self.store,
763            load_result.entities,
764            fallback.as_ref(),
765            Some(crate::entity::store_builder::LoadCollector {
766                warnings: &mut self.load_warnings,
767                known_suffixes: &known_suffixes,
768                mem_names: &mem_names,
769            }),
770        );
771        self.load_errors.extend(load_result.errors);
772
773        // Step 5: insert schema (kept in lockstep with `self.mounts`).
774        self.schemas_insert(mount.mem.clone(), schema);
775
776        // Re-run the parse-time relation validator now that the new
777        // mem's schema is in `self.schemas`. Mirrors the boot path
778        // (`Engine::from_mounts_inner`) — hand-edited or externally-
779        // generated markdown in the newly-attached mount goes through
780        // the same gauntlet (grammar / unknown_rel_type / shape /
781        // cycle) and offending relations are dropped with typed
782        // `PARSED_RELATION_INVALID` warnings on `self.load_warnings`.
783        // The newly-pushed mount isn't in `self.mounts` yet (that's
784        // Step 6 below), so build the map from `self.mounts` plus the
785        // about-to-be-attached mount we're still holding.
786        if run_global_passes {
787            let mut mount_caps: std::collections::HashMap<
788                String,
789                crate::workspace::MountCapability,
790            > = self
791                .mounts
792                .iter()
793                .map(|m| (m.mount.mem.clone(), m.mount.capability))
794                .collect();
795            mount_caps.insert(mount.mem.clone(), mount.capability);
796            crate::entity::store_builder::validate_loaded_relations(
797                &mut self.store,
798                &self.schemas,
799                &mount_caps,
800                &mut self.load_warnings,
801            );
802            crate::entity::store_builder::remap_alias_target_edge_sources(
803                &mut self.store,
804                &self.schemas,
805            );
806        }
807
808        // Step 6: push the MountedBackend.
809        let last_known_head = backend.current_head().ok().flatten();
810        let mem_name_for_router = mount.mem.clone();
811        let storage_for_router = mount.storage.clone();
812        let mount_capability_for_router = mount.capability;
813        self.mounts.push(MountedBackend {
814            mount,
815            backend,
816            last_known_head,
817            mem_config,
818            // A runtime-created mem is authored live, not installed from
819            // an archive — it carries no archive-borne provenance payload.
820            archive_provenance: None,
821            // Registered live and loaded in this call — never deferred.
822            deferred: false,
823        });
824
825        // Step 7: COW snapshot swap on mem_router, branched on the
826        // mount's capability — a read-only archive mount registers in
827        // the router's read-only slot (so `is_writable` stays false
828        // and `archive_path_for_mem` resolves), everything else in the
829        // writable slot. Folder mounts surface their on-disk path;
830        // other backends register with `dir: None` (mem-repo-backed
831        // mounts have no working tree).
832        match (&mount_capability_for_router, &storage_for_router) {
833            (crate::workspace::MountCapability::ReadOnly, MountStorage::Archive { path }) => {
834                Arc::make_mut(&mut self.mem_router)
835                    .add_read_only(mem_name_for_router, path.clone());
836            }
837            _ => {
838                let dir: Option<PathBuf> = match &storage_for_router {
839                    MountStorage::Folder { path } => Some(path.clone()),
840                    MountStorage::GitBranch { .. }
841                    | MountStorage::Archive { .. }
842                    | MountStorage::InMemory => None,
843                };
844                Arc::make_mut(&mut self.mem_router).add_writable(mem_name_for_router, dir, origin);
845            }
846        }
847
848        // Step 8: invalidate dependent memos.
849        if run_global_passes {
850            self.invalidate_communities();
851            self.invalidate_search_indexes();
852        }
853
854        Ok(())
855    }
856
857    /// The batched tail of `register_writable_mem_inner(...,
858    /// run_global_passes: false)`: one workspace-global relation
859    /// validation, one alias remap, one memo invalidation for the
860    /// whole batch of registrations.
861    fn finish_batched_registrations(&mut self) {
862        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
863            .mounts
864            .iter()
865            .map(|m| (m.mount.mem.clone(), m.mount.capability))
866            .collect();
867        crate::entity::store_builder::validate_loaded_relations(
868            &mut self.store,
869            &self.schemas,
870            &mount_caps,
871            &mut self.load_warnings,
872        );
873        crate::entity::store_builder::remap_alias_target_edge_sources(
874            &mut self.store,
875            &self.schemas,
876        );
877        self.invalidate_communities();
878        self.invalidate_search_indexes();
879    }
880
881    /// Additive full refresh — the warm-server half of "restart the
882    /// process": re-scan the schema sources and the mount manifest,
883    /// making newly installed schema versions resolvable and newly
884    /// registered mems usable, WITHOUT applying removals. The
885    /// asymmetry is deliberate and is the whole safety argument:
886    /// adding extends what the in-memory store can answer, while
887    /// removing can strand entities, in-flight handles, and cached
888    /// hashes the process is still serving. Removals are skipped and
889    /// reported; a restart applies them.
890    ///
891    /// Failure model is per-item: a schema source or a mount that
892    /// fails to refresh lands in `failures` and never surfaces as
893    /// newly available; the others proceed. Each mount registration
894    /// is all-or-nothing (every fallible step runs before the store
895    /// is touched), so a failed item leaves no half-updated state.
896    /// The workspace-global passes (relation validation, alias remap,
897    /// memo invalidation) run ONCE per refresh regardless of how many
898    /// mounts attached.
899    ///
900    /// A newly mounted mem starts cold and loads like any other
901    /// mount. Content reload of pre-existing mems is NOT part of this
902    /// method — callers that want both (the `memstead_reload
903    /// full=true` surface) run the existing content-reload sweep
904    /// alongside.
905    pub fn full_refresh(&mut self) -> crate::ops::FullRefreshReport {
906        let started = std::time::Instant::now();
907        let mut report = crate::ops::FullRefreshReport::default();
908
909        let Some(root) = self.workspace_root.clone() else {
910            report.failures.push(crate::ops::RefreshFailure {
911                item: "workspace".to_string(),
912                error: "engine has no workspace root (ad-hoc mount-list construction) — \
913                        nothing to re-scan"
914                    .to_string(),
915            });
916            report.elapsed_ms = started.elapsed().as_millis() as u64;
917            return report;
918        };
919
920        // Workspace policy — same best-effort refresh the
921        // workspace-wide content reload performs.
922        self.refresh_workspace_settings_if_possible();
923
924        // --- Schema sources, additively. ---
925        use crate::schema_source::SchemaSource as _;
926        let mut fresh: Vec<std::sync::Arc<memstead_schema::Schema>> = Vec::new();
927        let mut sources_complete = true;
928        match crate::schema_source::FolderSchemaSource::for_workspace(&root).read_schemas() {
929            Ok(mut s) => fresh.append(&mut s),
930            Err(e) => {
931                sources_complete = false;
932                report.failures.push(crate::ops::RefreshFailure {
933                    item: "schema-source:folder".to_string(),
934                    error: e.to_string(),
935                });
936            }
937        }
938        if let Some(ops) = self.git_branch_ops() {
939            match (ops.read_ref_schemas)(&root) {
940                Ok(mut s) => fresh.append(&mut s),
941                Err(e) => {
942                    sources_complete = false;
943                    report.failures.push(crate::ops::RefreshFailure {
944                        item: "schema-source:memstead-ref".to_string(),
945                        error: e.to_string(),
946                    });
947                }
948            }
949        }
950        let key = |s: &memstead_schema::Schema| {
951            let (name, version) = s.id();
952            format!("{name}@{version}")
953        };
954        let existing: std::collections::HashSet<String> =
955            self.workspace_schemas.iter().map(|s| key(s)).collect();
956        let fresh_keys: std::collections::HashSet<String> = fresh.iter().map(|s| key(s)).collect();
957        for schema in fresh {
958            let k = key(&schema);
959            if !existing.contains(&k) && !report.schemas_added.contains(&k) {
960                report.schemas_added.push(k);
961                self.workspace_schemas.push(schema);
962            }
963        }
964        report.schemas_added.sort();
965        // Removal detection is only meaningful when every source was
966        // actually readable — otherwise an unreadable source would
967        // masquerade as a mass removal.
968        if sources_complete {
969            report.schema_removals_skipped = existing
970                .difference(&fresh_keys)
971                .cloned()
972                .collect::<Vec<_>>();
973            report.schema_removals_skipped.sort();
974        }
975
976        // --- Mount manifest, additively. ---
977        let store = crate::workspace_store::FileWorkspaceStore::new();
978        match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
979            Err(e) => {
980                report.failures.push(crate::ops::RefreshFailure {
981                    item: "mount-manifest".to_string(),
982                    error: e.to_string(),
983                });
984            }
985            Ok(workspace) => {
986                let manifest_names: std::collections::HashSet<String> =
987                    workspace.mounts.iter().map(|m| m.mem.clone()).collect();
988                let mut any_mounted = false;
989                for mount in workspace.mounts {
990                    if self.mounts.iter().any(|m| m.mount.mem == mount.mem) {
991                        continue;
992                    }
993                    let name = mount.mem.clone();
994                    if mount.capability != crate::workspace::MountCapability::Write {
995                        report.failures.push(crate::ops::RefreshFailure {
996                            item: format!("mount:{name}"),
997                            error: "only writable mounts attach on a warm refresh — \
998                                    restart the process to attach this mount"
999                                .to_string(),
1000                        });
1001                        continue;
1002                    }
1003                    let backend = match (self.backend_factory)(&mount) {
1004                        Ok(b) => b,
1005                        Err(e) => {
1006                            report.failures.push(crate::ops::RefreshFailure {
1007                                item: format!("mount:{name}"),
1008                                error: e.to_string(),
1009                            });
1010                            continue;
1011                        }
1012                    };
1013                    match self.register_writable_mem_inner(
1014                        mount,
1015                        backend,
1016                        crate::mem::MemOrigin::ExplicitToml,
1017                        false,
1018                    ) {
1019                        Ok(()) => {
1020                            any_mounted = true;
1021                            report.mems_mounted.push(name);
1022                        }
1023                        Err(e) => report.failures.push(crate::ops::RefreshFailure {
1024                            item: format!("mount:{name}"),
1025                            error: e.to_string(),
1026                        }),
1027                    }
1028                }
1029                // Removals: a currently-mounted WRITABLE mem absent
1030                // from the manifest. Read-only attachments (archive
1031                // read-mems hydrated from per-mem configs) are not
1032                // manifest entries and never count as removals.
1033                report.mem_removals_skipped = self
1034                    .mounts
1035                    .iter()
1036                    .filter(|m| {
1037                        m.mount.capability == crate::workspace::MountCapability::Write
1038                            && !manifest_names.contains(&m.mount.mem)
1039                    })
1040                    .map(|m| m.mount.mem.clone())
1041                    .collect();
1042                report.mem_removals_skipped.sort();
1043                if any_mounted {
1044                    // One workspace-global pass for the whole batch.
1045                    self.finish_batched_registrations();
1046                }
1047            }
1048        }
1049
1050        report.elapsed_ms = started.elapsed().as_millis() as u64;
1051        report
1052    }
1053
1054    /// Override the workspace root after construction. The full
1055    /// boot helper `memstead_git_branch::engine_from_workspace_root`
1056    /// calls this so the engine knows the path even when the boot
1057    /// route runs through the full adapter rather than
1058    /// [`Self::from_workspace_root`].
1059    pub fn set_workspace_root(&mut self, root: PathBuf) {
1060        self.workspace_root = Some(root);
1061    }
1062
1063    /// Persist the engine's current mount list to the workspace
1064    /// store so a freshly-booted sibling process observes the same
1065    /// mem membership. Called by
1066    /// [`crate::mem_management::create_mem`] /
1067    /// [`crate::mem_management::delete_mem`] after the in-memory
1068    /// router mutation lands — without this, the per-mem content
1069    /// (branch + `__MEMSTEAD` config blob, or folder + `.memstead/config.json`)
1070    /// is already on disk, but the next process boot reads an empty
1071    /// `.memstead/state/mounts.json` and the engine starts with zero
1072    /// writable mems.
1073    ///
1074    /// No-op when `workspace_root` is unset (tests / ad-hoc
1075    /// consumers that build the engine directly from a mount list).
1076    /// Production boot paths (`Engine::from_workspace_root` and the
1077    /// full counterpart) always set the root, so the engine-side
1078    /// fix covers every caller — in-process embedders included
1079    /// — by construction.
1080    ///
1081    /// Hardcoded against [`crate::FileWorkspaceStore`] because that
1082    /// is the only V1 adapter; a future SQLite or remote adapter
1083    /// would install through a setter mirroring
1084    /// [`Self::set_backend_factory`].
1085    pub fn persist_state(&self) -> Result<(), EngineError> {
1086        let Some(root) = self.workspace_root.as_ref() else {
1087            return Ok(());
1088        };
1089        let workspace = crate::workspace::Workspace {
1090            mounts: self.mounts.iter().map(|m| m.mount.clone()).collect(),
1091            settings: self.settings.clone(),
1092        };
1093        let store = crate::FileWorkspaceStore::new();
1094        crate::workspace_store::WorkspaceStoreAdapter::save_state(&store, root, &workspace)
1095            .map_err(|e| EngineError::Mem(format!("persist workspace state: {e}")))
1096    }
1097    /// Set a mem's schema pin — the conformance-gated schema-migration
1098    /// trigger. Behaviour per the pinned contract:
1099    ///
1100    /// - requested == current pin → `Noop`, no state change.
1101    /// - requested != pin, mem integral against the target →
1102    ///   atomic switch (`schema_pin = target`, migration state
1103    ///   cleared) in one workspace-store write → `Switched`.
1104    /// - requested != pin, mem NOT integral → enter (or stay in)
1105    ///   dual-pin: `migration_target = target`, writes validate
1106    ///   against the target from this call on, `findings` carries
1107    ///   the non-integral entities → `MigrationStarted`
1108    ///   (first call) / `MigrationPending` (same target re-issued).
1109    /// - re-issued with the in-flight target once every entity is
1110    ///   integral → atomic switch → `Switched`.
1111    ///
1112    /// The trigger is a label change gated by the conformance check —
1113    /// no content hashing. The response hands the agent findings and
1114    /// nothing else (no migration scripts, no hints); each repair
1115    /// write is validated strictly against the target.
1116    pub fn set_mem_schema(
1117        &mut self,
1118        mem: &str,
1119        target: &memstead_schema::SchemaRef,
1120    ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
1121        use crate::engine::{SetSchemaOutcome, SetSchemaResult};
1122        // A quarantined mem's set-schema IS the repair path (an
1123        // unresolvable pin is the commonest quarantine cause): repin
1124        // the retained mount, then re-attempt the attach — the same
1125        // in-process recovery `reload` performs after an external
1126        // repair. Ordinary mounts proceed below unchanged.
1127        if self.quarantine_reason(mem).is_some() {
1128            return self.set_schema_on_quarantined(mem, target);
1129        }
1130        let mount_idx = self
1131            .mounts
1132            .iter()
1133            .position(|m| m.mount.mem == mem)
1134            .ok_or_else(|| self.unknown_mem_error(mem))?;
1135        // Capability gate, identical in shape and position to the six
1136        // sibling setters (`set_mem_version` … `set_mem_sync_state`):
1137        // a schema-pin change starts a migration — the one lifecycle
1138        // mutation a read-only mount must be able to refuse like any
1139        // other.
1140        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1141            return Err(EngineError::ReadOnlyMount(mem.to_string()));
1142        }
1143
1144        // The requested target must resolve before anything else —
1145        // an unknown ref is an error, not a migration into nowhere.
1146        let target_schema = self.resolve_schema_by_ref(target).ok_or_else(|| {
1147            // The migration resolver consulted workspace-authored
1148            // schemas layered over the built-ins (`resolve_schema_by_ref`).
1149            let consulted: Vec<_> = self
1150                .workspace_schemas
1151                .iter()
1152                .chain(self.builtin_schemas.iter())
1153                .cloned()
1154                .collect();
1155            EngineError::SchemaNotFound {
1156                mem: mem.to_string(),
1157                pin: target.as_display(),
1158                sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1159                    &target.name,
1160                    &target.version,
1161                    &consulted,
1162                ),
1163                install_hint: None,
1164            }
1165            .with_schema_install_probe(self.workspace_root())
1166        })?;
1167
1168        // `Mount.schema` is now the optional assertion; for a mem the
1169        // operator is actively re-pinning it is normally `Some` (and kept
1170        // in sync with the config by the switch below). `<unset>` covers a
1171        // mount that carried no assertion.
1172        let current_pin = self.mounts[mount_idx].mount.schema.clone();
1173        let current_pin_display = current_pin
1174            .as_ref()
1175            .map(|p| p.as_display())
1176            .unwrap_or_else(|| "<unset>".to_string());
1177        let in_flight = self.mounts[mount_idx].mount.migration_target.clone();
1178
1179        if current_pin.as_ref() == Some(target) {
1180            return Ok(SetSchemaOutcome {
1181                mem: mem.to_string(),
1182                schema_pin: current_pin_display,
1183                migration_target: in_flight.map(|t| t.as_display()),
1184                outcome: SetSchemaResult::Noop,
1185                findings: Vec::new(),
1186            });
1187        }
1188
1189        // Conformance gate against the requested target. The full
1190        // integrity definition includes the consistency axis, but the
1191        // schema-switch gate is conformance: consistency breaks are
1192        // schema-independent (they neither block nor are caused by a
1193        // pin change) and keep their always-available repair paths.
1194        let findings = crate::ops::integrity::conformance_findings(
1195            &self.store,
1196            mem,
1197            target_schema.as_ref(),
1198            &self.schemas,
1199        );
1200
1201        if findings.is_empty() {
1202            // Atomic switch. The pin's authoritative home is the backend
1203            // config (boot resolution prefers it over `Mount.schema`), so
1204            // persist there FIRST — if that write fails, every other piece
1205            // of state stays untouched and the switch is a clean no-op.
1206            // Without this the new pin landed only in `mounts.json` and was
1207            // silently reverted on the next process boot for any
1208            // config-present mem.
1209            self.persist_mem_schema_pin(mount_idx, target)?;
1210            self.mounts[mount_idx].mount.schema = Some(target.clone());
1211            self.mounts[mount_idx].mount.migration_target = None;
1212            self.schemas_insert(mem.to_string(), target_schema);
1213            self.invalidate_communities();
1214            // The index field set derives from the pinned schema — the
1215            // schema-switch staleness the whole-map drop used to mask
1216            // (flywheel W8/01, criterion 2): this mem's index must be
1217            // rebuilt against the new field set.
1218            self.invalidate_search_indexes();
1219            self.persist_state()?;
1220            return Ok(SetSchemaOutcome {
1221                mem: mem.to_string(),
1222                schema_pin: target.as_display(),
1223                migration_target: None,
1224                outcome: SetSchemaResult::Switched,
1225                findings: Vec::new(),
1226            });
1227        }
1228
1229        let outcome = if in_flight.as_ref() == Some(target) {
1230            SetSchemaResult::MigrationPending
1231        } else {
1232            SetSchemaResult::MigrationStarted
1233        };
1234        self.mounts[mount_idx].mount.migration_target = Some(target.clone());
1235        // Writes validate against the target from this point on —
1236        // the load-bearing dual-pin semantic.
1237        self.schemas_insert(mem.to_string(), target_schema);
1238        self.invalidate_communities();
1239        // Same field-set dependency as the atomic-switch branch above.
1240        self.invalidate_search_indexes();
1241        self.persist_state()?;
1242        Ok(SetSchemaOutcome {
1243            mem: mem.to_string(),
1244            schema_pin: current_pin_display,
1245            migration_target: Some(target.as_display()),
1246            outcome,
1247            findings,
1248        })
1249    }
1250
1251    /// Persist a mem's new schema pin into the authoritative backend
1252    /// config (`.memstead/config.json` for folder, the `__MEMSTEAD`
1253    /// mem-config blob for git-branch).
1254    ///
1255    /// Boot resolution treats the backend config as the authoritative
1256    /// settled pin and `Mount.schema` (the `mounts.json` copy) as a
1257    /// cross-checked assertion. A schema switch that updated only
1258    /// `mounts.json` would therefore be silently reverted on the next
1259    /// process boot — the config still names the old pin. This keeps the
1260    /// authoritative home in sync at switch time.
1261    ///
1262    /// Value-level field bump: only the `"schema"` string is rewritten;
1263    /// every other config field (`readMems`, write guidance, …) is
1264    /// preserved verbatim. Config-absent mems (no `config.json`) keep
1265    /// `Mount.schema` as their settled pin, so there is nothing to update
1266    /// — a clean no-op.
1267    fn persist_mem_schema_pin(
1268        &mut self,
1269        mount_idx: usize,
1270        target: &memstead_schema::SchemaRef,
1271    ) -> Result<(), EngineError> {
1272        let value = bump_backend_schema_pin(self.mounts[mount_idx].backend.as_ref(), target)?;
1273        // Refresh the cached parsed config so in-session reads observe the
1274        // new pin without a reload.
1275        if let Some(value) = value
1276            && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
1277        {
1278            self.mounts[mount_idx].mem_config = Some(cfg);
1279        }
1280        Ok(())
1281    }
1282
1283    /// Regenerate entity markdown files from the in-memory store.
1284    ///
1285    /// Dispatch:
1286    /// - When `mem_filter` is `Some(name)`, only that mem's mount
1287    ///   is considered. If its active backend doesn't support markdown
1288    ///   regeneration in place (today: anything other than
1289    ///   `MountStorage::Folder`), the call refuses with
1290    ///   [`EngineError::MarkdownExportUnsupportedBackend`] carrying
1291    ///   the active backend's id and the supported-backend list.
1292    /// - When `mem_filter` is `None`, every mount is iterated.
1293    ///   Folder mounts regenerate as today; non-folder mounts are
1294    ///   recorded in [`crate::ops::ExportResult::skipped_mounts`] so
1295    ///   the caller can surface the partial-success shape.
1296    ///
1297    /// Per-folder-mount behaviour: iterate the store, regenerate each
1298    /// non-stub entity belonging to the mount's mem, compare to the
1299    /// on-disk file, write if changed.
1300    ///
1301    /// `schema_filter` narrows the per-entity-type subset: when
1302    /// `Some(name)`, only entities whose `entity_type` matches are
1303    /// regenerated. `None` exports every type.
1304    ///
1305    /// Pre-fix this returned
1306    /// `ExportResult { written: 0, unchanged: 0 }` for git-branch /
1307    /// archive mounts — a successful-looking no-op that masked the
1308    /// backend-incompatibility. The typed refusal (per-mem) and the
1309    /// `skipped_mounts` channel (workspace-wide) give the caller an
1310    /// agent-actionable signal in one round-trip.
1311    pub fn export_markdown(
1312        &self,
1313        mem_filter: Option<&str>,
1314        schema_filter: Option<&str>,
1315    ) -> Result<crate::ops::ExportResult, EngineError> {
1316        use crate::workspace::MountStorage;
1317        let fallback = engine_fallback_type();
1318        let supported_backends = vec!["folder".to_string()];
1319
1320        if let Some(name) = mem_filter {
1321            let mount = self
1322                .mounts
1323                .iter()
1324                .find(|m| m.mount.mem == name)
1325                .ok_or_else(|| self.unknown_mem_error(name))?;
1326            if !matches!(mount.mount.storage, MountStorage::Folder { .. }) {
1327                return Err(EngineError::MarkdownExportUnsupportedBackend {
1328                    mem: name.to_string(),
1329                    active_backend: mount.mount.storage.backend_id().to_string(),
1330                    supported_backends,
1331                });
1332            }
1333        }
1334
1335        let mut total_written = 0;
1336        let mut total_unchanged = 0;
1337        let mut skipped_mounts: Vec<crate::ops::SkippedMount> = Vec::new();
1338
1339        for mount in &self.mounts {
1340            let mem_name = mount.mount.mem.as_str();
1341            if let Some(filter) = mem_filter
1342                && mem_name != filter
1343            {
1344                continue;
1345            }
1346            let MountStorage::Folder { path: mem_dir } = &mount.mount.storage else {
1347                skipped_mounts.push(crate::ops::SkippedMount {
1348                    mem: mem_name.to_string(),
1349                    active_backend: mount.mount.storage.backend_id().to_string(),
1350                    reason: "backend_does_not_support_markdown_export".to_string(),
1351                });
1352                continue;
1353            };
1354            let schema = match self.schemas.get(mem_name) {
1355                Some(s) => s,
1356                None => continue,
1357            };
1358
1359            for entity in self.store.all_entities() {
1360                if entity.stub || entity.file_path.is_empty() {
1361                    continue;
1362                }
1363                if entity.id.mem() != mem_name {
1364                    continue;
1365                }
1366                if let Some(filter) = schema_filter
1367                    && entity.entity_type != filter
1368                {
1369                    continue;
1370                }
1371                let type_def = schema
1372                    .get_type(&entity.entity_type)
1373                    .unwrap_or_else(|| fallback.clone());
1374                let generated = generate_markdown(entity, type_def.as_ref());
1375
1376                let full_path = mem_dir.join(&entity.file_path);
1377                let needs_write = match std::fs::read_to_string(&full_path) {
1378                    Ok(existing) => existing != generated,
1379                    Err(_) => true,
1380                };
1381                if needs_write {
1382                    let _ = crate::entity::writer::write_entity(entity, mem_dir, type_def.as_ref());
1383                    total_written += 1;
1384                } else {
1385                    total_unchanged += 1;
1386                }
1387            }
1388        }
1389
1390        Ok(crate::ops::ExportResult {
1391            written: total_written,
1392            unchanged: total_unchanged,
1393            skipped_mounts,
1394        })
1395    }
1396
1397    /// Export a mem as a portable `.mem` archive.
1398    ///
1399    /// Dispatch is internal: the engine looks up the mount whose mem
1400    /// name matches and branches on its `MountStorage`. Folder mounts
1401    /// produce a snapshot archive (current `.md` files + config);
1402    /// git-branch mounts invoke the registered [`GitBranchOps::export`]
1403    /// hook to produce a history archive (the per-mem branch tip's
1404    /// tree); archive mounts reject with `BackendError::Sealed`
1405    /// (already-an-archive — no meaningful re-export).
1406    ///
1407    /// The mem's `MemConfig` is looked up via
1408    /// [`Self::mem_config_for`]; unloaded configs (folder mounts
1409    /// without a `.memstead/config.json`, git-branch mounts without a
1410    /// `__MEMSTEAD:mems/<mem>/config.json`) surface as
1411    /// `EngineError::InvalidInput`. Workspace-level schema dir is
1412    /// threaded from `self.settings.schemas_dir` for the
1413    /// schema-source resolution chain.
1414    pub fn export_mem(
1415        &self,
1416        mem_name: &str,
1417        output_path: &std::path::Path,
1418    ) -> Result<crate::ops::MemExportResult, EngineError> {
1419        let mount = self
1420            .mounts
1421            .iter()
1422            .find(|m| m.mount.mem == mem_name)
1423            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1424        let config = self.mem_config_for(mem_name).ok_or_else(|| {
1425            EngineError::InvalidInput(format!(
1426                "mem '{mem_name}' has no loaded MemConfig — cannot export"
1427            ))
1428        })?;
1429        // F1: surface the missing-version case as a typed
1430        // `MEM_CONFIG_INCOMPLETE` envelope with structured recovery
1431        // details, rather than letting it bubble through as the
1432        // backend's `INTERNAL` collapse pointing at the wrong path
1433        // (`.memstead/config.json` is the folder-backend layout — the
1434        // mem-repo backend keeps the blob under `__MEMSTEAD:mems/`).
1435        // The check fires for both backends symmetrically.
1436        if config.version.is_none() {
1437            return Err(EngineError::MemConfigIncomplete {
1438                mem: mem_name.to_string(),
1439                missing_fields: vec!["version".to_string()],
1440            });
1441        }
1442        let workspace_root = self.workspace_root.as_deref();
1443        // Authored schemas live at the fixed `<workspace>/.memstead/schemas/`
1444        // location (the `schemas_dir` key is retired). Absent dir → the
1445        // schema-source chain falls through to cache/built-in, as before.
1446        let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
1447        let workspace_schemas_dir = fixed_schemas_dir.as_deref();
1448        match &mount.mount.storage {
1449            MountStorage::Folder { path } => crate::ops::export::export_mem(
1450                path,
1451                config,
1452                output_path,
1453                workspace_root,
1454                workspace_schemas_dir,
1455            )
1456            .map_err(|e| EngineError::Backend(BackendError::Other(format!("export_mem: {e}")))),
1457            MountStorage::GitBranch { gitdir, branch } => {
1458                let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
1459                    EngineError::Backend(BackendError::Other(
1460                        "git-branch export hook not installed (full flavour not loaded)"
1461                            .to_string(),
1462                    ))
1463                })?;
1464                // Source per-entity provenance from the git-branch mutation
1465                // log (commit trailers) and hand the serialised payload to
1466                // the export hook to embed — symmetric with the bytes path.
1467                let provenance_bytes = mount
1468                    .backend
1469                    .read_provenance(None)
1470                    .ok()
1471                    .and_then(|records| crate::ops::export::build_archive_provenance(&records))
1472                    .and_then(|prov| prov.to_archive_bytes().ok());
1473                // Source the anchors sidecar from the branch tip — symmetric
1474                // with the bytes-export path so the disk `.mem` carries anchors.
1475                let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
1476                (hook.export)(
1477                    gitdir,
1478                    branch,
1479                    mem_name,
1480                    config,
1481                    output_path,
1482                    workspace_root,
1483                    workspace_schemas_dir,
1484                    provenance_bytes.as_deref(),
1485                    anchors_bytes.as_deref(),
1486                )
1487                .map_err(EngineError::Backend)
1488            }
1489            MountStorage::Archive { .. } => Err(EngineError::Backend(BackendError::Sealed)),
1490            // `.mem` export from an in-memory mem lands with the
1491            // writable-session-server plan (it needs a backend-level
1492            // archive builder); this plan adds the backend, not the
1493            // export path, so refuse explicitly rather than silently.
1494            MountStorage::InMemory => Err(EngineError::Backend(BackendError::Other(
1495                "export not yet supported for in-memory backend".to_string(),
1496            ))),
1497        }
1498    }
1499
1500    /// Update a mem's `version` field in its per-mem config and
1501    /// persist it through the backend. Backend-symmetric: folder
1502    /// backends rewrite `.memstead/config.json`; git-branch backends
1503    /// commit `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts
1504    /// reject with `BackendError::Sealed`.
1505    ///
1506    /// Returns the (mem, old_version, new_version) triple so
1507    /// callers can surface the change without an extra read. Reads
1508    /// the current value from the in-memory `MemConfig` and
1509    /// updates it on success, keeping the next call free of a
1510    /// stale-version read.
1511    ///
1512    /// `EngineError::UnknownMem` when the name resolves to no
1513    /// mount; `EngineError::ReadOnlyMount` when the mount is sealed
1514    /// for writes; `EngineError::InvalidInput` when the mount has no
1515    /// loaded `MemConfig` (folder mount with no
1516    /// `.memstead/config.json`; the residual missing-config path is
1517    /// distinct from the missing-version path). F1.
1518    /// Record pipeline-edit provenance through `mem`'s backend — the
1519    /// bridge the pipeline-edit block (outside the engine module) uses
1520    /// to reach a mount's backend. A mem that isn't currently mounted
1521    /// is a successful no-op: pipeline configs may reference unmounted
1522    /// mems, and provenance is recorded against the mounted set.
1523    pub fn record_pipeline_edit_provenance(
1524        &self,
1525        mem: &str,
1526        kind: &str,
1527        edits: &[(String, Option<Vec<u8>>)],
1528        note: Option<&str>,
1529        verb: &str,
1530    ) -> Result<(), crate::backend::BackendError> {
1531        match self.mounts.iter().find(|m| m.mount.mem == mem) {
1532            Some(m) => m.backend.record_pipeline_edit(kind, edits, note, verb),
1533            None => Ok(()),
1534        }
1535    }
1536
1537    pub fn set_mem_version(
1538        &mut self,
1539        mem_name: &str,
1540        new_version: semver::Version,
1541        note: Option<&str>,
1542    ) -> Result<crate::ops::SetMemVersionOutcome, EngineError> {
1543        // Resolve the mount up-front so an unknown-mem name refuses
1544        // before any drift-probe side effect lands.
1545        let mount_idx = self
1546            .mounts
1547            .iter()
1548            .position(|m| m.mount.mem == mem_name)
1549            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1550        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1551            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1552        }
1553
1554        // Probe for concurrent-drift before the write — a sibling
1555        // engine that committed between our last snapshot and now
1556        // surfaces `MEM_RELOADED` on the response so callers see
1557        // the drift without a separate read round-trip. Drift
1558        // warnings ride alongside the success outcome; an
1559        // unreachable-backend probe collapses to no warnings (the
1560        // existing accessor warn-logs internally and skips).
1561        let mut warnings = self.reload_if_stale(Some(mem_name));
1562        // Provenance nudge — same posture as every other commit-
1563        // producing mutation: when `require_notes` is set and no note
1564        // was supplied, ride a non-blocking `NOTE_MISSING` warning.
1565        // The version bump still commits.
1566        if let Some(w) = self.note_missing_warning("set_mem_version", note) {
1567            warnings.push(w);
1568        }
1569
1570        let mounted = &mut self.mounts[mount_idx];
1571        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1572            EngineError::InvalidInput(format!(
1573                "mem '{mem_name}' has no loaded MemConfig — \
1574                     cannot set version (initialize the mem via `memstead init` \
1575                     or `memstead mem create` first)"
1576            ))
1577        })?;
1578        let old_version = config.version.clone();
1579        config.version = Some(new_version.clone());
1580
1581        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1582            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1583        })?;
1584        bytes.push(b'\n');
1585        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1586        mounted.mem_config = Some(config);
1587
1588        // Refresh the head cursor so the next drift probe doesn't
1589        // surface MEM_RELOADED for the commit we just produced
1590        // (the git-branch backend's `write_mem_config` writes a
1591        // commit on `__MEMSTEAD`; folder backends carry no head and the
1592        // refresh is a no-op).
1593        let new_head = mounted.backend.current_head().ok().flatten();
1594        if let Some(sha) = new_head {
1595            mounted.last_known_head = Some(sha);
1596        }
1597
1598        Ok(crate::ops::SetMemVersionOutcome {
1599            mem: mem_name.to_string(),
1600            old_version,
1601            new_version,
1602            warnings,
1603        })
1604    }
1605
1606    /// Update a mem's `description` field in its per-mem config and
1607    /// persist it through the backend — the one-line text mem-archive
1608    /// export embeds and the registry card surfaces. `None` clears the
1609    /// field. Same backend symmetry, drift probe, and provenance-note
1610    /// posture as [`Self::set_mem_version`]; archive mounts reject with
1611    /// `BackendError::Sealed`.
1612    pub fn set_mem_description(
1613        &mut self,
1614        mem_name: &str,
1615        new_description: Option<String>,
1616        note: Option<&str>,
1617    ) -> Result<crate::ops::SetMemDescriptionOutcome, EngineError> {
1618        let mount_idx = self
1619            .mounts
1620            .iter()
1621            .position(|m| m.mount.mem == mem_name)
1622            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1623        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1624            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1625        }
1626
1627        let mut warnings = self.reload_if_stale(Some(mem_name));
1628        if let Some(w) = self.note_missing_warning("set_mem_description", note) {
1629            warnings.push(w);
1630        }
1631
1632        let mounted = &mut self.mounts[mount_idx];
1633        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1634            EngineError::InvalidInput(format!(
1635                "mem '{mem_name}' has no loaded MemConfig — \
1636                     cannot set description (initialize the mem via `memstead init` \
1637                     or `memstead mem create` first)"
1638            ))
1639        })?;
1640        let old_description = config.description.clone();
1641        config.description = new_description.clone();
1642
1643        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1644            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1645        })?;
1646        bytes.push(b'\n');
1647        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1648        mounted.mem_config = Some(config);
1649
1650        let new_head = mounted.backend.current_head().ok().flatten();
1651        if let Some(sha) = new_head {
1652            mounted.last_known_head = Some(sha);
1653        }
1654
1655        Ok(crate::ops::SetMemDescriptionOutcome {
1656            mem: mem_name.to_string(),
1657            old_description,
1658            new_description,
1659            warnings,
1660        })
1661    }
1662
1663    /// Update a mem's display `title` — free text, NOT identity: the
1664    /// mem name stays the sole handle everywhere. `None` clears it.
1665    /// Mirrors [`Self::set_mem_description`] in backend symmetry,
1666    /// drift probe, and provenance-note posture.
1667    pub fn set_mem_title(
1668        &mut self,
1669        mem_name: &str,
1670        new_title: Option<String>,
1671        note: Option<&str>,
1672    ) -> Result<crate::ops::SetMemTitleOutcome, EngineError> {
1673        let mount_idx = self
1674            .mounts
1675            .iter()
1676            .position(|m| m.mount.mem == mem_name)
1677            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1678        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1679            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1680        }
1681
1682        let mut warnings = self.reload_if_stale(Some(mem_name));
1683        if let Some(w) = self.note_missing_warning("set_mem_title", note) {
1684            warnings.push(w);
1685        }
1686
1687        let mounted = &mut self.mounts[mount_idx];
1688        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1689            EngineError::InvalidInput(format!(
1690                "mem '{mem_name}' has no loaded MemConfig — cannot set title"
1691            ))
1692        })?;
1693        let old_title = config.title.clone();
1694        config.title = new_title.clone();
1695
1696        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1697            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1698        })?;
1699        bytes.push(b'\n');
1700        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1701        mounted.mem_config = Some(config);
1702
1703        let new_head = mounted.backend.current_head().ok().flatten();
1704        if let Some(sha) = new_head {
1705            mounted.last_known_head = Some(sha);
1706        }
1707
1708        Ok(crate::ops::SetMemTitleOutcome {
1709            mem: mem_name.to_string(),
1710            old_title,
1711            new_title,
1712            warnings,
1713        })
1714    }
1715
1716    /// Update a mem's `subject` block — scope, method, deliberate
1717    /// exclusions, published verbatim. `None` clears the block AS A
1718    /// UNIT. Mirrors [`Self::set_mem_description`].
1719    pub fn set_mem_subject(
1720        &mut self,
1721        mem_name: &str,
1722        new_subject: Option<memstead_schema::MemSubject>,
1723        note: Option<&str>,
1724    ) -> Result<crate::ops::SetMemSubjectOutcome, EngineError> {
1725        let mount_idx = self
1726            .mounts
1727            .iter()
1728            .position(|m| m.mount.mem == mem_name)
1729            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1730        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1731            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1732        }
1733
1734        let mut warnings = self.reload_if_stale(Some(mem_name));
1735        if let Some(w) = self.note_missing_warning("set_mem_subject", note) {
1736            warnings.push(w);
1737        }
1738
1739        let mounted = &mut self.mounts[mount_idx];
1740        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1741            EngineError::InvalidInput(format!(
1742                "mem '{mem_name}' has no loaded MemConfig — cannot set subject"
1743            ))
1744        })?;
1745        let old_subject = config.subject.clone();
1746        config.subject = new_subject.clone();
1747
1748        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1749            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1750        })?;
1751        bytes.push(b'\n');
1752        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1753        mounted.mem_config = Some(config);
1754
1755        let new_head = mounted.backend.current_head().ok().flatten();
1756        if let Some(sha) = new_head {
1757            mounted.last_known_head = Some(sha);
1758        }
1759
1760        Ok(crate::ops::SetMemSubjectOutcome {
1761            mem: mem_name.to_string(),
1762            old_subject,
1763            new_subject,
1764            warnings,
1765        })
1766    }
1767
1768    /// Mark (or unmark) a mem as **internal** — hidden from the default
1769    /// `memstead_overview` roster and public projections, while remaining a
1770    /// real, schema-validated, diffable mem (inspectable when explicitly
1771    /// scoped by name, and deletable). The ingest process-state redesign
1772    /// (candidate (b)) flags each `ingest/<name>` process mem this way so it
1773    /// does not clutter the roster alongside real content.
1774    ///
1775    /// Stored as the top-level `internal` config field (captured by the
1776    /// flattened `extra` map). Backend-symmetric like
1777    /// [`Self::set_mem_description`]; `EngineError::UnknownMem` /
1778    /// `ReadOnlyMount` / `InvalidInput` on the usual failures.
1779    pub fn set_mem_internal(
1780        &mut self,
1781        mem_name: &str,
1782        internal: bool,
1783        note: Option<&str>,
1784    ) -> Result<bool, EngineError> {
1785        let mount_idx = self
1786            .mounts
1787            .iter()
1788            .position(|m| m.mount.mem == mem_name)
1789            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1790        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1791            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1792        }
1793
1794        let _ = self.reload_if_stale(Some(mem_name));
1795
1796        let mounted = &mut self.mounts[mount_idx];
1797        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1798            EngineError::InvalidInput(format!(
1799                "mem '{mem_name}' has no loaded MemConfig — initialize the mem first"
1800            ))
1801        })?;
1802        if internal {
1803            config
1804                .extra
1805                .insert("internal".to_string(), serde_json::Value::Bool(true));
1806        } else {
1807            config.extra.remove("internal");
1808        }
1809
1810        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1811            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1812        })?;
1813        bytes.push(b'\n');
1814        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1815        mounted.mem_config = Some(config);
1816
1817        let new_head = mounted.backend.current_head().ok().flatten();
1818        if let Some(sha) = new_head {
1819            mounted.last_known_head = Some(sha);
1820        }
1821
1822        Ok(internal)
1823    }
1824
1825    /// Set (or clear) one opaque sync-state token in a mem's per-mem
1826    /// config and persist it through the backend. The ingest layer calls
1827    /// this after a successful pass over a source's changed slice to
1828    /// record "the source state the graph was last synced against".
1829    ///
1830    /// `key` and `token` are both opaque to the engine: the key is
1831    /// conventionally `"<ingest>/<facet>"` but the engine treats it as an
1832    /// arbitrary string; the token's meaning belongs to the medium-type
1833    /// layer (git → commit id, graph → snapshot token, filesystem → a
1834    /// JSON-stringified stat digest). The engine never parses either.
1835    /// An **empty** `token` removes the key — the surface for clearing a
1836    /// baseline (which the next ingest pass re-seeds at the current
1837    /// source state).
1838    ///
1839    /// Backend-symmetric like [`Self::set_mem_version`]: folder backends
1840    /// rewrite `.memstead/config.json`; git-branch backends commit
1841    /// `__MEMSTEAD:mems/<mem>/config.json`. Archive mounts reject with
1842    /// `BackendError::Sealed`.
1843    ///
1844    /// Returns the (mem, key, previous-token) triple so callers can
1845    /// surface the change without an extra read. `EngineError::UnknownMem`
1846    /// when the name resolves to no mount; `EngineError::ReadOnlyMount`
1847    /// when the mount is sealed for writes; `EngineError::InvalidInput`
1848    /// when the mount has no loaded `MemConfig`.
1849    pub fn set_mem_sync_state(
1850        &mut self,
1851        mem_name: &str,
1852        key: &str,
1853        token: &str,
1854        note: Option<&str>,
1855    ) -> Result<crate::ops::SetMemSyncStateOutcome, EngineError> {
1856        // Resolve the mount up-front so an unknown-mem name refuses
1857        // before any drift-probe side effect lands.
1858        let mount_idx = self
1859            .mounts
1860            .iter()
1861            .position(|m| m.mount.mem == mem_name)
1862            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1863        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
1864            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
1865        }
1866
1867        // Probe for concurrent-drift before the write — same posture as
1868        // every other commit-producing mutation; a sibling engine that
1869        // committed since our last snapshot surfaces `MEM_RELOADED`.
1870        let mut warnings = self.reload_if_stale(Some(mem_name));
1871        if let Some(w) = self.note_missing_warning("set_mem_sync_state", note) {
1872            warnings.push(w);
1873        }
1874
1875        let mounted = &mut self.mounts[mount_idx];
1876        let mut config = mounted.mem_config.clone().ok_or_else(|| {
1877            EngineError::InvalidInput(format!(
1878                "mem '{mem_name}' has no loaded MemConfig — \
1879                 cannot set sync state (initialize the mem via `memstead init` \
1880                 or `memstead mem create` first)"
1881            ))
1882        })?;
1883
1884        // Empty token clears the baseline; otherwise insert/overwrite.
1885        // `removed` distinguishes a no-op clear (key absent) from a real
1886        // one so the outcome is honest.
1887        let removed;
1888        let previous;
1889        if token.is_empty() {
1890            previous = config.sync_state.remove(key);
1891            removed = previous.is_some();
1892        } else {
1893            previous = config.sync_state.insert(key.to_string(), token.to_string());
1894            removed = false;
1895        }
1896
1897        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
1898            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
1899        })?;
1900        bytes.push(b'\n');
1901        mounted.backend.write_mem_config_with_note(&bytes, note)?;
1902        mounted.mem_config = Some(config);
1903
1904        // Refresh the head cursor so the next drift probe doesn't surface
1905        // MEM_RELOADED for the commit we just produced.
1906        let new_head = mounted.backend.current_head().ok().flatten();
1907        if let Some(sha) = new_head {
1908            mounted.last_known_head = Some(sha);
1909        }
1910
1911        Ok(crate::ops::SetMemSyncStateOutcome {
1912            mem: mem_name.to_string(),
1913            key: key.to_string(),
1914            previous,
1915            removed,
1916            warnings,
1917        })
1918    }
1919
1920    /// Re-read the named mount's backend entities and refresh the
1921    /// in-memory store for that mem. Returns the diff against the
1922    /// pre-reload snapshot — `added` (ids newly present), `removed`
1923    /// (ids no longer present), `changed` (same id, different
1924    /// `content_hash`).
1925    ///
1926    /// Operator-triggered: useful when an external writer modified
1927    /// disk while this engine instance was alive (the lean flavour
1928    /// assumes single-writer; this primitive is the escape hatch when
1929    /// that assumption breaks). On the happy path the diff is empty.
1930    ///
1931    /// Drift detection (whether disk *did* change) is not part of this
1932    /// surface — callers that want to short-circuit on "nothing
1933    /// changed" must compare `added.is_empty() && changed.is_empty()
1934    /// && removed.is_empty()` against the result. Backend-specific
1935    /// drift signals (git HEAD comparison, mtime check) live in the
1936    /// full-flavour engine where they have meaning.
1937    ///
1938    /// Invalidates community + search-index memos on success.
1939    /// Load every DEFERRED (lazy, not-yet-loaded) mem matching `mem`
1940    /// (`None` = all) into the store — the first-read trigger of the
1941    /// lazy-mount lifecycle. Runs before the staleness probes on every
1942    /// operation ([`Self::reload_if_stale`] calls it first), so an
1943    /// operation scoped to one mem loads exactly that mem, and a
1944    /// workspace-scoped operation (search, overview, health) loads
1945    /// whatever it needs to answer over a complete store — a count
1946    /// computed over a partial store is never presented as truth.
1947    ///
1948    /// Loading rides the ordinary per-mem reload (entity walk, store
1949    /// push, the workspace-global validation passes, memo
1950    /// invalidation), so a lazily-loaded mem gets the same refusals and
1951    /// warnings an eager boot produces for the same content — deferral
1952    /// changes WHEN the gauntlet runs, never whether. After each load,
1953    /// pending `SuspiciousNestedPrefix` warnings whose target arrived
1954    /// with this mem are dropped — the same legitimate-cross-mem
1955    /// exemption the eager boot applies once every mount is loaded.
1956    ///
1957    /// A deferred load that FAILS quarantines the mem with the same
1958    /// typed reporting an eager boot failure produces, at this first
1959    /// read: the mount leaves the serving roster, the quarantine roster
1960    /// gains the typed reason, and the existing reattach contract
1961    /// applies. Deferral never converts a load failure into an
1962    /// empty-mem impression.
1963    ///
1964    /// No-op for workspaces without lazy mounts, for already-loaded
1965    /// mems, and for a filter naming no deferred mem.
1966    pub fn ensure_mems_loaded(&mut self, mem: Option<&str>) {
1967        let pending: Vec<String> = self
1968            .mounts
1969            .iter()
1970            .filter(|m| m.deferred && mem.is_none_or(|v| m.mount.mem == v))
1971            .map(|m| m.mount.mem.clone())
1972            .collect();
1973        for name in pending {
1974            match self.reload_one_mem(&name) {
1975                Ok(_) => {
1976                    if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name) {
1977                        state.deferred = false;
1978                    }
1979                    // Cross-mem links INTO this mem were scanned by the
1980                    // nested-prefix detector against a store that did
1981                    // not yet carry it; now that its entities exist,
1982                    // drop any hit the arrival resolves (mirrors the
1983                    // boot-time retain over the complete store).
1984                    let store = &self.store;
1985                    self.load_warnings.retain(|w| match w {
1986                        crate::ops::WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1987                            store.get(resolved_id).is_none_or(|e| e.stub)
1988                        }
1989                        _ => true,
1990                    });
1991                }
1992                Err(e) => {
1993                    // Quarantine at the moment of first read — mirror
1994                    // the eager boot failure path byte-for-byte in
1995                    // consequence: out of the serving roster, onto the
1996                    // quarantine roster with the typed reason.
1997                    let Some(idx) = self.mounts.iter().position(|m| m.mount.mem == name) else {
1998                        continue;
1999                    };
2000                    let removed = self.mounts.remove(idx);
2001                    self.schemas_remove(&name);
2002                    self.quarantined.push(crate::engine::QuarantinedMem {
2003                        mount: removed.mount,
2004                        reason_code: e.code().to_string(),
2005                        reason_message: e.to_string(),
2006                    });
2007                    self.mem_router = std::sync::Arc::new(
2008                        crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2009                    );
2010                    self.invalidate_communities();
2011                    // The schemas epoch just moved (`schemas_remove`),
2012                    // so a filled search memo is stale-keyed — clear it
2013                    // here or the next search trips the memo-key
2014                    // assert (the first W8/01 grade demonstrated
2015                    // exactly that on this branch).
2016                    self.invalidate_search_indexes();
2017                }
2018            }
2019        }
2020    }
2021
2022    pub fn reload_one_mem(&mut self, mem: &str) -> Result<crate::ops::ReloadResult, EngineError> {
2023        // Per-mem reload refreshes THIS mem's slice of the engine-wide
2024        // `load_warnings` accumulator: stale boot-time warnings for the
2025        // mem drop, fresh re-parse warnings take their place, other
2026        // mems' entries stay untouched. (The earlier "intentionally
2027        // silent" contract let a reload heal drift on disk while
2028        // `health()` kept reporting the healed warning forever — the
2029        // same class of stale-state lie the mem-delete purge closes.)
2030        //
2031        // The sink is filtered by source-mem attribution before it
2032        // merges: `validate_loaded_relations` scans the whole store, so
2033        // in principle the sink can carry other mems' warnings. In the
2034        // common case those mems' invalid rows were already dropped
2035        // from the in-memory store at their own load, so the filter is
2036        // a no-op guard against cross-mem duplicates, not a routine
2037        // trim. Failure leaves the accumulator untouched (`?` fires
2038        // before the merge), matching the inner fn's no-mutation-on-
2039        // failed-read fence. Drift events still surface as
2040        // `MemReloaded` warnings via `reload_if_stale`.
2041        // A quarantined mem's reload is the way back into service:
2042        // re-attempt the whole attach (backend, schema resolution,
2043        // entity load). On success the roster entry disappears; on
2044        // failure the mem stays quarantined with a refreshed reason.
2045        if self.quarantine_reason(mem).is_some() {
2046            return self.reattach_quarantined_mem(mem);
2047        }
2048        let mut sink: Vec<WarningHint> = Vec::new();
2049        let result = self.reload_one_mem_inner(mem, &mut sink)?;
2050        self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2051        self.load_warnings
2052            .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2053        Ok(result)
2054    }
2055
2056    /// The quarantine branch of [`Self::set_mem_schema`]: repin the
2057    /// retained mount (target must resolve — the same booted resolver;
2058    /// repair never force-writes a pin that resolves nowhere), bump
2059    /// the backend config through the shared value-level writer,
2060    /// persist the mount state, then re-attempt the attach. A reattach
2061    /// that still fails (some second cause) leaves the mem quarantined
2062    /// with its refreshed reason — the pin switch itself is durable
2063    /// either way. The booted path's conformance gate cannot run over
2064    /// an unloaded mem; findings surface on the post-reattach health.
2065    fn set_schema_on_quarantined(
2066        &mut self,
2067        mem: &str,
2068        target: &memstead_schema::SchemaRef,
2069    ) -> Result<crate::engine::SetSchemaOutcome, EngineError> {
2070        use crate::engine::{SetSchemaOutcome, SetSchemaResult};
2071        // Same target-ref validation as the ordinary branch.
2072        if self.resolve_schema_by_ref(target).is_none() {
2073            let consulted: Vec<_> = self
2074                .workspace_schemas
2075                .iter()
2076                .chain(self.builtin_schemas.iter())
2077                .cloned()
2078                .collect();
2079            return Err(EngineError::SchemaNotFound {
2080                mem: mem.to_string(),
2081                pin: target.as_display(),
2082                sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
2083                    &target.name,
2084                    &target.version,
2085                    &consulted,
2086                ),
2087                install_hint: None,
2088            }
2089            .with_schema_install_probe(self.workspace_root()));
2090        }
2091        let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2092            return Err(self.unknown_mem_error(mem));
2093        };
2094        // Repin the retained mount and, where a backend can be
2095        // instantiated, the authoritative backend config (shared
2096        // value-level bump — same writer as the ordinary branch).
2097        self.quarantined[q_idx].mount.schema = Some(target.clone());
2098        self.quarantined[q_idx].mount.migration_target = None;
2099        if let Ok(backend) = (self.backend_factory)(&self.quarantined[q_idx].mount) {
2100            let _ = bump_backend_schema_pin(backend.as_ref(), target);
2101        }
2102        self.persist_state()?;
2103        // Re-attempt the attach. Failure keeps the quarantine (fresh
2104        // reason on the roster) but the pin switch stands — the
2105        // outcome reports the switch, the roster reports any
2106        // remaining cause.
2107        let _ = self.reattach_quarantined_mem(mem);
2108        Ok(SetSchemaOutcome {
2109            mem: mem.to_string(),
2110            schema_pin: target.as_display(),
2111            migration_target: None,
2112            outcome: SetSchemaResult::Switched,
2113            findings: Vec::new(),
2114        })
2115    }
2116
2117    /// Re-attempt the boot-time attach of a quarantined mem —
2118    /// backend instantiation, schema resolution (same resolver and
2119    /// catalogue layering as boot: workspace-authored schemas over
2120    /// built-ins), then a per-mem entity load. Success removes the
2121    /// roster entry and the mem serves again in the same process;
2122    /// any failure keeps (re-)quarantining with the fresh typed
2123    /// reason, so the roster never goes stale against the live state.
2124    fn reattach_quarantined_mem(
2125        &mut self,
2126        mem: &str,
2127    ) -> Result<crate::ops::ReloadResult, EngineError> {
2128        let Some(q_idx) = self.quarantined.iter().position(|q| q.mount.mem == mem) else {
2129            return Err(self.unknown_mem_error(mem));
2130        };
2131        let mount = self.quarantined[q_idx].mount.clone();
2132
2133        let requarantine = |this: &mut Self, e: &EngineError| {
2134            this.quarantined[q_idx].reason_code = e.code().to_string();
2135            this.quarantined[q_idx].reason_message = e.to_string();
2136        };
2137
2138        let backend = match (self.backend_factory)(&mount) {
2139            Ok(b) => b,
2140            Err(e) => {
2141                let err = EngineError::Mem(e.to_string());
2142                requarantine(self, &err);
2143                return Err(self.unknown_mem_error(mem));
2144            }
2145        };
2146
2147        // Same config / pin-authority reads as the boot loop.
2148        let last_known_head = backend.current_head().ok().flatten();
2149        let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
2150            let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2151            memstead_schema::config::parse_mem_config(&value).ok()
2152        });
2153        let archive_provenance = backend
2154            .read_archive_provenance()
2155            .ok()
2156            .flatten()
2157            .and_then(|bytes| memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok());
2158        let config_pin = mem_config.as_ref().and_then(|c| c.schema.clone());
2159        let effective_pin = mount
2160            .migration_target
2161            .clone()
2162            .or(config_pin)
2163            .or(mount.schema.clone());
2164        let Some(effective_pin) = effective_pin else {
2165            let err = EngineError::MemConfigIncomplete {
2166                mem: mem.to_string(),
2167                missing_fields: vec!["schema".to_string()],
2168            };
2169            requarantine(self, &err);
2170            return Err(self.unknown_mem_error(mem));
2171        };
2172        let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = self
2173            .workspace_schemas
2174            .iter()
2175            .chain(self.builtin_schemas.iter())
2176            .cloned()
2177            .collect();
2178        let schema = match crate::engine::SchemaResolver::new(&catalogue).resolve(&effective_pin) {
2179            Ok(s) => s,
2180            Err(sources) => {
2181                let err = EngineError::SchemaNotFound {
2182                    mem: mem.to_string(),
2183                    pin: effective_pin.as_display(),
2184                    sources,
2185                    install_hint: None,
2186                }
2187                .with_schema_install_probe(self.workspace_root());
2188                requarantine(self, &err);
2189                return Err(self.unknown_mem_error(mem));
2190            }
2191        };
2192
2193        // Attach, then load entities through the ordinary per-mem
2194        // reload. An entity-load failure re-quarantines (the mount is
2195        // detached again) — quarantine is not tolerance.
2196        self.quarantined.remove(q_idx);
2197        self.schemas_insert(mem.to_string(), schema);
2198        self.mounts.push(crate::engine::MountedBackend {
2199            mount,
2200            backend,
2201            last_known_head,
2202            mem_config,
2203            archive_provenance,
2204            // The reattach loads entities immediately below — a
2205            // quarantine return-to-service is never deferred.
2206            deferred: false,
2207        });
2208        self.mem_router = std::sync::Arc::new(crate::engine::boot::build_mem_router_from_mounts(
2209            &self.mounts,
2210        ));
2211        let mut sink: Vec<WarningHint> = Vec::new();
2212        match self.reload_one_mem_inner(mem, &mut sink) {
2213            Ok(result) => {
2214                self.load_warnings.retain(|w| w.source_mem() != Some(mem));
2215                self.load_warnings
2216                    .extend(sink.into_iter().filter(|w| w.source_mem() == Some(mem)));
2217                self.invalidate_communities();
2218                Ok(result)
2219            }
2220            Err(e) => {
2221                let mount_idx = self.mounts.len() - 1;
2222                let mounted = self.mounts.remove(mount_idx);
2223                self.schemas_remove(mem);
2224                self.mem_router = std::sync::Arc::new(
2225                    crate::engine::boot::build_mem_router_from_mounts(&self.mounts),
2226                );
2227                self.quarantined.push(crate::engine::QuarantinedMem {
2228                    mount: mounted.mount,
2229                    reason_code: e.code().to_string(),
2230                    reason_message: e.to_string(),
2231                });
2232                // Same epoch-moved staleness as the deferred-load
2233                // quarantine branch: `schemas_remove` bumped the
2234                // epoch, so both memos must clear.
2235                self.invalidate_communities();
2236                self.invalidate_search_indexes();
2237                Err(e)
2238            }
2239        }
2240    }
2241
2242    /// Inner per-mem body shared by [`Self::reload_one_mem`]
2243    /// and [`Self::reload_each_writable_mem`]. The caller passes
2244    /// a warning sink so the workspace-wide reload can forward
2245    /// warnings into `self.load_warnings` while the single-mem
2246    /// path keeps the accumulator pristine.
2247    fn reload_one_mem_inner(
2248        &mut self,
2249        mem: &str,
2250        warnings_sink: &mut Vec<WarningHint>,
2251    ) -> Result<crate::ops::ReloadResult, EngineError> {
2252        // Locate the target mount + schema. Unknown mem short-
2253        // circuits before any store mutation.
2254        let mount_idx = self
2255            .mounts
2256            .iter()
2257            .position(|m| m.mount.mem == mem)
2258            .ok_or_else(|| self.unknown_mem_error(mem))?;
2259        let schema = self
2260            .schemas
2261            .get(mem)
2262            .cloned()
2263            .ok_or_else(|| self.unknown_mem_error(mem))?;
2264
2265        // Snapshot pre-reload (id, content_hash) for this mem.
2266        let pre: HashMap<EntityId, String> = self
2267            .store
2268            .all_entities()
2269            .filter(|e| !e.stub && e.mem == mem)
2270            .map(|e| (e.id.clone(), e.content_hash.clone()))
2271            .collect();
2272        let pre_ids: std::collections::HashSet<EntityId> = pre.keys().cloned().collect();
2273
2274        // Walk the backend; surface read-time errors instead of
2275        // mutating the store on a failed reload.
2276        let backend = self.mounts[mount_idx].backend.as_ref();
2277        let (entries, read_errors) = collect_source_entries(backend)?;
2278        let load_result = parse_entries(entries, read_errors, mem, schema.as_ref());
2279
2280        // Build the LoadCollector inputs — mem roster + last-
2281        // segment suffixes — so the parser pipeline can emit
2282        // typed drift warnings into the caller's sink.
2283        let mem_names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2284        let known_suffixes: Vec<String> = mem_names
2285            .iter()
2286            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
2287            .collect();
2288
2289        // Failure fence above; below this point the store is mutated.
2290        self.store.remove_entities_by_mem(mem);
2291        let fallback = engine_fallback_type();
2292        push_entities_into_store(
2293            &mut self.store,
2294            load_result.entities,
2295            fallback.as_ref(),
2296            Some(crate::entity::store_builder::LoadCollector {
2297                warnings: warnings_sink,
2298                known_suffixes: &known_suffixes,
2299                mem_names: &mem_names,
2300            }),
2301        );
2302        // Re-run parse-time relation validation across the workspace.
2303        // A reload re-parses one mem but the validator's cycle pass
2304        // is global (acyclic-rel-type subgraphs span mems), so the
2305        // scan runs against the whole store. Hand-edits arriving via
2306        // sibling-writer commits get the same gauntlet boot enforces
2307        // (grammar / unknown_rel_type / shape / cycle).
2308        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> = self
2309            .mounts
2310            .iter()
2311            .map(|m| (m.mount.mem.clone(), m.mount.capability))
2312            .collect();
2313        // Restore cross-mem edges that point INTO this mem. The
2314        // removal cascade above dropped their incoming mirrors and the
2315        // re-push only rebuilt edges authored by this mem's own
2316        // entities, so a cross-mem `A→B` would silently vanish from the
2317        // index until a workspace-wide reload. Reconstruct from the
2318        // authoritative source records (in-memory only — no other mem is
2319        // re-read), then let the remap pass below reclassify alias sources.
2320        crate::entity::store_builder::reconstruct_incoming_cross_mem_edges(&mut self.store, mem);
2321        crate::entity::store_builder::validate_loaded_relations(
2322            &mut self.store,
2323            &self.schemas,
2324            &mount_caps,
2325            warnings_sink,
2326        );
2327        crate::entity::store_builder::remap_alias_target_edge_sources(
2328            &mut self.store,
2329            &self.schemas,
2330        );
2331        // Surface load errors back through the engine's accumulator
2332        // so subsequent `load_errors()` calls reflect the latest read.
2333        // THIS mem's stale entries are replaced, not accumulated — a
2334        // repaired file (e.g. a resolved merge conflict) must stop
2335        // reporting its old refusal, and a re-read of a still-broken
2336        // file must not duplicate its entry. Other mems' entries are
2337        // untouched (their reloads own them). Folder mounts key their
2338        // entries by absolute source path under the mem root; other
2339        // backends have no path-attributable entries to replace.
2340        if let crate::workspace::MountStorage::Folder { path } =
2341            &self.mounts[mount_idx].mount.storage
2342        {
2343            let root = path.clone();
2344            self.load_errors.retain(|(p, _)| !p.starts_with(&root));
2345            // The backend walk yields mem-relative paths while the boot
2346            // loader yields absolute ones — normalize to absolute so the
2347            // replace-on-reload key stays uniform across both origins.
2348            self.load_errors
2349                .extend(load_result.errors.into_iter().map(|(p, m)| {
2350                    let abs = if p.is_relative() { root.join(&p) } else { p };
2351                    (abs, m)
2352                }));
2353        } else {
2354            self.load_errors.extend(load_result.errors);
2355        }
2356
2357        // Refresh the mem's config from the backend too (D13). `sync_state`
2358        // (the projection baselines) and the schema pin / write guidance are
2359        // mem-scoped state that rides the mem branch, so an out-of-band write
2360        // — a sibling `projection advance` / `mem set-sync-state` — must become
2361        // visible after a per-mem reload, not only entity changes. A missing or
2362        // unparseable config leaves the cached value untouched (best-effort:
2363        // the reload never fails on a config read hiccup).
2364        if let Ok(Some(bytes)) = self.mounts[mount_idx].backend.read_mem_config()
2365            && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes)
2366            && let Ok(cfg) = memstead_schema::config::parse_mem_config(&value)
2367        {
2368            self.mounts[mount_idx].mem_config = Some(cfg);
2369        }
2370
2371        // Diff post-reload against the snapshot.
2372        let mut added: Vec<EntityId> = Vec::new();
2373        let mut changed: Vec<EntityId> = Vec::new();
2374        for entity in self.store.all_entities() {
2375            if entity.stub || entity.mem != mem {
2376                continue;
2377            }
2378            match pre.get(&entity.id) {
2379                None => added.push(entity.id.clone()),
2380                Some(prev_hash) if prev_hash != &entity.content_hash => {
2381                    changed.push(entity.id.clone());
2382                }
2383                Some(_) => {}
2384            }
2385        }
2386        let post_ids: std::collections::HashSet<EntityId> = self
2387            .store
2388            .all_entities()
2389            .filter(|e| !e.stub && e.mem == mem)
2390            .map(|e| e.id.clone())
2391            .collect();
2392        let mut removed: Vec<EntityId> = pre_ids.difference(&post_ids).cloned().collect();
2393        added.sort_by(|a, b| a.0.cmp(&b.0));
2394        changed.sort_by(|a, b| a.0.cmp(&b.0));
2395        removed.sort_by(|a, b| a.0.cmp(&b.0));
2396
2397        self.invalidate_communities();
2398        self.invalidate_search_indexes();
2399
2400        Ok(crate::ops::ReloadResult {
2401            added,
2402            changed,
2403            removed,
2404        })
2405    }
2406
2407    /// Rich-shape variant of [`Self::reload_one_mem`] that returns a
2408    /// [`crate::ops::ReloadReport`] (mem + head_before + head_after +
2409    /// entities_loaded + changed_entity_ids) instead of the slim
2410    /// [`crate::ops::ReloadResult`]. Handler-facing wrapper consumed
2411    /// by the `memstead_reload` MCP tool — the rich shape is the wire
2412    /// contract MCP callers depend on; the slim form stays for
2413    /// programmatic consumers that just want the diff lists.
2414    ///
2415    /// `head_before` is the engine's **prior cursor** for this mem
2416    /// (its cached `last_known_head`), *not* the current on-disk tip:
2417    /// when a sibling has committed since, the tip has already advanced,
2418    /// so reporting it would make the advertised
2419    /// `changes_since(since=head_before)` recipe span an empty range.
2420    /// `head_after` is the freshly-peeled tip from
2421    /// [`crate::backend::MemBackend::current_head`]; the reload also
2422    /// advances the cursor to it, so a follow-up staleness probe does
2423    /// not re-reload the same window. Backends without history (folder,
2424    /// archive) carry no cursor and return `Ok(None)`; both fields fall
2425    /// back to [`crate::ops::EMPTY_TREE_SHA`] for wire-shape stability.
2426    ///
2427    /// `entities_loaded` is the post-reload non-stub count for the
2428    /// mem — same semantic as full's report.
2429    ///
2430    /// `changed_entity_ids` is the union of `added ∪ changed ∪
2431    /// removed` from the underlying [`crate::ops::ReloadResult`]
2432    /// so callers don't have to merge three lists themselves —
2433    /// matches full's bundled wire shape.
2434    pub fn reload_one_mem_report(
2435        &mut self,
2436        mem: &str,
2437    ) -> Result<crate::ops::ReloadReport, EngineError> {
2438        // `head_before` is the engine's PRIOR cursor — the SHA it last
2439        // knew for this mem — not the current (possibly already
2440        // drifted) on-disk tip. Reporting the tip would collapse the
2441        // `changes_since(since=head_before)` range to empty in exactly
2442        // the sibling-drift case the recipe targets. Only history-backed
2443        // mounts (git-branch) carry a git cursor: folder / archive
2444        // backends have no `current_head`, so their `head_before` stays
2445        // the empty-tree sentinel that pairs with the equally-empty
2446        // `head_after` below.
2447        let tracks_head = self
2448            .mounts
2449            .iter()
2450            .find(|m| m.mount.mem == mem)
2451            .and_then(|m| m.backend.current_head().ok().flatten())
2452            .is_some();
2453        let head_before = if tracks_head {
2454            self.mounts
2455                .iter()
2456                .find(|m| m.mount.mem == mem)
2457                .and_then(|m| m.last_known_head.clone())
2458                .unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string())
2459        } else {
2460            crate::ops::EMPTY_TREE_SHA.to_string()
2461        };
2462
2463        let result = self.reload_one_mem(mem)?;
2464
2465        // Capture head_after = the freshly-peeled tip, and advance the
2466        // engine's cursor to it. Without this advance the next
2467        // operation's `reload_if_stale` would compare the stale cursor
2468        // against the same tip and re-reload the identical window,
2469        // re-emitting a spurious `MEM_RELOADED`. Only history-backed
2470        // mounts (current_head → Some) carry a cursor to advance.
2471        let head_after_raw = self
2472            .mounts
2473            .iter()
2474            .find(|m| m.mount.mem == mem)
2475            .and_then(|m| m.backend.current_head().ok().flatten());
2476        if let Some(new_head) = head_after_raw.clone()
2477            && let Some(m) = self.mounts.iter_mut().find(|m| m.mount.mem == mem)
2478        {
2479            m.last_known_head = Some(new_head);
2480        }
2481        let head_after = head_after_raw.unwrap_or_else(|| crate::ops::EMPTY_TREE_SHA.to_string());
2482
2483        let entities_loaded = self
2484            .store
2485            .all_entities()
2486            .filter(|e| !e.stub && e.mem == mem)
2487            .count();
2488
2489        // Union of added + changed + removed, sorted lexicographically
2490        // for deterministic wire output. Matches full's "single
2491        // changed_entity_ids list" contract — saves callers from
2492        // merging three slices themselves.
2493        let mut changed_entity_ids: Vec<EntityId> = result
2494            .added
2495            .into_iter()
2496            .chain(result.changed)
2497            .chain(result.removed)
2498            .collect();
2499        changed_entity_ids.sort_by(|a, b| a.0.cmp(&b.0));
2500
2501        Ok(crate::ops::ReloadReport {
2502            mem: mem.to_string(),
2503            head_before,
2504            head_after,
2505            entities_loaded,
2506            changed_entity_ids,
2507        })
2508    }
2509
2510    /// Batched rich-shape variant — returns one
2511    /// [`crate::ops::ReloadReport`] per mounted mem in declaration
2512    /// order. Counterpart to [`Self::reload_each_writable_mem`]
2513    /// (slim) that the `memstead_reload` MCP tool's no-mem path
2514    /// consumes.
2515    ///
2516    /// `load_warnings` semantics ride on the per-mem contract: each
2517    /// [`Self::reload_one_mem`] in the sweep refreshes its own mem's
2518    /// slice of the engine-wide accumulator, so a full sweep leaves
2519    /// the accumulator equivalent to a fresh boot. (Earlier this
2520    /// variant discarded every reload warning while its slim
2521    /// counterpart repopulated — the MCP workspace-wide reload could
2522    /// never clear a stale warning.) On first-error-abort, mems
2523    /// reloaded before the failure carry refreshed slices and the
2524    /// rest keep their boot-time entries — no slice is lost.
2525    ///
2526    /// Also re-reads `.memstead/workspace.toml` and refreshes
2527    /// [`crate::workspace::WorkspaceSettings`] before sweeping the
2528    /// mems — this is the pairing with the CLI's
2529    /// `memstead workspace allow-create / grant-cross-link / set-mutations`
2530    /// family. Without this re-read, a CLI write would land on disk but
2531    /// the running MCP would still serve the engine's boot-time policy
2532    /// snapshot; every subsequent `memstead_mem_create` against the new
2533    /// allowlist would fail with `MEM_PATH_NOT_ALLOWED` until process
2534    /// restart. The workspace-wide form runs the heavier path; the
2535    /// per-mem form (`reload_one_mem_report`) intentionally skips
2536    /// the workspace re-read — content drift doesn't imply policy
2537    /// drift.
2538    ///
2539    /// Reload of `workspace.toml` is best-effort: a missing or
2540    /// unparseable file leaves the existing settings untouched. The
2541    /// per-mem sweep is the primary contract — settings refresh is
2542    /// the additive bonus.
2543    ///
2544    /// First-error-aborts: if any mem's reload fails, the loop
2545    /// stops and the error propagates. Mems reloaded before the
2546    /// failing one are already mutated in the store; the returned
2547    /// error has no rollback. Operators run the per-mem form to
2548    /// retry the failing mem explicitly.
2549    pub fn reload_each_writable_mem_reports(
2550        &mut self,
2551    ) -> Result<Vec<crate::ops::ReloadReport>, EngineError> {
2552        self.refresh_workspace_settings_if_possible();
2553        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2554        let mut out = Vec::with_capacity(names.len());
2555        for name in names {
2556            let report = self.reload_one_mem_report(&name)?;
2557            out.push(report);
2558        }
2559        Ok(out)
2560    }
2561
2562    /// Best-effort refresh of [`crate::workspace::WorkspaceSettings`]
2563    /// from the workspace's `.memstead/workspace.toml`. Called by the
2564    /// workspace-wide reload sweep so CLI-driven policy edits become
2565    /// visible to a live engine without process restart.
2566    ///
2567    /// Silent no-op when the engine has no `workspace_root` (legacy
2568    /// in-memory constructions) or when the on-disk file is missing /
2569    /// unparseable. The per-mem reload contract stays the canonical
2570    /// failure surface; settings refresh failures are intentionally
2571    /// non-fatal so a malformed workspace.toml doesn't break content
2572    /// drift detection.
2573    fn refresh_workspace_settings_if_possible(&mut self) {
2574        let Some(root) = self.workspace_root.clone() else {
2575            return;
2576        };
2577        let store = crate::workspace_store::FileWorkspaceStore::new();
2578        let workspace = match crate::workspace_store::WorkspaceStoreAdapter::load(&store, &root) {
2579            Ok(w) => w,
2580            Err(_) => return,
2581        };
2582        self.set_settings(workspace.settings);
2583    }
2584
2585    /// Reload every mounted mem in declaration order; returns one
2586    /// `(mem, ReloadResult)` per mount.
2587    ///
2588    /// Failure model is **first-error-aborts**: if any mem's reload
2589    /// fails, the loop stops and the error propagates. Mems reloaded
2590    /// before the failing one are already mutated in the store; the
2591    /// returned error has no rollback. Operators run the per-mem
2592    /// form to retry the failing mem explicitly.
2593    ///
2594    /// Caller-friendly batching wrapper around [`Self::reload_one_mem`];
2595    /// internal cache invalidation happens once per mem (the inner
2596    /// call invalidates) so an N-mem batch invalidates the memos
2597    /// N times. That's wasteful for large workspaces; once the
2598    /// `memstead_reload` MCP handler migrates we can tighten this to one
2599    /// invalidation at the end.
2600    pub fn reload_each_writable_mem(
2601        &mut self,
2602    ) -> Result<Vec<(String, crate::ops::ReloadResult)>, EngineError> {
2603        let names: Vec<String> = self.mounts.iter().map(|m| m.mount.mem.clone()).collect();
2604        // Workspace-wide reload semantics: take the engine-wide
2605        // sink, clear it, route per-mem inner reloads through it,
2606        // put it back. The result is `self.load_warnings` carries
2607        // every typed drift warning the reload sweep produced (so
2608        // the next `engine.health()` call surfaces them).
2609        let mut sink = std::mem::take(&mut self.load_warnings);
2610        sink.clear();
2611        let mut out = Vec::with_capacity(names.len());
2612        let mut loop_err = None;
2613        for name in names {
2614            match self.reload_one_mem_inner(&name, &mut sink) {
2615                Ok(result) => out.push((name, result)),
2616                Err(e) => {
2617                    loop_err = Some(e);
2618                    break;
2619                }
2620            }
2621        }
2622        self.load_warnings = sink;
2623        if let Some(e) = loop_err {
2624            return Err(e);
2625        }
2626        Ok(out)
2627    }
2628}
2629
2630/// Value-level schema-pin bump on a backend's mem config: read the
2631/// config blob, rewrite ONLY the `"schema"` string, write it back —
2632/// every other field (`readMems`, write guidance, sync state, …) is
2633/// preserved verbatim. Config-absent backends (no `config.json`) are a
2634/// clean no-op returning `None`; the caller's `Mount.schema` then
2635/// stays the settled pin. Returns the updated JSON value on a write so
2636/// callers can refresh caches.
2637///
2638/// One shared implementation for the booted path
2639/// (`Engine::persist_mem_schema_pin`) and the below-boot repair path
2640/// (memstead-git-branch) — the two must never fork: a pin written by
2641/// repair must be byte-shaped exactly as one written by the engine.
2642pub fn bump_backend_schema_pin(
2643    backend: &dyn crate::backend::MemBackend,
2644    target: &memstead_schema::SchemaRef,
2645) -> Result<Option<serde_json::Value>, EngineError> {
2646    let Some(bytes) = backend
2647        .read_mem_config()
2648        .map_err(|e| EngineError::Mem(format!("read mem config for pin update: {e}")))?
2649    else {
2650        return Ok(None);
2651    };
2652    let mut value: serde_json::Value = serde_json::from_slice(&bytes)
2653        .map_err(|e| EngineError::Mem(format!("parse mem config for pin update: {e}")))?;
2654    value["schema"] = serde_json::Value::String(target.as_display());
2655    let new_bytes = serde_json::to_vec_pretty(&value)
2656        .map_err(|e| EngineError::Mem(format!("serialize mem config for pin update: {e}")))?;
2657    backend
2658        .write_mem_config(&new_bytes)
2659        .map_err(|e| EngineError::Mem(format!("write mem config for pin update: {e}")))?;
2660    Ok(Some(value))
2661}
2662
2663#[cfg(test)]
2664mod tests {
2665
2666    use tempfile::TempDir;
2667
2668    use crate::backend::{BackendError, MemBackend};
2669    use crate::engine::test_helpers::*;
2670    use crate::engine::{Engine, EngineError};
2671    use crate::mem::MemOrigin;
2672    use crate::ops::WarningHint;
2673    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
2674
2675    fn schema_package_files(heading: &str, manifest_name: &str) -> Vec<(String, Vec<u8>)> {
2676        let manifest = format!(
2677            r#"name: {manifest_name}
2678version: 1.0.0
2679description: Install-gate test schema
2680when_to_use: Tests
2681types:
2682  - sample
2683relationships:
2684  mode: strict
2685  definitions:
2686    - name: PART_OF
2687      description: hier
2688      default_weight: 3.0
2689    - name: _default
2690      description: fallback
2691      default_weight: 1.0
2692community:
2693  resolution: 1.0
2694  seed: 42
2695"#
2696        );
2697        let type_yaml = format!(
2698            r#"name: sample
2699description: t
2700when_to_use: tests
2701sections:
2702  - key: body
2703    heading: {heading}
2704    required: true
2705    search_weight: 10.0
2706    catch_all: true
2707    write_rules: []
2708metadata_fields: []
2709title_weight: 100.0
2710text_fields:
2711  - body
2712hierarchy_relationship: PART_OF
2713no_self_loop_relationships: []
2714updatable_fields:
2715  - title
2716  - body
2717health_required_fields:
2718  - body
2719staleness_threshold_days: 90
2720write_rules: []
2721"#
2722        );
2723        vec![
2724            ("schema.yaml".to_string(), manifest.into_bytes()),
2725            ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2726        ]
2727    }
2728
2729    /// The install gate accepts a conforming package and refuses one
2730    /// whose section heading cannot round-trip to its key — the last
2731    /// moment the author can act, since sealed schemas keep loading.
2732    #[test]
2733    fn install_gate_refuses_non_roundtrip_heading() {
2734        let ok =
2735            Engine::validate_schema_package("gate", "1.0.0", &schema_package_files("Body", "gate"));
2736        assert!(ok.is_ok(), "conforming package passes: {ok:?}");
2737
2738        let err = Engine::validate_schema_package(
2739            "gate",
2740            "1.0.0",
2741            &schema_package_files("Body Text", "gate"),
2742        )
2743        .expect_err("non-deriving heading must refuse install");
2744        match &err {
2745            EngineError::SchemaPackageInvalid { name, message, .. } => {
2746                assert_eq!(name, "gate");
2747                assert!(
2748                    message.contains("'body'") && message.contains("'Body Text'"),
2749                    "message names the offending tuple: {message}"
2750                );
2751            }
2752            other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2753        }
2754    }
2755
2756    /// Build a package whose single type carries an exemplar assembled
2757    /// from the given pieces — the fixture for the exemplar-gate
2758    /// tests. The type declares a required `body` section, a `status`
2759    /// enum field, PART_OF (unpinned), and REFINES pinned to
2760    /// `source_types: [other]` so a REFINES exemplar edge from
2761    /// `sample` violates shape.
2762    fn exemplar_package_files(
2763        section_key: &str,
2764        status_value: &str,
2765        rel_type: &str,
2766    ) -> Vec<(String, Vec<u8>)> {
2767        let manifest = r#"name: gate
2768version: 1.0.0
2769description: exemplar gate fixture
2770when_to_use: tests
2771types:
2772  - sample
2773  - other
2774relationships:
2775  mode: strict
2776  definitions:
2777    - name: PART_OF
2778      description: hier
2779      default_weight: 3.0
2780    - name: REFINES
2781      description: pinned
2782      default_weight: 1.0
2783      source_types: [other]
2784    - name: _default
2785      description: fallback
2786      default_weight: 1.0
2787community:
2788  resolution: 1.0
2789  seed: 42
2790"#
2791        .to_string();
2792        let type_yaml = format!(
2793            r#"name: sample
2794description: t
2795when_to_use: tests
2796sections:
2797  - key: body
2798    heading: Body
2799    required: true
2800    search_weight: 10.0
2801    catch_all: true
2802    write_rules: []
2803metadata_fields:
2804  - key: status
2805    description: workflow state
2806    field_type: string
2807    enum_values: [draft, final]
2808title_weight: 100.0
2809text_fields:
2810  - body
2811hierarchy_relationship: PART_OF
2812no_self_loop_relationships: []
2813updatable_fields:
2814  - title
2815  - body
2816health_required_fields:
2817  - body
2818staleness_threshold_days: 90
2819write_rules: []
2820exemplar:
2821  title: A Conforming Sample
2822  metadata:
2823    status: "{status_value}"
2824  sections:
2825    {section_key}: "One canonical body paragraph."
2826  relations:
2827    - to: parent-placeholder
2828      type: {rel_type}
2829"#
2830        );
2831        let other_yaml = r#"name: other
2832description: shape-pin partner
2833when_to_use: tests
2834sections:
2835  - key: body
2836    heading: Body
2837    required: true
2838    search_weight: 10.0
2839    catch_all: true
2840    write_rules: []
2841metadata_fields: []
2842title_weight: 100.0
2843text_fields:
2844  - body
2845hierarchy_relationship: PART_OF
2846no_self_loop_relationships: []
2847updatable_fields:
2848  - title
2849  - body
2850health_required_fields:
2851  - body
2852staleness_threshold_days: 90
2853write_rules: []
2854"#
2855        .to_string();
2856        vec![
2857            ("schema.yaml".to_string(), manifest.into_bytes()),
2858            ("types/sample.yaml".to_string(), type_yaml.into_bytes()),
2859            ("types/other.yaml".to_string(), other_yaml.into_bytes()),
2860        ]
2861    }
2862
2863    /// The exemplar gate (agent-trust plan 09): a package whose type
2864    /// carries a CONFORMANT exemplar installs; the same package broken
2865    /// three ways — wrong section key, illegal enum value, relationship
2866    /// shape violation — refuses with a typed error naming the type
2867    /// and the defect. No warn-and-carry path exists: the refusal is
2868    /// `SchemaPackageInvalid`, same as every other install-gate class.
2869    #[test]
2870    fn install_gate_validates_exemplars_through_the_real_create_path() {
2871        // Conformant exemplar → the package installs.
2872        let ok = Engine::validate_schema_package(
2873            "gate",
2874            "1.0.0",
2875            &exemplar_package_files("body", "draft", "PART_OF"),
2876        );
2877        assert!(ok.is_ok(), "conformant exemplar passes: {ok:?}");
2878
2879        // Variant 1 — wrong section key.
2880        let err = Engine::validate_schema_package(
2881            "gate",
2882            "1.0.0",
2883            &exemplar_package_files("bogus_section", "draft", "PART_OF"),
2884        )
2885        .expect_err("wrong section key must refuse");
2886        match &err {
2887            EngineError::SchemaPackageInvalid { message, .. } => {
2888                assert!(
2889                    message.contains("'sample'") && message.contains("exemplar"),
2890                    "names type and calls out the exemplar: {message}"
2891                );
2892                assert!(
2893                    message.contains("UNKNOWN_SECTION")
2894                        || message.contains("MISSING_REQUIRED_SECTION"),
2895                    "carries the typed defect code: {message}"
2896                );
2897            }
2898            other => panic!("expected SchemaPackageInvalid, got {other:?}"),
2899        }
2900
2901        // Variant 2 — illegal enum value.
2902        let err = Engine::validate_schema_package(
2903            "gate",
2904            "1.0.0",
2905            &exemplar_package_files("body", "not-a-legal-status", "PART_OF"),
2906        )
2907        .expect_err("illegal enum value must refuse");
2908        assert!(
2909            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2910                if message.contains("'sample'") && message.contains("INVALID_ENUM_VALUE")),
2911            "got {err:?}"
2912        );
2913
2914        // Variant 3 — relationship shape violation (REFINES is pinned
2915        // to source_types [other]; the exemplar's type is `sample`).
2916        let err = Engine::validate_schema_package(
2917            "gate",
2918            "1.0.0",
2919            &exemplar_package_files("body", "draft", "REFINES"),
2920        )
2921        .expect_err("relationship shape violation must refuse");
2922        assert!(
2923            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
2924                if message.contains("'sample'") && message.contains("INVALID_REL_SHAPE")),
2925            "got {err:?}"
2926        );
2927    }
2928
2929    /// The worked-example teaching package (`memstead-schema/examples/
2930    /// minimal`) models the exemplar practice — its exemplars validate
2931    /// through the same gate, so the material that teaches schema
2932    /// authoring can never itself teach a non-conformant shape.
2933    #[test]
2934    fn worked_example_package_exemplars_validate() {
2935        let pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2936            .join("../memstead-schema/examples/minimal");
2937        let schema = std::sync::Arc::new(
2938            memstead_schema::load_schema_from_dir(&pkg).expect("worked example loads"),
2939        );
2940        assert!(
2941            schema.types.values().all(|td| td.exemplar.is_some()),
2942            "every worked-example type models the exemplar practice"
2943        );
2944        Engine::validate_schema_exemplars(&schema).expect("worked-example exemplars conform");
2945    }
2946
2947    /// Every built-in schema's exemplars validate through the SAME
2948    /// gate the install path runs — a built-in exemplar broken by a
2949    /// future edit fails CI here. Completeness rides the same walk:
2950    /// the NEWEST version of every built-in name carries an exemplar
2951    /// on every type (older versions are sealed as shipped and may
2952    /// predate the field).
2953    #[test]
2954    fn builtin_exemplars_validate_through_the_install_gate() {
2955        let schemas = memstead_schema::builtins::load_builtin_schemas()
2956            .expect("built-in schemas always load");
2957        // Validity: every exemplar anywhere in the catalogue conforms.
2958        for schema in &schemas {
2959            if let Err(defect) = Engine::validate_schema_exemplars(schema) {
2960                let (name, version) = schema.id();
2961                panic!("built-in {name}@{version}: {defect}");
2962            }
2963        }
2964        // Completeness: the newest version per name is exemplar-complete.
2965        let mut newest: std::collections::HashMap<
2966            String,
2967            &std::sync::Arc<memstead_schema::Schema>,
2968        > = std::collections::HashMap::new();
2969        for schema in &schemas {
2970            let name = schema.manifest.name.clone();
2971            match newest.get(&name) {
2972                Some(cur) if cur.version >= schema.version => {}
2973                _ => {
2974                    newest.insert(name, schema);
2975                }
2976            }
2977        }
2978        for (name, schema) in &newest {
2979            for (type_name, td) in &schema.types {
2980                assert!(
2981                    td.exemplar.is_some(),
2982                    "built-in {name}@{} type '{type_name}' has no exemplar — the \
2983                     reference schemas model the practice completely",
2984                    schema.version
2985                );
2986            }
2987        }
2988    }
2989
2990    /// Exemplar relation targets are PLACEHOLDERS: a bare slug is
2991    /// legal (target existence is never checked — the absent target
2992    /// is the would-be-stub path), while a mem-prefixed target
2993    /// refuses with the placeholder rule named.
2994    #[test]
2995    fn exemplar_relation_targets_are_bare_placeholder_slugs() {
2996        let mut files = exemplar_package_files("body", "draft", "PART_OF");
2997        let patched = String::from_utf8(files[1].1.clone())
2998            .unwrap()
2999            .replace("to: parent-placeholder", "to: other--real-entity");
3000        files[1].1 = patched.into_bytes();
3001        let err = Engine::validate_schema_package("gate", "1.0.0", &files)
3002            .expect_err("mem-prefixed exemplar target must refuse");
3003        assert!(
3004            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3005                if message.contains("bare") && message.contains("'sample'")),
3006            "got {err:?}"
3007        );
3008    }
3009
3010    /// A manifest whose declared identity contradicts the install ref
3011    /// is refused — the schema would otherwise seal under a ref its
3012    /// own manifest disagrees with.
3013    #[test]
3014    fn install_gate_refuses_manifest_identity_mismatch() {
3015        let err = Engine::validate_schema_package(
3016            "gate",
3017            "1.0.0",
3018            &schema_package_files("Body", "other"),
3019        )
3020        .expect_err("identity mismatch must refuse install");
3021        assert!(
3022            matches!(&err, EngineError::SchemaPackageInvalid { message, .. }
3023                if message.contains("other@1.0.0")),
3024            "got {err:?}"
3025        );
3026    }
3027
3028    #[test]
3029    fn reload_each_writable_mem_repopulates_load_warnings() {
3030        // Boot with a clean mem, then mid-flight write a file
3031        // with a duplicate heading, then call reload_each_writable_mem.
3032        // The accumulator should pick up the new typed warning.
3033        let tmp = TempDir::new().unwrap();
3034        let mem_dir = tmp.path().to_path_buf();
3035        let writer = FilesystemMemWriter::new(mem_dir.clone());
3036        // Newest default generation so the clean-boot baseline isn't
3037        // tripped by the SCHEMA_GENERATIONS_BEHIND hint.
3038        let mut mount = folder_mount("specs", mem_dir.clone());
3039        mount.schema = Some("default@1.3.0".parse().unwrap());
3040        let mut engine =
3041            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3042        assert!(
3043            engine.load_warnings().is_empty(),
3044            "clean boot has no warnings"
3045        );
3046
3047        // Drop a markdown file with two `## Identity` headings.
3048        let body =
3049            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
3050        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
3051
3052        engine.reload_each_writable_mem().unwrap();
3053        let warnings = engine.load_warnings();
3054        assert!(
3055            warnings
3056                .iter()
3057                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3058            "workspace-wide reload must repopulate load_warnings: {warnings:?}",
3059        );
3060    }
3061
3062    /// `validate_loaded_relations` runs on the reload path too — a
3063    /// sibling-writer commit that injects a markdown file carrying a
3064    /// schema-undeclared rel-type must surface as a typed
3065    /// `PARSED_RELATION_INVALID` warning after `reload_each_writable_mem`.
3066    /// Without the reload-path wiring this drift would slip past the
3067    /// validator (boot only catches what existed at startup).
3068    #[test]
3069    fn reload_picks_up_parse_time_relation_drift_from_sibling_writer() {
3070        let tmp = TempDir::new().unwrap();
3071        let mem_dir = tmp.path().to_path_buf();
3072        // Seed a clean target entity at boot.
3073        let target_body = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
3074        std::fs::write(mem_dir.join("target.md"), target_body).unwrap();
3075        let writer = FilesystemMemWriter::new(mem_dir.clone());
3076        let mut engine = Engine::from_mounts(vec![(
3077            folder_mount("specs", mem_dir.clone()),
3078            Box::new(writer) as Box<dyn MemBackend>,
3079        )])
3080        .unwrap();
3081        // Clean boot — no parse-time relation warnings yet.
3082        assert!(
3083            !engine
3084                .load_warnings()
3085                .iter()
3086                .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. })),
3087            "clean boot must not emit ParsedRelationInvalid; got: {:?}",
3088            engine.load_warnings()
3089        );
3090
3091        // Sibling-writer drops a new file with an unknown rel-type.
3092        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";
3093        std::fs::write(mem_dir.join("source.md"), drift_body).unwrap();
3094
3095        engine.reload_each_writable_mem().unwrap();
3096
3097        let invalid: Vec<_> = engine
3098            .load_warnings()
3099            .iter()
3100            .filter_map(|w| match w {
3101                WarningHint::ParsedRelationInvalid {
3102                    rel_type,
3103                    reason,
3104                    origin,
3105                    ..
3106                } => Some((rel_type.clone(), reason.clone(), origin.clone())),
3107                _ => None,
3108            })
3109            .collect();
3110        assert_eq!(
3111            invalid.len(),
3112            1,
3113            "reload must surface the parse-time drift, got: {invalid:?}",
3114        );
3115        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
3116        assert_eq!(invalid[0].1, "unknown_rel_type");
3117        assert_eq!(invalid[0].2, "writable");
3118    }
3119
3120    #[test]
3121    fn reload_one_mem_refreshes_own_slice_and_keeps_other_mems() {
3122        // Boot two mems, each with a duplicate-heading file, so the
3123        // accumulator carries one warning per mem. Fix alpha's file on
3124        // disk, reload ONLY alpha: alpha's stale warning must drop
3125        // (reload heals drift — health() must stop reporting it) while
3126        // beta's untouched warning survives (per-mem reload never
3127        // clears other mems' slices).
3128        let tmp = TempDir::new().unwrap();
3129        let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3130        let a_dir = tmp.path().join("a");
3131        std::fs::create_dir_all(&a_dir).unwrap();
3132        std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3133        let b_dir = tmp.path().join("b");
3134        std::fs::create_dir_all(&b_dir).unwrap();
3135        std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3136        let mut engine = Engine::from_mounts(vec![
3137            (
3138                folder_mount("alpha", a_dir.clone()),
3139                Box::new(FilesystemMemWriter::new(a_dir.clone())) as Box<dyn MemBackend>,
3140            ),
3141            (
3142                folder_mount("beta", b_dir.clone()),
3143                Box::new(FilesystemMemWriter::new(b_dir.clone())) as Box<dyn MemBackend>,
3144            ),
3145        ])
3146        .unwrap();
3147        let mem_of = |w: &WarningHint| w.source_mem().map(str::to_string);
3148        let pre: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3149        assert!(
3150            pre.contains(&"alpha".to_string()) && pre.contains(&"beta".to_string()),
3151            "boot must populate one warning per mem: {pre:?}"
3152        );
3153
3154        // Heal alpha's file on disk, then reload only alpha.
3155        let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3156        std::fs::write(a_dir.join("dup.md"), clean_body).unwrap();
3157        engine.reload_one_mem("alpha").unwrap();
3158
3159        let post: Vec<_> = engine.load_warnings().iter().filter_map(mem_of).collect();
3160        assert!(
3161            !post.contains(&"alpha".to_string()),
3162            "reload must drop the healed mem's stale warning: {post:?}"
3163        );
3164        assert!(
3165            post.contains(&"beta".to_string()),
3166            "reload of alpha must not clear beta's slice: {post:?}"
3167        );
3168    }
3169
3170    #[test]
3171    fn unregister_writable_mem_purges_load_warnings_for_that_mem_only() {
3172        // Two mems, each contributing a boot-time warning. Deleting
3173        // alpha must purge alpha's warnings from the accumulator
3174        // (health() merges it unconditionally — leftovers would cite
3175        // entities the store no longer holds) while beta's survive.
3176        let tmp = TempDir::new().unwrap();
3177        let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3178        let a_dir = tmp.path().join("a");
3179        std::fs::create_dir_all(&a_dir).unwrap();
3180        std::fs::write(a_dir.join("dup.md"), dup_body).unwrap();
3181        let b_dir = tmp.path().join("b");
3182        std::fs::create_dir_all(&b_dir).unwrap();
3183        std::fs::write(b_dir.join("dup.md"), dup_body).unwrap();
3184        let mut engine = Engine::from_mounts(vec![
3185            (
3186                folder_mount("alpha", a_dir.clone()),
3187                Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3188            ),
3189            (
3190                folder_mount("beta", b_dir.clone()),
3191                Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3192            ),
3193        ])
3194        .unwrap();
3195        assert!(
3196            engine
3197                .load_warnings()
3198                .iter()
3199                .any(|w| w.source_mem() == Some("alpha")),
3200            "boot must carry alpha-sourced warnings"
3201        );
3202
3203        engine.unregister_writable_mem("alpha").unwrap();
3204
3205        let post = engine.load_warnings();
3206        assert!(
3207            !post.iter().any(|w| w.source_mem() == Some("alpha")),
3208            "delete must purge the removed mem's warnings: {post:?}"
3209        );
3210        assert!(
3211            post.iter().any(|w| w.source_mem() == Some("beta")),
3212            "delete of alpha must keep beta's warnings: {post:?}"
3213        );
3214    }
3215
3216    #[test]
3217    fn unregister_writable_mem_keeps_warnings_sourced_in_surviving_mems() {
3218        // Complement to the purge: a warning SOURCED in a surviving
3219        // mem whose TARGET pointed into the deleted mem must survive.
3220        // The invalid row still exists in the survivor's markdown —
3221        // it is live drift (recover-worthy), not stale state, so
3222        // purging by target would hide a real finding.
3223        let tmp = TempDir::new().unwrap();
3224        let a_dir = tmp.path().join("a");
3225        std::fs::create_dir_all(&a_dir).unwrap();
3226        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";
3227        std::fs::write(a_dir.join("source.md"), source_body).unwrap();
3228        let b_dir = tmp.path().join("b");
3229        std::fs::create_dir_all(&b_dir).unwrap();
3230        let target_body = "---\ntype: spec\n---\n# B1\n\n## Identity\n\nThe target.\n";
3231        std::fs::write(b_dir.join("b1.md"), target_body).unwrap();
3232        let mut engine = Engine::from_mounts(vec![
3233            (
3234                folder_mount("alpha", a_dir.clone()),
3235                Box::new(FilesystemMemWriter::new(a_dir)) as Box<dyn MemBackend>,
3236            ),
3237            (
3238                folder_mount("beta", b_dir.clone()),
3239                Box::new(FilesystemMemWriter::new(b_dir)) as Box<dyn MemBackend>,
3240            ),
3241        ])
3242        .unwrap();
3243        let alpha_sourced = |engine: &Engine| {
3244            engine
3245                .load_warnings()
3246                .iter()
3247                .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { entity_id, .. } if entity_id.mem() == "alpha"))
3248        };
3249        assert!(
3250            alpha_sourced(&engine),
3251            "boot must flag alpha's invalid row: {:?}",
3252            engine.load_warnings()
3253        );
3254
3255        engine.unregister_writable_mem("beta").unwrap();
3256
3257        assert!(
3258            alpha_sourced(&engine),
3259            "deleting the TARGET mem must not purge the survivor-sourced warning: {:?}",
3260            engine.load_warnings()
3261        );
3262    }
3263
3264    /// The `memstead_reload` MCP tool's no-mem path consumes the
3265    /// reports variant — it must refresh `load_warnings` like its slim
3266    /// counterpart, not discard the sweep's warnings. Regression for
3267    /// the split-brain where the slim variant repopulated and the
3268    /// reports variant silently kept the boot-time snapshot forever
3269    /// (observed live 2026-07-11: warnings for deleted mems survived a
3270    /// workspace-wide MCP reload).
3271    #[test]
3272    fn reload_each_writable_mem_reports_refreshes_load_warnings() {
3273        let tmp = TempDir::new().unwrap();
3274        let mem_dir = tmp.path().to_path_buf();
3275        let dup_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3276        std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3277        let writer = FilesystemMemWriter::new(mem_dir.clone());
3278        let mut engine = Engine::from_mounts(vec![(
3279            folder_mount("specs", mem_dir.clone()),
3280            Box::new(writer) as Box<dyn MemBackend>,
3281        )])
3282        .unwrap();
3283        assert!(
3284            !engine.load_warnings().is_empty(),
3285            "boot must populate load_warnings"
3286        );
3287
3288        // Heal the file on disk; the reports sweep must clear the
3289        // stale warning.
3290        let clean_body = "---\ntype: spec\n---\n# Dup\n\n## Identity\n\na.\n";
3291        std::fs::write(mem_dir.join("dup.md"), clean_body).unwrap();
3292        engine.reload_each_writable_mem_reports().unwrap();
3293        assert!(
3294            engine.load_warnings().is_empty(),
3295            "reports sweep must drop healed warnings: {:?}",
3296            engine.load_warnings()
3297        );
3298
3299        // And the inverse: fresh drift surfaces through the same sweep.
3300        std::fs::write(mem_dir.join("dup.md"), dup_body).unwrap();
3301        engine.reload_each_writable_mem_reports().unwrap();
3302        assert!(
3303            engine
3304                .load_warnings()
3305                .iter()
3306                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3307            "reports sweep must surface fresh drift: {:?}",
3308            engine.load_warnings()
3309        );
3310    }
3311
3312    /// A cross-mem edge `A→B` must survive a
3313    /// per-mem reload of the TARGET mem B. The removal cascade drops
3314    /// B's incoming mirrors (including the cross-mem one sourced from A)
3315    /// and the re-push only rebuilds edges authored by B, so without the
3316    /// reconstruction pass the edge silently vanishes from the in-memory
3317    /// index while staying intact in A's record and on disk — under-
3318    /// reporting topology until a workspace-wide reload heals it.
3319    #[test]
3320    fn per_mem_reload_of_target_preserves_incoming_cross_mem_edge() {
3321        let tmp = TempDir::new().unwrap();
3322        let a_dir = tmp.path().join("a");
3323        let b_dir = tmp.path().join("b");
3324        std::fs::create_dir_all(&a_dir).unwrap();
3325        std::fs::create_dir_all(&b_dir).unwrap();
3326        let a_writer = FilesystemMemWriter::new(a_dir.clone());
3327        let b_writer = FilesystemMemWriter::new(b_dir.clone());
3328        let mut engine = Engine::from_mounts(vec![
3329            (
3330                folder_mount("specs", a_dir),
3331                Box::new(a_writer) as Box<dyn MemBackend>,
3332            ),
3333            (
3334                folder_mount("memos", b_dir),
3335                Box::new(b_writer) as Box<dyn MemBackend>,
3336            ),
3337        ])
3338        .unwrap();
3339
3340        // Grant the cross-mem link specs → memos so the relate lands.
3341        let mut settings = crate::workspace::WorkspaceSettings::default();
3342        settings.cross_mem_links.insert(
3343            "specs".to_string(),
3344            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3345        );
3346        engine.set_settings(settings);
3347
3348        let (actor, client) = cli_actor();
3349        let source = engine
3350            .create_entity(
3351                empty_create_args("specs", "Source"),
3352                actor,
3353                Some(&client),
3354                None,
3355            )
3356            .unwrap();
3357        let target = engine
3358            .create_entity(
3359                empty_create_args("memos", "Target"),
3360                actor,
3361                Some(&client),
3362                None,
3363            )
3364            .unwrap();
3365        engine
3366            .relate_entity(
3367                crate::engine::RelateEntityArgs {
3368                    source: source.id.clone(),
3369                    expected_hash: Some(source.content_hash.clone()),
3370                    rel_type: "USES".to_string(),
3371                    target: target.id.clone(),
3372                    remove: false,
3373                    description: None,
3374                    dry_run: false,
3375                },
3376                actor,
3377                Some(&client),
3378                None,
3379            )
3380            .unwrap();
3381
3382        // (outgoing-present, incoming-present) for the A→B edge.
3383        let has_edge = |e: &Engine| {
3384            let out = e
3385                .store()
3386                .outgoing(&source.id)
3387                .iter()
3388                .any(|edge| edge.target == target.id);
3389            let inc = e
3390                .store()
3391                .incoming(&target.id)
3392                .iter()
3393                .any(|edge| edge.from == source.id);
3394            (out, inc)
3395        };
3396
3397        assert_eq!(
3398            has_edge(&engine),
3399            (true, true),
3400            "edge must be indexed in both directions after relate",
3401        );
3402
3403        // Per-mem reload of the TARGET mem — the bug trigger.
3404        engine.reload_one_mem("memos").unwrap();
3405        assert_eq!(
3406            has_edge(&engine),
3407            (true, true),
3408            "cross-mem edge into B must survive a per-mem reload of B",
3409        );
3410
3411        // Convergence: a workspace-wide reload yields the same incoming
3412        // adjacency for the target — no path-dependent difference.
3413        engine.reload_each_writable_mem().unwrap();
3414        assert_eq!(
3415            has_edge(&engine),
3416            (true, true),
3417            "per-mem and workspace reload converge on the same edge",
3418        );
3419
3420        // Complement: the edge stayed in the source record throughout —
3421        // the bug and the fix are about the index, not the records.
3422        assert!(
3423            engine
3424                .store()
3425                .get(&source.id)
3426                .unwrap()
3427                .relationships
3428                .iter()
3429                .any(|r| r.target == target.id),
3430            "source record must retain the relationship throughout",
3431        );
3432    }
3433
3434    /// A per-mem reload of the SOURCE
3435    /// mem leaves the cross-mem edge intact too — the source's own
3436    /// outgoing edges are rebuilt by the re-push, and the reconstruction
3437    /// pass for the OTHER mem is not needed here. Guards against a fix
3438    /// that fixates on the target case and perturbs the source case.
3439    #[test]
3440    fn per_mem_reload_of_source_preserves_outgoing_cross_mem_edge() {
3441        let tmp = TempDir::new().unwrap();
3442        let a_dir = tmp.path().join("a");
3443        let b_dir = tmp.path().join("b");
3444        std::fs::create_dir_all(&a_dir).unwrap();
3445        std::fs::create_dir_all(&b_dir).unwrap();
3446        let a_writer = FilesystemMemWriter::new(a_dir.clone());
3447        let b_writer = FilesystemMemWriter::new(b_dir.clone());
3448        let mut engine = Engine::from_mounts(vec![
3449            (
3450                folder_mount("specs", a_dir),
3451                Box::new(a_writer) as Box<dyn MemBackend>,
3452            ),
3453            (
3454                folder_mount("memos", b_dir),
3455                Box::new(b_writer) as Box<dyn MemBackend>,
3456            ),
3457        ])
3458        .unwrap();
3459        let mut settings = crate::workspace::WorkspaceSettings::default();
3460        settings.cross_mem_links.insert(
3461            "specs".to_string(),
3462            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
3463        );
3464        engine.set_settings(settings);
3465
3466        let (actor, client) = cli_actor();
3467        let source = engine
3468            .create_entity(
3469                empty_create_args("specs", "Source"),
3470                actor,
3471                Some(&client),
3472                None,
3473            )
3474            .unwrap();
3475        let target = engine
3476            .create_entity(
3477                empty_create_args("memos", "Target"),
3478                actor,
3479                Some(&client),
3480                None,
3481            )
3482            .unwrap();
3483        engine
3484            .relate_entity(
3485                crate::engine::RelateEntityArgs {
3486                    source: source.id.clone(),
3487                    expected_hash: Some(source.content_hash.clone()),
3488                    rel_type: "USES".to_string(),
3489                    target: target.id.clone(),
3490                    remove: false,
3491                    description: None,
3492                    dry_run: false,
3493                },
3494                actor,
3495                Some(&client),
3496                None,
3497            )
3498            .unwrap();
3499
3500        engine.reload_one_mem("specs").unwrap();
3501
3502        let out = engine
3503            .store()
3504            .outgoing(&source.id)
3505            .iter()
3506            .any(|edge| edge.target == target.id);
3507        let inc = engine
3508            .store()
3509            .incoming(&target.id)
3510            .iter()
3511            .any(|edge| edge.from == source.id);
3512        assert!(
3513            out && inc,
3514            "outgoing cross-mem edge must survive a source-mem reload"
3515        );
3516    }
3517
3518    #[test]
3519    fn workspace_root_setter_round_trips() {
3520        let tmp = TempDir::new().unwrap();
3521        let mem_dir = tmp.path().to_path_buf();
3522        let writer = FilesystemMemWriter::new(mem_dir.clone());
3523        let mut engine = Engine::from_mounts(vec![(
3524            folder_mount("specs", mem_dir),
3525            Box::new(writer) as Box<dyn MemBackend>,
3526        )])
3527        .unwrap();
3528        let root = tmp.path().to_path_buf();
3529        engine.set_workspace_root(root.clone());
3530        assert_eq!(engine.workspace_root(), Some(root.as_path()));
3531    }
3532
3533    #[test]
3534    fn export_mem_folder_backend_produces_archive() {
3535        // Folder-backed mem with config + one entity. The
3536        // export_mem dispatcher routes to the folder backend's
3537        // override which produces a deterministic .memstead archive.
3538        let tmp = TempDir::new().unwrap();
3539        let mem_dir = tmp.path().join("specs");
3540        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3541        let config_body = r#"{
3542            "format": 1,
3543            "schema": "default@1.0.0",
3544            "version": "1.0.0"
3545        }"#;
3546        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3547
3548        let writer = FilesystemMemWriter::new(mem_dir.clone());
3549        let engine = Engine::from_mounts(vec![(
3550            folder_mount("specs", mem_dir.clone()),
3551            Box::new(writer) as Box<dyn MemBackend>,
3552        )])
3553        .unwrap();
3554
3555        let archive_path = tmp.path().join("specs.mem");
3556        let result = engine.export_mem("specs", &archive_path).unwrap();
3557        assert!(archive_path.exists(), "archive must exist on disk");
3558        assert!(result.size_bytes > 0);
3559        // entity_count is 0 here (no .md files seeded); the function
3560        // still produces an archive carrying the config + schema.
3561        assert_eq!(result.entity_count, 0);
3562    }
3563
3564    #[test]
3565    fn export_mem_unknown_mem_returns_unknown_mem() {
3566        let tmp = TempDir::new().unwrap();
3567        let mem_dir = tmp.path().to_path_buf();
3568        let writer = FilesystemMemWriter::new(mem_dir.clone());
3569        let engine = Engine::from_mounts(vec![(
3570            folder_mount("specs", mem_dir),
3571            Box::new(writer) as Box<dyn MemBackend>,
3572        )])
3573        .unwrap();
3574        let output = tmp.path().join("out.mem");
3575        let err = engine.export_mem("missing", &output).unwrap_err();
3576        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
3577    }
3578
3579    #[test]
3580    fn export_mem_missing_config_returns_invalid_input() {
3581        // Folder mount with no .memstead/config.json — `mem_config_for`
3582        // returns None and `export_mem` surfaces InvalidInput
3583        // rather than reaching the backend.
3584        let tmp = TempDir::new().unwrap();
3585        let mem_dir = tmp.path().to_path_buf();
3586        let writer = FilesystemMemWriter::new(mem_dir.clone());
3587        let engine = Engine::from_mounts(vec![(
3588            folder_mount("specs", mem_dir),
3589            Box::new(writer) as Box<dyn MemBackend>,
3590        )])
3591        .unwrap();
3592        let output = tmp.path().join("out.mem");
3593        let err = engine.export_mem("specs", &output).unwrap_err();
3594        assert!(matches!(err, EngineError::InvalidInput(_)));
3595    }
3596
3597    #[test]
3598    fn export_mem_archive_backend_returns_sealed() {
3599        // Archive backends are already-an-archive — re-export is
3600        // intentionally rejected via BackendError::Sealed.
3601        let tmp = TempDir::new().unwrap();
3602        let archive_path = build_archive(
3603            tmp.path(),
3604            "ext",
3605            &[(
3606                ".memstead/config.json",
3607                b"{\"format\":1,\"schema\":\"default@1.0.0\",\"version\":\"1.0.0\"}",
3608            )],
3609        );
3610        let engine = Engine::from_mounts(vec![(
3611            archive_mount("ext", archive_path.clone()),
3612            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3613        )])
3614        .unwrap();
3615        let output = tmp.path().join("out.mem");
3616        let err = engine.export_mem("ext", &output).unwrap_err();
3617        assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
3618    }
3619
3620    #[test]
3621    fn export_markdown_writes_unchanged_files_zero_writes() {
3622        // Seed a folder-backed mem with one entity, then call
3623        // export_markdown. The entity's file already matches the
3624        // generated content (engine wrote it via create_entity), so
3625        // export reports `unchanged: 1, written: 0`.
3626        let tmp = TempDir::new().unwrap();
3627        let (engine, _seeded) = engine_with_seed(&tmp, "Sample");
3628        let result = engine.export_markdown(None, None).unwrap();
3629        assert_eq!(
3630            result.written, 0,
3631            "freshly-created entity's file already matches generated markdown"
3632        );
3633        assert_eq!(
3634            result.unchanged, 1,
3635            "the one seeded entity counts as unchanged"
3636        );
3637        assert!(
3638            result.skipped_mounts.is_empty(),
3639            "folder-only workspace has no skipped mounts"
3640        );
3641    }
3642
3643    #[test]
3644    fn export_markdown_skips_non_folder_mounts() {
3645        // Archive-mounted mem has no working tree — workspace-wide
3646        // export records it under skipped_mounts and reports zero
3647        // writes / zero unchanged for the rest.
3648        let tmp = TempDir::new().unwrap();
3649        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3650        let engine = Engine::from_mounts(vec![(
3651            archive_mount("ext", archive_path.clone()),
3652            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3653        )])
3654        .unwrap();
3655        let result = engine.export_markdown(None, None).unwrap();
3656        assert_eq!(result.written, 0);
3657        assert_eq!(result.unchanged, 0);
3658        assert_eq!(
3659            result.skipped_mounts.len(),
3660            1,
3661            "archive mount is in the skipped list"
3662        );
3663        let entry = &result.skipped_mounts[0];
3664        assert_eq!(entry.mem, "ext");
3665        assert_eq!(entry.active_backend, "archive");
3666        assert_eq!(entry.reason, "backend_does_not_support_markdown_export");
3667    }
3668
3669    #[test]
3670    fn export_markdown_per_mem_refuses_on_incompatible_backend() {
3671        // Per-mem export against an archive-backed mem returns
3672        // the typed `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND` refusal
3673        // naming the active backend and the supported-backend list.
3674        let tmp = TempDir::new().unwrap();
3675        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
3676        let engine = Engine::from_mounts(vec![(
3677            archive_mount("ext", archive_path.clone()),
3678            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3679        )])
3680        .unwrap();
3681        let err = engine.export_markdown(Some("ext"), None).unwrap_err();
3682        assert_eq!(err.code(), "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
3683        let details = err.details();
3684        assert_eq!(details["mem"], "ext");
3685        assert_eq!(details["active_backend"], "archive");
3686        assert_eq!(details["supported_backends"], serde_json::json!(["folder"]));
3687    }
3688
3689    #[test]
3690    fn register_writable_mem_adds_mount_and_router_entry() {
3691        // Start with one mem; register a second at runtime. Both
3692        // should be visible afterwards.
3693        let tmp = TempDir::new().unwrap();
3694        let mem_a = tmp.path().join("a");
3695        std::fs::create_dir_all(&mem_a).unwrap();
3696        let writer_a = FilesystemMemWriter::new(mem_a.clone());
3697
3698        let mut engine = Engine::from_mounts(vec![(
3699            folder_mount("alpha", mem_a),
3700            Box::new(writer_a) as Box<dyn MemBackend>,
3701        )])
3702        .unwrap();
3703        assert!(engine.mem_router().is_writable("alpha"));
3704
3705        let mem_b = tmp.path().join("b");
3706        std::fs::create_dir_all(&mem_b).unwrap();
3707        let writer_b = FilesystemMemWriter::new(mem_b.clone());
3708
3709        engine
3710            .register_writable_mem(
3711                folder_mount("beta", mem_b.clone()),
3712                Box::new(writer_b) as Box<dyn MemBackend>,
3713                MemOrigin::ExplicitToml,
3714            )
3715            .unwrap();
3716
3717        // Both mems are now writable + visible.
3718        assert!(engine.mem_router().is_writable("alpha"));
3719        assert!(engine.mem_router().is_writable("beta"));
3720        assert!(engine.mem_router().is_visible("beta"));
3721
3722        // Mount + schema lookups resolve.
3723        assert!(engine.mount("beta").is_some());
3724        assert!(engine.schemas().contains_key("beta"));
3725
3726        // Folder path surfaces via mem_router.
3727        assert_eq!(
3728            engine.mem_router().dir_for_mem("beta"),
3729            Some(mem_b.as_path()),
3730        );
3731    }
3732
3733    /// Schema-pin authority on the runtime-register path (symmetric with
3734    /// the boot path): a mem registered at runtime resolves its schema
3735    /// from its own config (`software@0.1.0`) even though the mount
3736    /// expects an unresolvable pin — register succeeds, and the
3737    /// disagreement surfaces a `SchemaPinMismatch` warning.
3738    #[test]
3739    fn register_writable_mem_resolves_schema_from_mem_config() {
3740        let tmp = TempDir::new().unwrap();
3741        let mem_a = tmp.path().join("a");
3742        std::fs::create_dir_all(&mem_a).unwrap();
3743        let mut engine = Engine::from_mounts(vec![(
3744            folder_mount("alpha", mem_a.clone()),
3745            Box::new(FilesystemMemWriter::new(mem_a)) as Box<dyn MemBackend>,
3746        )])
3747        .unwrap();
3748
3749        let mem_b = tmp.path().join("b");
3750        std::fs::create_dir_all(mem_b.join(".memstead")).unwrap();
3751        std::fs::write(
3752            mem_b.join(".memstead").join("config.json"),
3753            r#"{"schema":"software@0.1.0"}"#,
3754        )
3755        .unwrap();
3756        let mount_b = crate::workspace::Mount {
3757            mem: "beta".to_string(),
3758            schema: Some(memstead_schema::SchemaRef::new(
3759                "totally-not-a-schema",
3760                semver::Version::new(9, 9, 9),
3761            )),
3762            storage: crate::workspace::MountStorage::Folder {
3763                path: mem_b.clone(),
3764            },
3765            capability: crate::workspace::MountCapability::Write,
3766            lifecycle: crate::workspace::MountLifecycle::Eager,
3767            cross_linkable: true,
3768            migration_target: None,
3769        };
3770        engine
3771            .register_writable_mem(
3772                mount_b,
3773                Box::new(FilesystemMemWriter::new(mem_b)) as Box<dyn MemBackend>,
3774                MemOrigin::ExplicitToml,
3775            )
3776            .expect("config pin software@0.1.0 is authoritative — register must succeed despite the unresolvable mount pin");
3777
3778        assert!(engine.schemas().contains_key("beta"));
3779        let surfaced = engine.load_warnings().iter().any(|w| {
3780            matches!(
3781                w,
3782                WarningHint::SchemaPinMismatch { mem, config_pin, mount_pin }
3783                    if mem == "beta"
3784                        && config_pin == "software@0.1.0"
3785                        && mount_pin == "totally-not-a-schema@9.9.9"
3786            )
3787        });
3788        assert!(
3789            surfaced,
3790            "SchemaPinMismatch must surface for beta: {:?}",
3791            engine.load_warnings(),
3792        );
3793    }
3794
3795    #[test]
3796    fn register_writable_mem_rejects_existing_name() {
3797        // Re-registering an already-writable mem must fail with
3798        // MemNameCollision and not mutate the engine.
3799        let tmp = TempDir::new().unwrap();
3800        let mem_a = tmp.path().join("a");
3801        std::fs::create_dir_all(&mem_a).unwrap();
3802        let writer_a = FilesystemMemWriter::new(mem_a.clone());
3803
3804        let mut engine = Engine::from_mounts(vec![(
3805            folder_mount("alpha", mem_a),
3806            Box::new(writer_a) as Box<dyn MemBackend>,
3807        )])
3808        .unwrap();
3809        let mount_count_pre = engine.mounts().len();
3810
3811        let mem_collide = tmp.path().join("alpha-2");
3812        std::fs::create_dir_all(&mem_collide).unwrap();
3813        let writer_collide = FilesystemMemWriter::new(mem_collide.clone());
3814
3815        let err = engine
3816            .register_writable_mem(
3817                folder_mount("alpha", mem_collide),
3818                Box::new(writer_collide) as Box<dyn MemBackend>,
3819                MemOrigin::ExplicitToml,
3820            )
3821            .unwrap_err();
3822        match err {
3823            EngineError::MemNameCollision {
3824                name,
3825                source_origin,
3826            } => {
3827                assert_eq!(name, "alpha");
3828                // post-restructure source_origin references
3829                // `.memstead/workspace.toml`; the assertion stays
3830                // permissive (substring OR non-empty) so the test
3831                // doesn't lock the exact wording.
3832                assert!(
3833                    source_origin.contains(".memstead/workspace.toml") || !source_origin.is_empty()
3834                );
3835            }
3836            other => panic!("expected MemNameCollision, got {other:?}"),
3837        }
3838
3839        // Engine state unchanged.
3840        assert_eq!(engine.mounts().len(), mount_count_pre);
3841    }
3842
3843    #[test]
3844    fn register_writable_mem_loads_entities_into_store() {
3845        // The newly-registered mem's entities should surface in
3846        // the engine's store after registration.
3847        let tmp = TempDir::new().unwrap();
3848        let mem_a = tmp.path().join("a");
3849        std::fs::create_dir_all(&mem_a).unwrap();
3850        let writer_a = FilesystemMemWriter::new(mem_a.clone());
3851
3852        let mut engine = Engine::from_mounts(vec![(
3853            folder_mount("alpha", mem_a),
3854            Box::new(writer_a) as Box<dyn MemBackend>,
3855        )])
3856        .unwrap();
3857        let pre_count = engine.store().all_entities().count();
3858
3859        // Build mem_b with a markdown entity on disk.
3860        let mem_b = tmp.path().join("b");
3861        std::fs::create_dir_all(&mem_b).unwrap();
3862        std::fs::write(
3863            mem_b.join("b1.md"),
3864            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
3865        )
3866        .unwrap();
3867        let writer_b = FilesystemMemWriter::new(mem_b.clone());
3868
3869        engine
3870            .register_writable_mem(
3871                folder_mount("beta", mem_b),
3872                Box::new(writer_b) as Box<dyn MemBackend>,
3873                MemOrigin::ExplicitToml,
3874            )
3875            .unwrap();
3876
3877        let post_count = engine.store().all_entities().count();
3878        assert!(post_count > pre_count, "register must load entities");
3879        let beta_count = engine
3880            .store()
3881            .all_entities()
3882            .filter(|e| e.mem == "beta")
3883            .count();
3884        assert_eq!(beta_count, 1);
3885    }
3886
3887    #[test]
3888    fn register_then_unregister_round_trips() {
3889        // End-to-end check: register a mem, then unregister it,
3890        // and confirm the engine returns to the pre-registration
3891        // state.
3892        let tmp = TempDir::new().unwrap();
3893        let mem_a = tmp.path().join("a");
3894        std::fs::create_dir_all(&mem_a).unwrap();
3895        let writer_a = FilesystemMemWriter::new(mem_a.clone());
3896
3897        let mut engine = Engine::from_mounts(vec![(
3898            folder_mount("alpha", mem_a),
3899            Box::new(writer_a) as Box<dyn MemBackend>,
3900        )])
3901        .unwrap();
3902        let pre_mounts = engine.mounts().len();
3903
3904        let mem_b = tmp.path().join("b");
3905        std::fs::create_dir_all(&mem_b).unwrap();
3906        let writer_b = FilesystemMemWriter::new(mem_b);
3907
3908        engine
3909            .register_writable_mem(
3910                folder_mount("beta", tmp.path().join("b")),
3911                Box::new(writer_b) as Box<dyn MemBackend>,
3912                MemOrigin::ExplicitToml,
3913            )
3914            .unwrap();
3915        assert_eq!(engine.mounts().len(), pre_mounts + 1);
3916
3917        let removed = engine.unregister_writable_mem("beta").unwrap();
3918        assert!(removed.is_some());
3919        assert_eq!(engine.mounts().len(), pre_mounts);
3920        assert!(!engine.mem_router().is_writable("beta"));
3921    }
3922
3923    #[test]
3924    fn unregister_writable_mem_returns_false_for_unknown_name() {
3925        // Idempotent contract: repeated calls / unknown names are
3926        // not errors — return false so callers can branch without
3927        // a typed error envelope for the common "already gone" case.
3928        let tmp = TempDir::new().unwrap();
3929        let mem_dir = tmp.path().to_path_buf();
3930        let writer = FilesystemMemWriter::new(mem_dir.clone());
3931        let mut engine = Engine::from_mounts(vec![(
3932            folder_mount("specs", mem_dir),
3933            Box::new(writer) as Box<dyn MemBackend>,
3934        )])
3935        .unwrap();
3936        let removed = engine.unregister_writable_mem("missing").unwrap();
3937        assert!(removed.is_none(), "unknown mem returns Ok(None)");
3938        // The original mem is still present and readable.
3939        assert!(engine.mem_router().is_writable("specs"));
3940    }
3941
3942    #[test]
3943    fn unregister_writable_mem_drops_mount_and_router_entry() {
3944        // Heterogeneous engine: two mounts. Unregister one and
3945        // assert (a) it's gone from the mount list, (b) gone from
3946        // the mem_router's writable set, (c) the OTHER mount is
3947        // untouched.
3948        let tmp = TempDir::new().unwrap();
3949        let mem_a = tmp.path().join("a");
3950        std::fs::create_dir_all(&mem_a).unwrap();
3951        let writer_a = FilesystemMemWriter::new(mem_a.clone());
3952        let mem_b = tmp.path().join("b");
3953        std::fs::create_dir_all(&mem_b).unwrap();
3954        let writer_b = FilesystemMemWriter::new(mem_b.clone());
3955
3956        let mut engine = Engine::from_mounts(vec![
3957            (
3958                folder_mount("alpha", mem_a),
3959                Box::new(writer_a) as Box<dyn MemBackend>,
3960            ),
3961            (
3962                folder_mount("beta", mem_b),
3963                Box::new(writer_b) as Box<dyn MemBackend>,
3964            ),
3965        ])
3966        .unwrap();
3967
3968        let removed = engine.unregister_writable_mem("alpha").unwrap();
3969        assert!(removed.is_some());
3970
3971        // alpha is gone from every surface.
3972        assert!(!engine.mem_router().is_writable("alpha"));
3973        assert!(!engine.mem_router().is_visible("alpha"));
3974        assert!(engine.mount("alpha").is_none());
3975
3976        // beta survives unchanged.
3977        assert!(engine.mem_router().is_writable("beta"));
3978        assert!(engine.mount("beta").is_some());
3979    }
3980
3981    #[test]
3982    fn unregister_writable_mem_drops_entities_for_that_mem_only() {
3983        // Build an engine with two mems, write one entity to each
3984        // backend, build the engine (loads both), unregister one,
3985        // assert the store still has the other mem's entity.
3986        let tmp = TempDir::new().unwrap();
3987        let mem_a = tmp.path().join("a");
3988        std::fs::create_dir_all(&mem_a).unwrap();
3989        std::fs::write(
3990            mem_a.join("a1.md"),
3991            "---\ntype: spec\n---\n# A1\n\n## Identity\n\nseed.\n",
3992        )
3993        .unwrap();
3994        let writer_a = FilesystemMemWriter::new(mem_a.clone());
3995
3996        let mem_b = tmp.path().join("b");
3997        std::fs::create_dir_all(&mem_b).unwrap();
3998        std::fs::write(
3999            mem_b.join("b1.md"),
4000            "---\ntype: spec\n---\n# B1\n\n## Identity\n\nseed.\n",
4001        )
4002        .unwrap();
4003        let writer_b = FilesystemMemWriter::new(mem_b.clone());
4004
4005        let mut engine = Engine::from_mounts(vec![
4006            (
4007                folder_mount("alpha", mem_a),
4008                Box::new(writer_a) as Box<dyn MemBackend>,
4009            ),
4010            (
4011                folder_mount("beta", mem_b),
4012                Box::new(writer_b) as Box<dyn MemBackend>,
4013            ),
4014        ])
4015        .unwrap();
4016
4017        let pre_total = engine.store().all_entities().count();
4018        assert!(pre_total >= 2, "both mems must load entities");
4019
4020        engine.unregister_writable_mem("alpha").unwrap();
4021
4022        // alpha's entities are gone.
4023        let alpha_remaining = engine
4024            .store()
4025            .all_entities()
4026            .filter(|e| e.mem == "alpha")
4027            .count();
4028        assert_eq!(alpha_remaining, 0);
4029
4030        // beta's entities survive.
4031        let beta_remaining = engine
4032            .store()
4033            .all_entities()
4034            .filter(|e| e.mem == "beta")
4035            .count();
4036        assert!(beta_remaining > 0, "beta entities must survive");
4037    }
4038    #[test]
4039    fn reload_one_mem_returns_empty_diff_when_disk_is_unchanged() {
4040        let tmp = TempDir::new().unwrap();
4041        let mut engine = build_demo_engine(&tmp);
4042        let result = engine
4043            .reload_one_mem("specs")
4044            .expect("reload on stable disk must succeed");
4045        assert!(result.added.is_empty(), "added: {:?}", result.added);
4046        assert!(result.changed.is_empty(), "changed: {:?}", result.changed);
4047        assert!(result.removed.is_empty(), "removed: {:?}", result.removed);
4048    }
4049
4050    #[test]
4051    fn reload_one_mem_picks_up_external_addition() {
4052        let tmp = TempDir::new().unwrap();
4053        let mut engine = build_demo_engine(&tmp);
4054        // Simulate an external writer dropping a new entity on disk
4055        // without going through the engine.
4056        std::fs::write(
4057            tmp.path().join("external.md"),
4058            "---\ntype: spec\n---\n# External\n\n## Identity\n\nE.\n",
4059        )
4060        .unwrap();
4061        let result = engine.reload_one_mem("specs").unwrap();
4062        assert_eq!(
4063            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4064            vec!["specs--external"]
4065        );
4066        assert!(result.changed.is_empty());
4067        assert!(result.removed.is_empty());
4068        // The new entity is now reachable through the engine.
4069        assert!(
4070            engine
4071                .get_entity(&crate::EntityId::new("specs", "external"))
4072                .is_some()
4073        );
4074    }
4075
4076    #[test]
4077    fn reload_one_mem_picks_up_external_removal() {
4078        let tmp = TempDir::new().unwrap();
4079        let mut engine = build_demo_engine(&tmp);
4080        // Lonely Three exists from the demo fixture; remove it
4081        // off-engine and reload.
4082        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4083        let result = engine.reload_one_mem("specs").unwrap();
4084        assert!(result.added.is_empty());
4085        assert!(result.changed.is_empty());
4086        assert_eq!(
4087            result
4088                .removed
4089                .iter()
4090                .map(|i| i.as_ref())
4091                .collect::<Vec<_>>(),
4092            vec!["specs--lonely-three"]
4093        );
4094    }
4095
4096    #[test]
4097    fn reload_one_mem_picks_up_external_change() {
4098        let tmp = TempDir::new().unwrap();
4099        let mut engine = build_demo_engine(&tmp);
4100        // Overwrite an existing entity's content; the new
4101        // `content_hash` must surface in the `changed` diff.
4102        std::fs::write(
4103            tmp.path().join("source-one.md"),
4104            "---\ntype: spec\n---\n# Source One Edited\n\n## Identity\n\nNew body.\n",
4105        )
4106        .unwrap();
4107        let result = engine.reload_one_mem("specs").unwrap();
4108        assert!(result.added.is_empty());
4109        assert_eq!(
4110            result
4111                .changed
4112                .iter()
4113                .map(|i| i.as_ref())
4114                .collect::<Vec<_>>(),
4115            vec!["specs--source-one"]
4116        );
4117        assert!(result.removed.is_empty());
4118    }
4119
4120    #[test]
4121    fn reload_one_mem_rejects_unknown_mem() {
4122        let tmp = TempDir::new().unwrap();
4123        let mut engine = build_demo_engine(&tmp);
4124        let err = engine.reload_one_mem("nope").unwrap_err();
4125        match err {
4126            EngineError::UnknownMem(name) => assert_eq!(name, "nope"),
4127            other => panic!("expected UnknownMem, got {other:?}"),
4128        }
4129    }
4130
4131    #[test]
4132    fn reload_each_writable_mem_returns_one_entry_per_mount() {
4133        let tmp = TempDir::new().unwrap();
4134        let mut engine = build_demo_engine(&tmp);
4135        let reports = engine
4136            .reload_each_writable_mem()
4137            .expect("batch reload on stable disk must succeed");
4138        assert_eq!(reports.len(), 1);
4139        assert_eq!(reports[0].0, "specs");
4140        assert!(reports[0].1.added.is_empty());
4141        assert!(reports[0].1.changed.is_empty());
4142        assert!(reports[0].1.removed.is_empty());
4143    }
4144
4145    // ---- Engine::settings -------------------------------------------
4146
4147    #[test]
4148    fn settings_default_to_empty_on_fresh_engine() {
4149        let tmp = TempDir::new().unwrap();
4150        let engine = build_demo_engine(&tmp);
4151        let s = engine.settings();
4152        assert!(s.mem_create_rules.is_empty());
4153        assert!(s.mem_delete_rules.is_empty());
4154        assert!(s.cross_mem_links.is_empty());
4155    }
4156
4157    #[test]
4158    fn set_settings_replaces_workspace_policy() {
4159        use crate::workspace::{CreateRuleSetting, DeleteRuleSetting, WorkspaceSettings};
4160        let tmp = TempDir::new().unwrap();
4161        let mut engine = build_demo_engine(&tmp);
4162        let mut settings = WorkspaceSettings::default();
4163        settings.mem_create_rules.push(CreateRuleSetting {
4164            pattern: "exec-*".to_string(),
4165            schemas: vec!["default@1.0.0".to_string()],
4166            default_cross_links: None,
4167        });
4168        settings.mem_delete_rules.push(DeleteRuleSetting {
4169            pattern: "exec-*".to_string(),
4170        });
4171        engine.set_settings(settings);
4172        assert_eq!(engine.settings().mem_create_rules.len(), 1);
4173        assert_eq!(engine.settings().mem_create_rules[0].pattern, "exec-*");
4174        assert_eq!(engine.settings().mem_delete_rules.len(), 1);
4175        assert_eq!(engine.settings().mem_delete_rules[0].pattern, "exec-*");
4176    }
4177
4178    // ---- Engine::reload_each_writable_mem (continued) -------------
4179
4180    #[test]
4181    fn reload_each_writable_mem_picks_up_external_changes_per_mem() {
4182        let tmp = TempDir::new().unwrap();
4183        let mut engine = build_demo_engine(&tmp);
4184        // Mutate disk: add one entity, remove another, change a third.
4185        std::fs::write(
4186            tmp.path().join("new-via-disk.md"),
4187            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4188        )
4189        .unwrap();
4190        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4191        std::fs::write(
4192            tmp.path().join("source-one.md"),
4193            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4194        )
4195        .unwrap();
4196
4197        let reports = engine.reload_each_writable_mem().unwrap();
4198        assert_eq!(reports.len(), 1);
4199        let (mem, result) = &reports[0];
4200        assert_eq!(mem, "specs");
4201        assert_eq!(
4202            result.added.iter().map(|i| i.as_ref()).collect::<Vec<_>>(),
4203            vec!["specs--new-via-disk"]
4204        );
4205        assert_eq!(
4206            result
4207                .removed
4208                .iter()
4209                .map(|i| i.as_ref())
4210                .collect::<Vec<_>>(),
4211            vec!["specs--lonely-three"]
4212        );
4213        assert_eq!(
4214            result
4215                .changed
4216                .iter()
4217                .map(|i| i.as_ref())
4218                .collect::<Vec<_>>(),
4219            vec!["specs--source-one"]
4220        );
4221    }
4222
4223    // ---- Engine::reload_one_mem_report (rich-shape wrapper) -------
4224
4225    #[test]
4226    fn reload_one_mem_report_returns_rich_shape_for_folder_default() {
4227        // The folder backend's drift cursor is the changelog's
4228        // last-line timestamp (RFC3339-millis) — the same dialect
4229        // `folder_changes_since` accepts. With the demo engine's
4230        // creates already logged, both heads carry that cursor and,
4231        // with the disk unchanged between init and reload, they are
4232        // equal. entities_loaded reflects the post-reload count;
4233        // changed_entity_ids is empty when the disk is unchanged.
4234        let tmp = TempDir::new().unwrap();
4235        let mut engine = build_demo_engine(&tmp);
4236        let report = engine.reload_one_mem_report("specs").unwrap();
4237        assert_eq!(report.mem, "specs");
4238        assert_eq!(
4239            report.head_before, report.head_after,
4240            "unchanged disk → stable cursor"
4241        );
4242        assert!(
4243            crate::filesystem::changelog::parse_rfc3339_utc(&report.head_after).is_some(),
4244            "folder heads carry the changelog-ts cursor, got {}",
4245            report.head_after
4246        );
4247        // build_demo_engine seeds 3 entities (Source One, Target Two,
4248        // Lonely Three) — all real, no stubs from those creates.
4249        assert_eq!(report.entities_loaded, 3);
4250        // No external disk changes between init and reload → empty diff.
4251        assert!(report.changed_entity_ids.is_empty());
4252    }
4253
4254    #[test]
4255    fn reload_one_mem_report_unions_added_changed_removed_into_one_list() {
4256        // Mutate disk: add one, remove one, change one. The report's
4257        // changed_entity_ids unions the slim ReloadResult's three
4258        // diff lists into a single sorted vec — matches full's
4259        // wire contract.
4260        let tmp = TempDir::new().unwrap();
4261        let mut engine = build_demo_engine(&tmp);
4262        std::fs::write(
4263            tmp.path().join("new-via-disk.md"),
4264            "---\ntype: spec\n---\n# New Via Disk\n\n## Identity\n\nN.\n",
4265        )
4266        .unwrap();
4267        std::fs::remove_file(tmp.path().join("lonely-three.md")).unwrap();
4268        std::fs::write(
4269            tmp.path().join("source-one.md"),
4270            "---\ntype: spec\n---\n# Source One\n\n## Identity\n\nDifferent body.\n",
4271        )
4272        .unwrap();
4273
4274        let report = engine.reload_one_mem_report("specs").unwrap();
4275        assert_eq!(report.mem, "specs");
4276        let ids: Vec<&str> = report
4277            .changed_entity_ids
4278            .iter()
4279            .map(|id| id.as_ref())
4280            .collect();
4281        // Sorted lexicographically: lonely-three < new-via-disk < source-one
4282        assert_eq!(
4283            ids,
4284            vec![
4285                "specs--lonely-three",
4286                "specs--new-via-disk",
4287                "specs--source-one",
4288            ]
4289        );
4290    }
4291
4292    #[test]
4293    fn reload_one_mem_report_rejects_unknown_mem() {
4294        let tmp = TempDir::new().unwrap();
4295        let mut engine = build_demo_engine(&tmp);
4296        let err = engine.reload_one_mem_report("missing").unwrap_err();
4297        assert!(matches!(err, EngineError::UnknownMem(_)));
4298    }
4299
4300    #[test]
4301    fn reload_each_writable_mem_reports_returns_one_entry_per_mount() {
4302        let tmp = TempDir::new().unwrap();
4303        let mut engine = build_demo_engine(&tmp);
4304        let reports = engine.reload_each_writable_mem_reports().unwrap();
4305        assert_eq!(reports.len(), 1);
4306        assert_eq!(reports[0].mem, "specs");
4307        assert_eq!(reports[0].entities_loaded, 3);
4308    }
4309
4310    /// Workspace-wide reload re-reads `.memstead/workspace.toml` and
4311    /// refreshes [`WorkspaceSettings`]. This is the pairing with the
4312    /// CLI's `memstead workspace allow-create / grant-cross-link /
4313    /// set-mutations` family — without it, a CLI write lands on disk
4314    /// but the running engine keeps serving the boot-time policy
4315    /// snapshot until process restart.
4316    #[test]
4317    fn reload_each_writable_mem_reports_refreshes_workspace_settings() {
4318        let tmp = TempDir::new().unwrap();
4319
4320        // Minimum-viable workspace.toml (no rules) + one writable
4321        // folder-backed mem.
4322        let memstead_dir = tmp.path().join(".memstead");
4323        std::fs::create_dir_all(&memstead_dir).unwrap();
4324        let workspace_toml = memstead_dir.join("workspace.toml");
4325        std::fs::write(
4326            &workspace_toml,
4327            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4328        )
4329        .unwrap();
4330        let mounts_json = memstead_dir.join("state").join("mounts.json");
4331        std::fs::create_dir_all(mounts_json.parent().unwrap()).unwrap();
4332        let mem_dir = tmp.path().join("specs");
4333        std::fs::create_dir_all(&mem_dir).unwrap();
4334        let mounts_body = format!(
4335            r#"{{ "format": "memstead-mounts-3", "mounts": [{{ "mem": "specs", "schema": "default@1.0.0", "storage": {{ "type": "folder", "path": "{}" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#,
4336            mem_dir.display(),
4337        );
4338        std::fs::write(&mounts_json, mounts_body).unwrap();
4339
4340        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
4341        assert!(
4342            engine.settings().mem_create_rules.is_empty(),
4343            "boot-time settings carry no create rules"
4344        );
4345
4346        // Simulate an out-of-band CLI write to workspace.toml.
4347        std::fs::write(
4348            &workspace_toml,
4349            "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",
4350        )
4351        .unwrap();
4352
4353        engine.reload_each_writable_mem_reports().unwrap();
4354
4355        let rules = &engine.settings().mem_create_rules;
4356        assert_eq!(
4357            rules.len(),
4358            1,
4359            "workspace-wide reload must refresh the policy"
4360        );
4361        assert_eq!(rules[0].pattern, "exec-*");
4362    }
4363
4364    // ---- Engine::reload_if_stale ------------------------------
4365
4366    // ---- set_mem_schema / dual-pin migration ----
4367
4368    const MIG_TYPE_TAIL: &str = r#"sections:
4369  - key: body
4370    heading: Body
4371    required: true
4372    search_weight: 10.0
4373    catch_all: true
4374    write_rules: []
4375title_weight: 100.0
4376text_fields:
4377  - body
4378hierarchy_relationship: _default
4379no_self_loop_relationships: []
4380updatable_fields: []
4381health_required_fields: []
4382staleness_threshold_days: 90
4383write_rules: []
4384"#;
4385
4386    /// Schema manifest for the migration tests: `name@version` with a
4387    /// `doc` type. `with_status = true` adds a required, no-default
4388    /// enum field `status` — entities created without it are
4389    /// non-conformant against that schema.
4390    fn mig_manifest(name: &str, version: &str) -> String {
4391        format!(
4392            r#"name: {name}
4393version: {version}
4394description: migration test schema
4395when_to_use: tests
4396types:
4397  - doc
4398relationships:
4399  mode: strict
4400  definitions:
4401    - name: USES
4402      description: link
4403      default_weight: 1.0
4404    - name: _default
4405      description: fallback
4406      default_weight: 1.0
4407community:
4408  resolution: 1.0
4409  seed: 42
4410"#
4411        )
4412    }
4413
4414    fn mig_type_yaml(with_status: bool) -> String {
4415        let metadata = if with_status {
4416            "metadata_fields:\n  - key: status\n    description: Lifecycle state\n    field_type: string\n    required: true\n    enum_values:\n      - open\n      - closed\n"
4417        } else {
4418            "metadata_fields: []\n"
4419        };
4420        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{metadata}{MIG_TYPE_TAIL}")
4421    }
4422
4423    fn write_mig_schema(
4424        root: &std::path::Path,
4425        dir: &str,
4426        name: &str,
4427        version: &str,
4428        with_status: bool,
4429    ) {
4430        let d = root.join(dir);
4431        std::fs::create_dir_all(d.join("types")).unwrap();
4432        std::fs::write(d.join("schema.yaml"), mig_manifest(name, version)).unwrap();
4433        std::fs::write(d.join("types").join("doc.yaml"), mig_type_yaml(with_status)).unwrap();
4434    }
4435
4436    /// Engine with one mem pinned `mig-a@0.1.0` (no required
4437    /// metadata) plus loadable `mig-a@0.2.0` (identical shape) and
4438    /// `mig-b@0.1.0` (required enum `status`) in the workspace
4439    /// schemas dir. Two conformant-under-A entities are created.
4440    /// The criterion-4 property test (flywheel W8/01): a
4441    /// deterministic, seeded, hand-rolled generator (xorshift64 — the
4442    /// house discipline, no dependency) drives mutation sequences
4443    /// across EVERY kind — create, update, relate, delete, rename,
4444    /// batch update (applied AND refused/rolled-back), reload, and a
4445    /// schema switch — asserting at checkpoints and at sequence end
4446    /// that the maintained derived structures are identical to a
4447    /// from-scratch rebuild over the current store. Coverage is
4448    /// guaranteed by construction (the first pass cycles every kind
4449    /// once before the random tail), and asserted, so a silently
4450    /// narrowed generator fails the suite. A failing sequence
4451    /// reproduces from the seed printed in the panic message alone.
4452    #[test]
4453    fn derived_structures_match_rebuild_across_random_mutation_sequences() {
4454        for seed in [0x5eed_0001_u64, 0x5eed_0002, 0x5eed_0003] {
4455            run_mutation_sequence(seed);
4456        }
4457    }
4458
4459    struct Xorshift(u64);
4460    impl Xorshift {
4461        fn next(&mut self) -> u64 {
4462            let mut x = self.0;
4463            x ^= x << 13;
4464            x ^= x >> 7;
4465            x ^= x << 17;
4466            self.0 = x;
4467            x
4468        }
4469        fn pick(&mut self, n: usize) -> usize {
4470            (self.next() % n as u64) as usize
4471        }
4472    }
4473
4474    fn assert_derived_oracles(engine: &Engine, seed: u64, label: &str) {
4475        // Search oracle: the maintained per-mem index holds exactly
4476        // the ids a from-scratch build over the current store holds.
4477        let fresh = crate::search_index::build_all(engine.store(), &engine.schemas);
4478        let live = engine.search_indexes();
4479        let mut live_mems: Vec<&String> = live.keys().collect();
4480        let mut fresh_mems: Vec<&String> = fresh.keys().collect();
4481        live_mems.sort();
4482        fresh_mems.sort();
4483        assert_eq!(
4484            live_mems, fresh_mems,
4485            "seed {seed:#x} @ {label}: index mem set diverged from rebuild"
4486        );
4487        for (mem, idx) in live {
4488            let mut got = idx.stored_ids().unwrap();
4489            let mut want = fresh[mem].stored_ids().unwrap();
4490            got.sort();
4491            want.sort();
4492            assert_eq!(
4493                got, want,
4494                "seed {seed:#x} @ {label}: mem `{mem}` index contents diverged from rebuild"
4495            );
4496        }
4497        // Community oracle: the memoised partition equals a fresh
4498        // detection with the same parameter source (smallest mem name).
4499        let schema = engine
4500            .schemas
4501            .iter()
4502            .min_by(|a, b| a.0.cmp(b.0))
4503            .map(|(_, s)| s.clone())
4504            .expect("schema present");
4505        let weights_schema = schema.clone();
4506        let fresh_partition = crate::graph::community::detect_communities(
4507            engine.store(),
4508            schema.manifest.community.resolution,
4509            schema.manifest.community.seed,
4510            move |rel_type| {
4511                weights_schema
4512                    .manifest
4513                    .relationships
4514                    .definitions
4515                    .iter()
4516                    .find(|d| d.name == rel_type)
4517                    .map(|d| d.default_weight as f64)
4518                    .unwrap_or(1.0)
4519            },
4520        );
4521        assert_eq!(
4522            engine.communities().entity_cluster_map,
4523            fresh_partition.entity_cluster_map,
4524            "seed {seed:#x} @ {label}: partition diverged from a fresh detection"
4525        );
4526    }
4527
4528    fn run_mutation_sequence(seed: u64) {
4529        use indexmap::IndexMap;
4530
4531        let (_tmp, mut engine) = migration_engine();
4532        let mut rng = Xorshift(seed);
4533        let mut live: Vec<crate::EntityId> = vec![
4534            crate::EntityId::new("specs", "one"),
4535            crate::EntityId::new("specs", "two"),
4536        ];
4537        let mut counter = 0usize;
4538        let mut kinds_hit: std::collections::HashSet<&'static str> =
4539            std::collections::HashSet::new();
4540
4541        const KINDS: [&str; 7] = [
4542            "create",
4543            "update",
4544            "relate",
4545            "delete",
4546            "rename",
4547            "batch_applied",
4548            "batch_refused",
4549        ];
4550
4551        let bare_update = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4552            anchors: Vec::new(),
4553            anchors_unset: Vec::new(),
4554            id,
4555            expected_hash: None,
4556            sections: IndexMap::new(),
4557            append_sections: IndexMap::new(),
4558            patch_sections: IndexMap::new(),
4559            metadata: IndexMap::new(),
4560            metadata_unset: Vec::new(),
4561            declare_relations: Vec::new(),
4562            dry_run: false,
4563            relations_unset: Vec::new(),
4564        };
4565
4566        for op_i in 0..30usize {
4567            // First pass cycles every kind once (coverage by
4568            // construction); the tail is seed-driven.
4569            let kind = *KINDS
4570                .get(op_i)
4571                .unwrap_or_else(|| &KINDS[rng.pick(KINDS.len())]);
4572            match kind {
4573                "create" => {
4574                    counter += 1;
4575                    let mut args = empty_create_args("specs", &format!("Gen {counter}"));
4576                    args.entity_type = "doc".to_string();
4577                    args.sections = IndexMap::from_iter([(
4578                        "body".to_string(),
4579                        format!("generated body {counter}"),
4580                    )]);
4581                    let out = engine
4582                        .create_entity(args, crate::vcs::Actor::Cli, None, None)
4583                        .expect("generated create is conformant");
4584                    live.push(out.id);
4585                    kinds_hit.insert("create");
4586                }
4587                "update" => {
4588                    let id = live[rng.pick(live.len())].clone();
4589                    let mut args = bare_update(id);
4590                    args.append_sections
4591                        .insert("body".to_string(), format!("appended at op {op_i}"));
4592                    engine
4593                        .update_entity(args, crate::vcs::Actor::Cli, None, None)
4594                        .expect("append update is conformant");
4595                    kinds_hit.insert("update");
4596                }
4597                "relate" => {
4598                    if live.len() >= 2 {
4599                        let a = rng.pick(live.len());
4600                        let mut b = rng.pick(live.len());
4601                        if a == b {
4602                            b = (b + 1) % live.len();
4603                        }
4604                        engine
4605                            .relate_entity(
4606                                crate::engine::RelateEntityArgs {
4607                                    source: live[a].clone(),
4608                                    expected_hash: None,
4609                                    rel_type: "USES".to_string(),
4610                                    target: live[b].clone(),
4611                                    remove: false,
4612                                    description: None,
4613                                    dry_run: false,
4614                                },
4615                                crate::vcs::Actor::Cli,
4616                                None,
4617                                None,
4618                            )
4619                            .expect("USES relate is legal under mig-a");
4620                        kinds_hit.insert("relate");
4621                    }
4622                }
4623                "delete" => {
4624                    // Only reference-free entities delete cleanly; keep
4625                    // at least two so relate stays possible.
4626                    if live.len() > 2
4627                        && let Some(pos) = (0..live.len()).find(|&i| {
4628                            engine.store().incoming(&live[i]).is_empty()
4629                                && engine
4630                                    .store()
4631                                    .get(&live[i])
4632                                    .is_some_and(|e| e.relationships.is_empty())
4633                        })
4634                    {
4635                        let id = live.remove(pos);
4636                        engine
4637                            .delete_entity(
4638                                crate::engine::DeleteEntityArgs {
4639                                    id: id.clone(),
4640                                    expected_hash: None,
4641                                },
4642                                crate::vcs::Actor::Cli,
4643                                None,
4644                                None,
4645                            )
4646                            .expect("reference-free delete lands");
4647                        kinds_hit.insert("delete");
4648                    }
4649                }
4650                "rename" => {
4651                    counter += 1;
4652                    let pos = rng.pick(live.len());
4653                    let old = live[pos].clone();
4654                    let out = engine
4655                        .rename_entity(
4656                            crate::engine::RenameEntityArgs {
4657                                id: old,
4658                                new_title: format!("Renamed {counter}"),
4659                                expected_hash: None,
4660                            },
4661                            crate::vcs::Actor::Cli,
4662                            None,
4663                            None,
4664                        )
4665                        .expect("fresh-slug rename lands");
4666                    live[pos] = out.new_id;
4667                    kinds_hit.insert("rename");
4668                }
4669                "batch_applied" => {
4670                    let id_a = live[rng.pick(live.len())].clone();
4671                    let mut a = bare_update(id_a);
4672                    a.append_sections
4673                        .insert("body".to_string(), format!("batch line {op_i}"));
4674                    let result = engine
4675                        .batch_update(vec![(a, None)], crate::vcs::Actor::Cli, None, false)
4676                        .expect("batch envelope");
4677                    assert!(result.applied, "single-entry append batch applies");
4678                    kinds_hit.insert("batch_applied");
4679                }
4680                "batch_refused" => {
4681                    let id_a = live[rng.pick(live.len())].clone();
4682                    let mut a = bare_update(id_a);
4683                    a.append_sections
4684                        .insert("body".to_string(), "doomed".to_string());
4685                    let missing = bare_update(crate::EntityId::new("specs", "no-such-entity"));
4686                    let result = engine
4687                        .batch_update(
4688                            vec![(a, None), (missing, None)],
4689                            crate::vcs::Actor::Cli,
4690                            None,
4691                            false,
4692                        )
4693                        .expect("refused batch returns a report-all envelope");
4694                    assert!(!result.applied, "the missing target refuses the batch");
4695                    kinds_hit.insert("batch_refused");
4696                }
4697                _ => unreachable!(),
4698            }
4699
4700            if op_i == 14 {
4701                // The at-least-one schema switch the criterion demands
4702                // (identical field shape, so the index rebuild is
4703                // exercised through the epoch path).
4704                engine
4705                    .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4706                    .expect("integral switch");
4707                kinds_hit.insert("schema_switch");
4708            }
4709            if op_i == 19 {
4710                engine.reload_one_mem("specs").expect("reload lands");
4711                kinds_hit.insert("reload");
4712            }
4713
4714            if op_i % 10 == 9 {
4715                assert_derived_oracles(&engine, seed, &format!("checkpoint op {op_i}"));
4716            }
4717        }
4718
4719        assert_derived_oracles(&engine, seed, "sequence end");
4720
4721        for kind in KINDS.iter().copied().chain(["schema_switch", "reload"]) {
4722            assert!(
4723                kinds_hit.contains(kind),
4724                "seed {seed:#x}: generator coverage narrowed — kind `{kind}` never executed"
4725            );
4726        }
4727    }
4728
4729    fn migration_engine() -> (tempfile::TempDir, Engine) {
4730        let tmp = tempfile::TempDir::new().unwrap();
4731        let schemas_dir = tmp.path().join("schemas");
4732        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4733        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4734        write_mig_schema(&schemas_dir, "mig-b-1", "mig-b", "0.1.0", true);
4735        let mem_dir = tmp.path().join("mem");
4736        std::fs::create_dir_all(&mem_dir).unwrap();
4737        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4738        let mut mount = folder_mount("specs", mem_dir);
4739        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4740        let mut engine = Engine::from_mounts_with_schemas_dir(
4741            vec![(
4742                mount,
4743                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4744            )],
4745            Some(&schemas_dir),
4746        )
4747        .unwrap();
4748        for title in ["One", "Two"] {
4749            let mut args = empty_create_args("specs", title);
4750            args.entity_type = "doc".to_string();
4751            args.sections =
4752                indexmap::IndexMap::from_iter([("body".to_string(), "content".to_string())]);
4753            engine
4754                .create_entity(args, crate::vcs::Actor::Cli, None, None)
4755                .expect("conformant create under mig-a");
4756        }
4757        (tmp, engine)
4758    }
4759
4760    fn sref(s: &str) -> memstead_schema::SchemaRef {
4761        s.parse().unwrap()
4762    }
4763
4764    #[test]
4765    fn set_schema_noop_on_current_pin() {
4766        let (_tmp, mut engine) = migration_engine();
4767        let out = engine
4768            .set_mem_schema("specs", &sref("mig-a@0.1.0"))
4769            .unwrap();
4770        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Noop);
4771        assert_eq!(out.schema_pin, "mig-a@0.1.0");
4772        assert_eq!(out.migration_target, None);
4773        assert!(out.findings.is_empty());
4774    }
4775
4776    /// Schema-switch invalidation (flywheel W8/01, criterion 2): a
4777    /// schema switch changes NO store content — the store generation
4778    /// stays put — yet both derived memos depend on the schema
4779    /// (community weights, the index field set), so the switch must
4780    /// clear them. The schemas EPOCH is what carries that dependency
4781    /// into the memo key; without it the generation-checked hooks
4782    /// would keep both memos and serve results computed against the
4783    /// old schema (the staleness the whole-map drop used to mask).
4784    #[test]
4785    fn schema_switch_invalidates_both_memos_despite_unchanged_store() {
4786        let (_tmp, mut engine) = migration_engine();
4787
4788        let _ = engine.communities();
4789        let _ = engine.search_indexes();
4790        assert!(engine.community_memo.get().is_some());
4791        assert!(engine.search_indexes_memo.get().is_some());
4792        let store_gen_before = engine.store().generation();
4793
4794        engine
4795            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4796            .unwrap();
4797
4798        assert_eq!(
4799            engine.store().generation(),
4800            store_gen_before,
4801            "a schema switch mutates no store content"
4802        );
4803        assert!(
4804            engine.community_memo.get().is_none(),
4805            "the community memo must clear on a schema switch (weights derive from the schema)"
4806        );
4807        assert!(
4808            engine.search_indexes_memo.get().is_none(),
4809            "the search memo must clear on a schema switch (the field set derives from the schema)"
4810        );
4811    }
4812
4813    #[test]
4814    fn set_schema_switches_immediately_when_integral() {
4815        // Version bump within the same domain; entities conform to
4816        // the identical-shape 0.2.0, so the switch is immediate.
4817        let (_tmp, mut engine) = migration_engine();
4818        let out = engine
4819            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4820            .unwrap();
4821        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4822        assert_eq!(out.schema_pin, "mig-a@0.2.0");
4823        assert_eq!(out.migration_target, None);
4824        assert!(out.findings.is_empty());
4825        assert_eq!(
4826            engine.schema_pin("specs").unwrap().as_display(),
4827            "mig-a@0.2.0"
4828        );
4829        assert!(engine.migration_target("specs").is_none());
4830    }
4831
4832    /// Regression: an atomic switch must persist the new pin into the
4833    /// **authoritative** backend config, not just `mounts.json`. Boot
4834    /// resolution prefers the backend config's pin over `Mount.schema`,
4835    /// so before this fix the switch evaporated on the next process boot
4836    /// for any config-present mem (every `create_mem`-made mem).
4837    #[test]
4838    fn set_schema_switch_persists_pin_into_backend_config() {
4839        let tmp = tempfile::TempDir::new().unwrap();
4840        let schemas_dir = tmp.path().join("schemas");
4841        write_mig_schema(&schemas_dir, "mig-a-1", "mig-a", "0.1.0", false);
4842        write_mig_schema(&schemas_dir, "mig-a-2", "mig-a", "0.2.0", false);
4843        let mem_dir = tmp.path().join("mem");
4844        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4845        // Config-present mem: the authoritative pin lives here.
4846        std::fs::write(
4847            mem_dir.join(".memstead").join("config.json"),
4848            br#"{"schema":"mig-a@0.1.0"}"#,
4849        )
4850        .unwrap();
4851        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
4852        let mut mount = folder_mount("specs", mem_dir.clone());
4853        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
4854        let mut engine = Engine::from_mounts_with_schemas_dir(
4855            vec![(
4856                mount,
4857                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
4858            )],
4859            Some(&schemas_dir),
4860        )
4861        .unwrap();
4862
4863        let out = engine
4864            .set_mem_schema("specs", &sref("mig-a@0.2.0"))
4865            .unwrap();
4866        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4867
4868        // The authoritative backend config now carries the new pin —
4869        // otherwise the switch would evaporate on reboot.
4870        let cfg_bytes = std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap();
4871        let cfg: serde_json::Value = serde_json::from_slice(&cfg_bytes).unwrap();
4872        assert_eq!(
4873            cfg["schema"], "mig-a@0.2.0",
4874            "atomic switch must update the authoritative backend config"
4875        );
4876    }
4877
4878    #[test]
4879    fn set_schema_unknown_target_refuses_schema_not_found() {
4880        let (_tmp, mut engine) = migration_engine();
4881        let err = engine
4882            .set_mem_schema("specs", &sref("nope@9.9.9"))
4883            .unwrap_err();
4884        assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
4885        // No state change.
4886        assert!(engine.migration_target("specs").is_none());
4887    }
4888
4889    #[test]
4890    fn set_schema_migration_lifecycle_end_to_end() {
4891        let (_tmp, mut engine) = migration_engine();
4892        let target = sref("mig-b@0.1.0");
4893
4894        // 1. Non-integral target → migration starts; pin unchanged.
4895        let out = engine.set_mem_schema("specs", &target).unwrap();
4896        assert_eq!(
4897            out.outcome,
4898            crate::engine::SetSchemaResult::MigrationStarted
4899        );
4900        assert_eq!(out.schema_pin, "mig-a@0.1.0");
4901        assert_eq!(out.migration_target.as_deref(), Some("mig-b@0.1.0"));
4902        assert_eq!(out.findings.len(), 2, "both entities lack `status`");
4903        assert!(
4904            out.findings
4905                .iter()
4906                .all(|f| f.code == "REQUIRED_FIELD_UNSET")
4907        );
4908
4909        // 2. Reads of not-yet-repaired entities stay permissive.
4910        let one = crate::entity::EntityId::new("specs", "one");
4911        assert!(engine.store().get(&one).is_some());
4912
4913        // 3. Re-issue while unrepaired → pending, full remaining set.
4914        let out = engine.set_mem_schema("specs", &target).unwrap();
4915        assert_eq!(
4916            out.outcome,
4917            crate::engine::SetSchemaResult::MigrationPending
4918        );
4919        assert_eq!(out.findings.len(), 2);
4920
4921        // 4. Writes validate against the TARGET: `status` is unknown
4922        //    to the pinned mig-a but declared by mig-b — setting it
4923        //    must commit; an invalid enum value must refuse.
4924        let mut bad = crate::engine::UpdateEntityArgs {
4925            anchors: Vec::new(),
4926            id: one.clone(),
4927            expected_hash: None,
4928            sections: indexmap::IndexMap::new(),
4929            append_sections: indexmap::IndexMap::new(),
4930            patch_sections: indexmap::IndexMap::new(),
4931            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "banana".to_string())]),
4932            metadata_unset: Vec::new(),
4933            declare_relations: Vec::new(),
4934            dry_run: false,
4935            relations_unset: Vec::new(),
4936            anchors_unset: Vec::new(),
4937        };
4938        let err = engine
4939            .update_entity(bad.clone(), crate::vcs::Actor::Cli, None, None)
4940            .unwrap_err();
4941        assert_eq!(err.code(), "INVALID_ENUM_VALUE", "strict against target");
4942        bad.metadata = indexmap::IndexMap::from_iter([("status".to_string(), "open".to_string())]);
4943        engine
4944            .update_entity(bad, crate::vcs::Actor::Cli, None, None)
4945            .expect("repair write validated against the migration target");
4946
4947        // 5. One entity repaired → still pending, findings shrink.
4948        let out = engine.set_mem_schema("specs", &target).unwrap();
4949        assert_eq!(
4950            out.outcome,
4951            crate::engine::SetSchemaResult::MigrationPending
4952        );
4953        assert_eq!(out.findings.len(), 1, "only `two` remains non-integral");
4954
4955        // 6. Repair the second entity, re-issue → atomic switch.
4956        let two = crate::entity::EntityId::new("specs", "two");
4957        let repair = crate::engine::UpdateEntityArgs {
4958            anchors: Vec::new(),
4959            id: two.clone(),
4960            expected_hash: None,
4961            sections: indexmap::IndexMap::new(),
4962            append_sections: indexmap::IndexMap::new(),
4963            patch_sections: indexmap::IndexMap::new(),
4964            metadata: indexmap::IndexMap::from_iter([("status".to_string(), "closed".to_string())]),
4965            metadata_unset: Vec::new(),
4966            declare_relations: Vec::new(),
4967            dry_run: false,
4968            relations_unset: Vec::new(),
4969            anchors_unset: Vec::new(),
4970        };
4971        engine
4972            .update_entity(repair, crate::vcs::Actor::Cli, None, None)
4973            .unwrap();
4974        let out = engine.set_mem_schema("specs", &target).unwrap();
4975        assert_eq!(out.outcome, crate::engine::SetSchemaResult::Switched);
4976        assert_eq!(out.schema_pin, "mig-b@0.1.0");
4977        assert_eq!(out.migration_target, None);
4978        assert!(out.findings.is_empty());
4979        assert_eq!(
4980            engine.schema_pin("specs").unwrap().as_display(),
4981            "mig-b@0.1.0"
4982        );
4983        assert!(engine.migration_target("specs").is_none());
4984    }
4985
4986    /// During migration every not-yet-repaired entity is
4987    /// non-conformant against the target, so `relations_unset` works
4988    /// on exactly those entities with no mode flag — and the same
4989    /// update can complete the entity's repair.
4990    #[test]
4991    fn relations_unset_works_during_migration_without_mode_flag() {
4992        let (_tmp, mut engine) = migration_engine();
4993        let one = crate::entity::EntityId::new("specs", "one");
4994        let two = crate::entity::EntityId::new("specs", "two");
4995        engine
4996            .relate_entity(
4997                crate::engine::RelateEntityArgs {
4998                    source: one.clone(),
4999                    expected_hash: None,
5000                    rel_type: "USES".to_string(),
5001                    target: two.clone(),
5002                    remove: false,
5003                    description: None,
5004                    dry_run: false,
5005                },
5006                crate::vcs::Actor::Cli,
5007                None,
5008                None,
5009            )
5010            .unwrap();
5011        // Conformant under the pin → the repair gate is shut.
5012        let shut = engine
5013            .update_entity(
5014                crate::engine::UpdateEntityArgs {
5015                    anchors: Vec::new(),
5016                    id: one.clone(),
5017                    expected_hash: None,
5018                    sections: indexmap::IndexMap::new(),
5019                    append_sections: indexmap::IndexMap::new(),
5020                    patch_sections: indexmap::IndexMap::new(),
5021                    metadata: indexmap::IndexMap::new(),
5022                    metadata_unset: Vec::new(),
5023                    declare_relations: Vec::new(),
5024                    dry_run: false,
5025                    relations_unset: vec![crate::ops::RelationUnsetArg {
5026                        rel_type: "USES".to_string(),
5027                        target: two.clone(),
5028                    }],
5029                    anchors_unset: Vec::new(),
5030                },
5031                crate::vcs::Actor::Cli,
5032                None,
5033                None,
5034            )
5035            .unwrap_err();
5036        assert_eq!(shut.code(), "REPAIR_NOT_NEEDED");
5037
5038        // Enter migration → `one` is now non-conformant against the
5039        // target; the same call opens, removes the relation, and the
5040        // bundled `status` set makes the entity integral-against-target.
5041        engine
5042            .set_mem_schema("specs", &sref("mig-b@0.1.0"))
5043            .unwrap();
5044        engine
5045            .update_entity(
5046                crate::engine::UpdateEntityArgs {
5047                    anchors: Vec::new(),
5048                    id: one.clone(),
5049                    expected_hash: None,
5050                    sections: indexmap::IndexMap::new(),
5051                    append_sections: indexmap::IndexMap::new(),
5052                    patch_sections: indexmap::IndexMap::new(),
5053                    metadata: indexmap::IndexMap::from_iter([(
5054                        "status".to_string(),
5055                        "open".to_string(),
5056                    )]),
5057                    metadata_unset: Vec::new(),
5058                    declare_relations: Vec::new(),
5059                    dry_run: false,
5060                    relations_unset: vec![crate::ops::RelationUnsetArg {
5061                        rel_type: "USES".to_string(),
5062                        target: two.clone(),
5063                    }],
5064                    anchors_unset: Vec::new(),
5065                },
5066                crate::vcs::Actor::Cli,
5067                None,
5068                None,
5069            )
5070            .expect("repair-shaped update lands during migration without a flag");
5071        let entity = engine.store().get(&one).unwrap();
5072        assert!(entity.relationships.is_empty());
5073    }
5074
5075    /// Boot honors a persisted in-flight migration: a mount carrying
5076    /// `migration_target` validates writes against the target from
5077    /// the first call of the new process — the resumability half of
5078    /// the dual-pin contract.
5079    #[test]
5080    fn boot_resumes_dual_pin_validation_against_target() {
5081        let (tmp, engine) = migration_engine();
5082        drop(engine);
5083        let schemas_dir = tmp.path().join("schemas");
5084        let mem_dir = tmp.path().join("mem");
5085        let writer = crate::storage::FilesystemMemWriter::new(mem_dir.clone());
5086        let mut mount = folder_mount("specs", mem_dir);
5087        mount.schema = Some("mig-a@0.1.0".parse().unwrap());
5088        mount.migration_target = Some("mig-b@0.1.0".parse().unwrap());
5089        let engine = Engine::from_mounts_with_schemas_dir(
5090            vec![(
5091                mount,
5092                Box::new(writer) as Box<dyn crate::backend::MemBackend>,
5093            )],
5094            Some(&schemas_dir),
5095        )
5096        .unwrap();
5097        // Effective validation schema is the target...
5098        let (name, version) = {
5099            let s = engine.schema_for("specs").unwrap();
5100            let (n, v) = s.id();
5101            (n.to_string(), v.to_string())
5102        };
5103        assert_eq!((name.as_str(), version.as_str()), ("mig-b", "0.1.0"));
5104        // ...while the settled pin and the in-flight target read back
5105        // distinctly.
5106        assert_eq!(
5107            engine.schema_pin("specs").unwrap().as_display(),
5108            "mig-a@0.1.0"
5109        );
5110        assert_eq!(
5111            engine.migration_target("specs").unwrap().as_display(),
5112            "mig-b@0.1.0"
5113        );
5114    }
5115
5116    /// Every lifecycle setter refuses `READ_ONLY_MOUNT` on a read-only
5117    /// mount — the family, not an instance. `set_mem_schema` was the
5118    /// one ungated sibling (a schema-pin change starts a migration —
5119    /// the last mutation a sealed mount should accept); this test
5120    /// enumerates all seven current setters — extend it when adding an
5121    /// eighth (the enumeration is manual, not reflective). Refusal complement: the same calls succeed (or fail
5122    /// for their own non-capability reasons) against a writable mount —
5123    /// covered by the existing per-setter tests; `set_mem_schema`'s
5124    /// writable-mount behaviour is pinned by the migration tests above.
5125    #[test]
5126    fn every_lifecycle_setter_refuses_on_read_only_mount() {
5127        let tmp = TempDir::new().unwrap();
5128        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# Title: Foo\n")]);
5129        let mut engine = Engine::from_mounts(vec![(
5130            archive_mount("ext", archive_path.clone()),
5131            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
5132        )])
5133        .unwrap();
5134
5135        let default_pin: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
5136        let attempts: Vec<(&str, EngineError)> = vec![
5137            (
5138                "set_mem_schema",
5139                engine.set_mem_schema("ext", &default_pin).unwrap_err(),
5140            ),
5141            (
5142                "set_mem_version",
5143                engine
5144                    .set_mem_version("ext", semver::Version::new(9, 9, 9), None)
5145                    .unwrap_err(),
5146            ),
5147            (
5148                "set_mem_description",
5149                engine
5150                    .set_mem_description("ext", Some("x".into()), None)
5151                    .unwrap_err(),
5152            ),
5153            (
5154                "set_mem_title",
5155                engine
5156                    .set_mem_title("ext", Some("x".into()), None)
5157                    .unwrap_err(),
5158            ),
5159            (
5160                "set_mem_subject",
5161                engine.set_mem_subject("ext", None, None).unwrap_err(),
5162            ),
5163            (
5164                "set_mem_internal",
5165                engine.set_mem_internal("ext", true, None).unwrap_err(),
5166            ),
5167            (
5168                "set_mem_sync_state",
5169                engine
5170                    .set_mem_sync_state("ext", "k", "t", None)
5171                    .unwrap_err(),
5172            ),
5173        ];
5174        for (setter, err) in attempts {
5175            match err {
5176                EngineError::ReadOnlyMount(v) => {
5177                    assert_eq!(v, "ext", "{setter} must name the refused mem")
5178                }
5179                other => panic!("{setter} must refuse ReadOnlyMount, got {other:?}"),
5180            }
5181        }
5182    }
5183}