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