Skip to main content

memstead_base/engine/
boot.rs

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