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