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