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