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