Skip to main content

memstead_base/engine/
boot.rs

1//! Engine construction — `from_mounts*` and `from_workspace_root`.
2//!
3//! `from_mounts` is the in-process constructor every test, in-process
4//! embedder, and the MCP filesystem server reach through.
5//! `from_workspace_root` is the lean boot helper that produces the
6//! same engine from a workspace root; the full counterpart lives in
7//! `memstead_git_branch::engine_from_workspace_root` and follows the same
8//! shape with the git-branch backend added to the factory.
9//!
10//! Free helpers in this module materialise the workspace schemas
11//! catalogue, walk each mount's backend at load-time, and synthesise
12//! the [`MemRouterSnapshot`] from the resolved mount list — pieces
13//! the two entry points share.
14
15use std::cell::OnceCell;
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22use crate::backend::MemBackend;
23use crate::engine_fallback_type;
24use crate::entity::loader::parse_entries;
25use crate::entity::source::{SourceEntry, SourceReadError};
26use crate::entity::store_builder::push_entities_into_store;
27use crate::mem::{MemOrigin, MemRouterSnapshot};
28use crate::ops::WarningHint;
29use crate::store::Store;
30use crate::workspace::{Mount, MountCapability, MountStorage, WorkspaceSettings};
31
32use super::{BootError, Engine, EngineError, MountedBackend};
33
34impl Engine {
35    /// Build an engine from `(mount, backend)` pairs. The backend
36    /// is the implementor that will serve reads / writes for that
37    /// mount's mem.
38    ///
39    /// Returns [`EngineError::DuplicateMem`] when two mounts name
40    /// the same mem; that's a configuration error the caller must
41    /// fix before the engine can route deterministically. An empty
42    /// mount list is allowed (returns an engine that errors
43    /// `UnknownMem` on every read) — useful for tests; production
44    /// callers will reject empty inputs at the persistence-adapter
45    /// layer.
46    pub fn from_mounts(mounts: Vec<(Mount, Box<dyn MemBackend>)>) -> Result<Self, EngineError> {
47        Self::from_mounts_inner(mounts, Vec::new(), Vec::new())
48    }
49
50    /// Construct an engine from mounts plus an optional workspace
51    /// schemas directory. Loads every subdirectory of `schemas_dir`
52    /// as a workspace-authored schema and combines with the builtin
53    /// catalogue for per-mem schema-pin resolution. Workspace
54    /// schemas take precedence on (name, version) collision —
55    /// matches full's behaviour.
56    ///
57    /// `schemas_dir = None` is equivalent to [`Self::from_mounts`].
58    /// Used by `engine_from_workspace_root` to thread the
59    /// `[schemas_dir]` workspace-toml entry into schema resolution.
60    pub fn from_mounts_with_schemas_dir(
61        mounts: Vec<(Mount, Box<dyn MemBackend>)>,
62        schemas_dir: Option<&Path>,
63    ) -> Result<Self, EngineError> {
64        let (extra_schemas, failed) = load_workspace_schemas_with_failures(schemas_dir);
65        Self::from_mounts_inner(mounts, extra_schemas, failed)
66    }
67
68    /// Like [`Self::from_mounts_with_schemas_dir`] but layers additional,
69    /// pre-loaded local-storage schemas (e.g. those a git-branch backend
70    /// reads from its `__MEMSTEAD:schemas/` ref via `SchemaSource`) on
71    /// top of the folder `schemas_dir` set. Both are local-storage
72    /// schemas — they override built-ins on `(name, version)` collision.
73    /// The git-branch boot path uses this to make ref-installed schemas
74    /// resolvable, which `from_mounts_with_schemas_dir` (folder only)
75    /// does not.
76    pub fn from_mounts_with_schemas_dir_and_extra(
77        mounts: Vec<(Mount, Box<dyn MemBackend>)>,
78        schemas_dir: Option<&Path>,
79        mut extra: Vec<Arc<memstead_schema::Schema>>,
80    ) -> Result<Self, EngineError> {
81        let (mut local, failed) = load_workspace_schemas_with_failures(schemas_dir);
82        local.append(&mut extra);
83        Self::from_mounts_inner(mounts, local, failed)
84    }
85
86    pub(crate) fn from_mounts_inner(
87        mounts: Vec<(Mount, Box<dyn MemBackend>)>,
88        extra_schemas: Vec<Arc<memstead_schema::Schema>>,
89        failed_schema_packages: Vec<FailedSchemaPackage>,
90    ) -> Result<Self, EngineError> {
91        let mut seen: std::collections::HashSet<String> =
92            std::collections::HashSet::with_capacity(mounts.len());
93        let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
94        for (mount, backend) in mounts {
95            if !seen.insert(mount.mem.clone()) {
96                return Err(EngineError::DuplicateMem(mount.mem));
97            }
98            // Seed the per-mount drift baseline. A backend that
99            // doesn't track HEAD (folder, archive) returns Ok(None)
100            // — drift detection is then a no-op for the mount. A
101            // probe failure during init falls back to None so a
102            // later successful probe can establish the baseline.
103            let last_known_head = backend.current_head().ok().flatten();
104            // Load the per-mem `.memstead/config.json` via the
105            // backend trait. Each backend resolves its own
106            // canonical location (folder: `<root>/.memstead/config.json`;
107            // archive: inside the zip; git-branch:
108            // `__MEMSTEAD:mems/<leaf>/config.json`). Read failures
109            // or missing files surface as
110            // `None` — `memstead_health` accommodates the missing-config
111            // case (handler emits empty `writeGuidance` + `extra`).
112            let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
113                let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
114                memstead_schema::config::parse_mem_config(&value).ok()
115            });
116            // Read the optional authoring-provenance payload the archive
117            // carries (`.memstead/provenance.json`). A malformed payload is
118            // downgraded to `None` (the member is additive — a parse
119            // failure means "provenance absent", not "mount failed").
120            let archive_provenance =
121                backend
122                    .read_archive_provenance()
123                    .ok()
124                    .flatten()
125                    .and_then(|bytes| {
126                        memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
127                    });
128            mounted.push(MountedBackend {
129                mount,
130                backend,
131                last_known_head,
132                mem_config,
133                archive_provenance,
134                // Set below, after the schema pin resolves: a lazy mount
135                // whose METADATA half fails still quarantines at boot;
136                // only the entity load defers.
137                deferred: false,
138            });
139        }
140
141        // Walk each backend, parse entries, populate one shared Store.
142        // Resolve each mount's schema pin against the built-in schema
143        // catalogue. The schema-registry resolver (which would also
144        // honor workspace-authored schemas living inside the storage
145        // backend) lands as a separate plan; this resolution closes
146        // the gap for the built-in catalogue so a workspace pinning
147        // a non-default built-in (e.g. `software`, `memory`) surfaces
148        // the right schema rather than silently downgrading to
149        // `default`.
150        let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
151            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
152        // Workspace-authored schemas resolve first (override builtins
153        // on (name, version) collision); builtins fill the rest.
154        let workspace_schemas = extra_schemas.clone();
155        let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
156            Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
157        catalogue.extend(extra_schemas);
158        catalogue.extend(builtin_schemas_only.clone());
159        let builtin_schemas = catalogue;
160        let mut store = Store::new();
161        let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
162        let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
163        let fallback = engine_fallback_type();
164
165        // Derive the mem roster + last-segment suffixes ONCE so the
166        // per-mount load loop hands the same view to every
167        // `LoadCollector`. `known_suffixes` is the input the
168        // nested-prefix detector compares against; the full
169        // `mem_names` list feeds the two-pass cross-mem resolver
170        // in `push_entities_into_store`.
171        let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
172        let known_suffixes: Vec<String> = mem_names
173            .iter()
174            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
175            .collect();
176        let mut load_warnings: Vec<WarningHint> = Vec::new();
177
178        // Mem-level failures quarantine the mem instead of failing the
179        // workspace (degrade, never disappear — plenum/expertise
180        // 2026-08-06/07, where one broken mem took every healthy
181        // sibling offline). Nothing is weakened: everything that
182        // failed the boot still fails it, the blast radius shrinks to
183        // the one mem, which serves nothing until repaired + reloaded.
184        let mut quarantined: Vec<crate::engine::QuarantinedMem> = Vec::new();
185        let mut quarantined_idx: std::collections::HashSet<usize> =
186            std::collections::HashSet::new();
187        // Mounts whose entity load is DEFERRED (`lifecycle: lazy`): the
188        // metadata half above and the schema resolution below still run
189        // at boot — the roster must know the mem exists, with its pin —
190        // but the entity walk is skipped until the first operation that
191        // needs the mem triggers [`Engine::ensure_mems_loaded`].
192        let mut deferred_idx: std::collections::HashSet<usize> = std::collections::HashSet::new();
193
194        for (m_idx, m) in mounted.iter().enumerate() {
195            // Schema-pin authority: the mem's own per-mem config is
196            // the authoritative settled pin, so a copied or cloned mem
197            // resolves its schema from its own backend without consulting
198            // this workspace's `mounts.json`. `Mount.schema` (the mount
199            // record's pin) is the fallback when the config carries no
200            // schema, and an expectation assertion when it does — a
201            // disagreement surfaces a `SchemaPinMismatch` warning rather
202            // than silently preferring either.
203            let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
204            let mount_pin = m.mount.schema.as_ref();
205            // `Mount.schema` is an optional expectation assertion: warn
206            // only when it is set *and* disagrees with the authoritative
207            // config pin.
208            if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
209                && cfg != mp
210            {
211                load_warnings.push(WarningHint::SchemaPinMismatch {
212                    mem: m.mount.mem.clone(),
213                    config_pin: cfg.as_display(),
214                    mount_pin: mp.as_display(),
215                });
216            }
217            // Boot-honesty skew check: a mem whose engine-owned
218            // mutation stamp names a different engine version than
219            // this binary gets a warn-tier hint — informative, never
220            // fatal, and a stamp-less (pre-stamp) mem is silent by
221            // construction. Read-only: the stamp is only ever
222            // rewritten by the next mutation. Full build versions
223            // (semver + git build sha) compare as full strings, so a
224            // rebuild between mutations fires the hint even between
225            // releases; a plain-semver stamp from an older binary
226            // comparing against a sha-carrying build fires too — that
227            // is desired, no migration.
228            if let Some(stamp) = m
229                .mem_config
230                .as_ref()
231                .and_then(|c| c.mutation_stamp.as_ref())
232                && stamp.engine_version != crate::build_info::full_version()
233            {
234                load_warnings.push(WarningHint::EngineVersionSkew {
235                    mem: m.mount.mem.clone(),
236                    stamped_engine: stamp.engine_version.clone(),
237                    running_engine: crate::build_info::full_version().to_string(),
238                    stamped_schema: stamp.schema.clone(),
239                });
240            }
241            // Authoritative pin first (the backend config), then the
242            // mount assertion as fallback when the config carries none.
243            let settled_pin = config_pin.or(mount_pin);
244            // Dual-pin: a mem mid-migration validates against the
245            // migration target, not the settled pin.
246            let Some(effective_pin) = m.mount.migration_target.as_ref().or(settled_pin) else {
247                // Missing pin: quarantine, don't abort the workspace.
248                let e = EngineError::MemConfigIncomplete {
249                    mem: m.mount.mem.clone(),
250                    missing_fields: vec!["schema".to_string()],
251                };
252                quarantined.push(crate::engine::QuarantinedMem {
253                    mount: m.mount.clone(),
254                    reason_code: e.code().to_string(),
255                    reason_message: e.to_string(),
256                });
257                quarantined_idx.insert(m_idx);
258                continue;
259            };
260            let schema = match SchemaResolver::new(&builtin_schemas).resolve(effective_pin) {
261                Ok(schema) => schema,
262                Err(sources) => {
263                    // Unresolvable pin: the plenum failure class —
264                    // quarantine this mem, serve the rest. When the
265                    // pin names a workspace-authored package that
266                    // FAILED to load (e.g. one still on the retired
267                    // `propagating_relationships` key), that load
268                    // failure is the honest reason — not a generic
269                    // not-found.
270                    let failed = failed_schema_packages.iter().find(|f| {
271                        f.name.as_deref() == Some(effective_pin.name.as_str())
272                            && f.version
273                                .as_deref()
274                                .is_none_or(|v| v == effective_pin.version.to_string())
275                    });
276                    let (reason_code, reason_message) = match failed {
277                        Some(f) => (
278                            "SCHEMA_LOAD_FAILED".to_string(),
279                            format!(
280                                "schema package at {} failed to load: {}",
281                                f.path.display(),
282                                f.error
283                            ),
284                        ),
285                        None => {
286                            let e = EngineError::SchemaNotFound {
287                                mem: m.mount.mem.clone(),
288                                pin: effective_pin.as_display(),
289                                sources,
290                                install_hint: None,
291                            };
292                            (e.code().to_string(), e.to_string())
293                        }
294                    };
295                    quarantined.push(crate::engine::QuarantinedMem {
296                        mount: m.mount.clone(),
297                        reason_code,
298                        reason_message,
299                    });
300                    quarantined_idx.insert(m_idx);
301                    continue;
302                }
303            };
304            schemas.insert(m.mount.mem.clone(), schema.clone());
305
306            // Generation-behind hint (warn-tier, ungated, never
307            // blocking): the pin resolved from the BUILT-IN catalogue
308            // and the catalogue registers at least one strictly-higher
309            // version of the same name. Locally-installed
310            // (workspace-storage) pins are silent — the engine only
311            // knows generations for built-ins, and a local install
312            // shadowing a built-in (name, version) counts as local
313            // (that is also the resolver's precedence). Real semver
314            // ordering via `semver::Version`, never string ordering.
315            let locally_installed = workspace_schemas.iter().any(|s| {
316                s.manifest.name == effective_pin.name && s.version == effective_pin.version
317            });
318            let is_builtin = builtin_schemas_only.iter().any(|s| {
319                s.manifest.name == effective_pin.name && s.version == effective_pin.version
320            });
321            if !locally_installed
322                && is_builtin
323                && let Some(newest) =
324                    newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
325                && *newest > effective_pin.version
326            {
327                load_warnings.push(WarningHint::SchemaGenerationsBehind {
328                    mem: m.mount.mem.clone(),
329                    pinned: effective_pin.as_display(),
330                    newest: newest.to_string(),
331                });
332            }
333
334            // Sealed schemas keep loading even when they violate the
335            // heading round-trip rule new installs are refused for —
336            // the violation surfaces as a health finding here, never
337            // as a boot failure (refusing would brick the workspace).
338            if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
339                memstead_schema::check_section_heading_roundtrip(&schema)
340            {
341                let (name, version) = schema.id();
342                load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
343                    mem: m.mount.mem.clone(),
344                    schema_ref: format!("{name}@{version}"),
345                    violations: violations.iter().map(Into::into).collect(),
346                });
347            }
348
349            // Lazy lifecycle: everything above (config, provenance, pin
350            // resolution, schema warnings — the metadata half) ran; the
351            // entity walk is the expensive leg and defers to first read.
352            // A lazy mount with a broken pin still quarantined above —
353            // deferral never converts a metadata failure into silence.
354            if m.mount.lifecycle == crate::workspace::MountLifecycle::Lazy {
355                if let Some(w) = unbacked_mount_warning(&m.mount, m.backend.as_ref(), None) {
356                    load_warnings.push(w);
357                }
358                deferred_idx.insert(m_idx);
359                continue;
360            }
361
362            let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
363                Ok(pair) => pair,
364                Err(e) => {
365                    // Backend read failure: quarantine this mem, serve
366                    // the rest.
367                    quarantined.push(crate::engine::QuarantinedMem {
368                        mount: m.mount.clone(),
369                        reason_code: e.code().to_string(),
370                        reason_message: e.to_string(),
371                    });
372                    quarantined_idx.insert(m_idx);
373                    schemas.remove(&m.mount.mem);
374                    continue;
375                }
376            };
377            // A mount that resolves to nothing says so: a missing branch
378            // or folder lists as empty exactly like an empty one, and
379            // until 2026-08-23 both sat in the writable roster silently.
380            if let Some(w) =
381                unbacked_mount_warning(&m.mount, m.backend.as_ref(), Some(entries.len()))
382            {
383                load_warnings.push(w);
384            }
385            let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
386            // Wire the LoadCollector so the parser/store-builder
387            // pipeline forwards typed drift warnings
388            // (`SuspiciousNestedPrefix`, `DuplicateSectionHeading`,
389            // `InlineWikiLinkAutoStubbed`) into `load_warnings`.
390            // Mutation paths still pass `None` to stay silent.
391            push_entities_into_store(
392                &mut store,
393                load_result.entities,
394                fallback.as_ref(),
395                Some(crate::entity::store_builder::LoadCollector {
396                    warnings: &mut load_warnings,
397                    known_suffixes: &known_suffixes,
398                    mem_names: &mem_names,
399                }),
400            );
401            // Normalize folder-mount error paths to absolute (the
402            // backend walk yields mem-relative ones) so the per-mem
403            // reload can later replace exactly this mem's entries —
404            // a repaired file must stop reporting its old refusal.
405            if let crate::workspace::MountStorage::Folder { path } = &m.mount.storage {
406                let root = path.clone();
407                load_errors.extend(load_result.errors.into_iter().map(|(p, msg)| {
408                    let abs = if p.is_relative() { root.join(&p) } else { p };
409                    (abs, msg)
410                }));
411            } else {
412                load_errors.extend(load_result.errors);
413            }
414        }
415
416        // Stamp the deferred flags before the quarantine retain below
417        // renumbers the vector.
418        for idx in &deferred_idx {
419            mounted[*idx].deferred = true;
420        }
421
422        // Drop quarantined mounts from the serving roster: a
423        // quarantined mem has no backend in service, no entities in
424        // the store, no schema in the per-mem map — it exists only on
425        // the quarantine roster until repair + reload re-attach it.
426        if !quarantined_idx.is_empty() {
427            let mut keep_idx = 0usize;
428            mounted.retain(|_| {
429                let keep = !quarantined_idx.contains(&keep_idx);
430                keep_idx += 1;
431                keep
432            });
433        }
434
435        // Parse-time relation validation runs after every mount's
436        // entities are loaded so cross-mem target types are
437        // resolvable. Hand-edits, external tooling, and embedder
438        // editor surfaces can inject relations that bypass
439        // `memstead_relate`; this is the only place those get caught.
440        // Mutation paths pre-validate before writing, so they
441        // never trip the warning post-load.
442        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
443            mounted
444                .iter()
445                .map(|m| (m.mount.mem.clone(), m.mount.capability))
446                .collect();
447        crate::entity::store_builder::validate_loaded_relations(
448            &mut store,
449            &schemas,
450            &mount_caps,
451            &mut load_warnings,
452        );
453
454        // Stamp `EdgeSource::BodyLink` on edges whose rel-type matches
455        // the source mem's `alias_target_rel_type` pointer. Runs
456        // after `validate_loaded_relations` so the surviving relation
457        // set is schema-clean before the labeling pass.
458        crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
459
460        // The nested-prefix drift scan runs per mount, so a cross-mem
461        // link into a mem loaded LATER in the mount order probes an
462        // incomplete store and false-positives on a perfectly valid id
463        // (e.g. `registry--registry-service` referenced from a mem that
464        // mounts before `registry`). Now that every mount is loaded,
465        // drop any hit whose resolved target exists as a real entity —
466        // the same legitimate-cross-mem-reference exemption the
467        // in-batch scan already applies when load order permits.
468        load_warnings.retain(|w| match w {
469            WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
470                store.get(resolved_id).is_none_or(|e| e.stub)
471            }
472            _ => true,
473        });
474
475        // Derive the runtime mem router from the mount list.
476        // Mirrors full's `Engine::from_init` step that registers every
477        // mount with `MemRouterSnapshot` so handlers reach a
478        // consistent writable/visible roster regardless of which
479        // backend serves the mem.
480        let mem_router = build_mem_router_from_mounts(&mounted);
481
482        Ok(Self {
483            mounts: mounted,
484            store,
485            schemas,
486            workspace_schemas,
487            builtin_schemas: builtin_schemas_only,
488            load_errors,
489            community_memo: OnceCell::new(),
490            labelling_memo: OnceCell::new(),
491            #[cfg(not(target_arch = "wasm32"))]
492            search_indexes_memo: OnceCell::new(),
493            settings: WorkspaceSettings::default(),
494            create_rule_set_memo: OnceCell::new(),
495            declared_origins: HashMap::new(),
496            workspace_root: None,
497            load_warnings,
498            quarantined,
499            boot_diagnosis: None,
500            pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
501            mem_router: Arc::new(mem_router),
502            backend_factory: crate::workspace_store::instantiate_lean_backend,
503            unmounted_storage_prober: None,
504            schemas_epoch: 0,
505            git_branch_ops: None,
506            event_subscribers: Arc::new(std::sync::Mutex::new(
507                crate::engine::events::SubscriberRegistry::new(),
508            )),
509            pending_mem_changed: Vec::new(),
510            mutation_clock: Arc::new(std::time::SystemTime::now),
511            current_role: crate::vcs::Role::Unspecified,
512        })
513    }
514
515    /// Boot an engine from a workspace root using only lean-flavour
516    /// backends (folder + archive). The MCP filesystem server and the
517    /// CLI's lean dispatcher reach the new engine through this entry
518    /// point — replacing per-flavour init code with one call.
519    ///
520    /// Loads the workspace through [`crate::FileWorkspaceStore`],
521    /// instantiates each mount's backend via
522    /// [`crate::instantiate_lean_backend`], and constructs the
523    /// engine via [`Engine::from_mounts`].
524    ///
525    /// Errors:
526    /// - [`Layout::Empty`](crate::Layout) → [`BootError::NotInitialised`]
527    /// - any mount declaring [`crate::workspace::MountStorage::GitBranch`]
528    ///   → [`BootError::Instantiate`] wrapping
529    ///   [`crate::InstantiateError::GitBranchRequiresMemRepoFeature`]
530    /// - underlying store / engine failures lift through the
531    ///   `#[from]` conversions
532    pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
533        use crate::workspace_store::{
534            FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
535            instantiate_lean_backend,
536        };
537
538        let workspace = match detect_layout(workspace_root) {
539            // Standalone collapse: a bare folder mem (`.memstead/config.json`,
540            // no `workspace.toml`) roots as a one-mount workspace rather than
541            // refusing — the lone-mem boot path is the unified one.
542            Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
543                Some(ws) => ws,
544                None => {
545                    return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
546                }
547            },
548            Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
549        };
550
551        let settings = workspace.settings.clone();
552        let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
553            Vec::with_capacity(workspace.mounts.len());
554        // Backend-instantiation failures quarantine the mem instead of
555        // failing the workspace (degrade, never disappear); the roster
556        // entry lands on the engine after construction.
557        let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
558        for mount in workspace.mounts {
559            match instantiate_lean_backend(&mount) {
560                Ok(backend) => mounts.push((mount, backend)),
561                Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
562                    reason_code: e.code().to_string(),
563                    reason_message: e.to_string(),
564                    mount,
565                }),
566            }
567        }
568        // Folder-backend authoring path: authored schema packages live
569        // at the fixed `<workspace>/.memstead/schemas/<name>@<version>/`
570        // location — the folder analogue of the git-branch backend's
571        // `__MEMSTEAD:schemas/` ref. Read them through the folder
572        // `SchemaSource` (which no-ops when the directory is absent, so a
573        // workspace that authored no schemas resolves exactly as before —
574        // built-ins only). This is the lean flavour's schema-authoring
575        // path, which it lacked.
576        let fixed_dir = workspace_root.join(".memstead").join("schemas");
577        let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
578        // Root is known here, so an unresolved pin can be enriched with
579        // the never-installed-package hint before it surfaces.
580        let mut engine = Engine::from_mounts_inner(mounts, local, failed)
581            .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
582        engine.quarantined.extend(instantiate_quarantine);
583        engine.set_settings(settings);
584        engine.workspace_root = Some(workspace_root.to_path_buf());
585        // Load the workspace store's pipeline configs — the v2 single-record
586        // binding store — and expose them read-only. A malformed config
587        // surfaces a typed `StoreError::Parse` naming the file (early
588        // validation of operator-edited configs); an absent `projections/`
589        // directory resolves to empty. A pre-v2 store refuses boot with
590        // `StoreError::LegacyProjectionStore` naming `memstead projection
591        // migrate` — the engine never reads a prior generation (2026-07-18
592        // consolidation, no compatibility layer). The migrate command itself
593        // operates below engine boot, so an unmigrated workspace can still
594        // run it.
595        engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
596            workspace_root,
597        )?);
598        // Publish the authoring meta-schemas into `.memstead/meta-schemas/`
599        // so an editor validates authored schema YAML against them
600        // (resolved by each package's `# yaml-language-server:` directive).
601        // Best-effort — a read-only workspace still boots.
602        let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
603        Ok(engine)
604    }
605}
606
607/// Derive a [`MemRouterSnapshot`] from the engine's resolved mount
608/// list. Mirrors full's `Engine::from_init` mount-register loop so the
609/// runtime router carries the same writable/visible roster regardless
610/// of which backend serves each mem.
611///
612/// One pass over the mounts:
613/// - Writable mounts ([`MountCapability::Write`]) register via
614///   `add_writable` with the storage's worktree path. Folder mounts
615///   surface `MountStorage::Folder.path`; git-branch mounts surface
616///   `None` (the mem content lives only inside the gitdir).
617///   Archive mounts should never be writable; if one slips through,
618///   it registers with `dir: None`.
619/// - Read-only folder / git-branch mounts also register via
620///   `add_writable` with `dir: None`, then are *visible-only* —
621///   `is_writable` returns `false` because we follow up with a
622///   `remove_writable` (no-op for archives because archives are
623///   registered as `add_read_only`).
624///
625/// Actually we keep it simple: writable mounts go through
626/// `add_writable`; read-only mounts go through `add_read_only` with
627/// a synthesized archive-style path. For folder/git-branch read-only
628/// mounts we use the path the storage offers as the archive_path
629/// argument — semantically wrong but the router treats
630/// `add_read_only` data as opaque for visibility tracking. The two
631/// callers that care (`archive_path_for_mem`, `dir_for_mem`)
632/// branch on backend type at the handler level rather than reading
633/// these synthesized paths.
634///
635/// Origin is `MemOrigin::ExplicitToml` for every mount built from
636/// `Workspace.mounts` — the file-adapter case. `RuntimeCreated`
637/// origins land when `memstead_mem_create` migrates onto the unified
638/// engine and produces fresh runtime registrations.
639pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
640    let mut router = MemRouterSnapshot::new();
641    for m in mounts {
642        match m.mount.capability {
643            MountCapability::Write => {
644                let dir: Option<PathBuf> = match &m.mount.storage {
645                    MountStorage::Folder { path } => Some(path.clone()),
646                    MountStorage::GitBranch { .. } => None,
647                    MountStorage::Archive { .. } => None,
648                    // In-memory mounts have no on-disk working dir —
649                    // they register writable with `dir: None`, the same
650                    // shape mem-repo-backed mounts use.
651                    MountStorage::InMemory => None,
652                };
653                router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
654            }
655            MountCapability::ReadOnly => match &m.mount.storage {
656                MountStorage::Archive { path } => {
657                    router.add_read_only(m.mount.mem.clone(), path.clone());
658                }
659                MountStorage::Folder { path } => {
660                    router.add_read_only(m.mount.mem.clone(), path.clone());
661                }
662                MountStorage::GitBranch { gitdir, .. } => {
663                    router.add_read_only(m.mount.mem.clone(), gitdir.clone());
664                }
665                // A read-only in-memory mount has no on-disk read
666                // source to register. The engine never produces this
667                // configuration (in-memory mounts are created writable
668                // for ephemeral sessions); handled here only to keep
669                // the match total.
670                MountStorage::InMemory => {}
671            },
672        }
673    }
674    router
675}
676
677/// Public re-export of [`resolve_builtin_schema_pin`] for lifecycle
678/// orchestrators in `memstead-engine`. Mirrors full's
679/// `resolve_mem_schema` against the built-in catalogue;
680/// workspace-schema-registry resolution lifts later.
681pub fn resolve_builtin_schema_pin_pub(
682    pin: &memstead_schema::SchemaRef,
683    catalogue: &[Arc<memstead_schema::Schema>],
684) -> Option<Arc<memstead_schema::Schema>> {
685    resolve_builtin_schema_pin(pin, catalogue)
686}
687
688/// The newest version registered in the built-in catalogue under
689/// `name` — real `semver::Version` ordering (0.10.0 beats 0.9.0),
690/// never string ordering. `None` when no built-in carries the name.
691/// Feeds the `SCHEMA_GENERATIONS_BEHIND` boot hint.
692fn newest_builtin_version<'a>(
693    name: &str,
694    builtins: &'a [Arc<memstead_schema::Schema>],
695) -> Option<&'a semver::Version> {
696    builtins
697        .iter()
698        .filter(|s| s.manifest.name == name)
699        .map(|s| &s.version)
700        .max()
701}
702
703/// The engine's schema-pin resolver — the single named entry point a
704/// load path resolves a `name@version` pin through. Consults schema
705/// sources in a fixed order: **local storage** (the mem's own storage
706/// backend — folder `.memstead/schemas/` or the git-branch
707/// `__MEMSTEAD:schemas/` ref, layered first into the catalogue so it
708/// wins on `(name, version)` collision), **built-in** (compiled into the
709/// binary), **remote** (memstead.io, reserved, not implemented). The
710/// order is fixed in code — local-over-built-in by the catalogue's
711/// insertion precedence, remote always last. On a miss it yields the
712/// per-source [`SchemaSourceDiagnostic`] trail the `SCHEMA_NOT_FOUND`
713/// envelope carries.
714///
715/// Holds a borrowed view of the merged catalogue (`local ⧺ built-in`)
716/// the boot / register paths assemble, so resolution allocates nothing.
717pub struct SchemaResolver<'a> {
718    catalogue: &'a [Arc<memstead_schema::Schema>],
719}
720
721impl<'a> SchemaResolver<'a> {
722    /// Wrap the merged resolution catalogue (workspace-authored schemas
723    /// layered over the built-in set, local winning on collision).
724    pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
725        Self { catalogue }
726    }
727
728    /// Resolve a pin to its schema, or the fixed-order source
729    /// diagnostics on a miss (fed straight into
730    /// `EngineError::SchemaNotFound`'s `sources`).
731    pub fn resolve(
732        &self,
733        pin: &memstead_schema::SchemaRef,
734    ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
735    {
736        resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
737            crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
738                &pin.name,
739                &pin.version,
740                self.catalogue,
741            )
742        })
743    }
744}
745
746/// Walk `schemas_dir` and load every immediate subdirectory as a
747/// workspace-authored schema. Each subdirectory must contain a
748/// `schema.yaml` manifest (and optional `types/*.yaml`) — silently
749/// skips entries that don't carry the manifest. `pub` so the folder
750/// `SchemaSource` and the below-boot repair path (memstead-git-branch)
751/// read through the same walker the boot path uses — one loader, no
752/// resolution fork between the booted and below-boot surfaces.
753pub fn load_workspace_schemas(
754    schemas_dir: Option<&Path>,
755) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
756    Ok(load_workspace_schemas_with_failures(schemas_dir).0)
757}
758
759/// One workspace-authored schema package that failed to load — the
760/// package is SKIPPED (never fails the boot; degrade, never
761/// disappear), and a mem pinning it quarantines with this failure as
762/// its typed reason. `name`/`version` are best-effort peeks at the
763/// package's `schema.yaml` header so the pin match works even though
764/// the full load refused.
765#[derive(Debug, Clone)]
766pub struct FailedSchemaPackage {
767    pub path: PathBuf,
768    pub name: Option<String>,
769    pub version: Option<String>,
770    /// The loader's typed failure, rendered.
771    pub error: String,
772}
773
774/// Tolerant form of [`load_workspace_schemas`]: broken packages are
775/// skipped and recorded instead of failing the whole walk (the
776/// historical `?` made one refusing package — e.g. a schema still on
777/// the retired `propagating_relationships` key after a binary
778/// upgrade — take every mem in the workspace down).
779pub fn load_workspace_schemas_with_failures(
780    schemas_dir: Option<&Path>,
781) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
782    let Some(dir) = schemas_dir else {
783        return (Vec::new(), Vec::new());
784    };
785    if !dir.is_dir() {
786        return (Vec::new(), Vec::new());
787    }
788    let entries = match std::fs::read_dir(dir) {
789        Ok(e) => e,
790        Err(_) => return (Vec::new(), Vec::new()),
791    };
792    let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
793    let mut failures: Vec<FailedSchemaPackage> = Vec::new();
794    for entry in entries.flatten() {
795        let path = entry.path();
796        if !path.is_dir() {
797            continue;
798        }
799        if !path.join("schema.yaml").is_file() {
800            continue;
801        }
802        match memstead_schema::load_schema_from_dir(&path) {
803            Ok(schema) => schemas.push(Arc::new(schema)),
804            Err(e) => {
805                // Best-effort header peek without a YAML dependency:
806                // top-level `name:` / `version:` are single-line
807                // scalars in every real package.
808                let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
809                let peek = |k: &str| {
810                    header
811                        .lines()
812                        .find_map(|l| l.strip_prefix(&format!("{k}:")))
813                        .map(|v| v.trim().trim_matches('"').to_string())
814                        .filter(|v| !v.is_empty())
815                };
816                failures.push(FailedSchemaPackage {
817                    path: path.clone(),
818                    name: peek("name"),
819                    version: peek("version"),
820                    error: e.to_string(),
821                });
822            }
823        }
824    }
825    (schemas, failures)
826}
827
828pub(super) fn resolve_builtin_schema_pin(
829    pin: &memstead_schema::SchemaRef,
830    catalogue: &[Arc<memstead_schema::Schema>],
831) -> Option<Arc<memstead_schema::Schema>> {
832    catalogue
833        .iter()
834        .find(|s| {
835            let id = s.id();
836            id.0 == pin.name && id.1 == pin.version
837        })
838        .cloned()
839}
840
841/// The `MOUNT_UNBACKED` probe for one mount: `Some(warning)` when the
842/// storage the mount names does not exist (`missing_ref` /
843/// `missing_path`, from [`MemBackend::storage_present`]) or, when
844/// `entity_count` is known and zero, holds no entity (`empty`). A
845/// probe failure reads as present — best-effort, never a boot failure.
846/// Lazy mounts pass `None` for the count: their walk is deferred, so
847/// only the storage half is judged at boot.
848pub(super) fn unbacked_mount_warning(
849    mount: &crate::workspace::Mount,
850    backend: &dyn MemBackend,
851    entity_count: Option<usize>,
852) -> Option<WarningHint> {
853    use crate::ops::MountUnbackedReason;
854    use crate::workspace::MountStorage;
855    let (location, missing_reason) = match &mount.storage {
856        MountStorage::GitBranch { branch, .. } => (branch.clone(), MountUnbackedReason::MissingRef),
857        MountStorage::Folder { path } => {
858            (path.display().to_string(), MountUnbackedReason::MissingPath)
859        }
860        MountStorage::Archive { path } => {
861            (path.display().to_string(), MountUnbackedReason::MissingPath)
862        }
863        MountStorage::InMemory => return None,
864    };
865    if !backend.storage_present().unwrap_or(true) {
866        return Some(WarningHint::MountUnbacked {
867            mem: mount.mem.clone(),
868            reason: missing_reason,
869            location,
870        });
871    }
872    if entity_count == Some(0) {
873        return Some(WarningHint::MountUnbacked {
874            mem: mount.mem.clone(),
875            reason: MountUnbackedReason::Empty,
876            location,
877        });
878    }
879    None
880}
881
882pub(super) fn collect_source_entries(
883    backend: &dyn MemBackend,
884) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
885    let paths = backend.list_entities()?;
886    let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
887    let mut errors: Vec<SourceReadError> = Vec::new();
888    for path in paths {
889        match backend.read_entity(&path) {
890            Ok(Some(bytes)) => match String::from_utf8(bytes) {
891                Ok(content) => entries.push(SourceEntry {
892                    relative_path: path.to_string_lossy().into_owned(),
893                    source_path: path.clone(),
894                    content,
895                }),
896                Err(e) => errors.push(SourceReadError {
897                    source_path: path,
898                    error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
899                }),
900            },
901            Ok(None) => {
902                // Listed-but-absent: list/read race. Skip silently.
903            }
904            Err(e) => errors.push(SourceReadError {
905                source_path: path,
906                error: std::io::Error::other(e.to_string()),
907            }),
908        }
909    }
910    Ok((entries, errors))
911}
912
913#[cfg(test)]
914mod tests {
915
916    use std::path::Path;
917
918    use memstead_schema::SchemaRef;
919    use tempfile::TempDir;
920
921    use crate::backend::MemBackend;
922    use crate::engine::test_helpers::*;
923    use crate::engine::{Engine, EngineError};
924    use crate::ops::WarningHint;
925    use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
926    use crate::vcs::CommitContext;
927    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
928
929    /// The unbacked-mount probe on the folder backend: a path that does
930    /// not exist is `missing_path`, an existing directory with no
931    /// entity is `empty`, a directory holding one entity is silent, and
932    /// a lazy mount (count unknown) is judged on storage presence only.
933    #[test]
934    fn unbacked_mount_probe_classes_missing_path_empty_and_present() {
935        use crate::engine::boot::unbacked_mount_warning;
936        use crate::ops::MountUnbackedReason;
937        let tmp = TempDir::new().unwrap();
938        let mount = |path: std::path::PathBuf| Mount {
939            mem: "probe".into(),
940            schema: None,
941            storage: MountStorage::Folder { path },
942            capability: MountCapability::Write,
943            lifecycle: MountLifecycle::Eager,
944            cross_linkable: true,
945            migration_target: None,
946        };
947
948        let gone = tmp.path().join("gone");
949        let backend = FilesystemMemWriter::new(gone.clone());
950        let w = unbacked_mount_warning(&mount(gone.clone()), &backend, Some(0))
951            .expect("a missing folder is unbacked");
952        match &w {
953            WarningHint::MountUnbacked {
954                mem,
955                reason,
956                location,
957            } => {
958                assert_eq!(mem, "probe");
959                assert_eq!(*reason, MountUnbackedReason::MissingPath);
960                assert_eq!(location, &gone.display().to_string());
961            }
962            other => panic!("unexpected variant: {other:?}"),
963        }
964        assert_eq!(w.code(), "MOUNT_UNBACKED");
965        let json = serde_json::to_value(&w).unwrap();
966        assert_eq!(json["details"]["reason"], "missing_path");
967        // Lazy: storage presence alone decides, and the folder is gone.
968        assert!(unbacked_mount_warning(&mount(gone), &backend, None).is_some());
969
970        let hollow = tmp.path().join("hollow");
971        std::fs::create_dir_all(&hollow).unwrap();
972        let backend = FilesystemMemWriter::new(hollow.clone());
973        let w = unbacked_mount_warning(&mount(hollow.clone()), &backend, Some(0))
974            .expect("an entity-less folder is unbacked");
975        assert_eq!(
976            serde_json::to_value(&w).unwrap()["details"]["reason"],
977            "empty"
978        );
979        // Lazy with the folder present: nothing to say at boot.
980        assert!(unbacked_mount_warning(&mount(hollow.clone()), &backend, None).is_none());
981        // One entity: silent.
982        assert!(unbacked_mount_warning(&mount(hollow), &backend, Some(1)).is_none());
983    }
984
985    /// The `SchemaResolver` resolves a pin against the catalogue and, on
986    /// a miss, yields the fixed-order (`local_storage` → `builtin` →
987    /// `remote`) source diagnostics the `SCHEMA_NOT_FOUND` envelope carries.
988    #[test]
989    fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
990        let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
991        let resolver = super::SchemaResolver::new(&catalogue);
992
993        let ok: SchemaRef = "default@1.0.0".parse().unwrap();
994        assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
995
996        let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
997        let sources = resolver.resolve(&miss).unwrap_err();
998        let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
999        assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1000        assert!(sources.iter().all(|s| !s.pinned_version_match));
1001    }
1002
1003    #[test]
1004    fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
1005        let engine = Engine::from_mounts(Vec::new()).unwrap();
1006        assert!(engine.mem_names().is_empty());
1007        match engine.list_entities("missing") {
1008            Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
1009            other => panic!("expected UnknownMem, got {other:?}"),
1010        }
1011    }
1012
1013    #[test]
1014    fn duplicate_mem_names_rejected_at_construction() {
1015        let tmp = TempDir::new().unwrap();
1016        let writer1: Box<dyn MemBackend> =
1017            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1018        let writer2: Box<dyn MemBackend> =
1019            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
1020        let err = Engine::from_mounts(vec![
1021            (folder_mount("specs", tmp.path().to_path_buf()), writer1),
1022            (folder_mount("specs", tmp.path().to_path_buf()), writer2),
1023        ])
1024        .unwrap_err();
1025        assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
1026    }
1027
1028    #[test]
1029    fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
1030        // A markdown file with the same `## Identity` heading twice
1031        // should cause the parser to emit a typed
1032        // `DuplicateSectionHeading` warning. With the
1033        // LoadCollector wiring, that warning lands on
1034        // `engine.load_warnings()`.
1035        let tmp = TempDir::new().unwrap();
1036        let mem_dir = tmp.path().to_path_buf();
1037        let body =
1038            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
1039        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
1040
1041        let writer = FilesystemMemWriter::new(mem_dir.clone());
1042        let engine = Engine::from_mounts(vec![(
1043            folder_mount("specs", mem_dir),
1044            Box::new(writer) as Box<dyn MemBackend>,
1045        )])
1046        .unwrap();
1047
1048        let warnings = engine.load_warnings();
1049        assert!(
1050            warnings
1051                .iter()
1052                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1053            "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
1054        );
1055    }
1056
1057    /// Generation-behind hint: a mem pinning an OLD built-in
1058    /// generation (`default@1.0.0`; the catalogue retains up to
1059    /// 1.2.0) boots with the warn-tier `SCHEMA_GENERATIONS_BEHIND`
1060    /// naming the pinned ref and the newest version — and the hint
1061    /// never blocks: the boot serves and mutations succeed. A mem
1062    /// pinning the NEWEST generation stays silent, so its health
1063    /// output is unchanged.
1064    #[test]
1065    fn generation_behind_hint_fires_for_old_builtin_pin_only() {
1066        // Old pin → hint, non-blocking.
1067        let tmp = TempDir::new().unwrap();
1068        let mem_dir = tmp.path().to_path_buf();
1069        let writer = FilesystemMemWriter::new(mem_dir.clone());
1070        let mut engine = Engine::from_mounts(vec![(
1071            folder_mount("specs", mem_dir),
1072            Box::new(writer) as Box<dyn MemBackend>,
1073        )])
1074        .unwrap();
1075        let behind: Vec<_> = engine
1076            .load_warnings()
1077            .iter()
1078            .filter_map(|w| match w {
1079                WarningHint::SchemaGenerationsBehind {
1080                    mem,
1081                    pinned,
1082                    newest,
1083                } => Some((mem.clone(), pinned.clone(), newest.clone())),
1084                _ => None,
1085            })
1086            .collect();
1087        assert_eq!(
1088            behind,
1089            vec![(
1090                "specs".to_string(),
1091                "default@1.0.0".to_string(),
1092                "1.3.0".to_string()
1093            )],
1094            "old built-in pin must surface the generation-behind hint"
1095        );
1096        assert!(
1097            engine
1098                .health()
1099                .warnings
1100                .iter()
1101                .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1102            "the hint rides health without an include gate"
1103        );
1104        // Never blocking: the warned mem still mutates.
1105        engine
1106            .create_entity_with_ctx(
1107                crate::engine::CreateEntityArgs {
1108                    anchors: Vec::new(),
1109                    mem: "specs".to_string(),
1110                    title: "Still writable".to_string(),
1111                    entity_type: "spec".to_string(),
1112                    sections: indexmap::IndexMap::from_iter([
1113                        ("identity".to_string(), "i".to_string()),
1114                        ("purpose".to_string(), "p".to_string()),
1115                    ]),
1116                    metadata: indexmap::IndexMap::new(),
1117                    relations: Vec::new(),
1118                    dry_run: false,
1119                },
1120                &crate::vcs::CommitContext::internal(),
1121            )
1122            .expect("generation-behind hint must never block mutations");
1123
1124        // Newest pin → silent (health output unchanged).
1125        let tmp = TempDir::new().unwrap();
1126        let mem_dir = tmp.path().to_path_buf();
1127        let writer = FilesystemMemWriter::new(mem_dir.clone());
1128        let mut mount = folder_mount("specs", mem_dir);
1129        mount.schema = Some("default@1.3.0".parse().unwrap());
1130        let engine =
1131            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1132        assert!(
1133            !engine
1134                .load_warnings()
1135                .iter()
1136                .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
1137            "newest built-in pin must stay silent: {:?}",
1138            engine.load_warnings()
1139        );
1140        let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
1141        assert!(
1142            !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
1143            "newest pin: health output carries no generation hint"
1144        );
1145    }
1146
1147    /// The newest-generation lookup uses real semver ordering — a
1148    /// two-digit minor beats a one-digit one (string ordering would
1149    /// invert them).
1150    #[test]
1151    fn newest_builtin_version_orders_by_semver_not_string() {
1152        let manifest = |version: &str| {
1153            format!(
1154                r#"name: gen-test
1155version: {version}
1156description: test
1157when_to_use: test
1158types:
1159  - note
1160relationships:
1161  mode: strict
1162  definitions:
1163    - name: _default
1164      description: default
1165      default_weight: 1.0
1166    - name: PART_OF
1167      description: hier
1168      default_weight: 3.0
1169community:
1170  resolution: 1.0
1171  seed: 42
1172"#
1173            )
1174        };
1175        let type_yaml = r#"name: note
1176description: test
1177when_to_use: test
1178sections:
1179  - key: body
1180    heading: Body
1181    required: true
1182    search_weight: 10.0
1183    catch_all: true
1184metadata_fields: []
1185title_weight: 1.0
1186text_fields: [body]
1187hierarchy_relationship: PART_OF
1188no_self_loop_relationships: []
1189updatable_fields: [title, body]
1190health_required_fields: [body]
1191staleness_threshold_days: 30
1192write_rules: []
1193"#;
1194        let types = vec![("note".to_string(), type_yaml.to_string())];
1195        let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1196            .iter()
1197            .map(|v| {
1198                std::sync::Arc::new(
1199                    memstead_schema::load_schema_from_memory(&manifest(v), &types)
1200                        .expect("fixture schema loads"),
1201                )
1202            })
1203            .collect();
1204        let newest = super::newest_builtin_version("gen-test", &catalogue)
1205            .expect("name present in catalogue");
1206        assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1207        assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1208    }
1209
1210    /// Parse-time relation validation drops relations whose `rel_type`
1211    /// is not declared in the source mem's strict-mode schema and
1212    /// emits `PARSED_RELATION_INVALID { reason: "unknown_rel_type" }`.
1213    /// The entity itself loads normally; only the bad relation goes
1214    /// missing from the in-memory store.
1215    #[test]
1216    fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1217        let tmp = TempDir::new().unwrap();
1218        let mem_dir = tmp.path().to_path_buf();
1219        // Hand-authored markdown with a `## Relationships` entry whose
1220        // type isn't declared in the default schema (strict mode).
1221        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1222        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1223        std::fs::write(mem_dir.join("target.md"), target).unwrap();
1224        std::fs::write(mem_dir.join("source.md"), source).unwrap();
1225
1226        let writer = FilesystemMemWriter::new(mem_dir.clone());
1227        let engine = Engine::from_mounts(vec![(
1228            folder_mount("specs", mem_dir),
1229            Box::new(writer) as Box<dyn MemBackend>,
1230        )])
1231        .unwrap();
1232
1233        let source_id = crate::entity::EntityId::new("specs", "source");
1234        let target_id = crate::entity::EntityId::new("specs", "target");
1235        let source_entity = engine.get_entity(&source_id).expect("source loaded");
1236        // The offending relation does not survive into the entity's
1237        // in-memory relationships list.
1238        assert!(
1239            source_entity.relationships.is_empty(),
1240            "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1241            source_entity.relationships,
1242        );
1243        // Nor into the store's edge index.
1244        let outgoing: Vec<_> = engine
1245            .store()
1246            .outgoing(&source_id)
1247            .iter()
1248            .filter(|e| e.rel_type == "MADE_UP_TYPE")
1249            .collect();
1250        assert!(
1251            outgoing.is_empty(),
1252            "MADE_UP_TYPE edge must be dropped from the store"
1253        );
1254        // The warning surfaces with the correct payload.
1255        let parsed_invalid: Vec<_> = engine
1256            .load_warnings()
1257            .iter()
1258            .filter_map(|w| match w {
1259                WarningHint::ParsedRelationInvalid {
1260                    entity_id,
1261                    rel_type,
1262                    target,
1263                    reason,
1264                    origin,
1265                    recovery,
1266                } => Some((
1267                    entity_id.clone(),
1268                    rel_type.clone(),
1269                    target.clone(),
1270                    reason.clone(),
1271                    origin.clone(),
1272                    recovery.clone(),
1273                )),
1274                _ => None,
1275            })
1276            .collect();
1277        assert_eq!(
1278            parsed_invalid.len(),
1279            1,
1280            "expected one warning, got {parsed_invalid:?}"
1281        );
1282        assert_eq!(parsed_invalid[0].0, source_id);
1283        assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1284        assert_eq!(parsed_invalid[0].2, target_id);
1285        assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1286        assert_eq!(parsed_invalid[0].4, "writable");
1287        // Writable-origin warnings carry the abstract recovery action.
1288        let recovery = parsed_invalid[0]
1289            .5
1290            .as_ref()
1291            .expect("writable-origin warning must carry recovery");
1292        assert_eq!(
1293            recovery.kind,
1294            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1295        );
1296        assert_eq!(recovery.source_id, parsed_invalid[0].0);
1297        assert_eq!(recovery.target_id, parsed_invalid[0].2);
1298        assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1299    }
1300
1301    /// Hand-edited markdown can inject a cycle in an `acyclic: true`
1302    /// rel-type's subgraph — the mutation surface's `would_cycle`
1303    /// guard never fires for that path. The boot validator's
1304    /// second pass finds the back-edge and drops it with
1305    /// `reason: "cycle"`. The entity itself loads normally; one of
1306    /// the two cycle-closing edges goes missing from the in-memory
1307    /// store; the other survives.
1308    #[test]
1309    fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1310        let tmp = TempDir::new().unwrap();
1311        let mem_dir = tmp.path().to_path_buf();
1312        // Mutual PART_OF — acyclic in the default schema. The
1313        // wiki-link grammar admits both as well-formed cross-
1314        // references, so only the cycle pass can catch this.
1315        let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1316        let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1317        std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1318        std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1319
1320        let writer = FilesystemMemWriter::new(mem_dir.clone());
1321        let engine = Engine::from_mounts(vec![(
1322            folder_mount("specs", mem_dir),
1323            Box::new(writer) as Box<dyn MemBackend>,
1324        )])
1325        .unwrap();
1326
1327        let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1328        let beta_id = crate::entity::EntityId::new("specs", "beta");
1329
1330        // Both entities are real — only the relation in the cycle
1331        // gets dropped.
1332        assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1333        assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1334
1335        // Exactly one of the two PART_OF edges survives — the cycle
1336        // is broken by dropping a single back-edge.
1337        let surviving: Vec<_> = engine
1338            .store()
1339            .all_entities()
1340            .flat_map(|e| {
1341                engine
1342                    .store()
1343                    .outgoing(&e.id)
1344                    .iter()
1345                    .filter(|edge| edge.rel_type == "PART_OF")
1346                    .map(|edge| (e.id.clone(), edge.target.clone()))
1347                    .collect::<Vec<_>>()
1348            })
1349            .collect();
1350        assert_eq!(
1351            surviving.len(),
1352            1,
1353            "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1354        );
1355
1356        // The warning surfaces with `reason: "cycle"` and names the
1357        // dropped pair.
1358        let cycle_drops: Vec<_> = engine
1359            .load_warnings()
1360            .iter()
1361            .filter_map(|w| match w {
1362                WarningHint::ParsedRelationInvalid {
1363                    entity_id,
1364                    rel_type,
1365                    target,
1366                    reason,
1367                    ..
1368                } if reason == "cycle" => {
1369                    Some((entity_id.clone(), rel_type.clone(), target.clone()))
1370                }
1371                _ => None,
1372            })
1373            .collect();
1374        assert_eq!(
1375            cycle_drops.len(),
1376            1,
1377            "exactly one cycle warning must fire, got {cycle_drops:?}",
1378        );
1379        // The dropped edge is one of the two PART_OF entries.
1380        let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1381        assert_eq!(dropped_rel_type, "PART_OF");
1382        let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1383        let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1384        assert!(
1385            is_alpha_to_beta || is_beta_to_alpha,
1386            "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1387        );
1388        // And the surviving edge isn't the same as the dropped one.
1389        assert_ne!(
1390            (&surviving[0].0, &surviving[0].1),
1391            (dropped_from, dropped_to),
1392            "surviving edge must differ from the dropped one",
1393        );
1394    }
1395
1396    /// The per-mount nested-prefix drift scan probes an incomplete
1397    /// store: a cross-mem link into a mem loaded LATER in the mount
1398    /// order can't see the real target yet and would false-positive on
1399    /// a perfectly valid id whose slug repeats its mem name (the
1400    /// `registry--registry-service` case). The post-load sweep must
1401    /// drop that hit — while a genuine drift link (target never
1402    /// materialises as a real entity) keeps its warning.
1403    #[test]
1404    fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1405        let tmp = TempDir::new().unwrap();
1406        let project_dir = tmp.path().join("project");
1407        let registry_dir = tmp.path().join("registry");
1408        std::fs::create_dir_all(&project_dir).unwrap();
1409        std::fs::create_dir_all(&registry_dir).unwrap();
1410
1411        // Mount 1 (loads first) links both a real later-loaded entity
1412        // and a genuinely missing one.
1413        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1414        std::fs::write(project_dir.join("source.md"), source).unwrap();
1415
1416        // Mount 2 (loads second) carries the real target whose slug
1417        // repeats its mem name — the shape the heuristic suspects.
1418        let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1419        std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1420
1421        let engine = Engine::from_mounts(vec![
1422            (
1423                folder_mount("project", project_dir.clone()),
1424                Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1425            ),
1426            (
1427                folder_mount("registry", registry_dir.clone()),
1428                Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1429            ),
1430        ])
1431        .unwrap();
1432
1433        let nested: Vec<_> = engine
1434            .load_warnings()
1435            .iter()
1436            .filter_map(|w| match w {
1437                WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1438                    Some(resolved_id.to_string())
1439                }
1440                _ => None,
1441            })
1442            .collect();
1443        assert!(
1444            !nested.contains(&"registry--registry-service".to_string()),
1445            "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1446        );
1447        assert!(
1448            nested.contains(&"registry--never-created".to_string()),
1449            "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1450        );
1451    }
1452
1453    /// Shape-invalid relations on a writable-origin mount get dropped
1454    /// with `reason: "shape"`; the warning carries a
1455    /// `remove_explicit_relation` recovery hint whose ids and rel-type
1456    /// mirror the warning's top-level fields. Same envelope shape as
1457    /// the `unknown_rel_type` reason — `reason` discriminates the
1458    /// cause; `recovery.kind` discriminates the action. Uses a
1459    /// synthetic schema with `source_types` / `target_types`
1460    /// constraints because the default schema's rel-types are
1461    /// unconstrained.
1462    #[test]
1463    fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1464        use crate::engine::test_helpers::write_schema_files_with_default_type;
1465
1466        let tmp = TempDir::new().unwrap();
1467        let schemas_dir = tmp.path().join("schemas");
1468        std::fs::create_dir_all(&schemas_dir).unwrap();
1469        // A schema declaring a single rel-type whose shape only
1470        // admits `actor -> doc`. The source markdown below uses
1471        // `doc -> doc`, which trips the shape validator.
1472        let manifest = r#"name: shape-test
1473version: 0.1.0
1474description: shape-constraint schema
1475when_to_use: tests
1476types:
1477  - doc
1478  - actor
1479relationships:
1480  mode: strict
1481  definitions:
1482    - name: OWNS
1483      description: actor owns doc
1484      default_weight: 1.0
1485      source_types: [actor]
1486      target_types: [doc]
1487    - name: _default
1488      description: fallback
1489      default_weight: 1.0
1490community:
1491  resolution: 1.0
1492  seed: 42
1493"#;
1494        write_schema_files_with_default_type(
1495            &schemas_dir,
1496            "shape-test",
1497            manifest,
1498            &["doc", "actor"],
1499        );
1500
1501        let mem_dir = tmp.path().join("mem");
1502        std::fs::create_dir_all(&mem_dir).unwrap();
1503        // Source is type `doc`; target is also type `doc`. The
1504        // declared `OWNS` rel-type expects `actor -> doc`, so the
1505        // shape check rejects this pair at load.
1506        let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1507        let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1508        std::fs::write(mem_dir.join("target.md"), target).unwrap();
1509        std::fs::write(mem_dir.join("source.md"), source).unwrap();
1510
1511        let writer = FilesystemMemWriter::new(mem_dir.clone());
1512        let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1513        let mount = Mount {
1514            mem: "specs".to_string(),
1515            schema: Some(pin),
1516            storage: MountStorage::Folder { path: mem_dir },
1517            capability: MountCapability::Write,
1518            lifecycle: MountLifecycle::Eager,
1519            cross_linkable: true,
1520            migration_target: None,
1521        };
1522        let engine = Engine::from_mounts_with_schemas_dir(
1523            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1524            Some(&schemas_dir),
1525        )
1526        .unwrap();
1527
1528        let source_id = crate::entity::EntityId::new("specs", "source");
1529        let target_id = crate::entity::EntityId::new("specs", "target");
1530
1531        let shape_drops: Vec<_> = engine
1532            .load_warnings()
1533            .iter()
1534            .filter_map(|w| match w {
1535                WarningHint::ParsedRelationInvalid {
1536                    entity_id,
1537                    rel_type,
1538                    target,
1539                    reason,
1540                    origin,
1541                    recovery,
1542                } if reason == "shape" => Some((
1543                    entity_id.clone(),
1544                    rel_type.clone(),
1545                    target.clone(),
1546                    origin.clone(),
1547                    recovery.clone(),
1548                )),
1549                _ => None,
1550            })
1551            .collect();
1552        assert_eq!(
1553            shape_drops.len(),
1554            1,
1555            "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1556            engine.load_warnings(),
1557        );
1558        let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1559            shape_drops.into_iter().next().unwrap();
1560        assert_eq!(drop_from, source_id);
1561        assert_eq!(drop_type, "OWNS");
1562        assert_eq!(drop_to, target_id);
1563        assert_eq!(drop_origin, "writable");
1564        // Recovery mirrors the warning's top-level fields and names
1565        // the abstract `remove_explicit_relation` action.
1566        let recovery = drop_recovery.expect("writable origin must carry recovery");
1567        assert_eq!(
1568            recovery.kind,
1569            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1570        );
1571        assert_eq!(recovery.source_id, source_id);
1572        assert_eq!(recovery.target_id, target_id);
1573        assert_eq!(recovery.rel_type, "OWNS");
1574    }
1575
1576    /// Read-only-origin warnings omit the recovery hint — the engine
1577    /// cannot rewrite a read-only mount's markdown, so no abstract
1578    /// action is available. The message field still names the
1579    /// operator-level path (uninstall the archive or accept the
1580    /// drift); structured consumers branch on `recovery.is_none()`.
1581    #[test]
1582    fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1583        let tmp = TempDir::new().unwrap();
1584        // Archive content with a `MADE_UP_TYPE` row that the schema
1585        // does not declare — parses to a `PARSED_RELATION_INVALID`
1586        // with `reason: "unknown_rel_type"` on a read-only mount.
1587        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1588        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1589        let archive_path = build_archive(
1590            tmp.path(),
1591            "ext",
1592            &[
1593                ("target.md", target.as_bytes()),
1594                ("source.md", source.as_bytes()),
1595            ],
1596        );
1597
1598        let engine = Engine::from_mounts(vec![(
1599            archive_mount("external", archive_path.clone()),
1600            Box::new(ArchiveBackend::new(archive_path)),
1601        )])
1602        .unwrap();
1603
1604        let invalid: Vec<_> = engine
1605            .load_warnings()
1606            .iter()
1607            .filter_map(|w| match w {
1608                WarningHint::ParsedRelationInvalid {
1609                    rel_type,
1610                    reason,
1611                    origin,
1612                    recovery,
1613                    ..
1614                } => Some((
1615                    rel_type.clone(),
1616                    reason.clone(),
1617                    origin.clone(),
1618                    recovery.clone(),
1619                )),
1620                _ => None,
1621            })
1622            .collect();
1623        assert_eq!(
1624            invalid.len(),
1625            1,
1626            "expected one parse-time drop on the readonly mount, got {invalid:?}",
1627        );
1628        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1629        assert_eq!(invalid[0].1, "unknown_rel_type");
1630        assert_eq!(invalid[0].2, "readonly");
1631        assert!(
1632            invalid[0].3.is_none(),
1633            "readonly-origin warning must omit the recovery hint, got {:?}",
1634            invalid[0].3,
1635        );
1636    }
1637
1638    #[test]
1639    fn load_on_init_populates_store_from_folder_mount() {
1640        // Real markdown content: minimal but parses cleanly against
1641        // the builtin default schema.
1642        let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1643
1644        let tmp = TempDir::new().unwrap();
1645        let mem_dir = tmp.path().to_path_buf();
1646        let writer = FilesystemMemWriter::new(mem_dir.clone());
1647        <FilesystemMemWriter as MemWriter>::write_entity(
1648            &writer,
1649            Path::new("hello.md"),
1650            body.as_bytes(),
1651        )
1652        .unwrap();
1653        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1654            .unwrap();
1655
1656        let engine = Engine::from_mounts(vec![(
1657            folder_mount("specs", mem_dir),
1658            Box::new(writer) as Box<dyn MemBackend>,
1659        )])
1660        .unwrap();
1661
1662        // Store is populated.
1663        assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1664        let id = crate::EntityId::new("specs", "hello");
1665        let entity = engine.get_entity(&id).expect("entity must be present");
1666        assert_eq!(entity.title, "Hello");
1667        assert_eq!(entity.entity_type, "spec");
1668        assert!(engine.load_errors().is_empty());
1669        // Schema map carries one entry per mount.
1670        assert_eq!(engine.schemas().len(), 1);
1671        assert!(engine.schemas().contains_key("specs"));
1672    }
1673
1674    #[test]
1675    fn load_on_init_populates_store_from_archive_mount() {
1676        let body =
1677            "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1678
1679        let tmp = TempDir::new().unwrap();
1680        let archive_path =
1681            build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1682
1683        let engine = Engine::from_mounts(vec![(
1684            archive_mount("external", archive_path.clone()),
1685            Box::new(ArchiveBackend::new(archive_path)),
1686        )])
1687        .unwrap();
1688
1689        let id = crate::EntityId::new("external", "from-archive");
1690        let entity = engine.get_entity(&id).expect("entity must be present");
1691        assert_eq!(entity.title, "From Archive");
1692        assert!(engine.load_errors().is_empty());
1693    }
1694
1695    #[test]
1696    fn load_on_init_populates_store_from_heterogeneous_mounts() {
1697        let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1698        let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1699
1700        let tmp = TempDir::new().unwrap();
1701
1702        let folder_dir = tmp.path().join("folder-mem");
1703        std::fs::create_dir_all(&folder_dir).unwrap();
1704        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1705        <FilesystemMemWriter as MemWriter>::write_entity(
1706            &folder_writer,
1707            Path::new("local.md"),
1708            folder_body.as_bytes(),
1709        )
1710        .unwrap();
1711        <FilesystemMemWriter as MemWriter>::commit(
1712            &folder_writer,
1713            "seed",
1714            &CommitContext::internal(),
1715        )
1716        .unwrap();
1717
1718        let archive_path = build_archive(
1719            tmp.path(),
1720            "external",
1721            &[("external.md", archive_body.as_bytes())],
1722        );
1723
1724        let engine = Engine::from_mounts(vec![
1725            (
1726                folder_mount("local", folder_dir),
1727                Box::new(folder_writer) as Box<dyn MemBackend>,
1728            ),
1729            (
1730                archive_mount("external", archive_path.clone()),
1731                Box::new(ArchiveBackend::new(archive_path)),
1732            ),
1733        ])
1734        .unwrap();
1735
1736        // Both mems' entities live in one shared store.
1737        assert_eq!(engine.store().len(), 2);
1738        assert!(
1739            engine
1740                .get_entity(&crate::EntityId::new("local", "local"))
1741                .is_some()
1742        );
1743        assert!(
1744            engine
1745                .get_entity(&crate::EntityId::new("external", "external"))
1746                .is_some()
1747        );
1748    }
1749
1750    #[test]
1751    fn load_on_init_collects_per_file_parse_errors_without_failing() {
1752        // One good file + one with malformed frontmatter — the parser
1753        // produces an error for the malformed file but the good one
1754        // still loads.
1755        let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1756        let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1757
1758        let tmp = TempDir::new().unwrap();
1759        let mem_dir = tmp.path().to_path_buf();
1760        let writer = FilesystemMemWriter::new(mem_dir.clone());
1761        <FilesystemMemWriter as MemWriter>::write_entity(
1762            &writer,
1763            Path::new("good.md"),
1764            good.as_bytes(),
1765        )
1766        .unwrap();
1767        <FilesystemMemWriter as MemWriter>::write_entity(
1768            &writer,
1769            Path::new("bad.md"),
1770            bad.as_bytes(),
1771        )
1772        .unwrap();
1773        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1774            .unwrap();
1775
1776        let engine = Engine::from_mounts(vec![(
1777            folder_mount("specs", mem_dir),
1778            Box::new(writer) as Box<dyn MemBackend>,
1779        )])
1780        .unwrap();
1781
1782        // The good entity is in the store; construction did not fail.
1783        assert!(
1784            engine
1785                .get_entity(&crate::EntityId::new("specs", "good"))
1786                .is_some(),
1787            "good.md must parse and reach the store"
1788        );
1789        // Either bad.md surfaces as a load error, or it parses
1790        // permissively — both are acceptable outcomes here. The
1791        // contract under test is "construction does not fail on a
1792        // single bad file".
1793        let bad_known_to_engine = engine
1794            .get_entity(&crate::EntityId::new("specs", "bad"))
1795            .is_some()
1796            || !engine.load_errors().is_empty();
1797        assert!(
1798            bad_known_to_engine,
1799            "bad.md must either parse or surface in load_errors"
1800        );
1801    }
1802
1803    #[test]
1804    fn empty_mount_list_yields_empty_store() {
1805        let engine = Engine::from_mounts(Vec::new()).unwrap();
1806        assert!(engine.store().is_empty());
1807        assert!(engine.schemas().is_empty());
1808        assert!(engine.load_errors().is_empty());
1809    }
1810
1811    // ---- Engine::create_entity --------------------------------------
1812
1813    #[test]
1814    fn from_workspace_root_errors_for_empty_layout() {
1815        let tmp = TempDir::new().unwrap();
1816        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1817        match err {
1818            crate::BootError::NotInitialised(p) => {
1819                assert_eq!(p, tmp.path());
1820            }
1821            other => panic!("expected NotInitialised, got {other:?}"),
1822        }
1823    }
1824
1825    #[test]
1826    fn from_workspace_root_loads_new_two_layer_layout() {
1827        let tmp = TempDir::new().unwrap();
1828        let mem_dir = tmp.path().join("mem");
1829        std::fs::create_dir_all(&mem_dir).unwrap();
1830        std::fs::write(
1831            mem_dir.join("hello.md"),
1832            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1833        )
1834        .unwrap();
1835
1836        let memstead = tmp.path().join(".memstead");
1837        std::fs::create_dir_all(&memstead).unwrap();
1838        std::fs::write(
1839            memstead.join("workspace.toml"),
1840            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1841        )
1842        .unwrap();
1843        // Save the mount via the file adapter so the JSON shape matches
1844        // the wire format the loader expects.
1845        use crate::workspace_store::WorkspaceStoreAdapter;
1846        let store = crate::FileWorkspaceStore::new();
1847        store
1848            .save_state(
1849                tmp.path(),
1850                &crate::workspace::Workspace {
1851                    mounts: vec![folder_mount("specs", mem_dir)],
1852                    settings: crate::workspace::WorkspaceSettings::default(),
1853                },
1854            )
1855            .unwrap();
1856
1857        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1858        assert_eq!(engine.mem_names(), vec!["specs"]);
1859        let entity = engine
1860            .get_entity(&crate::EntityId::new("specs", "hello"))
1861            .expect("seeded entity must load through from_workspace_root");
1862        assert_eq!(entity.title, "Hello");
1863    }
1864
1865    /// The engine-side pipeline loader: with a workspace store carrying one
1866    /// v2 binding, the engine on boot enumerates it through its read-only
1867    /// queryable surface; a pre-v2 store refuses boot with the
1868    /// migrate-naming error (the loader never reads a prior generation).
1869    #[test]
1870    fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1871        use crate::pipeline::{MediumType, Projection};
1872        let tmp = TempDir::new().unwrap();
1873        let mem_dir = tmp.path().join("mem");
1874        std::fs::create_dir_all(&mem_dir).unwrap();
1875
1876        let memstead = tmp.path().join(".memstead");
1877        std::fs::create_dir_all(&memstead).unwrap();
1878        std::fs::write(
1879            memstead.join("workspace.toml"),
1880            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1881        )
1882        .unwrap();
1883        use crate::workspace_store::WorkspaceStoreAdapter;
1884        crate::FileWorkspaceStore::new()
1885            .save_state(
1886                tmp.path(),
1887                &crate::workspace::Workspace {
1888                    mounts: vec![folder_mount("specs", mem_dir)],
1889                    settings: crate::workspace::WorkspaceSettings::default(),
1890                },
1891            )
1892            .unwrap();
1893
1894        // One v2 binding in the store.
1895        crate::pipeline_store::write_binding(
1896            tmp.path(),
1897            "specs",
1898            "graph",
1899            &sample_v2_binding("specs"),
1900        )
1901        .unwrap();
1902
1903        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1904        let pc = engine.pipeline_configs();
1905        assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1906        assert_eq!(pc.bindings[0].mem, "specs");
1907        assert_eq!(pc.bindings[0].name, "graph");
1908        assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1909        assert_eq!(
1910            pc.bindings[0].config.sources[0].medium_type,
1911            MediumType::Codebase
1912        );
1913
1914        // QUARANTINE (agent-trust plan 04 re-routing of the historical
1915        // wholesale refusal): a pre-v2 (version-less gen-2) projection
1916        // file no longer fails the boot — the affected binding
1917        // quarantines with the migrate-naming reason, still never
1918        // read, still never tolerated; the workspace and the healthy
1919        // binding keep serving.
1920        crate::pipeline_store::write_projection(
1921            tmp.path(),
1922            "specs",
1923            "legacy",
1924            &Projection {
1925                intent: None,
1926                source_facets: vec!["view".to_string()],
1927                reference_mems: Vec::new(),
1928                destination_mem: "specs".to_string(),
1929                rules: None,
1930            },
1931        )
1932        .unwrap();
1933        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1934        let pc = engine.pipeline_configs();
1935        assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
1936        assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
1937        assert_eq!(pc.quarantined[0].name, "legacy");
1938        assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
1939        assert!(
1940            pc.quarantined[0]
1941                .reason_message
1942                .contains("memstead projection migrate"),
1943            "quarantine reason names the migrate command, got: {}",
1944            pc.quarantined[0].reason_message
1945        );
1946    }
1947
1948    /// One v2 binding with a single codebase source under `pointer` — the
1949    /// shared fixture of the boot tests.
1950    fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
1951        v2_binding_with_pointer(dest, "..")
1952    }
1953
1954    fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
1955        use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
1956        use crate::pipeline::{IngestTrigger, MediumType, Source};
1957        Binding {
1958            version: BINDING_VERSION,
1959            intent: None,
1960            sources: vec![Source {
1961                name: "src".to_string(),
1962                medium_type: MediumType::Codebase,
1963                pointer: pointer.to_string(),
1964                change_detection: None,
1965                scope: Vec::new(),
1966                engagement: None,
1967                preparation: None,
1968            }],
1969            reference_mems: Vec::new(),
1970            destination_mem: dest.to_string(),
1971            deny_paths: Vec::new(),
1972            coverage_semantics: None,
1973            rules: None,
1974            prune: None,
1975            operations: Operations {
1976                build: Some(BuildOperation {
1977                    mode: BuildMode::Discovery,
1978                    trigger: IngestTrigger::Loop,
1979                    batch_size: 10,
1980                    post_actions: None,
1981                }),
1982                sync: None,
1983                verify: None,
1984            },
1985        }
1986    }
1987
1988    /// Live per-anchor state (criteria 1, 9 — path-medium subset): a
1989    /// single-medium `path` mem observes working-tree existence at the current
1990    /// HEAD. Absent artifact ⇒ `orphaned`; present + non-hash class ⇒
1991    /// `resolves`; present + hash-bearing class ⇒ the prepared-content hash
1992    /// comparison adjudicates deterministically — a recorded hash matching
1993    /// the observed prepared form `resolves`, a stable-medium mismatch is
1994    /// `drifted` (a real content drift, no longer deferred to `recheck`).
1995    #[test]
1996    fn entity_anchors_resolve_live_state_for_path_medium() {
1997        use crate::anchor::{AnchorInput, AnchorState};
1998        use crate::vcs::Actor;
1999        use crate::workspace_store::WorkspaceStoreAdapter;
2000        use indexmap::IndexMap;
2001
2002        let tmp = TempDir::new().unwrap();
2003        let mem_dir = tmp.path().join("mem");
2004        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2005        std::fs::write(
2006            mem_dir.join(".memstead").join("config.json"),
2007            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2008        )
2009        .unwrap();
2010
2011        let memstead = tmp.path().join(".memstead");
2012        std::fs::create_dir_all(&memstead).unwrap();
2013        std::fs::write(
2014            memstead.join("workspace.toml"),
2015            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2016        )
2017        .unwrap();
2018        crate::FileWorkspaceStore::new()
2019            .save_state(
2020                tmp.path(),
2021                &crate::workspace::Workspace {
2022                    mounts: vec![folder_mount("specs", mem_dir.clone())],
2023                    settings: crate::workspace::WorkspaceSettings::default(),
2024                },
2025            )
2026            .unwrap();
2027
2028        // A single `path` source rooted at `<workspace>/src` (medium context
2029        // now derives from the mem's binding sources). Anchor artifact ids
2030        // are workspace-relative (pointer-prefixed) — the dialect
2031        // enumeration / coverage / advance share — so `src/present.rs`
2032        // observes present and `src/gone.rs` observes absent.
2033        crate::pipeline_store::write_binding(
2034            tmp.path(),
2035            "specs",
2036            "graph",
2037            &v2_binding_with_pointer("specs", "src"),
2038        )
2039        .unwrap();
2040        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
2041        std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
2042        // Exists at write time (the write gate refuses dead references);
2043        // deleted after the write to produce the orphaned READ state.
2044        std::fs::write(tmp.path().join("src").join("gone.rs"), "fn gone() {}").unwrap();
2045
2046        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2047
2048        let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
2049            artifact: Some(artifact.to_string()),
2050            grain: Some("file".to_string()),
2051            class: Some(class.to_string()),
2052            hash: hash.map(str::to_string),
2053            hash_stability: Some("stable".to_string()),
2054            ..Default::default()
2055        };
2056        let mut sections = IndexMap::new();
2057        sections.insert("identity".to_string(), "Covers src.".to_string());
2058        sections.insert("purpose".to_string(), "Track sources.".to_string());
2059        // The prepared-form hash of the present artifact, as the observation
2060        // computes it — an anchor recording it must resolve clean.
2061        let present_hash = crate::anchor::prepared_content_hash(
2062            &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
2063        );
2064        let created = engine
2065            .create_entity(
2066                crate::CreateEntityArgs {
2067                    mem: "specs".to_string(),
2068                    title: "Covers".to_string(),
2069                    entity_type: "spec".to_string(),
2070                    sections,
2071                    metadata: IndexMap::new(),
2072                    relations: Vec::new(),
2073                    anchors: vec![
2074                        anchor("src/present.rs", "anchored", Some(&present_hash)), // hash matches → resolves
2075                        anchor("src/present.rs", "informed-by", None), // present + non-hash → resolves
2076                        anchor("src/gone.rs", "anchored", Some("h2")), // absent → orphaned
2077                        anchor("src/present.rs", "derived", Some("stale")), // hash mismatch, stable → drifted
2078                    ],
2079                    dry_run: false,
2080                },
2081                Actor::Agent,
2082                None,
2083                None,
2084            )
2085            .unwrap();
2086
2087        std::fs::remove_file(tmp.path().join("src").join("gone.rs")).unwrap();
2088        let resolved = engine.entity_anchors_resolved(&created.id);
2089        assert_eq!(resolved.len(), 4);
2090        let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
2091            resolved
2092                .iter()
2093                .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
2094                .and_then(|r| r.state)
2095        };
2096        assert_eq!(
2097            state_of(
2098                "src/present.rs",
2099                crate::anchor::AnchorProvenanceClass::Anchored
2100            ),
2101            Some(AnchorState::Resolves),
2102            "recorded hash matches the observed prepared form → resolves"
2103        );
2104        assert_eq!(
2105            state_of(
2106                "src/present.rs",
2107                crate::anchor::AnchorProvenanceClass::Derived
2108            ),
2109            Some(AnchorState::Drifted),
2110            "recorded hash mismatches the observed prepared form on a stable medium → drifted"
2111        );
2112        assert_eq!(
2113            state_of(
2114                "src/present.rs",
2115                crate::anchor::AnchorProvenanceClass::InformedBy
2116            ),
2117            Some(AnchorState::Resolves),
2118            "present non-hash anchor resolves on existence"
2119        );
2120        assert_eq!(
2121            state_of(
2122                "src/gone.rs",
2123                crate::anchor::AnchorProvenanceClass::Anchored
2124            ),
2125            Some(AnchorState::Orphaned),
2126            "absent artifact is orphaned"
2127        );
2128    }
2129
2130    /// The engine edit surface: a wrapper edit (`add_projection_json`)
2131    /// routes through the pipeline-edit layer, writes the store, and
2132    /// refreshes the in-memory snapshot in place (no `reload()`); the JSON
2133    /// read counterpart reflects the collapsed `{bindings}`-only shape.
2134    #[test]
2135    fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
2136        use crate::workspace_store::WorkspaceStoreAdapter;
2137
2138        let tmp = TempDir::new().unwrap();
2139        let mem_dir = tmp.path().join("mem");
2140        std::fs::create_dir_all(&mem_dir).unwrap();
2141        let memstead = tmp.path().join(".memstead");
2142        std::fs::create_dir_all(&memstead).unwrap();
2143        std::fs::write(
2144            memstead.join("workspace.toml"),
2145            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2146        )
2147        .unwrap();
2148        crate::FileWorkspaceStore::new()
2149            .save_state(
2150                tmp.path(),
2151                &crate::workspace::Workspace {
2152                    mounts: vec![folder_mount("specs", mem_dir)],
2153                    settings: crate::workspace::WorkspaceSettings::default(),
2154                },
2155            )
2156            .unwrap();
2157
2158        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2159        assert!(engine.pipeline_configs().bindings.is_empty());
2160
2161        // The JSON entry point (the FFI-facing shape) deserializes and lands.
2162        engine
2163            .add_projection_json(
2164                "specs",
2165                "graph",
2166                r#"{
2167                    "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2168                                  "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2169                    "destination_mem": "specs"
2170                }"#,
2171                None,
2172            )
2173            .unwrap();
2174        // Snapshot refreshed in place.
2175        assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2176        assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2177        assert_eq!(
2178            engine.pipeline_configs().bindings[0].config.sources[0].name,
2179            "src"
2180        );
2181
2182        // A malformed payload is refused without touching the store.
2183        let err = engine
2184            .add_projection_json("specs", "bad", "{ not json", None)
2185            .unwrap_err();
2186        assert!(
2187            matches!(
2188                err,
2189                crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2190            ),
2191            "got {err:?}"
2192        );
2193
2194        // Update patches over the stored record; delete removes and refreshes.
2195        engine
2196            .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2197            .unwrap();
2198        assert_eq!(
2199            engine.pipeline_configs().bindings[0]
2200                .config
2201                .intent
2202                .as_deref(),
2203            Some("i2")
2204        );
2205
2206        // Rename moves the record and refreshes the snapshot.
2207        engine
2208            .rename_projection("specs", "graph", "graph2", None)
2209            .unwrap();
2210        assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2211
2212        // The JSON read counterpart reflects the live store in the
2213        // `{bindings}`-only shape — no `mediums` / `facets` keys.
2214        let json = engine.pipeline_configs_json();
2215        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2216        assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2217        assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2218        let bindings = parsed["bindings"].as_array().unwrap();
2219        assert_eq!(bindings.len(), 1);
2220        assert_eq!(bindings[0]["name"], "graph2");
2221        assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2222
2223        engine.delete_projection("specs", "graph2", None).unwrap();
2224        assert!(engine.pipeline_configs().bindings.is_empty());
2225    }
2226
2227    /// The lean folder authoring path: a schema package authored at the
2228    /// fixed `<workspace>/.memstead/schemas/<name>@<version>/` location
2229    /// is resolved at boot, so a folder mem can pin a non-built-in
2230    /// schema. Before this wiring `from_workspace_root` loaded only
2231    /// built-ins, so the pin would refuse with `SCHEMA_NOT_FOUND`.
2232    #[test]
2233    fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2234        use crate::engine::test_helpers::write_schema_files_with_default_type;
2235
2236        let tmp = TempDir::new().unwrap();
2237        let mem_dir = tmp.path().join("mem");
2238        std::fs::create_dir_all(&mem_dir).unwrap();
2239
2240        // Author a schema package at the fixed folder location.
2241        let authored_dir = tmp.path().join(".memstead").join("schemas");
2242        let manifest = r#"name: authored
2243version: 0.1.0
2244description: an authored-in-workspace test schema
2245when_to_use: tests
2246types:
2247  - doc
2248relationships:
2249  mode: strict
2250  definitions:
2251    - name: _default
2252      description: fallback
2253      default_weight: 1.0
2254community:
2255  resolution: 1.0
2256  seed: 42
2257"#;
2258        write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2259
2260        // A folder mem pinning the authored (non-built-in) schema.
2261        let memstead = tmp.path().join(".memstead");
2262        std::fs::create_dir_all(&memstead).unwrap();
2263        std::fs::write(
2264            memstead.join("workspace.toml"),
2265            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2266        )
2267        .unwrap();
2268        let mount = Mount {
2269            mem: "specs".to_string(),
2270            schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2271            storage: MountStorage::Folder { path: mem_dir },
2272            capability: MountCapability::Write,
2273            lifecycle: MountLifecycle::Eager,
2274            cross_linkable: true,
2275            migration_target: None,
2276        };
2277        use crate::workspace_store::WorkspaceStoreAdapter;
2278        crate::FileWorkspaceStore::new()
2279            .save_state(
2280                tmp.path(),
2281                &crate::workspace::Workspace {
2282                    mounts: vec![mount],
2283                    settings: crate::workspace::WorkspaceSettings::default(),
2284                },
2285            )
2286            .unwrap();
2287
2288        // Boots cleanly — the authored pin resolved against the fixed
2289        // location rather than refusing as an unknown built-in.
2290        let engine = Engine::from_workspace_root(tmp.path())
2291            .expect("authored schema at .memstead/schemas/ must resolve at boot");
2292        assert_eq!(engine.mem_names(), vec!["specs"]);
2293    }
2294
2295    /// Authoring-drift health axis (plan 10): a STAMPED sealed schema
2296    /// reports a missing authoring package and (separately) a diverged
2297    /// one; an unmodified package, a cosmetic-only difference (editor
2298    /// header comment lines), and an unstamped seal produce NO
2299    /// finding; and the checks alter neither copy.
2300    #[test]
2301    fn health_reports_authoring_drift_for_stamped_schemas_only() {
2302        use crate::engine::test_helpers::write_schema_files_with_default_type;
2303
2304        let tmp = TempDir::new().unwrap();
2305        let mem_dir = tmp.path().join("mem");
2306        std::fs::create_dir_all(&mem_dir).unwrap();
2307        let manifest = r#"name: authored
2308version: 0.1.0
2309description: an authored-in-workspace test schema
2310when_to_use: tests
2311types:
2312  - doc
2313relationships:
2314  mode: strict
2315  definitions:
2316    - name: _default
2317      description: fallback
2318      default_weight: 1.0
2319community:
2320  resolution: 1.0
2321  seed: 42
2322"#;
2323        // Sealed copy at the fixed install location; authoring copy in
2324        // the working tree.
2325        let sealed_root = tmp.path().join(".memstead").join("schemas");
2326        write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2327        let author_root = tmp.path().join("author");
2328        write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2329        let authoring_dir = author_root.join("authored@0.1.0");
2330        let sealed_dir = sealed_root.join("authored@0.1.0");
2331        // The install-time stamp: the seal records where it came from.
2332        let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2333        std::fs::write(
2334            &stamp_path,
2335            serde_json::to_vec_pretty(&serde_json::json!({
2336                "authoring_path": authoring_dir.display().to_string(),
2337            }))
2338            .unwrap(),
2339        )
2340        .unwrap();
2341
2342        let memstead = tmp.path().join(".memstead");
2343        std::fs::write(
2344            memstead.join("workspace.toml"),
2345            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2346        )
2347        .unwrap();
2348        let mount = Mount {
2349            mem: "specs".to_string(),
2350            schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2351            storage: MountStorage::Folder { path: mem_dir },
2352            capability: MountCapability::Write,
2353            lifecycle: MountLifecycle::Eager,
2354            cross_linkable: true,
2355            migration_target: None,
2356        };
2357        use crate::workspace_store::WorkspaceStoreAdapter;
2358        crate::FileWorkspaceStore::new()
2359            .save_state(
2360                tmp.path(),
2361                &crate::workspace::Workspace {
2362                    mounts: vec![mount],
2363                    settings: crate::workspace::WorkspaceSettings::default(),
2364                },
2365            )
2366            .unwrap();
2367        let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2368        let drift_codes = |e: &Engine| -> Vec<String> {
2369            e.health()
2370                .warnings
2371                .iter()
2372                .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2373                .map(|w| w.code().to_string())
2374                .collect()
2375        };
2376
2377        // Unmodified authoring package: no finding, and the check
2378        // touched neither copy.
2379        let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2380        let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2381        assert_eq!(drift_codes(&engine), Vec::<String>::new());
2382        assert_eq!(
2383            std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2384            sealed_before,
2385            "health must not touch the sealed copy"
2386        );
2387        assert_eq!(
2388            std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2389            author_before,
2390            "health must not touch the authoring copy"
2391        );
2392
2393        // Cosmetic-only difference (the CLI-injected editor-header
2394        // line + a comment): still no finding — parsed equivalence,
2395        // never raw bytes.
2396        std::fs::write(
2397            authoring_dir.join("schema.yaml"),
2398            format!(
2399                "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2400            ),
2401        )
2402        .unwrap();
2403        assert_eq!(drift_codes(&engine), Vec::<String>::new());
2404
2405        // Semantic change: DIVERGED, naming schema, version, and the
2406        // pinning mems.
2407        std::fs::write(
2408            authoring_dir.join("schema.yaml"),
2409            manifest.replace(
2410                "an authored-in-workspace test schema",
2411                "a semantically different description",
2412            ),
2413        )
2414        .unwrap();
2415        let warnings = engine.health().warnings;
2416        let diverged = warnings
2417            .iter()
2418            .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2419            .expect("semantic change must surface as DIVERGED");
2420        let d = serde_json::to_value(diverged).unwrap();
2421        assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2422        assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2423
2424        // Authoring package gone: the DIFFERENT finding — MISSING.
2425        std::fs::remove_dir_all(&authoring_dir).unwrap();
2426        let warnings = engine.health().warnings;
2427        let missing = warnings
2428            .iter()
2429            .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2430            .expect("vanished authoring package must surface as MISSING");
2431        let m = serde_json::to_value(missing).unwrap();
2432        assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2433        assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2434        assert_eq!(
2435            m["details"]["stamped_path"],
2436            authoring_dir.display().to_string()
2437        );
2438        assert!(
2439            !warnings
2440                .iter()
2441                .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2442            "missing and diverged are distinct findings"
2443        );
2444
2445        // No stamp → no finding, even with the package still gone.
2446        std::fs::remove_file(&stamp_path).unwrap();
2447        assert_eq!(drift_codes(&engine), Vec::<String>::new());
2448    }
2449
2450    /// Plan 12: `full_refresh` makes an out-of-band schema install and
2451    /// an out-of-band mem registration usable warm — additively.
2452    /// Removals are skipped and reported; a failed mount is reported
2453    /// per-item and does not abort the rest.
2454    #[test]
2455    fn full_refresh_is_additive_and_reports_skipped_removals() {
2456        use crate::engine::test_helpers::write_schema_files_with_default_type;
2457        use crate::workspace_store::WorkspaceStoreAdapter;
2458
2459        let tmp = TempDir::new().unwrap();
2460        let root = tmp.path();
2461        let mem_a = root.join("mem-a");
2462        std::fs::create_dir_all(&mem_a).unwrap();
2463        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2464        std::fs::write(
2465            root.join(".memstead").join("workspace.toml"),
2466            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2467        )
2468        .unwrap();
2469        let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2470            mem: mem.to_string(),
2471            schema: Some(SchemaRef::new(schema, version)),
2472            storage: MountStorage::Folder {
2473                path: dir.to_path_buf(),
2474            },
2475            capability: MountCapability::Write,
2476            lifecycle: MountLifecycle::Eager,
2477            cross_linkable: true,
2478            migration_target: None,
2479        };
2480        let save = |mounts: Vec<Mount>| {
2481            crate::FileWorkspaceStore::new()
2482                .save_state(
2483                    root,
2484                    &crate::workspace::Workspace {
2485                        mounts,
2486                        settings: crate::workspace::WorkspaceSettings::default(),
2487                    },
2488                )
2489                .unwrap();
2490        };
2491        save(vec![mount(
2492            "specs",
2493            &mem_a,
2494            "default",
2495            semver::Version::new(1, 0, 0),
2496        )]);
2497        let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2498        assert_eq!(engine.mem_names(), vec!["specs"]);
2499
2500        // --- Out of band, while the "server" runs: install a schema
2501        // and register a mem pinned to it. ---
2502        let manifest = r#"name: authored
2503version: 0.1.0
2504description: an out-of-band installed schema
2505when_to_use: tests
2506types:
2507  - doc
2508relationships:
2509  mode: strict
2510  definitions:
2511    - name: _default
2512      description: fallback
2513      default_weight: 1.0
2514community:
2515  resolution: 1.0
2516  seed: 42
2517"#;
2518        write_schema_files_with_default_type(
2519            &root.join(".memstead").join("schemas"),
2520            "authored@0.1.0",
2521            manifest,
2522            &["doc"],
2523        );
2524        let mem_b = root.join("mem-b");
2525        std::fs::create_dir_all(&mem_b).unwrap();
2526        save(vec![
2527            mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
2528            mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2529        ]);
2530
2531        // Refusal complement, pre-refresh: the running engine still
2532        // refuses — the refresh is what changes the outcome.
2533        let (actor, client) = cli_actor();
2534        let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
2535        pre.entity_type = "doc".to_string();
2536        pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2537        let err = engine
2538            .create_entity(pre.clone(), actor, Some(&client), None)
2539            .unwrap_err();
2540        assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
2541        assert!(
2542            !engine
2543                .workspace_schemas()
2544                .iter()
2545                .any(|s| s.id().0 == "authored"),
2546            "schema catalogue is fixed pre-refresh"
2547        );
2548
2549        // --- Full refresh: both become usable, warm. ---
2550        let report = engine.full_refresh();
2551        assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
2552        assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
2553        assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
2554        assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
2555        assert!(report.failures.is_empty(), "{report:?}");
2556        engine
2557            .create_entity(pre, actor, Some(&client), None)
2558            .expect("newly mounted mem accepts writes after the refresh");
2559
2560        // --- Removals do NOT take effect: drop `specs` from the
2561        // manifest and delete the schema package from its source. ---
2562        std::fs::remove_dir_all(
2563            root.join(".memstead")
2564                .join("schemas")
2565                .join("authored@0.1.0"),
2566        )
2567        .unwrap();
2568        save(vec![mount(
2569            "notes",
2570            &mem_b,
2571            "authored",
2572            semver::Version::new(0, 1, 0),
2573        )]);
2574        let report = engine.full_refresh();
2575        assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
2576        assert_eq!(
2577            report.schema_removals_skipped,
2578            vec!["authored@0.1.0".to_string()]
2579        );
2580        assert!(report.schemas_added.is_empty());
2581        assert!(report.mems_mounted.is_empty());
2582        // Both stay live: the unregistered mem still accepts writes,
2583        // the removed schema version still resolves for its mem.
2584        engine
2585            .create_entity(
2586                crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
2587                actor,
2588                Some(&client),
2589                None,
2590            )
2591            .expect("skipped-removal mem stays writable");
2592        let mut into_notes =
2593            crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
2594        into_notes.entity_type = "doc".to_string();
2595        into_notes.sections =
2596            indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2597        engine
2598            .create_entity(into_notes, actor, Some(&client), None)
2599            .expect("removed-from-source schema stays resolvable");
2600
2601        // --- Per-item failure: a manifest mount whose path is a FILE
2602        // fails alone; the rest of the refresh proceeds. ---
2603        let broken = root.join("broken-mem");
2604        std::fs::write(&broken, b"not a directory").unwrap();
2605        save(vec![
2606            mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2607            mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
2608        ]);
2609        let report = engine.full_refresh();
2610        assert!(
2611            report.failures.iter().any(|f| f.item == "mount:broken"),
2612            "failed mount must be reported per-item: {report:?}"
2613        );
2614        assert!(
2615            !report.mems_mounted.contains(&"broken".to_string()),
2616            "a failed mount never surfaces as newly available"
2617        );
2618        assert!(
2619            engine
2620                .get_entity(&crate::EntityId::new("broken", "anything"))
2621                .is_none()
2622                && engine.mem_names().contains(&"notes"),
2623            "other mounts unaffected"
2624        );
2625    }
2626
2627    #[test]
2628    fn from_workspace_root_propagates_mem_management_settings() {
2629        // workspace.toml carries [mem_management] rules; the file
2630        // adapter parses them into Workspace.settings; from_workspace_root
2631        // calls Engine::set_settings so the engine surface reflects them.
2632        // End-to-end check that the carriers, parser, and plumbing connect.
2633        let tmp = TempDir::new().unwrap();
2634        let mem_dir = tmp.path().join("mem");
2635        std::fs::create_dir_all(&mem_dir).unwrap();
2636
2637        let memstead = tmp.path().join(".memstead");
2638        std::fs::create_dir_all(&memstead).unwrap();
2639        std::fs::write(
2640            memstead.join("workspace.toml"),
2641            r#"format = "memstead-git-branch-2"
2642
2643[persistence_adapter]
2644name = "file-two-layer"
2645
2646[[mem_management.create]]
2647pattern = "exec-*"
2648schemas = ["default@1.0.0"]
2649
2650[[mem_management.delete]]
2651pattern = "exec-*"
2652"#,
2653        )
2654        .unwrap();
2655        use crate::workspace_store::WorkspaceStoreAdapter;
2656        let store = crate::FileWorkspaceStore::new();
2657        store
2658            .save_state(
2659                tmp.path(),
2660                &crate::workspace::Workspace {
2661                    mounts: vec![folder_mount("specs", mem_dir)],
2662                    settings: crate::workspace::WorkspaceSettings::default(),
2663                },
2664            )
2665            .unwrap();
2666
2667        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2668        let s = engine.settings();
2669        assert_eq!(s.mem_create_rules.len(), 1);
2670        assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
2671        assert_eq!(
2672            s.mem_create_rules[0].schemas,
2673            vec!["default@1.0.0".to_string()]
2674        );
2675        assert_eq!(s.mem_delete_rules.len(), 1);
2676        assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
2677    }
2678
2679    /// Deliberate replacement of the historical wholesale-abort test
2680    /// (`from_mounts_rejects_unknown_schema_pin_with_typed_error`,
2681    /// agent-trust plan 04): an unresolvable pin no longer fails the
2682    /// workspace — the mem is QUARANTINED with the same typed
2683    /// `SCHEMA_NOT_FOUND` reason (nothing is weakened, the blast
2684    /// radius shrinks), operations naming it refuse `MEM_QUARANTINED`,
2685    /// and the roster surfaces on health.
2686    #[test]
2687    fn from_mounts_quarantines_unknown_schema_pin() {
2688        let tmp = TempDir::new().unwrap();
2689        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
2690        let mount = Mount {
2691            mem: "specs".to_string(),
2692            schema: Some(SchemaRef::new(
2693                "totally-not-a-schema",
2694                semver::Version::new(1, 0, 0),
2695            )),
2696            storage: MountStorage::Folder {
2697                path: tmp.path().to_path_buf(),
2698            },
2699            capability: MountCapability::Write,
2700            lifecycle: MountLifecycle::Eager,
2701            cross_linkable: true,
2702            migration_target: None,
2703        };
2704        let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2705            .expect("a broken mem quarantines, never fails the workspace");
2706
2707        let roster = engine.quarantined_mems();
2708        assert_eq!(roster.len(), 1);
2709        assert_eq!(roster[0].mount.mem, "specs");
2710        assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
2711        assert!(
2712            roster[0].reason_message.contains("totally-not-a-schema"),
2713            "reason carries the failing pin: {}",
2714            roster[0].reason_message
2715        );
2716        // The mem serves nothing: it is not on the mount roster …
2717        assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
2718        // … and lookups refuse with the typed quarantine code, not
2719        // UNKNOWN_MEM.
2720        let err = engine.unknown_mem_error("specs");
2721        assert_eq!(err.code(), "MEM_QUARANTINED");
2722        assert!(
2723            err.to_string().contains("SCHEMA_NOT_FOUND"),
2724            "quarantine refusal carries the underlying reason: {err}"
2725        );
2726        // Health carries the roster without an include gate.
2727        let health = engine.health();
2728        assert_eq!(health.quarantined.len(), 1);
2729        assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
2730    }
2731
2732    /// Criterion 5 (agent-trust plan 04): quarantine → repair →
2733    /// reload returns the mem to service in the same engine instance;
2734    /// the roster entry disappears. The repair here is the same
2735    /// value-level config-pin rewrite `memstead mem set-schema`
2736    /// performs below boot (plan 03).
2737    #[test]
2738    fn reload_returns_repaired_mem_from_quarantine() {
2739        let tmp = TempDir::new().unwrap();
2740        let dir = tmp.path().to_path_buf();
2741        std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2742        let config_path = dir.join(".memstead").join("config.json");
2743        std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
2744        let writer = FilesystemMemWriter::new(dir.clone());
2745        let mount = Mount {
2746            mem: "specs".to_string(),
2747            schema: None,
2748            storage: MountStorage::Folder { path: dir },
2749            capability: MountCapability::Write,
2750            lifecycle: MountLifecycle::Eager,
2751            cross_linkable: true,
2752            migration_target: None,
2753        };
2754        let mut engine =
2755            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2756        assert_eq!(engine.quarantined_mems().len(), 1);
2757        // Un-repaired reload keeps the quarantine (refreshed reason,
2758        // typed refusal).
2759        let err = engine.reload_one_mem("specs").unwrap_err();
2760        assert_eq!(err.code(), "MEM_QUARANTINED");
2761        assert_eq!(engine.quarantined_mems().len(), 1);
2762
2763        // Repair: repin the config to a resolvable schema (what
2764        // `mem set-schema` does below boot), then reload.
2765        std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
2766        engine
2767            .reload_one_mem("specs")
2768            .expect("repaired mem re-attaches on reload");
2769        assert!(
2770            engine.quarantined_mems().is_empty(),
2771            "roster entry disappears after re-attach"
2772        );
2773        // …and the mem serves again in the same process.
2774        let mut sections = indexmap::IndexMap::new();
2775        sections.insert("identity".to_string(), "back".to_string());
2776        sections.insert("purpose".to_string(), "post-repair service".to_string());
2777        engine
2778            .create_entity_with_ctx(
2779                crate::engine::CreateEntityArgs {
2780                    anchors: Vec::new(),
2781                    mem: "specs".to_string(),
2782                    title: "Back".to_string(),
2783                    entity_type: "spec".to_string(),
2784                    sections,
2785                    metadata: indexmap::IndexMap::new(),
2786                    relations: Vec::new(),
2787                    dry_run: false,
2788                },
2789                &crate::vcs::CommitContext::internal(),
2790            )
2791            .expect("reattached mem serves writes");
2792    }
2793
2794    /// Criterion 2 complement (agent-trust plan 04): a healthy mem
2795    /// whose entity body wiki-links INTO a quarantined mem loads
2796    /// normally — the link degrades like any dangling cross-mem link
2797    /// (stub target), no cascade failure.
2798    #[test]
2799    fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
2800        let tmp = TempDir::new().unwrap();
2801        let healthy_dir = tmp.path().join("healthy");
2802        std::fs::create_dir_all(&healthy_dir).unwrap();
2803        std::fs::write(
2804            healthy_dir.join("linker.md"),
2805            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
2806             # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
2807        )
2808        .unwrap();
2809        let badpin_dir = tmp.path().join("badpin");
2810        std::fs::create_dir_all(&badpin_dir).unwrap();
2811        let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
2812            (
2813                Mount {
2814                    mem: mem.to_string(),
2815                    schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
2816                    storage: MountStorage::Folder { path: dir.clone() },
2817                    capability: MountCapability::Write,
2818                    lifecycle: MountLifecycle::Eager,
2819                    cross_linkable: true,
2820                    migration_target: None,
2821                },
2822                Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
2823            )
2824        };
2825        let engine = Engine::from_mounts(vec![
2826            mount("healthy", healthy_dir, "default"),
2827            mount("badpin", badpin_dir, "ghost"),
2828        ])
2829        .expect("boot survives the cross-mem link into the quarantined mem");
2830        assert_eq!(engine.quarantined_mems().len(), 1);
2831        // The linking entity loaded; its target degrades to a stub /
2832        // dangling link — no cascade, no partial-truth serving of the
2833        // quarantined mem.
2834        let linker = engine
2835            .get_entity(&crate::EntityId::new("healthy", "linker"))
2836            .expect("linking entity loads");
2837        assert_eq!(linker.entity_type, "spec");
2838        assert!(
2839            engine
2840                .get_entity(&crate::EntityId::new("badpin", "target"))
2841                .is_none_or(|e| e.stub),
2842            "the quarantined-side target is at most a stub, never real data"
2843        );
2844    }
2845
2846    /// Agent-trust plan 06, criterion 3 complement: a workspace where
2847    /// one mem pins an authored schema still on the retired
2848    /// `propagating_relationships` key boots — that mem quarantines
2849    /// with the rename error as its reason (never workspace-fatal),
2850    /// while healthy mems load and serve.
2851    #[test]
2852    fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
2853        let tmp = TempDir::new().unwrap();
2854        let root = tmp.path();
2855        // Workspace marker + two folder mems.
2856        std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
2857        std::fs::write(
2858            root.join(".memstead").join("workspace.toml"),
2859            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2860        )
2861        .unwrap();
2862        for m in ["healthy", "oldkey"] {
2863            std::fs::create_dir_all(root.join(m)).unwrap();
2864        }
2865        std::fs::write(
2866            root.join(".memstead").join("state").join("mounts.json"),
2867            r#"{ "format": "memstead-mounts-3", "mounts": [
2868                { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
2869                { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
2870            ] }"#,
2871        )
2872        .unwrap();
2873        // The authored package, still on the retired key.
2874        let pkg = root
2875            .join(".memstead")
2876            .join("schemas")
2877            .join("fieldschema@0.1.0");
2878        std::fs::create_dir_all(pkg.join("types")).unwrap();
2879        std::fs::write(
2880            pkg.join("schema.yaml"),
2881            "name: fieldschema\nversion: 0.1.0\ndescription: field schema\nwhen_to_use: tests\ntypes:\n  - thing\nrelationships:\n  mode: strict\n  definitions:\n    - name: PART_OF\n      description: h\n      default_weight: 1.0\n      acyclic: true\n    - name: _default\n      description: f\n      default_weight: 1.0\ncommunity:\n  resolution: 1.0\n  seed: 42\n",
2882        )
2883        .unwrap();
2884        std::fs::write(
2885            pkg.join("types").join("thing.yaml"),
2886            "name: thing\ndescription: t\nwhen_to_use: h\nsections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\npropagating_relationships: []\nupdatable_fields:\n  - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
2887        )
2888        .unwrap();
2889
2890        let engine = Engine::from_workspace_root(root)
2891            .expect("the old-key schema quarantines its mem, never the workspace");
2892        assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
2893        let q = engine
2894            .quarantine_reason("oldkey")
2895            .expect("oldkey mem is quarantined");
2896        assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
2897        assert!(
2898            q.reason_message.contains("no_self_loop_relationships"),
2899            "quarantine reason is the rename error naming the new key: {}",
2900            q.reason_message
2901        );
2902    }
2903
2904    /// Agent-trust plan 06, criterion 2: a mem pinned to the new
2905    /// ingest@0.3.0 reports its edge-less entry entities as leaf
2906    /// population, zero false orphans; the prior version (0.2.0) is
2907    /// unchanged — the same entity still counts as an orphan there.
2908    #[test]
2909    fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
2910        let entry_md = "---\ntype: coverage_gap\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nstatus: open\n---\n# Gap\n\n## Area\n\nan uncovered area.\n";
2911        let boot = |pin: &str| {
2912            let tmp = TempDir::new().unwrap();
2913            let dir = tmp.path().to_path_buf();
2914            std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2915            std::fs::write(
2916                dir.join(".memstead").join("config.json"),
2917                format!("{{ \"schema\": \"{pin}\" }}"),
2918            )
2919            .unwrap();
2920            std::fs::write(dir.join("gap.md"), entry_md).unwrap();
2921            let writer = FilesystemMemWriter::new(dir.clone());
2922            let mount = Mount {
2923                mem: "proc".to_string(),
2924                schema: None,
2925                storage: MountStorage::Folder { path: dir },
2926                capability: MountCapability::Write,
2927                lifecycle: MountLifecycle::Eager,
2928                cross_linkable: true,
2929                migration_target: None,
2930            };
2931            let engine =
2932                Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2933                    .unwrap();
2934            (engine.health(), tmp)
2935        };
2936
2937        let (health_new, _t1) = boot("ingest@0.3.0");
2938        assert_eq!(
2939            health_new.orphan_count, 0,
2940            "0.3.0 entry types are leaves — zero false orphans"
2941        );
2942        assert_eq!(
2943            health_new
2944                .leaf_entities_by_type
2945                .get("ingest@0.3.0:coverage_gap"),
2946            Some(&1),
2947            "the population stays visible: {:?}",
2948            health_new.leaf_entities_by_type
2949        );
2950
2951        let (health_old, _t2) = boot("ingest@0.2.0");
2952        assert_eq!(
2953            health_old.orphan_count, 1,
2954            "the prior version's behaviour is unchanged"
2955        );
2956        assert!(health_old.leaf_entities_by_type.is_empty());
2957    }
2958
2959    /// A workspace mixing one broken mem with healthy siblings boots,
2960    /// serves the healthy mems fully, and refuses typed on the
2961    /// quarantined one — the plenum shape (one bad pin, thirteen
2962    /// healthy hostages) can no longer occur. Drives the pin-failure
2963    /// and missing-pin variants in one fixture.
2964    #[test]
2965    fn broken_mem_quarantines_while_healthy_siblings_serve() {
2966        let tmp = TempDir::new().unwrap();
2967        let make_mount = |mem: &str, pin: Option<SchemaRef>| {
2968            let dir = tmp.path().join(mem);
2969            std::fs::create_dir_all(&dir).unwrap();
2970            let writer = FilesystemMemWriter::new(dir.clone());
2971            (
2972                Mount {
2973                    mem: mem.to_string(),
2974                    schema: pin,
2975                    storage: MountStorage::Folder { path: dir },
2976                    capability: MountCapability::Write,
2977                    lifecycle: MountLifecycle::Eager,
2978                    cross_linkable: true,
2979                    migration_target: None,
2980                },
2981                Box::new(writer) as Box<dyn MemBackend>,
2982            )
2983        };
2984        let healthy = make_mount(
2985            "healthy",
2986            Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2987        );
2988        let bad_pin = make_mount(
2989            "badpin",
2990            Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
2991        );
2992        let missing_pin = make_mount("nopin", None);
2993        // Backend-failure variant: the mount's storage path is a FILE,
2994        // so the backend's entity walk fails at read time.
2995        let bad_io_path = tmp.path().join("badio");
2996        std::fs::write(&bad_io_path, "not a directory").unwrap();
2997        let bad_io = (
2998            Mount {
2999                mem: "badio".to_string(),
3000                schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
3001                storage: MountStorage::Folder {
3002                    path: bad_io_path.clone(),
3003                },
3004                capability: MountCapability::Write,
3005                lifecycle: MountLifecycle::Eager,
3006                cross_linkable: true,
3007                migration_target: None,
3008            },
3009            Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
3010        );
3011        let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
3012            .expect("mixed workspace boots");
3013
3014        // Roster: both broken mems, each with its own typed reason.
3015        let codes: std::collections::HashMap<String, String> = engine
3016            .quarantined_mems()
3017            .iter()
3018            .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
3019            .collect();
3020        assert_eq!(
3021            codes.get("badpin").map(String::as_str),
3022            Some("SCHEMA_NOT_FOUND")
3023        );
3024        assert_eq!(
3025            codes.get("nopin").map(String::as_str),
3026            Some("MEM_CONFIG_INCOMPLETE")
3027        );
3028        assert!(
3029            codes.contains_key("badio"),
3030            "backend read failure quarantines too: {codes:?}"
3031        );
3032
3033        // The healthy mem is fully writable.
3034        let mut sections = indexmap::IndexMap::new();
3035        sections.insert("identity".to_string(), "alive".to_string());
3036        sections.insert("purpose".to_string(), "proof of service".to_string());
3037        let created = engine
3038            .create_entity_with_ctx(
3039                crate::engine::CreateEntityArgs {
3040                    anchors: Vec::new(),
3041                    mem: "healthy".to_string(),
3042                    title: "Alive".to_string(),
3043                    entity_type: "spec".to_string(),
3044                    sections,
3045                    metadata: indexmap::IndexMap::new(),
3046                    relations: Vec::new(),
3047                    dry_run: false,
3048                },
3049                &crate::vcs::CommitContext::internal(),
3050            )
3051            .expect("healthy mem serves writes");
3052        assert_eq!(created.id.to_string(), "healthy--alive");
3053
3054        // Writes against a quarantined mem refuse with the typed code.
3055        let mut sections = indexmap::IndexMap::new();
3056        sections.insert("identity".to_string(), "x".to_string());
3057        let err = engine
3058            .create_entity_with_ctx(
3059                crate::engine::CreateEntityArgs {
3060                    anchors: Vec::new(),
3061                    mem: "badpin".to_string(),
3062                    title: "Nope".to_string(),
3063                    entity_type: "spec".to_string(),
3064                    sections,
3065                    metadata: indexmap::IndexMap::new(),
3066                    relations: Vec::new(),
3067                    dry_run: false,
3068                },
3069                &crate::vcs::CommitContext::internal(),
3070            )
3071            .unwrap_err();
3072        assert_eq!(err.code(), "MEM_QUARANTINED");
3073    }
3074
3075    /// Schema-pin authority: the mem's own per-mem config is the
3076    /// authoritative settled pin. Here the config pins a resolvable
3077    /// schema (`software@0.1.0`) while the workspace mount expects an
3078    /// unresolvable one — boot succeeds (proving the config pin won,
3079    /// not the mount's) and surfaces a `SchemaPinMismatch` warning
3080    /// naming both pins.
3081    #[test]
3082    fn mem_config_schema_is_authoritative_over_mount_pin() {
3083        let tmp = TempDir::new().unwrap();
3084        let mem_dir = tmp.path().to_path_buf();
3085        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3086        std::fs::write(
3087            mem_dir.join(".memstead").join("config.json"),
3088            r#"{"schema":"software@0.1.0"}"#,
3089        )
3090        .unwrap();
3091        let writer = FilesystemMemWriter::new(mem_dir.clone());
3092        let mount = Mount {
3093            mem: "specs".to_string(),
3094            schema: Some(SchemaRef::new(
3095                "totally-not-a-schema",
3096                semver::Version::new(9, 9, 9),
3097            )),
3098            storage: MountStorage::Folder { path: mem_dir },
3099            capability: MountCapability::Write,
3100            lifecycle: MountLifecycle::Eager,
3101            cross_linkable: true,
3102            migration_target: None,
3103        };
3104        let engine = Engine::from_mounts(vec![(
3105            mount,
3106            Box::new(writer) as Box<dyn MemBackend>,
3107        )])
3108        .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
3109
3110        let mismatch = engine
3111            .load_warnings()
3112            .iter()
3113            .find_map(|w| match w {
3114                WarningHint::SchemaPinMismatch {
3115                    mem,
3116                    config_pin,
3117                    mount_pin,
3118                } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
3119                _ => None,
3120            })
3121            .expect("SchemaPinMismatch warning must surface naming both pins");
3122        assert_eq!(mismatch.0, "specs");
3123        assert_eq!(mismatch.1, "software@0.1.0");
3124        assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
3125    }
3126
3127    #[test]
3128    fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
3129        let tmp = TempDir::new().unwrap();
3130        let memstead = tmp.path().join(".memstead");
3131        std::fs::create_dir_all(&memstead).unwrap();
3132        std::fs::write(
3133            memstead.join("workspace.toml"),
3134            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3135        )
3136        .unwrap();
3137        // Hand-craft a state/mounts.json carrying a git-branch mount —
3138        // the lean boot path can't instantiate that backend.
3139        let state_dir = memstead.join("state");
3140        std::fs::create_dir_all(&state_dir).unwrap();
3141        std::fs::write(
3142            state_dir.join("mounts.json"),
3143            r#"{
3144                "format": "memstead-mounts-3",
3145                "mounts": [
3146                    {
3147                        "mem": "specs",
3148                        "schema": "default@1.0.0",
3149                        "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
3150                        "capability": "write",
3151                        "lifecycle": "eager",
3152                        "cross_linkable": true
3153                    }
3154                ]
3155            }"#,
3156        )
3157        .unwrap();
3158        // Deliberate replacement of the historical wholesale-abort
3159        // assertion (agent-trust plan 04): the lean binary meeting a
3160        // git-branch mount QUARANTINES that mem (typed
3161        // UNSUPPORTED_WORKSPACE_SHAPE reason) instead of refusing the
3162        // whole workspace — the judgment is unchanged, the blast
3163        // radius shrinks to the one mount the lean flavour cannot
3164        // serve.
3165        let engine = Engine::from_workspace_root(tmp.path())
3166            .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3167        let roster = engine.quarantined_mems();
3168        assert_eq!(roster.len(), 1);
3169        assert_eq!(roster[0].mount.mem, "specs");
3170        assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3171        assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3172    }
3173
3174    #[test]
3175    fn from_workspace_root_roots_standalone_folder_mem() {
3176        // Standalone collapse: a bare folder mem — `.memstead/config.json`
3177        // pinning a schema, no `workspace.toml` — boots as a one-mount
3178        // workspace instead of refusing with NotInitialised.
3179        let tmp = TempDir::new().unwrap();
3180        let root = tmp.path();
3181        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3182        std::fs::write(
3183            root.join(".memstead").join("config.json"),
3184            r#"{"schema":"default@1.0.0"}"#,
3185        )
3186        .unwrap();
3187        // A collapsed single-mem folder keeps its `.md` files at the root.
3188        std::fs::write(
3189            root.join("hello.md"),
3190            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3191        )
3192        .unwrap();
3193
3194        let engine = Engine::from_workspace_root(root)
3195            .expect("a bare folder mem must root as a one-mount workspace");
3196        assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3197        assert!(
3198            engine.status().entity_count >= 1,
3199            "the standalone mem's entity must load"
3200        );
3201    }
3202
3203    #[test]
3204    fn from_workspace_root_still_rejects_truly_empty_dir() {
3205        // Refusal complement: a directory with neither `workspace.toml` nor a
3206        // `.memstead/config.json` is not a mem — it still refuses, so the
3207        // standalone path never masks a genuinely uninitialised directory.
3208        let tmp = TempDir::new().unwrap();
3209        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3210        assert!(
3211            matches!(err, crate::BootError::NotInitialised(_)),
3212            "got {err:?}"
3213        );
3214    }
3215
3216    /// A workspace whose installed schema violates the heading
3217    /// round-trip rule still boots and serves reads; the violation
3218    /// surfaces as a `SCHEMA_HEADING_ROUNDTRIP_VIOLATION` load warning
3219    /// (merged into health), never as a boot failure — refusing at
3220    /// boot would brick every workspace that installed such a schema
3221    /// before the install gate existed.
3222    #[test]
3223    fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3224        let tmp = TempDir::new().unwrap();
3225        let schemas_dir = tmp.path().join("schemas");
3226        let pkg = schemas_dir.join("debate");
3227        std::fs::create_dir_all(pkg.join("types")).unwrap();
3228        std::fs::write(
3229            pkg.join("schema.yaml"),
3230            r#"name: debate
3231version: 0.1.0
3232description: sealed-violator fixture
3233when_to_use: tests
3234types:
3235  - question
3236relationships:
3237  mode: strict
3238  definitions:
3239    - name: PART_OF
3240      description: hier
3241      default_weight: 3.0
3242    - name: _default
3243      description: fallback
3244      default_weight: 1.0
3245community:
3246  resolution: 1.0
3247  seed: 42
3248"#,
3249        )
3250        .unwrap();
3251        std::fs::write(
3252            pkg.join("types").join("question.yaml"),
3253            r#"name: question
3254description: t
3255when_to_use: tests
3256sections:
3257  - key: answers
3258    heading: Answers argued
3259    required: true
3260    search_weight: 10.0
3261    write_rules: []
3262  - key: notes
3263    heading: Notes
3264    required: false
3265    search_weight: 3.0
3266    catch_all: true
3267    write_rules: []
3268metadata_fields: []
3269title_weight: 100.0
3270text_fields:
3271  - answers
3272  - notes
3273hierarchy_relationship: PART_OF
3274no_self_loop_relationships: []
3275updatable_fields:
3276  - title
3277  - answers
3278  - notes
3279health_required_fields:
3280  - answers
3281staleness_threshold_days: 90
3282write_rules: []
3283"#,
3284        )
3285        .unwrap();
3286
3287        let mem_dir = tmp.path().join("mem");
3288        std::fs::create_dir_all(&mem_dir).unwrap();
3289        std::fs::write(
3290            mem_dir.join("q.md"),
3291            "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3292        )
3293        .unwrap();
3294
3295        let writer = FilesystemMemWriter::new(mem_dir.clone());
3296        let mount = Mount {
3297            mem: "debate-mem".to_string(),
3298            schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3299            storage: MountStorage::Folder { path: mem_dir },
3300            capability: MountCapability::Write,
3301            lifecycle: MountLifecycle::Eager,
3302            cross_linkable: true,
3303            migration_target: None,
3304        };
3305        let engine = Engine::from_mounts_with_schemas_dir(
3306            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3307            Some(&schemas_dir),
3308        )
3309        .expect("a violating sealed schema must keep loading, never refuse boot");
3310
3311        // Reads still serve.
3312        assert!(
3313            engine.status().entity_count >= 1,
3314            "entities load despite the schema violation"
3315        );
3316
3317        // The violation is a health finding with the full tuple.
3318        let hits: Vec<_> = engine
3319            .load_warnings()
3320            .iter()
3321            .filter_map(|w| match w {
3322                WarningHint::SchemaHeadingRoundtripViolation {
3323                    mem,
3324                    schema_ref,
3325                    violations,
3326                } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3327                _ => None,
3328            })
3329            .collect();
3330        assert_eq!(
3331            hits.len(),
3332            1,
3333            "exactly one schema-level finding; all warnings = {:?}",
3334            engine.load_warnings()
3335        );
3336        let (mem, schema_ref, violations) = &hits[0];
3337        assert_eq!(mem, "debate-mem");
3338        assert_eq!(schema_ref, "debate@0.1.0");
3339        assert_eq!(violations.len(), 1);
3340        assert_eq!(violations[0].type_name, "question");
3341        assert_eq!(violations[0].key, "answers");
3342        assert_eq!(violations[0].heading, "Answers argued");
3343        assert_eq!(violations[0].derived_key, "answers_argued");
3344    }
3345
3346    /// The other half of "still serves reads AND writes": a mem pinned
3347    /// to a sealed heading-round-trip-violating schema accepts writes.
3348    /// The update commits (refusal complement: it is NOT refused), the
3349    /// schema-level health finding persists after the write, and the
3350    /// write-path `SECTION_HEADING_DIVERGENCE` warning fires where its
3351    /// condition holds (the file carries a heading that derives to the
3352    /// written key while the schema declares a different heading text).
3353    #[test]
3354    fn sealed_violator_mem_still_serves_writes() {
3355        // Same fixture as the read test above.
3356        let tmp = TempDir::new().unwrap();
3357        let schemas_dir = tmp.path().join("schemas");
3358        let pkg = schemas_dir.join("debate");
3359        std::fs::create_dir_all(pkg.join("types")).unwrap();
3360        std::fs::write(
3361            pkg.join("schema.yaml"),
3362            r#"name: debate
3363version: 0.1.0
3364description: sealed-violator fixture
3365when_to_use: tests
3366types:
3367  - question
3368relationships:
3369  mode: strict
3370  definitions:
3371    - name: PART_OF
3372      description: hier
3373      default_weight: 3.0
3374    - name: _default
3375      description: fallback
3376      default_weight: 1.0
3377community:
3378  resolution: 1.0
3379  seed: 42
3380"#,
3381        )
3382        .unwrap();
3383        std::fs::write(
3384            pkg.join("types").join("question.yaml"),
3385            r#"name: question
3386description: t
3387when_to_use: tests
3388sections:
3389  - key: answers
3390    heading: Answers argued
3391    required: true
3392    search_weight: 10.0
3393    write_rules: []
3394  - key: notes
3395    heading: Notes
3396    required: false
3397    search_weight: 3.0
3398    catch_all: true
3399    write_rules: []
3400metadata_fields: []
3401title_weight: 100.0
3402text_fields:
3403  - answers
3404  - notes
3405hierarchy_relationship: PART_OF
3406no_self_loop_relationships: []
3407updatable_fields:
3408  - title
3409  - answers
3410  - notes
3411health_required_fields:
3412  - answers
3413staleness_threshold_days: 90
3414write_rules: []
3415"#,
3416        )
3417        .unwrap();
3418
3419        let mem_dir = tmp.path().join("mem");
3420        std::fs::create_dir_all(&mem_dir).unwrap();
3421        // The file's own heading "Answers" derives to the key
3422        // `answers`, differing from the schema's declared
3423        // "Answers argued" — the divergence-warning condition.
3424        std::fs::write(
3425            mem_dir.join("q.md"),
3426            "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3427        )
3428        .unwrap();
3429
3430        let writer = FilesystemMemWriter::new(mem_dir.clone());
3431        let mount = Mount {
3432            mem: "debate-mem".to_string(),
3433            schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3434            storage: MountStorage::Folder { path: mem_dir },
3435            capability: MountCapability::Write,
3436            lifecycle: MountLifecycle::Eager,
3437            cross_linkable: true,
3438            migration_target: None,
3439        };
3440        let mut engine = Engine::from_mounts_with_schemas_dir(
3441            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3442            Some(&schemas_dir),
3443        )
3444        .expect("a violating sealed schema must keep loading");
3445
3446        let id = crate::EntityId::new("debate-mem", "q");
3447        let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3448        let mut sections = indexmap::IndexMap::new();
3449        sections.insert("answers".to_string(), "Updated answers body.".to_string());
3450        let outcome = engine
3451            .update_entity(
3452                crate::engine::UpdateEntityArgs {
3453                    anchors: Vec::new(),
3454                    anchors_unset: Vec::new(),
3455                    id: id.clone(),
3456                    expected_hash: Some(hash),
3457                    sections,
3458                    append_sections: indexmap::IndexMap::new(),
3459                    patch_sections: indexmap::IndexMap::new(),
3460                    metadata: indexmap::IndexMap::new(),
3461                    metadata_unset: Vec::new(),
3462                    declare_relations: Vec::new(),
3463                    dry_run: false,
3464                    relations_unset: Vec::new(),
3465                },
3466                crate::vcs::Actor::Cli,
3467                None,
3468                None,
3469            )
3470            .expect("a write against a sealed-violator mem must NOT be refused");
3471        assert!(!outcome.commit_sha.is_empty(), "the write commits");
3472        assert!(
3473            outcome
3474                .warnings
3475                .iter()
3476                .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
3477            "the write-path divergence warning fires where its condition holds: {:?}",
3478            outcome.warnings
3479        );
3480
3481        // The schema-level finding persists after the write.
3482        assert!(
3483            engine.load_warnings().iter().any(|w| matches!(
3484                w,
3485                WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
3486            )),
3487            "the health finding persists across writes"
3488        );
3489        // The written content is durably on disk and survives the
3490        // reparse — under the catch-all, because the violating schema's
3491        // declared heading cannot round-trip to the written key. That
3492        // fork is exactly what the divergence warning announced (and
3493        // what the persisting health finding tells the operator to fix
3494        // at the schema); the "serves writes" guarantee is that the
3495        // write lands and nothing refuses, not that a broken schema
3496        // routes content correctly.
3497        let entity = engine.get_entity(&id).unwrap();
3498        assert!(
3499            entity
3500                .sections
3501                .values()
3502                .any(|s| s.contains("Updated answers body.")),
3503            "written content survives the round-trip (in the catch-all): {:?}",
3504            entity.sections
3505        );
3506    }
3507
3508    /// A search `mem` filter naming no visible mem refuses typed
3509    /// `UNKNOWN_MEM` — matching every other mem-naming surface — while a
3510    /// quarantined mem keeps its established typed refusal and a VALID
3511    /// mem with no matches still returns success with 0 hits. Absence of
3512    /// mem and absence of matches are never the same answer
3513    /// (backlog-sweep plan 05, decision 4).
3514    #[test]
3515    fn search_mem_filter_gates_against_visible_roster() {
3516        use crate::vcs::Actor;
3517        use crate::workspace_store::WorkspaceStoreAdapter;
3518        use indexmap::IndexMap;
3519
3520        let tmp = TempDir::new().unwrap();
3521        let mem_dir = tmp.path().join("mem");
3522        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3523        std::fs::write(
3524            mem_dir.join(".memstead").join("config.json"),
3525            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3526        )
3527        .unwrap();
3528        let memstead = tmp.path().join(".memstead");
3529        std::fs::create_dir_all(&memstead).unwrap();
3530        std::fs::write(
3531            memstead.join("workspace.toml"),
3532            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3533        )
3534        .unwrap();
3535        crate::FileWorkspaceStore::new()
3536            .save_state(
3537                tmp.path(),
3538                &crate::workspace::Workspace {
3539                    mounts: vec![folder_mount("specs", mem_dir.clone())],
3540                    settings: crate::workspace::WorkspaceSettings::default(),
3541                },
3542            )
3543            .unwrap();
3544        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
3545        let mut sections = IndexMap::new();
3546        sections.insert(
3547            "identity".to_string(),
3548            "Zebra searching fixture.".to_string(),
3549        );
3550        sections.insert("purpose".to_string(), "Search gate test.".to_string());
3551        engine
3552            .create_entity(
3553                crate::CreateEntityArgs {
3554                    mem: "specs".to_string(),
3555                    title: "Zebra".to_string(),
3556                    entity_type: "spec".to_string(),
3557                    sections,
3558                    metadata: IndexMap::new(),
3559                    relations: Vec::new(),
3560                    anchors: Vec::new(),
3561                    dry_run: false,
3562                },
3563                Actor::Agent,
3564                None,
3565                None,
3566            )
3567            .unwrap();
3568
3569        let scope = |mem: Option<&str>, term: &str| crate::ops::SearchScope {
3570            query: Some(crate::ops::Query {
3571                any: vec![term.to_string()],
3572                ..Default::default()
3573            }),
3574            mem: mem.map(str::to_string),
3575            ..Default::default()
3576        };
3577
3578        // Nonexistent mem → typed UNKNOWN_MEM, never success-with-0-hits.
3579        let err = engine
3580            .search(&scope(Some("no-such-mem"), "zebra"))
3581            .expect_err("a nonexistent mem filter must refuse");
3582        assert_eq!(err.code(), "UNKNOWN_MEM", "got {err:?}");
3583
3584        // Valid mem, matching query → hits.
3585        let hit = engine.search(&scope(Some("specs"), "zebra")).unwrap();
3586        assert!(hit.total >= 1, "the fixture entity matches: {hit:?}");
3587
3588        // Valid mem, no matches → success with 0 hits (the gate
3589        // distinguishes absence of mem from absence of matches).
3590        let none = engine
3591            .search(&scope(Some("specs"), "quixotic-nonword"))
3592            .unwrap();
3593        assert_eq!(none.total, 0, "{none:?}");
3594    }
3595
3596    // ---- Engine::reload_one_mem -----------------------------------
3597
3598    // ---- lazy mounts (flywheel W7/01) -----------------------------
3599
3600    fn lazy_folder_mount(mem: &str, path: std::path::PathBuf) -> Mount {
3601        Mount {
3602            lifecycle: MountLifecycle::Lazy,
3603            ..folder_mount(mem, path)
3604        }
3605    }
3606
3607    fn write_spec(dir: &Path, slug: &str, title: &str, extra: &str) {
3608        std::fs::write(
3609            dir.join(format!("{slug}.md")),
3610            format!("---\ntype: spec\n---\n# {title}\n\n## Identity\n\nBody.\n{extra}"),
3611        )
3612        .unwrap();
3613    }
3614
3615    fn two_mem_dirs(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
3616        let eager_dir = tmp.path().join("eag");
3617        let lazy_dir = tmp.path().join("laz");
3618        std::fs::create_dir_all(&eager_dir).unwrap();
3619        std::fs::create_dir_all(&lazy_dir).unwrap();
3620        write_spec(&eager_dir, "alpha", "Alpha", "");
3621        write_spec(&lazy_dir, "omega", "Omega", "");
3622        (eager_dir, lazy_dir)
3623    }
3624
3625    fn mixed_engine(eager_dir: &Path, lazy_dir: &Path) -> Engine {
3626        Engine::from_mounts(vec![
3627            (
3628                folder_mount("eag", eager_dir.to_path_buf()),
3629                Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
3630            ),
3631            (
3632                lazy_folder_mount("laz", lazy_dir.to_path_buf()),
3633                Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
3634            ),
3635        ])
3636        .unwrap()
3637    }
3638
3639    /// The lazy lifecycle defers exactly the entity load: boot carries
3640    /// the mem on the roster with its schema resolved but no entities;
3641    /// the first operation touching it (the `reload_if_stale` funnel)
3642    /// triggers the load; afterwards the mem behaves identically to an
3643    /// eager mount and the deferred state is gone for good.
3644    #[test]
3645    fn lazy_mount_defers_and_first_read_loads() {
3646        let tmp = TempDir::new().unwrap();
3647        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3648        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3649
3650        // Boot: eager loaded, lazy on the roster but deferred.
3651        assert!(
3652            engine
3653                .get_entity(&crate::EntityId::new("eag", "alpha"))
3654                .is_some()
3655        );
3656        assert_eq!(engine.deferred_mems(), vec!["laz"]);
3657        assert!(engine.mem_is_deferred("laz"));
3658        assert!(
3659            engine
3660                .get_entity(&crate::EntityId::new("laz", "omega"))
3661                .is_none(),
3662            "deferred mem's entities are not in the store yet"
3663        );
3664        // Never absent: the mount roster and schema map both carry it.
3665        assert!(engine.schema_for("laz").is_some());
3666
3667        // First read triggers the load through the operation funnel.
3668        engine.reload_if_stale(Some("laz"));
3669        assert!(engine.deferred_mems().is_empty());
3670        let omega = engine
3671            .get_entity(&crate::EntityId::new("laz", "omega"))
3672            .expect("first read loads the mem");
3673        assert_eq!(omega.title, "Omega");
3674
3675        // Identical to eager from here on: a write round-trips.
3676        let (actor, client) = cli_actor();
3677        engine
3678            .create_entity(
3679                empty_create_args("laz", "Later"),
3680                actor,
3681                Some(&client),
3682                None,
3683            )
3684            .unwrap();
3685        assert!(
3686            engine
3687                .get_entity(&crate::EntityId::new("laz", "later"))
3688                .is_some()
3689        );
3690    }
3691
3692    /// An operation scoped to an eager mem never loads a lazy sibling
3693    /// as a side effect; a workspace-scoped funnel pass loads every
3694    /// deferred mem so no answer computes over a partial store.
3695    #[test]
3696    fn scoped_operation_never_loads_lazy_sibling() {
3697        let tmp = TempDir::new().unwrap();
3698        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3699        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3700
3701        engine.reload_if_stale(Some("eag"));
3702        assert_eq!(
3703            engine.deferred_mems(),
3704            vec!["laz"],
3705            "an eager-scoped operation must not load the lazy sibling"
3706        );
3707
3708        engine.reload_if_stale(None);
3709        assert!(
3710            engine.deferred_mems().is_empty(),
3711            "a workspace-scoped pass loads every deferred mem"
3712        );
3713    }
3714
3715    /// A deferred load that fails quarantines the mem at the moment of
3716    /// first read, with the same typed reporting an eager boot failure
3717    /// produces — never an empty-mem impression.
3718    #[test]
3719    fn lazy_load_failure_quarantines_typed() {
3720        let tmp = TempDir::new().unwrap();
3721        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3722        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3723
3724        // The backend's tree is destroyed between boot and first read —
3725        // a plain file now sits where the mem directory was, so the
3726        // entity walk errors rather than reading an empty directory.
3727        std::fs::remove_dir_all(&lazy_dir).unwrap();
3728        std::fs::write(&lazy_dir, b"not a directory").unwrap();
3729        engine.reload_if_stale(Some("laz"));
3730
3731        let q = engine
3732            .quarantine_reason("laz")
3733            .expect("failed deferred load quarantines, never serves empty");
3734        assert!(!q.reason_code.is_empty());
3735        assert!(
3736            !engine.mem_names().contains(&"laz"),
3737            "a quarantined mem leaves the serving roster"
3738        );
3739        assert!(engine.deferred_mems().is_empty());
3740    }
3741
3742    /// Quarantine-ENTRY invalidation pin (flywheel W8/01, first
3743    /// grade's refutation): entering quarantine from a failed deferred
3744    /// load removes the mem's schema (epoch bump) — a search memo
3745    /// filled beforehand is then stale-keyed and MUST clear, or the
3746    /// next search trips the memo-key debug_assert (the grade's live
3747    /// repro). Same rule pinned for the reattach-failure branch by
3748    /// re-failing the reattach.
3749    #[test]
3750    fn quarantine_entry_invalidates_both_memos() {
3751        let tmp = TempDir::new().unwrap();
3752        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3753        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3754
3755        // Fill both memos while the lazy mem is still deferred.
3756        let _ = engine.communities();
3757        let _ = engine.search_indexes();
3758        assert!(engine.search_indexes_memo.get().is_some());
3759
3760        // Destroy the backend; the first read quarantines the mem.
3761        std::fs::remove_dir_all(&lazy_dir).unwrap();
3762        std::fs::write(&lazy_dir, b"not a directory").unwrap();
3763        engine.reload_if_stale(Some("laz"));
3764        assert!(engine.quarantine_reason("laz").is_some());
3765        assert!(
3766            engine.search_indexes_memo.get().is_none(),
3767            "quarantine entry bumps the schemas epoch — the search memo must clear"
3768        );
3769        assert!(
3770            engine.community_memo.get().is_none(),
3771            "quarantine entry must clear the community memo too"
3772        );
3773        // The next search must not trip the memo-key assert.
3774        let _ = engine.search_indexes();
3775
3776        // Reattach FAILURE (backend still broken): same rule.
3777        let _ = engine.communities();
3778        let _ = engine.search_indexes();
3779        let _ = engine.reload_one_mem("laz");
3780        assert!(engine.quarantine_reason("laz").is_some());
3781        assert!(
3782            engine.search_indexes_memo.get().is_none(),
3783            "a failed reattach bumps the epoch — the search memo must clear"
3784        );
3785        let _ = engine.search_indexes();
3786    }
3787
3788    /// Quarantine-reattach regression pin (flywheel W8/01, criterion
3789    /// 2's complement): the reattach path routes through the one-mem
3790    /// reload, which invalidates BOTH derived memos — pinned so
3791    /// incremental maintenance can never silently degrade it.
3792    #[test]
3793    fn quarantine_reattach_invalidates_both_memos() {
3794        let tmp = TempDir::new().unwrap();
3795        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3796        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3797
3798        // Quarantine the lazy mem: destroy its backend, trigger load.
3799        std::fs::remove_dir_all(&lazy_dir).unwrap();
3800        std::fs::write(&lazy_dir, b"not a directory").unwrap();
3801        engine.reload_if_stale(Some("laz"));
3802        assert!(engine.quarantine_reason("laz").is_some());
3803
3804        // Fill both memos while the mem sits quarantined.
3805        let _ = engine.communities();
3806        let _ = engine.search_indexes();
3807        assert!(engine.community_memo.get().is_some());
3808        assert!(engine.search_indexes_memo.get().is_some());
3809
3810        // Restore the backend and reattach via the reload path.
3811        std::fs::remove_file(&lazy_dir).unwrap();
3812        std::fs::create_dir_all(&lazy_dir).unwrap();
3813        write_spec(&lazy_dir, "omega", "Omega", "");
3814        engine
3815            .reload_one_mem("laz")
3816            .expect("reattach succeeds once the backend is back");
3817        assert!(engine.quarantine_reason("laz").is_none());
3818
3819        // Both memos cleared — the reattached mem's entities must be
3820        // visible to the next partition and the next search.
3821        assert!(
3822            engine.community_memo.get().is_none(),
3823            "reattach must invalidate the community memo"
3824        );
3825        assert!(
3826            engine.search_indexes_memo.get().is_none(),
3827            "reattach must invalidate the search memo"
3828        );
3829    }
3830
3831    /// The lazy load runs the same validation gauntlet an eager boot
3832    /// runs: content an eager boot warns about produces the SAME
3833    /// warning when its mem loads lazily — deferral changes when, not
3834    /// whether.
3835    #[test]
3836    fn lazy_load_runs_the_same_validation_gauntlet() {
3837        let tmp = TempDir::new().unwrap();
3838        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3839        // Hand-authored invalid relation in the LAZY mem.
3840        std::fs::write(
3841            lazy_dir.join("bad.md"),
3842            "---\ntype: spec\n---\n# Bad\n\n## Identity\n\nx.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[laz--omega]]\n",
3843        )
3844        .unwrap();
3845
3846        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3847        let warned_before = engine
3848            .load_warnings()
3849            .iter()
3850            .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3851        engine.reload_if_stale(Some("laz"));
3852        let warned_after = engine
3853            .load_warnings()
3854            .iter()
3855            .any(|w| matches!(w, WarningHint::ParsedRelationInvalid { .. }));
3856        assert!(
3857            !warned_before && warned_after,
3858            "the gauntlet fires at load time: before={warned_before} after={warned_after}, \
3859             warnings: {:?}",
3860            engine.load_warnings()
3861        );
3862        // And the offending relation was dropped, as an eager boot drops it.
3863        let bad = engine
3864            .get_entity(&crate::EntityId::new("laz", "bad"))
3865            .unwrap();
3866        assert!(bad.relationships.is_empty());
3867    }
3868
3869    /// The destructive guard sees referrers living in DEFERRED mems:
3870    /// deleting an entity whose incoming references originate in a lazy,
3871    /// not-yet-loaded mem refuses `HAS_INCOMING_REFS` exactly as an
3872    /// eager boot refuses it. The third final grade demonstrated the
3873    /// counterexample live — the scoped reload left the referrer's mem
3874    /// unloaded and the delete destroyed the entity an eager boot
3875    /// protects. The guard now takes the full load first.
3876    #[test]
3877    fn delete_guard_sees_referrers_in_deferred_mems() {
3878        use crate::engine::{DeleteEntityArgs, RelateEntityArgs};
3879
3880        let tmp = TempDir::new().unwrap();
3881        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
3882        let (actor, client) = cli_actor();
3883
3884        // Author the cross-mem referrer through the mutation surface
3885        // while both mems are eager (with the cross-link grant), so the
3886        // persisted relation is exactly what a real workspace carries.
3887        {
3888            let mut authoring = Engine::from_mounts(vec![
3889                (
3890                    folder_mount("eag", eager_dir.to_path_buf()),
3891                    Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf()))
3892                        as Box<dyn MemBackend>,
3893                ),
3894                (
3895                    folder_mount("laz", lazy_dir.to_path_buf()),
3896                    Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf()))
3897                        as Box<dyn MemBackend>,
3898                ),
3899            ])
3900            .unwrap();
3901            let mut settings = crate::workspace::WorkspaceSettings::default();
3902            settings.cross_mem_links.insert(
3903                "laz".to_string(),
3904                memstead_schema::workspace_config::CrossLinkValue::List(vec!["eag".to_string()]),
3905            );
3906            authoring.set_settings(settings);
3907            authoring
3908                .relate_entity(
3909                    RelateEntityArgs {
3910                        source: crate::EntityId::new("laz", "omega"),
3911                        expected_hash: None,
3912                        rel_type: "USES".to_string(),
3913                        target: crate::EntityId::new("eag", "alpha"),
3914                        remove: false,
3915                        description: None,
3916                        dry_run: false,
3917                    },
3918                    actor,
3919                    Some(&client),
3920                    None,
3921                )
3922                .expect("cross-mem relate lands under the grant");
3923        }
3924
3925        // Fresh boot with the REFERRER's mem lazy: the incoming edge
3926        // into `eag--alpha` lives in an unloaded mem. The guard must
3927        // still see it — full load before destructive adjudication.
3928        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
3929        assert!(engine.mem_is_deferred("laz"));
3930        let err = engine
3931            .delete_entity(
3932                DeleteEntityArgs {
3933                    id: crate::EntityId::new("eag", "alpha"),
3934                    expected_hash: None,
3935                },
3936                actor,
3937                Some(&client),
3938                None,
3939            )
3940            .expect_err("a referenced entity must refuse deletion, lazy referrer or not");
3941        assert!(
3942            matches!(err, EngineError::HasIncomingRefs { .. }),
3943            "expected HAS_INCOMING_REFS, got {err:?}"
3944        );
3945        assert!(
3946            engine
3947                .get_entity(&crate::EntityId::new("eag", "alpha"))
3948                .is_some(),
3949            "the entity survives"
3950        );
3951    }
3952
3953    /// The write-time acyclicity guard sees edges living in DEFERRED
3954    /// mems: an add that closes a cycle THROUGH a lazy, not-yet-loaded
3955    /// mem refuses `RELATIONSHIP_CYCLE` exactly as an eager boot
3956    /// refuses it. The fourth final grade demonstrated the
3957    /// counterexample on a three-mem chain — the mid-path edge lived on
3958    /// the lazy mem's entity, the walk over the endpoint mems missed
3959    /// it, the cycle landed, and the next eager boot dropped an
3960    /// INNOCENT pre-existing edge to break it. A two-mem fixture would
3961    /// pass vacuously (relate loads both endpoint mems); three mems
3962    /// with the middle one lazy is the discriminating shape.
3963    #[test]
3964    fn acyclicity_guard_sees_edges_in_deferred_mems() {
3965        use crate::engine::RelateEntityArgs;
3966        use memstead_schema::workspace_config::CrossLinkValue;
3967
3968        let tmp = TempDir::new().unwrap();
3969        let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
3970            .iter()
3971            .map(|m| {
3972                let d = tmp.path().join(m);
3973                std::fs::create_dir_all(&d).unwrap();
3974                d
3975            })
3976            .collect();
3977        write_spec(&dirs[0], "node", "Node A", "");
3978        write_spec(&dirs[1], "node", "Node B", "");
3979        write_spec(&dirs[2], "node", "Node C", "");
3980
3981        let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
3982            ["ma", "mb", "mc"]
3983                .iter()
3984                .zip(dirs.iter())
3985                .map(|(m, d)| {
3986                    let mut mount = folder_mount(m, d.clone());
3987                    if lazy_mid && *m == "ma" {
3988                        mount.lifecycle = MountLifecycle::Lazy;
3989                    }
3990                    (
3991                        mount,
3992                        Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
3993                    )
3994                })
3995                .collect()
3996        };
3997        let grants = || {
3998            let mut settings = crate::workspace::WorkspaceSettings::default();
3999            for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4000                settings
4001                    .cross_mem_links
4002                    .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4003            }
4004            settings
4005        };
4006        let relate = |engine: &mut Engine, from: &str, to: &str| {
4007            let (actor, client) = cli_actor();
4008            engine.relate_entity(
4009                RelateEntityArgs {
4010                    source: crate::EntityId::new(from, "node"),
4011                    expected_hash: None,
4012                    rel_type: "DEPENDS_ON".to_string(),
4013                    target: crate::EntityId::new(to, "node"),
4014                    remove: false,
4015                    description: None,
4016                    dry_run: false,
4017                },
4018                actor,
4019                Some(&client),
4020                None,
4021            )
4022        };
4023
4024        // Author the chain mb→ma→mc while everything is eager.
4025        {
4026            let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4027            authoring.set_settings(grants());
4028            relate(&mut authoring, "mb", "ma").expect("mb→ma lands");
4029            relate(&mut authoring, "ma", "mc").expect("ma→mc lands");
4030        }
4031
4032        // Fresh boot with the MID-PATH mem lazy: closing mc→mb would
4033        // complete the cycle mb→ma→mc→mb through the unloaded mem.
4034        let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4035        engine.set_settings(grants());
4036        assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4037        let err = relate(&mut engine, "mc", "mb")
4038            .expect_err("a cycle through a deferred mem must refuse, as eager refuses");
4039        assert!(
4040            matches!(err, EngineError::RelationshipCycle { .. }),
4041            "expected RELATIONSHIP_CYCLE, got {err:?}"
4042        );
4043    }
4044
4045    /// The BATCH relate path runs the same acyclicity guard over the
4046    /// same full store: a two-entry batch (the shape MCP routes to
4047    /// `batch_relate`) whose first entry closes a cycle through a
4048    /// deferred mid-path mem is refused whole, exactly as an eager
4049    /// boot refuses it. The fifth lazy-mount grade demonstrated the
4050    /// complement live: without the batch-path full load the cycle
4051    /// committed silently.
4052    #[test]
4053    fn batch_acyclicity_guard_sees_edges_in_deferred_mems() {
4054        use crate::engine::RelateEntityArgs;
4055        use memstead_schema::workspace_config::CrossLinkValue;
4056
4057        let tmp = TempDir::new().unwrap();
4058        let dirs: Vec<std::path::PathBuf> = ["ma", "mb", "mc"]
4059            .iter()
4060            .map(|m| {
4061                let d = tmp.path().join(m);
4062                std::fs::create_dir_all(&d).unwrap();
4063                d
4064            })
4065            .collect();
4066        write_spec(&dirs[0], "node", "Node A", "");
4067        write_spec(&dirs[1], "node", "Node B", "");
4068        write_spec(&dirs[2], "node", "Node C", "");
4069        // A second entity in mc so the batch's second entry can be an
4070        // intra-mem edge that touches ONLY mc — a target in any other
4071        // mem would put that mem on the batch's touched-mems reload
4072        // list and load it by that route, masking the guard under test.
4073        write_spec(&dirs[2], "node2", "Node C2", "");
4074
4075        let mounts = |lazy_mid: bool| -> Vec<(Mount, Box<dyn MemBackend>)> {
4076            ["ma", "mb", "mc"]
4077                .iter()
4078                .zip(dirs.iter())
4079                .map(|(m, d)| {
4080                    let mut mount = folder_mount(m, d.clone());
4081                    if lazy_mid && *m == "ma" {
4082                        mount.lifecycle = MountLifecycle::Lazy;
4083                    }
4084                    (
4085                        mount,
4086                        Box::new(FilesystemMemWriter::new(d.clone())) as Box<dyn MemBackend>,
4087                    )
4088                })
4089                .collect()
4090        };
4091        let grants = || {
4092            let mut settings = crate::workspace::WorkspaceSettings::default();
4093            for (from, to) in [("mb", "ma"), ("ma", "mc"), ("mc", "mb")] {
4094                settings
4095                    .cross_mem_links
4096                    .insert(from.to_string(), CrossLinkValue::List(vec![to.to_string()]));
4097            }
4098            settings
4099        };
4100        let relate_args = |from: &str, to: &str| RelateEntityArgs {
4101            source: crate::EntityId::new(from, "node"),
4102            expected_hash: None,
4103            rel_type: "DEPENDS_ON".to_string(),
4104            target: crate::EntityId::new(to, "node"),
4105            remove: false,
4106            description: None,
4107            dry_run: false,
4108        };
4109
4110        // Author the chain mb→ma→mc while everything is eager.
4111        {
4112            let (actor, client) = cli_actor();
4113            let mut authoring = Engine::from_mounts(mounts(false)).unwrap();
4114            authoring.set_settings(grants());
4115            authoring
4116                .relate_entity(relate_args("mb", "ma"), actor, Some(&client), None)
4117                .expect("mb→ma lands");
4118            let (actor2, client2) = cli_actor();
4119            authoring
4120                .relate_entity(relate_args("ma", "mc"), actor2, Some(&client2), None)
4121                .expect("ma→mc lands");
4122        }
4123
4124        // Fresh boot with the MID-PATH mem lazy; a two-entry batch
4125        // whose first edge mc→mb completes the cycle mb→ma→mc→mb
4126        // through the unloaded mem must refuse whole.
4127        let mut engine = Engine::from_mounts(mounts(true)).unwrap();
4128        engine.set_settings(grants());
4129        assert!(engine.mem_is_deferred("ma"), "the mid-path mem is deferred");
4130        let (actor, client) = cli_actor();
4131        let result = engine
4132            .batch_relate(
4133                vec![
4134                    (relate_args("mc", "mb"), None),
4135                    // The second entry makes the batch two entries —
4136                    // the shape MCP routes to `batch_relate` — and is
4137                    // an intra-mem edge inside mc: it touches no other
4138                    // mem (so it cannot load ma via the touched-mems
4139                    // reload) and closes no cycle of its own.
4140                    (
4141                        RelateEntityArgs {
4142                            source: crate::EntityId::new("mc", "node2"),
4143                            expected_hash: None,
4144                            rel_type: "DEPENDS_ON".to_string(),
4145                            target: crate::EntityId::new("mc", "node"),
4146                            remove: false,
4147                            description: None,
4148                            dry_run: false,
4149                        },
4150                        None,
4151                    ),
4152                ],
4153                actor,
4154                Some(&client),
4155                false,
4156            )
4157            .expect("the batch call itself returns a report-all envelope");
4158        assert!(
4159            !result.applied,
4160            "a batch closing a cycle through a deferred mem must refuse, as eager refuses; got applied with {} succeeded",
4161            result.succeeded
4162        );
4163    }
4164
4165    /// Write-time cross-mem target verification (flywheel W7/02): a
4166    /// relate into a DEFERRED Write mem verifies the target against
4167    /// storage without loading the mem. A storage-verified target is
4168    /// admitted with a LoadTime stub and NO auto-stub warning (the
4169    /// entity exists — it resolves when the mem loads); a genuinely
4170    /// absent target keeps today's forward-reference mechanic, warning
4171    /// included. Either way the target mem stays deferred.
4172    #[test]
4173    fn relate_into_deferred_mem_verifies_against_storage_without_load() {
4174        use crate::engine::RelateEntityArgs;
4175        use memstead_schema::workspace_config::CrossLinkValue;
4176
4177        let tmp = TempDir::new().unwrap();
4178        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4179        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4180        let mut settings = crate::workspace::WorkspaceSettings::default();
4181        settings.cross_mem_links.insert(
4182            "eag".to_string(),
4183            CrossLinkValue::List(vec!["laz".to_string()]),
4184        );
4185        engine.set_settings(settings);
4186
4187        let relate = |engine: &mut Engine, to: &str| {
4188            let (actor, client) = cli_actor();
4189            engine.relate_entity(
4190                RelateEntityArgs {
4191                    source: crate::EntityId::new("eag", "alpha"),
4192                    expected_hash: None,
4193                    rel_type: "SUPPORTS".to_string(),
4194                    target: crate::EntityId::new("laz", to),
4195                    remove: false,
4196                    description: None,
4197                    dry_run: false,
4198                },
4199                actor,
4200                Some(&client),
4201                None,
4202            )
4203        };
4204
4205        // Storage-verified target: laz--omega exists on disk.
4206        let outcome = relate(&mut engine, "omega").expect("verified target admits");
4207        assert!(
4208            engine.mem_is_deferred("laz"),
4209            "verification never loads the mem"
4210        );
4211        assert!(
4212            !outcome
4213                .warnings
4214                .iter()
4215                .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4216            "a storage-verified target is not an auto-stub case: {:?}",
4217            outcome.warnings
4218        );
4219        let stub = engine
4220            .store()
4221            .get(&crate::EntityId::new("laz", "omega"))
4222            .expect("until-load stub present");
4223        assert!(stub.stub);
4224        assert_eq!(
4225            stub.stub_kind,
4226            Some(crate::entity::StubKind::LoadTime),
4227            "verified-in-storage stub carries the load-time kind"
4228        );
4229
4230        // Genuinely absent target: forward-reference mechanic intact.
4231        let outcome = relate(&mut engine, "missing").expect("absent Write-mem target auto-stubs");
4232        assert!(engine.mem_is_deferred("laz"), "still no load");
4233        assert!(
4234            outcome
4235                .warnings
4236                .iter()
4237                .any(|w| matches!(w, WarningHint::AutoStubCreated { .. })),
4238            "absent target keeps the auto-stub warning: {:?}",
4239            outcome.warnings
4240        );
4241        let stub = engine
4242            .store()
4243            .get(&crate::EntityId::new("laz", "missing"))
4244            .expect("forward-reference stub present");
4245        assert_eq!(
4246            stub.stub_kind,
4247            Some(crate::entity::StubKind::ForwardReference)
4248        );
4249    }
4250
4251    /// The read-only contract, now answerable without load (flywheel
4252    /// W7/02): an entity PRESENT in a deferred read-only mem's storage
4253    /// is admitted — the refusal never fires merely because the mem is
4254    /// unloaded — and an ABSENT one refuses with the existing typed
4255    /// error. The mem stays deferred through both.
4256    #[test]
4257    fn readonly_deferred_target_answers_from_storage() {
4258        use crate::engine::RelateEntityArgs;
4259        use memstead_schema::workspace_config::CrossLinkValue;
4260
4261        let tmp = TempDir::new().unwrap();
4262        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4263        let mut ro_mount = lazy_folder_mount("laz", lazy_dir.to_path_buf());
4264        ro_mount.capability = crate::workspace::MountCapability::ReadOnly;
4265        let mut engine = Engine::from_mounts(vec![
4266            (
4267                folder_mount("eag", eager_dir.to_path_buf()),
4268                Box::new(FilesystemMemWriter::new(eager_dir.to_path_buf())) as Box<dyn MemBackend>,
4269            ),
4270            (
4271                ro_mount,
4272                Box::new(FilesystemMemWriter::new(lazy_dir.to_path_buf())) as Box<dyn MemBackend>,
4273            ),
4274        ])
4275        .unwrap();
4276        let mut settings = crate::workspace::WorkspaceSettings::default();
4277        settings.cross_mem_links.insert(
4278            "eag".to_string(),
4279            CrossLinkValue::List(vec!["laz".to_string()]),
4280        );
4281        engine.set_settings(settings);
4282
4283        let relate = |engine: &mut Engine, to: &str| {
4284            let (actor, client) = cli_actor();
4285            engine.relate_entity(
4286                RelateEntityArgs {
4287                    source: crate::EntityId::new("eag", "alpha"),
4288                    expected_hash: None,
4289                    rel_type: "SUPPORTS".to_string(),
4290                    target: crate::EntityId::new("laz", to),
4291                    remove: false,
4292                    description: None,
4293                    dry_run: false,
4294                },
4295                actor,
4296                Some(&client),
4297                None,
4298            )
4299        };
4300
4301        relate(&mut engine, "omega").expect("present-in-storage RO target admits");
4302        assert!(
4303            engine.mem_is_deferred("laz"),
4304            "the admit never loads the mem"
4305        );
4306
4307        let err =
4308            relate(&mut engine, "missing").expect_err("absent RO target keeps the typed refusal");
4309        assert!(
4310            matches!(err, EngineError::CrossMemTargetNotFound { .. }),
4311            "expected CROSS_MEM_TARGET_NOT_FOUND, got {err:?}"
4312        );
4313        assert!(
4314            engine.mem_is_deferred("laz"),
4315            "the refusal never loads the mem either"
4316        );
4317    }
4318
4319    /// A cross-mem body link from an eager mem into a lazy one is a
4320    /// stub until the target mem loads, and resolves to the real entity
4321    /// afterwards — never silently dropped, never a spurious permanent
4322    /// warning.
4323    #[test]
4324    fn cross_mem_link_into_lazy_mem_resolves_on_load() {
4325        let tmp = TempDir::new().unwrap();
4326        let (eager_dir, lazy_dir) = two_mem_dirs(&tmp);
4327        write_spec(
4328            &eager_dir,
4329            "linker",
4330            "Linker",
4331            "\nSee [[laz--omega]] for detail.\n",
4332        );
4333
4334        let mut engine = mixed_engine(&eager_dir, &lazy_dir);
4335        let target = crate::EntityId::new("laz", "omega");
4336        assert!(
4337            engine.get_entity(&target).is_none_or(|e| e.stub),
4338            "before the lazy load the cross-mem target is at most a stub, never real"
4339        );
4340
4341        engine.reload_if_stale(Some("laz"));
4342        let resolved = engine.get_entity(&target).expect("target loaded");
4343        assert!(!resolved.stub, "after the load the target is real");
4344        assert!(
4345            !engine.load_warnings().iter().any(
4346                |w| matches!(w, WarningHint::SuspiciousNestedPrefix { resolved_id, .. } if resolved_id.as_ref() == target.as_ref())
4347            ),
4348            "no lingering nested-prefix warning for a resolved cross-mem link: {:?}",
4349            engine.load_warnings()
4350        );
4351    }
4352}