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