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