Skip to main content

memstead_base/engine/
lifecycle.rs

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