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, the macOS
4//! UniFFI consumer, 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            });
135        }
136
137        // Walk each backend, parse entries, populate one shared Store.
138        // Resolve each mount's schema pin against the built-in schema
139        // catalogue. The schema-registry resolver (which would also
140        // honor workspace-authored schemas living inside the storage
141        // backend) lands as a separate plan; this resolution closes
142        // the gap for the built-in catalogue so a workspace pinning
143        // a non-default built-in (e.g. `software`, `memory`) surfaces
144        // the right schema rather than silently downgrading to
145        // `default`.
146        let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
147            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
148        // Workspace-authored schemas resolve first (override builtins
149        // on (name, version) collision); builtins fill the rest.
150        let workspace_schemas = extra_schemas.clone();
151        let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
152            Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
153        catalogue.extend(extra_schemas);
154        catalogue.extend(builtin_schemas_only.clone());
155        let builtin_schemas = catalogue;
156        let mut store = Store::new();
157        let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
158        let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
159        let fallback = engine_fallback_type();
160
161        // Derive the mem roster + last-segment suffixes ONCE so the
162        // per-mount load loop hands the same view to every
163        // `LoadCollector`. `known_suffixes` is the input the
164        // nested-prefix detector compares against; the full
165        // `mem_names` list feeds the two-pass cross-mem resolver
166        // in `push_entities_into_store`.
167        let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
168        let known_suffixes: Vec<String> = mem_names
169            .iter()
170            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
171            .collect();
172        let mut load_warnings: Vec<WarningHint> = Vec::new();
173
174        // Mem-level failures quarantine the mem instead of failing the
175        // workspace (degrade, never disappear — plenum/expertise
176        // 2026-08-06/07, where one broken mem took every healthy
177        // sibling offline). Nothing is weakened: everything that
178        // failed the boot still fails it, the blast radius shrinks to
179        // the one mem, which serves nothing until repaired + reloaded.
180        let mut quarantined: Vec<crate::engine::QuarantinedMem> = Vec::new();
181        let mut quarantined_idx: std::collections::HashSet<usize> =
182            std::collections::HashSet::new();
183
184        for (m_idx, m) in mounted.iter().enumerate() {
185            // Schema-pin authority: the mem's own per-mem config is
186            // the authoritative settled pin, so a copied or cloned mem
187            // resolves its schema from its own backend without consulting
188            // this workspace's `mounts.json`. `Mount.schema` (the mount
189            // record's pin) is the fallback when the config carries no
190            // schema, and an expectation assertion when it does — a
191            // disagreement surfaces a `SchemaPinMismatch` warning rather
192            // than silently preferring either.
193            let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
194            let mount_pin = m.mount.schema.as_ref();
195            // `Mount.schema` is an optional expectation assertion: warn
196            // only when it is set *and* disagrees with the authoritative
197            // config pin.
198            if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
199                && cfg != mp
200            {
201                load_warnings.push(WarningHint::SchemaPinMismatch {
202                    mem: m.mount.mem.clone(),
203                    config_pin: cfg.as_display(),
204                    mount_pin: mp.as_display(),
205                });
206            }
207            // Boot-honesty skew check: a mem whose engine-owned
208            // mutation stamp names a different engine version than
209            // this binary gets a warn-tier hint — informative, never
210            // fatal, and a stamp-less (pre-stamp) mem is silent by
211            // construction. Read-only: the stamp is only ever
212            // rewritten by the next mutation. Full build versions
213            // (semver + git build sha) compare as full strings, so a
214            // rebuild between mutations fires the hint even between
215            // releases; a plain-semver stamp from an older binary
216            // comparing against a sha-carrying build fires too — that
217            // is desired, no migration.
218            if let Some(stamp) = m
219                .mem_config
220                .as_ref()
221                .and_then(|c| c.mutation_stamp.as_ref())
222                && stamp.engine_version != crate::build_info::full_version()
223            {
224                load_warnings.push(WarningHint::EngineVersionSkew {
225                    mem: m.mount.mem.clone(),
226                    stamped_engine: stamp.engine_version.clone(),
227                    running_engine: crate::build_info::full_version().to_string(),
228                    stamped_schema: stamp.schema.clone(),
229                });
230            }
231            // Authoritative pin first (the backend config), then the
232            // mount assertion as fallback when the config carries none.
233            let settled_pin = config_pin.or(mount_pin);
234            // Dual-pin: a mem mid-migration validates against the
235            // migration target, not the settled pin.
236            let Some(effective_pin) = m.mount.migration_target.as_ref().or(settled_pin) else {
237                // Missing pin: quarantine, don't abort the workspace.
238                let e = EngineError::MemConfigIncomplete {
239                    mem: m.mount.mem.clone(),
240                    missing_fields: vec!["schema".to_string()],
241                };
242                quarantined.push(crate::engine::QuarantinedMem {
243                    mount: m.mount.clone(),
244                    reason_code: e.code().to_string(),
245                    reason_message: e.to_string(),
246                });
247                quarantined_idx.insert(m_idx);
248                continue;
249            };
250            let schema = match SchemaResolver::new(&builtin_schemas).resolve(effective_pin) {
251                Ok(schema) => schema,
252                Err(sources) => {
253                    // Unresolvable pin: the plenum failure class —
254                    // quarantine this mem, serve the rest. When the
255                    // pin names a workspace-authored package that
256                    // FAILED to load (e.g. one still on the retired
257                    // `propagating_relationships` key), that load
258                    // failure is the honest reason — not a generic
259                    // not-found.
260                    let failed = failed_schema_packages.iter().find(|f| {
261                        f.name.as_deref() == Some(effective_pin.name.as_str())
262                            && f.version
263                                .as_deref()
264                                .is_none_or(|v| v == effective_pin.version.to_string())
265                    });
266                    let (reason_code, reason_message) = match failed {
267                        Some(f) => (
268                            "SCHEMA_LOAD_FAILED".to_string(),
269                            format!(
270                                "schema package at {} failed to load: {}",
271                                f.path.display(),
272                                f.error
273                            ),
274                        ),
275                        None => {
276                            let e = EngineError::SchemaNotFound {
277                                mem: m.mount.mem.clone(),
278                                pin: effective_pin.as_display(),
279                                sources,
280                                install_hint: None,
281                            };
282                            (e.code().to_string(), e.to_string())
283                        }
284                    };
285                    quarantined.push(crate::engine::QuarantinedMem {
286                        mount: m.mount.clone(),
287                        reason_code,
288                        reason_message,
289                    });
290                    quarantined_idx.insert(m_idx);
291                    continue;
292                }
293            };
294            schemas.insert(m.mount.mem.clone(), schema.clone());
295
296            // Generation-behind hint (warn-tier, ungated, never
297            // blocking): the pin resolved from the BUILT-IN catalogue
298            // and the catalogue registers at least one strictly-higher
299            // version of the same name. Locally-installed
300            // (workspace-storage) pins are silent — the engine only
301            // knows generations for built-ins, and a local install
302            // shadowing a built-in (name, version) counts as local
303            // (that is also the resolver's precedence). Real semver
304            // ordering via `semver::Version`, never string ordering.
305            let locally_installed = workspace_schemas.iter().any(|s| {
306                s.manifest.name == effective_pin.name && s.version == effective_pin.version
307            });
308            let is_builtin = builtin_schemas_only.iter().any(|s| {
309                s.manifest.name == effective_pin.name && s.version == effective_pin.version
310            });
311            if !locally_installed
312                && is_builtin
313                && let Some(newest) =
314                    newest_builtin_version(&effective_pin.name, &builtin_schemas_only)
315                && *newest > effective_pin.version
316            {
317                load_warnings.push(WarningHint::SchemaGenerationsBehind {
318                    mem: m.mount.mem.clone(),
319                    pinned: effective_pin.as_display(),
320                    newest: newest.to_string(),
321                });
322            }
323
324            // Sealed schemas keep loading even when they violate the
325            // heading round-trip rule new installs are refused for —
326            // the violation surfaces as a health finding here, never
327            // as a boot failure (refusing would brick the workspace).
328            if let Err(memstead_schema::SchemaLoadError::SectionHeadingMismatch { violations }) =
329                memstead_schema::check_section_heading_roundtrip(&schema)
330            {
331                let (name, version) = schema.id();
332                load_warnings.push(WarningHint::SchemaHeadingRoundtripViolation {
333                    mem: m.mount.mem.clone(),
334                    schema_ref: format!("{name}@{version}"),
335                    violations: violations.iter().map(Into::into).collect(),
336                });
337            }
338
339            let (entries, read_errors) = match collect_source_entries(m.backend.as_ref()) {
340                Ok(pair) => pair,
341                Err(e) => {
342                    // Backend read failure: quarantine this mem, serve
343                    // the rest.
344                    quarantined.push(crate::engine::QuarantinedMem {
345                        mount: m.mount.clone(),
346                        reason_code: e.code().to_string(),
347                        reason_message: e.to_string(),
348                    });
349                    quarantined_idx.insert(m_idx);
350                    schemas.remove(&m.mount.mem);
351                    continue;
352                }
353            };
354            let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
355            // Wire the LoadCollector so the parser/store-builder
356            // pipeline forwards typed drift warnings
357            // (`SuspiciousNestedPrefix`, `DuplicateSectionHeading`,
358            // `InlineWikiLinkAutoStubbed`) into `load_warnings`.
359            // Mutation paths still pass `None` to stay silent.
360            push_entities_into_store(
361                &mut store,
362                load_result.entities,
363                fallback.as_ref(),
364                Some(crate::entity::store_builder::LoadCollector {
365                    warnings: &mut load_warnings,
366                    known_suffixes: &known_suffixes,
367                    mem_names: &mem_names,
368                }),
369            );
370            load_errors.extend(load_result.errors);
371        }
372
373        // Drop quarantined mounts from the serving roster: a
374        // quarantined mem has no backend in service, no entities in
375        // the store, no schema in the per-mem map — it exists only on
376        // the quarantine roster until repair + reload re-attach it.
377        if !quarantined_idx.is_empty() {
378            let mut keep_idx = 0usize;
379            mounted.retain(|_| {
380                let keep = !quarantined_idx.contains(&keep_idx);
381                keep_idx += 1;
382                keep
383            });
384        }
385
386        // Parse-time relation validation runs after every mount's
387        // entities are loaded so cross-mem target types are
388        // resolvable. Hand-edits, external tooling, and the macOS
389        // app's editor surface can inject relations that bypass
390        // `memstead_relate`; this is the only place those get caught.
391        // Mutation paths pre-validate before writing, so they
392        // never trip the warning post-load.
393        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
394            mounted
395                .iter()
396                .map(|m| (m.mount.mem.clone(), m.mount.capability))
397                .collect();
398        crate::entity::store_builder::validate_loaded_relations(
399            &mut store,
400            &schemas,
401            &mount_caps,
402            &mut load_warnings,
403        );
404
405        // Stamp `EdgeSource::BodyLink` on edges whose rel-type matches
406        // the source mem's `alias_target_rel_type` pointer. Runs
407        // after `validate_loaded_relations` so the surviving relation
408        // set is schema-clean before the labeling pass.
409        crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
410
411        // The nested-prefix drift scan runs per mount, so a cross-mem
412        // link into a mem loaded LATER in the mount order probes an
413        // incomplete store and false-positives on a perfectly valid id
414        // (e.g. `registry--registry-service` referenced from a mem that
415        // mounts before `registry`). Now that every mount is loaded,
416        // drop any hit whose resolved target exists as a real entity —
417        // the same legitimate-cross-mem-reference exemption the
418        // in-batch scan already applies when load order permits.
419        load_warnings.retain(|w| match w {
420            WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
421                store.get(resolved_id).is_none_or(|e| e.stub)
422            }
423            _ => true,
424        });
425
426        // Derive the runtime mem router from the mount list.
427        // Mirrors full's `Engine::from_init` step that registers every
428        // mount with `MemRouterSnapshot` so handlers reach a
429        // consistent writable/visible roster regardless of which
430        // backend serves the mem.
431        let mem_router = build_mem_router_from_mounts(&mounted);
432
433        Ok(Self {
434            mounts: mounted,
435            store,
436            schemas,
437            workspace_schemas,
438            builtin_schemas: builtin_schemas_only,
439            load_errors,
440            community_memo: OnceCell::new(),
441            #[cfg(not(target_arch = "wasm32"))]
442            search_indexes_memo: OnceCell::new(),
443            settings: WorkspaceSettings::default(),
444            create_rule_set_memo: OnceCell::new(),
445            declared_origins: HashMap::new(),
446            workspace_root: None,
447            load_warnings,
448            quarantined,
449            boot_diagnosis: None,
450            pipeline_configs: crate::pipeline_store::BindingConfigs::default(),
451            mem_router: Arc::new(mem_router),
452            backend_factory: crate::workspace_store::instantiate_lean_backend,
453            git_branch_ops: None,
454            event_subscribers: Arc::new(std::sync::Mutex::new(
455                crate::engine::events::SubscriberRegistry::new(),
456            )),
457            pending_mem_changed: Vec::new(),
458            mutation_clock: Arc::new(std::time::SystemTime::now),
459            current_role: crate::vcs::Role::Unspecified,
460        })
461    }
462
463    /// Boot an engine from a workspace root using only lean-flavour
464    /// backends (folder + archive). The MCP filesystem server, the
465    /// CLI's lean dispatcher, and the macOS UniFFI consumer all reach
466    /// the new engine through this entry point — replacing per-flavour
467    /// init code with one call.
468    ///
469    /// Loads the workspace through [`crate::FileWorkspaceStore`],
470    /// instantiates each mount's backend via
471    /// [`crate::instantiate_lean_backend`], and constructs the
472    /// engine via [`Engine::from_mounts`].
473    ///
474    /// Errors:
475    /// - [`Layout::Empty`](crate::Layout) → [`BootError::NotInitialised`]
476    /// - any mount declaring [`crate::workspace::MountStorage::GitBranch`]
477    ///   → [`BootError::Instantiate`] wrapping
478    ///   [`crate::InstantiateError::GitBranchRequiresMemRepoFeature`]
479    /// - underlying store / engine failures lift through the
480    ///   `#[from]` conversions
481    pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
482        use crate::workspace_store::{
483            FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
484            instantiate_lean_backend,
485        };
486
487        let workspace = match detect_layout(workspace_root) {
488            // Standalone collapse: a bare folder mem (`.memstead/config.json`,
489            // no `workspace.toml`) roots as a one-mount workspace rather than
490            // refusing — the lone-mem boot path is the unified one.
491            Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
492                Some(ws) => ws,
493                None => {
494                    return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
495                }
496            },
497            Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
498        };
499
500        let settings = workspace.settings.clone();
501        let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
502            Vec::with_capacity(workspace.mounts.len());
503        // Backend-instantiation failures quarantine the mem instead of
504        // failing the workspace (degrade, never disappear); the roster
505        // entry lands on the engine after construction.
506        let mut instantiate_quarantine: Vec<crate::engine::QuarantinedMem> = Vec::new();
507        for mount in workspace.mounts {
508            match instantiate_lean_backend(&mount) {
509                Ok(backend) => mounts.push((mount, backend)),
510                Err(e) => instantiate_quarantine.push(crate::engine::QuarantinedMem {
511                    reason_code: e.code().to_string(),
512                    reason_message: e.to_string(),
513                    mount,
514                }),
515            }
516        }
517        // Folder-backend authoring path: authored schema packages live
518        // at the fixed `<workspace>/.memstead/schemas/<name>@<version>/`
519        // location — the folder analogue of the git-branch backend's
520        // `__MEMSTEAD:schemas/` ref. Read them through the folder
521        // `SchemaSource` (which no-ops when the directory is absent, so a
522        // workspace that authored no schemas resolves exactly as before —
523        // built-ins only). This is the lean flavour's schema-authoring
524        // path, which it lacked.
525        let fixed_dir = workspace_root.join(".memstead").join("schemas");
526        let (local, failed) = load_workspace_schemas_with_failures(Some(fixed_dir.as_path()));
527        // Root is known here, so an unresolved pin can be enriched with
528        // the never-installed-package hint before it surfaces.
529        let mut engine = Engine::from_mounts_inner(mounts, local, failed)
530            .map_err(|e| e.with_schema_install_probe(Some(workspace_root)))?;
531        engine.quarantined.extend(instantiate_quarantine);
532        engine.set_settings(settings);
533        engine.workspace_root = Some(workspace_root.to_path_buf());
534        // Load the workspace store's pipeline configs — the v2 single-record
535        // binding store — and expose them read-only. A malformed config
536        // surfaces a typed `StoreError::Parse` naming the file (early
537        // validation of operator-edited configs); an absent `projections/`
538        // directory resolves to empty. A pre-v2 store refuses boot with
539        // `StoreError::LegacyProjectionStore` naming `memstead projection
540        // migrate` — the engine never reads a prior generation (2026-07-18
541        // consolidation, no compatibility layer). The migrate command itself
542        // operates below engine boot, so an unmigrated workspace can still
543        // run it.
544        engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
545            workspace_root,
546        )?);
547        // Publish the authoring meta-schemas into `.memstead/meta-schemas/`
548        // so an editor validates authored schema YAML against them
549        // (resolved by each package's `# yaml-language-server:` directive).
550        // Best-effort — a read-only workspace still boots.
551        let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
552        Ok(engine)
553    }
554}
555
556/// Derive a [`MemRouterSnapshot`] from the engine's resolved mount
557/// list. Mirrors full's `Engine::from_init` mount-register loop so the
558/// runtime router carries the same writable/visible roster regardless
559/// of which backend serves each mem.
560///
561/// One pass over the mounts:
562/// - Writable mounts ([`MountCapability::Write`]) register via
563///   `add_writable` with the storage's worktree path. Folder mounts
564///   surface `MountStorage::Folder.path`; git-branch mounts surface
565///   `None` (the mem content lives only inside the gitdir).
566///   Archive mounts should never be writable; if one slips through,
567///   it registers with `dir: None`.
568/// - Read-only folder / git-branch mounts also register via
569///   `add_writable` with `dir: None`, then are *visible-only* —
570///   `is_writable` returns `false` because we follow up with a
571///   `remove_writable` (no-op for archives because archives are
572///   registered as `add_read_only`).
573///
574/// Actually we keep it simple: writable mounts go through
575/// `add_writable`; read-only mounts go through `add_read_only` with
576/// a synthesized archive-style path. For folder/git-branch read-only
577/// mounts we use the path the storage offers as the archive_path
578/// argument — semantically wrong but the router treats
579/// `add_read_only` data as opaque for visibility tracking. The two
580/// callers that care (`archive_path_for_mem`, `dir_for_mem`)
581/// branch on backend type at the handler level rather than reading
582/// these synthesized paths.
583///
584/// Origin is `MemOrigin::ExplicitToml` for every mount built from
585/// `Workspace.mounts` — the file-adapter case. `RuntimeCreated`
586/// origins land when `memstead_mem_create` migrates onto the unified
587/// engine and produces fresh runtime registrations.
588pub(crate) fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
589    let mut router = MemRouterSnapshot::new();
590    for m in mounts {
591        match m.mount.capability {
592            MountCapability::Write => {
593                let dir: Option<PathBuf> = match &m.mount.storage {
594                    MountStorage::Folder { path } => Some(path.clone()),
595                    MountStorage::GitBranch { .. } => None,
596                    MountStorage::Archive { .. } => None,
597                    // In-memory mounts have no on-disk working dir —
598                    // they register writable with `dir: None`, the same
599                    // shape mem-repo-backed mounts use.
600                    MountStorage::InMemory => None,
601                };
602                router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
603            }
604            MountCapability::ReadOnly => match &m.mount.storage {
605                MountStorage::Archive { path } => {
606                    router.add_read_only(m.mount.mem.clone(), path.clone());
607                }
608                MountStorage::Folder { path } => {
609                    router.add_read_only(m.mount.mem.clone(), path.clone());
610                }
611                MountStorage::GitBranch { gitdir, .. } => {
612                    router.add_read_only(m.mount.mem.clone(), gitdir.clone());
613                }
614                // A read-only in-memory mount has no on-disk read
615                // source to register. The engine never produces this
616                // configuration (in-memory mounts are created writable
617                // for ephemeral sessions); handled here only to keep
618                // the match total.
619                MountStorage::InMemory => {}
620            },
621        }
622    }
623    router
624}
625
626/// Public re-export of [`resolve_builtin_schema_pin`] for lifecycle
627/// orchestrators in `memstead-engine`. Mirrors full's
628/// `resolve_mem_schema` against the built-in catalogue;
629/// workspace-schema-registry resolution lifts later.
630pub fn resolve_builtin_schema_pin_pub(
631    pin: &memstead_schema::SchemaRef,
632    catalogue: &[Arc<memstead_schema::Schema>],
633) -> Option<Arc<memstead_schema::Schema>> {
634    resolve_builtin_schema_pin(pin, catalogue)
635}
636
637/// The newest version registered in the built-in catalogue under
638/// `name` — real `semver::Version` ordering (0.10.0 beats 0.9.0),
639/// never string ordering. `None` when no built-in carries the name.
640/// Feeds the `SCHEMA_GENERATIONS_BEHIND` boot hint.
641fn newest_builtin_version<'a>(
642    name: &str,
643    builtins: &'a [Arc<memstead_schema::Schema>],
644) -> Option<&'a semver::Version> {
645    builtins
646        .iter()
647        .filter(|s| s.manifest.name == name)
648        .map(|s| &s.version)
649        .max()
650}
651
652/// The engine's schema-pin resolver — the single named entry point a
653/// load path resolves a `name@version` pin through. Consults schema
654/// sources in a fixed order: **local storage** (the mem's own storage
655/// backend — folder `.memstead/schemas/` or the git-branch
656/// `__MEMSTEAD:schemas/` ref, layered first into the catalogue so it
657/// wins on `(name, version)` collision), **built-in** (compiled into the
658/// binary), **remote** (memstead.io, reserved, not implemented). The
659/// order is fixed in code — local-over-built-in by the catalogue's
660/// insertion precedence, remote always last. On a miss it yields the
661/// per-source [`SchemaSourceDiagnostic`] trail the `SCHEMA_NOT_FOUND`
662/// envelope carries.
663///
664/// Holds a borrowed view of the merged catalogue (`local ⧺ built-in`)
665/// the boot / register paths assemble, so resolution allocates nothing.
666pub struct SchemaResolver<'a> {
667    catalogue: &'a [Arc<memstead_schema::Schema>],
668}
669
670impl<'a> SchemaResolver<'a> {
671    /// Wrap the merged resolution catalogue (workspace-authored schemas
672    /// layered over the built-in set, local winning on collision).
673    pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
674        Self { catalogue }
675    }
676
677    /// Resolve a pin to its schema, or the fixed-order source
678    /// diagnostics on a miss (fed straight into
679    /// `EngineError::SchemaNotFound`'s `sources`).
680    pub fn resolve(
681        &self,
682        pin: &memstead_schema::SchemaRef,
683    ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
684    {
685        resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
686            crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
687                &pin.name,
688                &pin.version,
689                self.catalogue,
690            )
691        })
692    }
693}
694
695/// Walk `schemas_dir` and load every immediate subdirectory as a
696/// workspace-authored schema. Each subdirectory must contain a
697/// `schema.yaml` manifest (and optional `types/*.yaml`) — silently
698/// skips entries that don't carry the manifest. `pub` so the folder
699/// `SchemaSource` and the below-boot repair path (memstead-git-branch)
700/// read through the same walker the boot path uses — one loader, no
701/// resolution fork between the booted and below-boot surfaces.
702pub fn load_workspace_schemas(
703    schemas_dir: Option<&Path>,
704) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
705    Ok(load_workspace_schemas_with_failures(schemas_dir).0)
706}
707
708/// One workspace-authored schema package that failed to load — the
709/// package is SKIPPED (never fails the boot; degrade, never
710/// disappear), and a mem pinning it quarantines with this failure as
711/// its typed reason. `name`/`version` are best-effort peeks at the
712/// package's `schema.yaml` header so the pin match works even though
713/// the full load refused.
714#[derive(Debug, Clone)]
715pub struct FailedSchemaPackage {
716    pub path: PathBuf,
717    pub name: Option<String>,
718    pub version: Option<String>,
719    /// The loader's typed failure, rendered.
720    pub error: String,
721}
722
723/// Tolerant form of [`load_workspace_schemas`]: broken packages are
724/// skipped and recorded instead of failing the whole walk (the
725/// historical `?` made one refusing package — e.g. a schema still on
726/// the retired `propagating_relationships` key after a binary
727/// upgrade — take every mem in the workspace down).
728pub fn load_workspace_schemas_with_failures(
729    schemas_dir: Option<&Path>,
730) -> (Vec<Arc<memstead_schema::Schema>>, Vec<FailedSchemaPackage>) {
731    let Some(dir) = schemas_dir else {
732        return (Vec::new(), Vec::new());
733    };
734    if !dir.is_dir() {
735        return (Vec::new(), Vec::new());
736    }
737    let entries = match std::fs::read_dir(dir) {
738        Ok(e) => e,
739        Err(_) => return (Vec::new(), Vec::new()),
740    };
741    let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
742    let mut failures: Vec<FailedSchemaPackage> = Vec::new();
743    for entry in entries.flatten() {
744        let path = entry.path();
745        if !path.is_dir() {
746            continue;
747        }
748        if !path.join("schema.yaml").is_file() {
749            continue;
750        }
751        match memstead_schema::load_schema_from_dir(&path) {
752            Ok(schema) => schemas.push(Arc::new(schema)),
753            Err(e) => {
754                // Best-effort header peek without a YAML dependency:
755                // top-level `name:` / `version:` are single-line
756                // scalars in every real package.
757                let header = std::fs::read_to_string(path.join("schema.yaml")).unwrap_or_default();
758                let peek = |k: &str| {
759                    header
760                        .lines()
761                        .find_map(|l| l.strip_prefix(&format!("{k}:")))
762                        .map(|v| v.trim().trim_matches('"').to_string())
763                        .filter(|v| !v.is_empty())
764                };
765                failures.push(FailedSchemaPackage {
766                    path: path.clone(),
767                    name: peek("name"),
768                    version: peek("version"),
769                    error: e.to_string(),
770                });
771            }
772        }
773    }
774    (schemas, failures)
775}
776
777pub(super) fn resolve_builtin_schema_pin(
778    pin: &memstead_schema::SchemaRef,
779    catalogue: &[Arc<memstead_schema::Schema>],
780) -> Option<Arc<memstead_schema::Schema>> {
781    catalogue
782        .iter()
783        .find(|s| {
784            let id = s.id();
785            id.0 == pin.name && id.1 == pin.version
786        })
787        .cloned()
788}
789
790pub(super) fn collect_source_entries(
791    backend: &dyn MemBackend,
792) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
793    let paths = backend.list_entities()?;
794    let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
795    let mut errors: Vec<SourceReadError> = Vec::new();
796    for path in paths {
797        match backend.read_entity(&path) {
798            Ok(Some(bytes)) => match String::from_utf8(bytes) {
799                Ok(content) => entries.push(SourceEntry {
800                    relative_path: path.to_string_lossy().into_owned(),
801                    source_path: path.clone(),
802                    content,
803                }),
804                Err(e) => errors.push(SourceReadError {
805                    source_path: path,
806                    error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
807                }),
808            },
809            Ok(None) => {
810                // Listed-but-absent: list/read race. Skip silently.
811            }
812            Err(e) => errors.push(SourceReadError {
813                source_path: path,
814                error: std::io::Error::other(e.to_string()),
815            }),
816        }
817    }
818    Ok((entries, errors))
819}
820
821#[cfg(test)]
822mod tests {
823
824    use std::path::Path;
825
826    use memstead_schema::SchemaRef;
827    use tempfile::TempDir;
828
829    use crate::backend::MemBackend;
830    use crate::engine::test_helpers::*;
831    use crate::engine::{Engine, EngineError};
832    use crate::ops::WarningHint;
833    use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
834    use crate::vcs::CommitContext;
835    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
836
837    /// The `SchemaResolver` resolves a pin against the catalogue and, on
838    /// a miss, yields the fixed-order (`local_storage` → `builtin` →
839    /// `remote`) source diagnostics the `SCHEMA_NOT_FOUND` envelope carries.
840    #[test]
841    fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
842        let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
843        let resolver = super::SchemaResolver::new(&catalogue);
844
845        let ok: SchemaRef = "default@1.0.0".parse().unwrap();
846        assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
847
848        let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
849        let sources = resolver.resolve(&miss).unwrap_err();
850        let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
851        assert_eq!(labels, ["local_storage", "builtin", "remote"]);
852        assert!(sources.iter().all(|s| !s.pinned_version_match));
853    }
854
855    #[test]
856    fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
857        let engine = Engine::from_mounts(Vec::new()).unwrap();
858        assert!(engine.mem_names().is_empty());
859        match engine.list_entities("missing") {
860            Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
861            other => panic!("expected UnknownMem, got {other:?}"),
862        }
863    }
864
865    #[test]
866    fn duplicate_mem_names_rejected_at_construction() {
867        let tmp = TempDir::new().unwrap();
868        let writer1: Box<dyn MemBackend> =
869            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
870        let writer2: Box<dyn MemBackend> =
871            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
872        let err = Engine::from_mounts(vec![
873            (folder_mount("specs", tmp.path().to_path_buf()), writer1),
874            (folder_mount("specs", tmp.path().to_path_buf()), writer2),
875        ])
876        .unwrap_err();
877        assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
878    }
879
880    #[test]
881    fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
882        // A markdown file with the same `## Identity` heading twice
883        // should cause the parser to emit a typed
884        // `DuplicateSectionHeading` warning. With the
885        // LoadCollector wiring, that warning lands on
886        // `engine.load_warnings()`.
887        let tmp = TempDir::new().unwrap();
888        let mem_dir = tmp.path().to_path_buf();
889        let body =
890            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
891        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
892
893        let writer = FilesystemMemWriter::new(mem_dir.clone());
894        let engine = Engine::from_mounts(vec![(
895            folder_mount("specs", mem_dir),
896            Box::new(writer) as Box<dyn MemBackend>,
897        )])
898        .unwrap();
899
900        let warnings = engine.load_warnings();
901        assert!(
902            warnings
903                .iter()
904                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
905            "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
906        );
907    }
908
909    /// Generation-behind hint: a mem pinning an OLD built-in
910    /// generation (`default@1.0.0`; the catalogue retains up to
911    /// 1.2.0) boots with the warn-tier `SCHEMA_GENERATIONS_BEHIND`
912    /// naming the pinned ref and the newest version — and the hint
913    /// never blocks: the boot serves and mutations succeed. A mem
914    /// pinning the NEWEST generation stays silent, so its health
915    /// output is unchanged.
916    #[test]
917    fn generation_behind_hint_fires_for_old_builtin_pin_only() {
918        // Old pin → hint, non-blocking.
919        let tmp = TempDir::new().unwrap();
920        let mem_dir = tmp.path().to_path_buf();
921        let writer = FilesystemMemWriter::new(mem_dir.clone());
922        let mut engine = Engine::from_mounts(vec![(
923            folder_mount("specs", mem_dir),
924            Box::new(writer) as Box<dyn MemBackend>,
925        )])
926        .unwrap();
927        let behind: Vec<_> = engine
928            .load_warnings()
929            .iter()
930            .filter_map(|w| match w {
931                WarningHint::SchemaGenerationsBehind {
932                    mem,
933                    pinned,
934                    newest,
935                } => Some((mem.clone(), pinned.clone(), newest.clone())),
936                _ => None,
937            })
938            .collect();
939        assert_eq!(
940            behind,
941            vec![(
942                "specs".to_string(),
943                "default@1.0.0".to_string(),
944                "1.3.0".to_string()
945            )],
946            "old built-in pin must surface the generation-behind hint"
947        );
948        assert!(
949            engine
950                .health()
951                .warnings
952                .iter()
953                .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
954            "the hint rides health without an include gate"
955        );
956        // Never blocking: the warned mem still mutates.
957        engine
958            .create_entity_with_ctx(
959                crate::engine::CreateEntityArgs {
960                    anchors: Vec::new(),
961                    mem: "specs".to_string(),
962                    title: "Still writable".to_string(),
963                    entity_type: "spec".to_string(),
964                    sections: indexmap::IndexMap::from_iter([
965                        ("identity".to_string(), "i".to_string()),
966                        ("purpose".to_string(), "p".to_string()),
967                    ]),
968                    metadata: indexmap::IndexMap::new(),
969                    relations: Vec::new(),
970                    dry_run: false,
971                },
972                &crate::vcs::CommitContext::internal(),
973            )
974            .expect("generation-behind hint must never block mutations");
975
976        // Newest pin → silent (health output unchanged).
977        let tmp = TempDir::new().unwrap();
978        let mem_dir = tmp.path().to_path_buf();
979        let writer = FilesystemMemWriter::new(mem_dir.clone());
980        let mut mount = folder_mount("specs", mem_dir);
981        mount.schema = Some("default@1.3.0".parse().unwrap());
982        let engine =
983            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
984        assert!(
985            !engine
986                .load_warnings()
987                .iter()
988                .any(|w| w.code() == "SCHEMA_GENERATIONS_BEHIND"),
989            "newest built-in pin must stay silent: {:?}",
990            engine.load_warnings()
991        );
992        let health_json = serde_json::to_string(&engine.health().warnings).unwrap();
993        assert!(
994            !health_json.contains("SCHEMA_GENERATIONS_BEHIND"),
995            "newest pin: health output carries no generation hint"
996        );
997    }
998
999    /// The newest-generation lookup uses real semver ordering — a
1000    /// two-digit minor beats a one-digit one (string ordering would
1001    /// invert them).
1002    #[test]
1003    fn newest_builtin_version_orders_by_semver_not_string() {
1004        let manifest = |version: &str| {
1005            format!(
1006                r#"name: gen-test
1007version: {version}
1008description: test
1009when_to_use: test
1010types:
1011  - note
1012relationships:
1013  mode: strict
1014  definitions:
1015    - name: _default
1016      description: default
1017      default_weight: 1.0
1018    - name: PART_OF
1019      description: hier
1020      default_weight: 3.0
1021community:
1022  resolution: 1.0
1023  seed: 42
1024"#
1025            )
1026        };
1027        let type_yaml = r#"name: note
1028description: test
1029when_to_use: test
1030sections:
1031  - key: body
1032    heading: Body
1033    required: true
1034    search_weight: 10.0
1035    catch_all: true
1036metadata_fields: []
1037title_weight: 1.0
1038text_fields: [body]
1039hierarchy_relationship: PART_OF
1040no_self_loop_relationships: []
1041updatable_fields: [title, body]
1042health_required_fields: [body]
1043staleness_threshold_days: 30
1044write_rules: []
1045"#;
1046        let types = vec![("note".to_string(), type_yaml.to_string())];
1047        let catalogue: Vec<std::sync::Arc<memstead_schema::Schema>> = ["0.9.0", "0.10.0", "0.2.0"]
1048            .iter()
1049            .map(|v| {
1050                std::sync::Arc::new(
1051                    memstead_schema::load_schema_from_memory(&manifest(v), &types)
1052                        .expect("fixture schema loads"),
1053                )
1054            })
1055            .collect();
1056        let newest = super::newest_builtin_version("gen-test", &catalogue)
1057            .expect("name present in catalogue");
1058        assert_eq!(newest.to_string(), "0.10.0", "semver, not string, ordering");
1059        assert!(super::newest_builtin_version("absent", &catalogue).is_none());
1060    }
1061
1062    /// Parse-time relation validation drops relations whose `rel_type`
1063    /// is not declared in the source mem's strict-mode schema and
1064    /// emits `PARSED_RELATION_INVALID { reason: "unknown_rel_type" }`.
1065    /// The entity itself loads normally; only the bad relation goes
1066    /// missing from the in-memory store.
1067    #[test]
1068    fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
1069        let tmp = TempDir::new().unwrap();
1070        let mem_dir = tmp.path().to_path_buf();
1071        // Hand-authored markdown with a `## Relationships` entry whose
1072        // type isn't declared in the default schema (strict mode).
1073        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1074        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
1075        std::fs::write(mem_dir.join("target.md"), target).unwrap();
1076        std::fs::write(mem_dir.join("source.md"), source).unwrap();
1077
1078        let writer = FilesystemMemWriter::new(mem_dir.clone());
1079        let engine = Engine::from_mounts(vec![(
1080            folder_mount("specs", mem_dir),
1081            Box::new(writer) as Box<dyn MemBackend>,
1082        )])
1083        .unwrap();
1084
1085        let source_id = crate::entity::EntityId::new("specs", "source");
1086        let target_id = crate::entity::EntityId::new("specs", "target");
1087        let source_entity = engine.get_entity(&source_id).expect("source loaded");
1088        // The offending relation does not survive into the entity's
1089        // in-memory relationships list.
1090        assert!(
1091            source_entity.relationships.is_empty(),
1092            "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
1093            source_entity.relationships,
1094        );
1095        // Nor into the store's edge index.
1096        let outgoing: Vec<_> = engine
1097            .store()
1098            .outgoing(&source_id)
1099            .iter()
1100            .filter(|e| e.rel_type == "MADE_UP_TYPE")
1101            .collect();
1102        assert!(
1103            outgoing.is_empty(),
1104            "MADE_UP_TYPE edge must be dropped from the store"
1105        );
1106        // The warning surfaces with the correct payload.
1107        let parsed_invalid: Vec<_> = engine
1108            .load_warnings()
1109            .iter()
1110            .filter_map(|w| match w {
1111                WarningHint::ParsedRelationInvalid {
1112                    entity_id,
1113                    rel_type,
1114                    target,
1115                    reason,
1116                    origin,
1117                    recovery,
1118                } => Some((
1119                    entity_id.clone(),
1120                    rel_type.clone(),
1121                    target.clone(),
1122                    reason.clone(),
1123                    origin.clone(),
1124                    recovery.clone(),
1125                )),
1126                _ => None,
1127            })
1128            .collect();
1129        assert_eq!(
1130            parsed_invalid.len(),
1131            1,
1132            "expected one warning, got {parsed_invalid:?}"
1133        );
1134        assert_eq!(parsed_invalid[0].0, source_id);
1135        assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
1136        assert_eq!(parsed_invalid[0].2, target_id);
1137        assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
1138        assert_eq!(parsed_invalid[0].4, "writable");
1139        // Writable-origin warnings carry the abstract recovery action.
1140        let recovery = parsed_invalid[0]
1141            .5
1142            .as_ref()
1143            .expect("writable-origin warning must carry recovery");
1144        assert_eq!(
1145            recovery.kind,
1146            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1147        );
1148        assert_eq!(recovery.source_id, parsed_invalid[0].0);
1149        assert_eq!(recovery.target_id, parsed_invalid[0].2);
1150        assert_eq!(recovery.rel_type, parsed_invalid[0].1);
1151    }
1152
1153    /// Hand-edited markdown can inject a cycle in an `acyclic: true`
1154    /// rel-type's subgraph — the mutation surface's `would_cycle`
1155    /// guard never fires for that path. The boot validator's
1156    /// second pass finds the back-edge and drops it with
1157    /// `reason: "cycle"`. The entity itself loads normally; one of
1158    /// the two cycle-closing edges goes missing from the in-memory
1159    /// store; the other survives.
1160    #[test]
1161    fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
1162        let tmp = TempDir::new().unwrap();
1163        let mem_dir = tmp.path().to_path_buf();
1164        // Mutual PART_OF — acyclic in the default schema. The
1165        // wiki-link grammar admits both as well-formed cross-
1166        // references, so only the cycle pass can catch this.
1167        let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
1168        let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
1169        std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
1170        std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
1171
1172        let writer = FilesystemMemWriter::new(mem_dir.clone());
1173        let engine = Engine::from_mounts(vec![(
1174            folder_mount("specs", mem_dir),
1175            Box::new(writer) as Box<dyn MemBackend>,
1176        )])
1177        .unwrap();
1178
1179        let alpha_id = crate::entity::EntityId::new("specs", "alpha");
1180        let beta_id = crate::entity::EntityId::new("specs", "beta");
1181
1182        // Both entities are real — only the relation in the cycle
1183        // gets dropped.
1184        assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
1185        assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
1186
1187        // Exactly one of the two PART_OF edges survives — the cycle
1188        // is broken by dropping a single back-edge.
1189        let surviving: Vec<_> = engine
1190            .store()
1191            .all_entities()
1192            .flat_map(|e| {
1193                engine
1194                    .store()
1195                    .outgoing(&e.id)
1196                    .iter()
1197                    .filter(|edge| edge.rel_type == "PART_OF")
1198                    .map(|edge| (e.id.clone(), edge.target.clone()))
1199                    .collect::<Vec<_>>()
1200            })
1201            .collect();
1202        assert_eq!(
1203            surviving.len(),
1204            1,
1205            "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
1206        );
1207
1208        // The warning surfaces with `reason: "cycle"` and names the
1209        // dropped pair.
1210        let cycle_drops: Vec<_> = engine
1211            .load_warnings()
1212            .iter()
1213            .filter_map(|w| match w {
1214                WarningHint::ParsedRelationInvalid {
1215                    entity_id,
1216                    rel_type,
1217                    target,
1218                    reason,
1219                    ..
1220                } if reason == "cycle" => {
1221                    Some((entity_id.clone(), rel_type.clone(), target.clone()))
1222                }
1223                _ => None,
1224            })
1225            .collect();
1226        assert_eq!(
1227            cycle_drops.len(),
1228            1,
1229            "exactly one cycle warning must fire, got {cycle_drops:?}",
1230        );
1231        // The dropped edge is one of the two PART_OF entries.
1232        let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
1233        assert_eq!(dropped_rel_type, "PART_OF");
1234        let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
1235        let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
1236        assert!(
1237            is_alpha_to_beta || is_beta_to_alpha,
1238            "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
1239        );
1240        // And the surviving edge isn't the same as the dropped one.
1241        assert_ne!(
1242            (&surviving[0].0, &surviving[0].1),
1243            (dropped_from, dropped_to),
1244            "surviving edge must differ from the dropped one",
1245        );
1246    }
1247
1248    /// The per-mount nested-prefix drift scan probes an incomplete
1249    /// store: a cross-mem link into a mem loaded LATER in the mount
1250    /// order can't see the real target yet and would false-positive on
1251    /// a perfectly valid id whose slug repeats its mem name (the
1252    /// `registry--registry-service` case). The post-load sweep must
1253    /// drop that hit — while a genuine drift link (target never
1254    /// materialises as a real entity) keeps its warning.
1255    #[test]
1256    fn nested_prefix_warning_exempts_real_cross_mem_target_loaded_later() {
1257        let tmp = TempDir::new().unwrap();
1258        let project_dir = tmp.path().join("project");
1259        let registry_dir = tmp.path().join("registry");
1260        std::fs::create_dir_all(&project_dir).unwrap();
1261        std::fs::create_dir_all(&registry_dir).unwrap();
1262
1263        // Mount 1 (loads first) links both a real later-loaded entity
1264        // and a genuinely missing one.
1265        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nReal: [[registry--registry-service]]. Drifted: [[registry--never-created]].\n";
1266        std::fs::write(project_dir.join("source.md"), source).unwrap();
1267
1268        // Mount 2 (loads second) carries the real target whose slug
1269        // repeats its mem name — the shape the heuristic suspects.
1270        let service = "---\ntype: spec\n---\n# Registry Service\n\n## Identity\n\nA real entity.\n";
1271        std::fs::write(registry_dir.join("registry-service.md"), service).unwrap();
1272
1273        let engine = Engine::from_mounts(vec![
1274            (
1275                folder_mount("project", project_dir.clone()),
1276                Box::new(FilesystemMemWriter::new(project_dir)) as Box<dyn MemBackend>,
1277            ),
1278            (
1279                folder_mount("registry", registry_dir.clone()),
1280                Box::new(FilesystemMemWriter::new(registry_dir)) as Box<dyn MemBackend>,
1281            ),
1282        ])
1283        .unwrap();
1284
1285        let nested: Vec<_> = engine
1286            .load_warnings()
1287            .iter()
1288            .filter_map(|w| match w {
1289                WarningHint::SuspiciousNestedPrefix { resolved_id, .. } => {
1290                    Some(resolved_id.to_string())
1291                }
1292                _ => None,
1293            })
1294            .collect();
1295        assert!(
1296            !nested.contains(&"registry--registry-service".to_string()),
1297            "a valid cross-mem id resolving to a real entity must not warn, got {nested:?}",
1298        );
1299        assert!(
1300            nested.contains(&"registry--never-created".to_string()),
1301            "a genuinely unresolved nested-prefix link must keep its warning, got {nested:?}",
1302        );
1303    }
1304
1305    /// Shape-invalid relations on a writable-origin mount get dropped
1306    /// with `reason: "shape"`; the warning carries a
1307    /// `remove_explicit_relation` recovery hint whose ids and rel-type
1308    /// mirror the warning's top-level fields. Same envelope shape as
1309    /// the `unknown_rel_type` reason — `reason` discriminates the
1310    /// cause; `recovery.kind` discriminates the action. Uses a
1311    /// synthetic schema with `source_types` / `target_types`
1312    /// constraints because the default schema's rel-types are
1313    /// unconstrained.
1314    #[test]
1315    fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
1316        use crate::engine::test_helpers::write_schema_files_with_default_type;
1317
1318        let tmp = TempDir::new().unwrap();
1319        let schemas_dir = tmp.path().join("schemas");
1320        std::fs::create_dir_all(&schemas_dir).unwrap();
1321        // A schema declaring a single rel-type whose shape only
1322        // admits `actor -> doc`. The source markdown below uses
1323        // `doc -> doc`, which trips the shape validator.
1324        let manifest = r#"name: shape-test
1325version: 0.1.0
1326description: shape-constraint schema
1327when_to_use: tests
1328types:
1329  - doc
1330  - actor
1331relationships:
1332  mode: strict
1333  definitions:
1334    - name: OWNS
1335      description: actor owns doc
1336      default_weight: 1.0
1337      source_types: [actor]
1338      target_types: [doc]
1339    - name: _default
1340      description: fallback
1341      default_weight: 1.0
1342community:
1343  resolution: 1.0
1344  seed: 42
1345"#;
1346        write_schema_files_with_default_type(
1347            &schemas_dir,
1348            "shape-test",
1349            manifest,
1350            &["doc", "actor"],
1351        );
1352
1353        let mem_dir = tmp.path().join("mem");
1354        std::fs::create_dir_all(&mem_dir).unwrap();
1355        // Source is type `doc`; target is also type `doc`. The
1356        // declared `OWNS` rel-type expects `actor -> doc`, so the
1357        // shape check rejects this pair at load.
1358        let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
1359        let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
1360        std::fs::write(mem_dir.join("target.md"), target).unwrap();
1361        std::fs::write(mem_dir.join("source.md"), source).unwrap();
1362
1363        let writer = FilesystemMemWriter::new(mem_dir.clone());
1364        let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
1365        let mount = Mount {
1366            mem: "specs".to_string(),
1367            schema: Some(pin),
1368            storage: MountStorage::Folder { path: mem_dir },
1369            capability: MountCapability::Write,
1370            lifecycle: MountLifecycle::Eager,
1371            cross_linkable: true,
1372            migration_target: None,
1373        };
1374        let engine = Engine::from_mounts_with_schemas_dir(
1375            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1376            Some(&schemas_dir),
1377        )
1378        .unwrap();
1379
1380        let source_id = crate::entity::EntityId::new("specs", "source");
1381        let target_id = crate::entity::EntityId::new("specs", "target");
1382
1383        let shape_drops: Vec<_> = engine
1384            .load_warnings()
1385            .iter()
1386            .filter_map(|w| match w {
1387                WarningHint::ParsedRelationInvalid {
1388                    entity_id,
1389                    rel_type,
1390                    target,
1391                    reason,
1392                    origin,
1393                    recovery,
1394                } if reason == "shape" => Some((
1395                    entity_id.clone(),
1396                    rel_type.clone(),
1397                    target.clone(),
1398                    origin.clone(),
1399                    recovery.clone(),
1400                )),
1401                _ => None,
1402            })
1403            .collect();
1404        assert_eq!(
1405            shape_drops.len(),
1406            1,
1407            "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
1408            engine.load_warnings(),
1409        );
1410        let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
1411            shape_drops.into_iter().next().unwrap();
1412        assert_eq!(drop_from, source_id);
1413        assert_eq!(drop_type, "OWNS");
1414        assert_eq!(drop_to, target_id);
1415        assert_eq!(drop_origin, "writable");
1416        // Recovery mirrors the warning's top-level fields and names
1417        // the abstract `remove_explicit_relation` action.
1418        let recovery = drop_recovery.expect("writable origin must carry recovery");
1419        assert_eq!(
1420            recovery.kind,
1421            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
1422        );
1423        assert_eq!(recovery.source_id, source_id);
1424        assert_eq!(recovery.target_id, target_id);
1425        assert_eq!(recovery.rel_type, "OWNS");
1426    }
1427
1428    /// Read-only-origin warnings omit the recovery hint — the engine
1429    /// cannot rewrite a read-only mount's markdown, so no abstract
1430    /// action is available. The message field still names the
1431    /// operator-level path (uninstall the archive or accept the
1432    /// drift); structured consumers branch on `recovery.is_none()`.
1433    #[test]
1434    fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
1435        let tmp = TempDir::new().unwrap();
1436        // Archive content with a `MADE_UP_TYPE` row that the schema
1437        // does not declare — parses to a `PARSED_RELATION_INVALID`
1438        // with `reason: "unknown_rel_type"` on a read-only mount.
1439        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1440        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1441        let archive_path = build_archive(
1442            tmp.path(),
1443            "ext",
1444            &[
1445                ("target.md", target.as_bytes()),
1446                ("source.md", source.as_bytes()),
1447            ],
1448        );
1449
1450        let engine = Engine::from_mounts(vec![(
1451            archive_mount("external", archive_path.clone()),
1452            Box::new(ArchiveBackend::new(archive_path)),
1453        )])
1454        .unwrap();
1455
1456        let invalid: Vec<_> = engine
1457            .load_warnings()
1458            .iter()
1459            .filter_map(|w| match w {
1460                WarningHint::ParsedRelationInvalid {
1461                    rel_type,
1462                    reason,
1463                    origin,
1464                    recovery,
1465                    ..
1466                } => Some((
1467                    rel_type.clone(),
1468                    reason.clone(),
1469                    origin.clone(),
1470                    recovery.clone(),
1471                )),
1472                _ => None,
1473            })
1474            .collect();
1475        assert_eq!(
1476            invalid.len(),
1477            1,
1478            "expected one parse-time drop on the readonly mount, got {invalid:?}",
1479        );
1480        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1481        assert_eq!(invalid[0].1, "unknown_rel_type");
1482        assert_eq!(invalid[0].2, "readonly");
1483        assert!(
1484            invalid[0].3.is_none(),
1485            "readonly-origin warning must omit the recovery hint, got {:?}",
1486            invalid[0].3,
1487        );
1488    }
1489
1490    #[test]
1491    fn load_on_init_populates_store_from_folder_mount() {
1492        // Real markdown content: minimal but parses cleanly against
1493        // the builtin default schema.
1494        let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1495
1496        let tmp = TempDir::new().unwrap();
1497        let mem_dir = tmp.path().to_path_buf();
1498        let writer = FilesystemMemWriter::new(mem_dir.clone());
1499        <FilesystemMemWriter as MemWriter>::write_entity(
1500            &writer,
1501            Path::new("hello.md"),
1502            body.as_bytes(),
1503        )
1504        .unwrap();
1505        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1506            .unwrap();
1507
1508        let engine = Engine::from_mounts(vec![(
1509            folder_mount("specs", mem_dir),
1510            Box::new(writer) as Box<dyn MemBackend>,
1511        )])
1512        .unwrap();
1513
1514        // Store is populated.
1515        assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1516        let id = crate::EntityId::new("specs", "hello");
1517        let entity = engine.get_entity(&id).expect("entity must be present");
1518        assert_eq!(entity.title, "Hello");
1519        assert_eq!(entity.entity_type, "spec");
1520        assert!(engine.load_errors().is_empty());
1521        // Schema map carries one entry per mount.
1522        assert_eq!(engine.schemas().len(), 1);
1523        assert!(engine.schemas().contains_key("specs"));
1524    }
1525
1526    #[test]
1527    fn load_on_init_populates_store_from_archive_mount() {
1528        let body =
1529            "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1530
1531        let tmp = TempDir::new().unwrap();
1532        let archive_path =
1533            build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1534
1535        let engine = Engine::from_mounts(vec![(
1536            archive_mount("external", archive_path.clone()),
1537            Box::new(ArchiveBackend::new(archive_path)),
1538        )])
1539        .unwrap();
1540
1541        let id = crate::EntityId::new("external", "from-archive");
1542        let entity = engine.get_entity(&id).expect("entity must be present");
1543        assert_eq!(entity.title, "From Archive");
1544        assert!(engine.load_errors().is_empty());
1545    }
1546
1547    #[test]
1548    fn load_on_init_populates_store_from_heterogeneous_mounts() {
1549        let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1550        let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1551
1552        let tmp = TempDir::new().unwrap();
1553
1554        let folder_dir = tmp.path().join("folder-mem");
1555        std::fs::create_dir_all(&folder_dir).unwrap();
1556        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1557        <FilesystemMemWriter as MemWriter>::write_entity(
1558            &folder_writer,
1559            Path::new("local.md"),
1560            folder_body.as_bytes(),
1561        )
1562        .unwrap();
1563        <FilesystemMemWriter as MemWriter>::commit(
1564            &folder_writer,
1565            "seed",
1566            &CommitContext::internal(),
1567        )
1568        .unwrap();
1569
1570        let archive_path = build_archive(
1571            tmp.path(),
1572            "external",
1573            &[("external.md", archive_body.as_bytes())],
1574        );
1575
1576        let engine = Engine::from_mounts(vec![
1577            (
1578                folder_mount("local", folder_dir),
1579                Box::new(folder_writer) as Box<dyn MemBackend>,
1580            ),
1581            (
1582                archive_mount("external", archive_path.clone()),
1583                Box::new(ArchiveBackend::new(archive_path)),
1584            ),
1585        ])
1586        .unwrap();
1587
1588        // Both mems' entities live in one shared store.
1589        assert_eq!(engine.store().len(), 2);
1590        assert!(
1591            engine
1592                .get_entity(&crate::EntityId::new("local", "local"))
1593                .is_some()
1594        );
1595        assert!(
1596            engine
1597                .get_entity(&crate::EntityId::new("external", "external"))
1598                .is_some()
1599        );
1600    }
1601
1602    #[test]
1603    fn load_on_init_collects_per_file_parse_errors_without_failing() {
1604        // One good file + one with malformed frontmatter — the parser
1605        // produces an error for the malformed file but the good one
1606        // still loads.
1607        let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1608        let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1609
1610        let tmp = TempDir::new().unwrap();
1611        let mem_dir = tmp.path().to_path_buf();
1612        let writer = FilesystemMemWriter::new(mem_dir.clone());
1613        <FilesystemMemWriter as MemWriter>::write_entity(
1614            &writer,
1615            Path::new("good.md"),
1616            good.as_bytes(),
1617        )
1618        .unwrap();
1619        <FilesystemMemWriter as MemWriter>::write_entity(
1620            &writer,
1621            Path::new("bad.md"),
1622            bad.as_bytes(),
1623        )
1624        .unwrap();
1625        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1626            .unwrap();
1627
1628        let engine = Engine::from_mounts(vec![(
1629            folder_mount("specs", mem_dir),
1630            Box::new(writer) as Box<dyn MemBackend>,
1631        )])
1632        .unwrap();
1633
1634        // The good entity is in the store; construction did not fail.
1635        assert!(
1636            engine
1637                .get_entity(&crate::EntityId::new("specs", "good"))
1638                .is_some(),
1639            "good.md must parse and reach the store"
1640        );
1641        // Either bad.md surfaces as a load error, or it parses
1642        // permissively — both are acceptable outcomes here. The
1643        // contract under test is "construction does not fail on a
1644        // single bad file".
1645        let bad_known_to_engine = engine
1646            .get_entity(&crate::EntityId::new("specs", "bad"))
1647            .is_some()
1648            || !engine.load_errors().is_empty();
1649        assert!(
1650            bad_known_to_engine,
1651            "bad.md must either parse or surface in load_errors"
1652        );
1653    }
1654
1655    #[test]
1656    fn empty_mount_list_yields_empty_store() {
1657        let engine = Engine::from_mounts(Vec::new()).unwrap();
1658        assert!(engine.store().is_empty());
1659        assert!(engine.schemas().is_empty());
1660        assert!(engine.load_errors().is_empty());
1661    }
1662
1663    // ---- Engine::create_entity --------------------------------------
1664
1665    #[test]
1666    fn from_workspace_root_errors_for_empty_layout() {
1667        let tmp = TempDir::new().unwrap();
1668        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1669        match err {
1670            crate::BootError::NotInitialised(p) => {
1671                assert_eq!(p, tmp.path());
1672            }
1673            other => panic!("expected NotInitialised, got {other:?}"),
1674        }
1675    }
1676
1677    #[test]
1678    fn from_workspace_root_loads_new_two_layer_layout() {
1679        let tmp = TempDir::new().unwrap();
1680        let mem_dir = tmp.path().join("mem");
1681        std::fs::create_dir_all(&mem_dir).unwrap();
1682        std::fs::write(
1683            mem_dir.join("hello.md"),
1684            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1685        )
1686        .unwrap();
1687
1688        let memstead = tmp.path().join(".memstead");
1689        std::fs::create_dir_all(&memstead).unwrap();
1690        std::fs::write(
1691            memstead.join("workspace.toml"),
1692            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1693        )
1694        .unwrap();
1695        // Save the mount via the file adapter so the JSON shape matches
1696        // the wire format the loader expects.
1697        use crate::workspace_store::WorkspaceStoreAdapter;
1698        let store = crate::FileWorkspaceStore::new();
1699        store
1700            .save_state(
1701                tmp.path(),
1702                &crate::workspace::Workspace {
1703                    mounts: vec![folder_mount("specs", mem_dir)],
1704                    settings: crate::workspace::WorkspaceSettings::default(),
1705                },
1706            )
1707            .unwrap();
1708
1709        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1710        assert_eq!(engine.mem_names(), vec!["specs"]);
1711        let entity = engine
1712            .get_entity(&crate::EntityId::new("specs", "hello"))
1713            .expect("seeded entity must load through from_workspace_root");
1714        assert_eq!(entity.title, "Hello");
1715    }
1716
1717    /// The engine-side pipeline loader: with a workspace store carrying one
1718    /// v2 binding, the engine on boot enumerates it through its read-only
1719    /// queryable surface; a pre-v2 store refuses boot with the
1720    /// migrate-naming error (the loader never reads a prior generation).
1721    #[test]
1722    fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1723        use crate::pipeline::{MediumType, Projection};
1724        let tmp = TempDir::new().unwrap();
1725        let mem_dir = tmp.path().join("mem");
1726        std::fs::create_dir_all(&mem_dir).unwrap();
1727
1728        let memstead = tmp.path().join(".memstead");
1729        std::fs::create_dir_all(&memstead).unwrap();
1730        std::fs::write(
1731            memstead.join("workspace.toml"),
1732            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1733        )
1734        .unwrap();
1735        use crate::workspace_store::WorkspaceStoreAdapter;
1736        crate::FileWorkspaceStore::new()
1737            .save_state(
1738                tmp.path(),
1739                &crate::workspace::Workspace {
1740                    mounts: vec![folder_mount("specs", mem_dir)],
1741                    settings: crate::workspace::WorkspaceSettings::default(),
1742                },
1743            )
1744            .unwrap();
1745
1746        // One v2 binding in the store.
1747        crate::pipeline_store::write_binding(
1748            tmp.path(),
1749            "specs",
1750            "graph",
1751            &sample_v2_binding("specs"),
1752        )
1753        .unwrap();
1754
1755        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1756        let pc = engine.pipeline_configs();
1757        assert_eq!(pc.bindings.len(), 1, "one binding enumerated");
1758        assert_eq!(pc.bindings[0].mem, "specs");
1759        assert_eq!(pc.bindings[0].name, "graph");
1760        assert_eq!(pc.bindings[0].config.destination_mem, "specs");
1761        assert_eq!(
1762            pc.bindings[0].config.sources[0].medium_type,
1763            MediumType::Codebase
1764        );
1765
1766        // QUARANTINE (agent-trust plan 04 re-routing of the historical
1767        // wholesale refusal): a pre-v2 (version-less gen-2) projection
1768        // file no longer fails the boot — the affected binding
1769        // quarantines with the migrate-naming reason, still never
1770        // read, still never tolerated; the workspace and the healthy
1771        // binding keep serving.
1772        crate::pipeline_store::write_projection(
1773            tmp.path(),
1774            "specs",
1775            "legacy",
1776            &Projection {
1777                intent: None,
1778                source_facets: vec!["view".to_string()],
1779                reference_mems: Vec::new(),
1780                destination_mem: "specs".to_string(),
1781                rules: None,
1782            },
1783        )
1784        .unwrap();
1785        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1786        let pc = engine.pipeline_configs();
1787        assert_eq!(pc.bindings.len(), 1, "the healthy binding still serves");
1788        assert_eq!(pc.quarantined.len(), 1, "the legacy one quarantines");
1789        assert_eq!(pc.quarantined[0].name, "legacy");
1790        assert_eq!(pc.quarantined[0].reason_code, "PROJECTION_STORE_LEGACY");
1791        assert!(
1792            pc.quarantined[0]
1793                .reason_message
1794                .contains("memstead projection migrate"),
1795            "quarantine reason names the migrate command, got: {}",
1796            pc.quarantined[0].reason_message
1797        );
1798    }
1799
1800    /// One v2 binding with a single codebase source under `pointer` — the
1801    /// shared fixture of the boot tests.
1802    fn sample_v2_binding(dest: &str) -> crate::binding::Binding {
1803        v2_binding_with_pointer(dest, "..")
1804    }
1805
1806    fn v2_binding_with_pointer(dest: &str, pointer: &str) -> crate::binding::Binding {
1807        use crate::binding::{BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations};
1808        use crate::pipeline::{IngestTrigger, MediumType, Source};
1809        Binding {
1810            version: BINDING_VERSION,
1811            intent: None,
1812            sources: vec![Source {
1813                name: "src".to_string(),
1814                medium_type: MediumType::Codebase,
1815                pointer: pointer.to_string(),
1816                change_detection: None,
1817                scope: Vec::new(),
1818                engagement: None,
1819                preparation: None,
1820            }],
1821            reference_mems: Vec::new(),
1822            destination_mem: dest.to_string(),
1823            deny_paths: Vec::new(),
1824            coverage_semantics: None,
1825            rules: None,
1826            prune: None,
1827            operations: Operations {
1828                build: Some(BuildOperation {
1829                    mode: BuildMode::Discovery,
1830                    trigger: IngestTrigger::Loop,
1831                    batch_size: 10,
1832                    post_actions: None,
1833                }),
1834                sync: None,
1835                verify: None,
1836            },
1837        }
1838    }
1839
1840    /// Live per-anchor state (criteria 1, 9 — path-medium subset): a
1841    /// single-medium `path` mem observes working-tree existence at the current
1842    /// HEAD. Absent artifact ⇒ `orphaned`; present + non-hash class ⇒
1843    /// `resolves`; present + hash-bearing class ⇒ the prepared-content hash
1844    /// comparison adjudicates deterministically — a recorded hash matching
1845    /// the observed prepared form `resolves`, a stable-medium mismatch is
1846    /// `drifted` (a real content drift, no longer deferred to `recheck`).
1847    #[test]
1848    fn entity_anchors_resolve_live_state_for_path_medium() {
1849        use crate::anchor::{AnchorInput, AnchorState};
1850        use crate::vcs::Actor;
1851        use crate::workspace_store::WorkspaceStoreAdapter;
1852        use indexmap::IndexMap;
1853
1854        let tmp = TempDir::new().unwrap();
1855        let mem_dir = tmp.path().join("mem");
1856        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1857        std::fs::write(
1858            mem_dir.join(".memstead").join("config.json"),
1859            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1860        )
1861        .unwrap();
1862
1863        let memstead = tmp.path().join(".memstead");
1864        std::fs::create_dir_all(&memstead).unwrap();
1865        std::fs::write(
1866            memstead.join("workspace.toml"),
1867            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1868        )
1869        .unwrap();
1870        crate::FileWorkspaceStore::new()
1871            .save_state(
1872                tmp.path(),
1873                &crate::workspace::Workspace {
1874                    mounts: vec![folder_mount("specs", mem_dir.clone())],
1875                    settings: crate::workspace::WorkspaceSettings::default(),
1876                },
1877            )
1878            .unwrap();
1879
1880        // A single `path` source rooted at `<workspace>/src` (medium context
1881        // now derives from the mem's binding sources). Anchor artifact ids
1882        // are workspace-relative (pointer-prefixed) — the dialect
1883        // enumeration / coverage / advance share — so `src/present.rs`
1884        // observes present and `src/gone.rs` observes absent.
1885        crate::pipeline_store::write_binding(
1886            tmp.path(),
1887            "specs",
1888            "graph",
1889            &v2_binding_with_pointer("specs", "src"),
1890        )
1891        .unwrap();
1892        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1893        std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
1894
1895        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1896
1897        let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
1898            artifact: Some(artifact.to_string()),
1899            grain: Some("file".to_string()),
1900            class: Some(class.to_string()),
1901            hash: hash.map(str::to_string),
1902            hash_stability: Some("stable".to_string()),
1903            ..Default::default()
1904        };
1905        let mut sections = IndexMap::new();
1906        sections.insert("identity".to_string(), "Covers src.".to_string());
1907        sections.insert("purpose".to_string(), "Track sources.".to_string());
1908        // The prepared-form hash of the present artifact, as the observation
1909        // computes it — an anchor recording it must resolve clean.
1910        let present_hash = crate::anchor::prepared_content_hash(
1911            &std::fs::read(tmp.path().join("src").join("present.rs")).unwrap(),
1912        );
1913        let created = engine
1914            .create_entity(
1915                crate::CreateEntityArgs {
1916                    mem: "specs".to_string(),
1917                    title: "Covers".to_string(),
1918                    entity_type: "spec".to_string(),
1919                    sections,
1920                    metadata: IndexMap::new(),
1921                    relations: Vec::new(),
1922                    anchors: vec![
1923                        anchor("src/present.rs", "anchored", Some(&present_hash)), // hash matches → resolves
1924                        anchor("src/present.rs", "informed-by", None), // present + non-hash → resolves
1925                        anchor("src/gone.rs", "anchored", Some("h2")), // absent → orphaned
1926                        anchor("src/present.rs", "derived", Some("stale")), // hash mismatch, stable → drifted
1927                    ],
1928                    dry_run: false,
1929                },
1930                Actor::Agent,
1931                None,
1932                None,
1933            )
1934            .unwrap();
1935
1936        let resolved = engine.entity_anchors_resolved(&created.id);
1937        assert_eq!(resolved.len(), 4);
1938        let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
1939            resolved
1940                .iter()
1941                .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
1942                .and_then(|r| r.state)
1943        };
1944        assert_eq!(
1945            state_of(
1946                "src/present.rs",
1947                crate::anchor::AnchorProvenanceClass::Anchored
1948            ),
1949            Some(AnchorState::Resolves),
1950            "recorded hash matches the observed prepared form → resolves"
1951        );
1952        assert_eq!(
1953            state_of(
1954                "src/present.rs",
1955                crate::anchor::AnchorProvenanceClass::Derived
1956            ),
1957            Some(AnchorState::Drifted),
1958            "recorded hash mismatches the observed prepared form on a stable medium → drifted"
1959        );
1960        assert_eq!(
1961            state_of(
1962                "src/present.rs",
1963                crate::anchor::AnchorProvenanceClass::InformedBy
1964            ),
1965            Some(AnchorState::Resolves),
1966            "present non-hash anchor resolves on existence"
1967        );
1968        assert_eq!(
1969            state_of(
1970                "src/gone.rs",
1971                crate::anchor::AnchorProvenanceClass::Anchored
1972            ),
1973            Some(AnchorState::Orphaned),
1974            "absent artifact is orphaned"
1975        );
1976    }
1977
1978    /// The engine edit surface: a wrapper edit (`add_projection_json`)
1979    /// routes through the pipeline-edit layer, writes the store, and
1980    /// refreshes the in-memory snapshot in place (no `reload()`); the JSON
1981    /// read counterpart reflects the collapsed `{bindings}`-only shape.
1982    #[test]
1983    fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
1984        use crate::workspace_store::WorkspaceStoreAdapter;
1985
1986        let tmp = TempDir::new().unwrap();
1987        let mem_dir = tmp.path().join("mem");
1988        std::fs::create_dir_all(&mem_dir).unwrap();
1989        let memstead = tmp.path().join(".memstead");
1990        std::fs::create_dir_all(&memstead).unwrap();
1991        std::fs::write(
1992            memstead.join("workspace.toml"),
1993            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1994        )
1995        .unwrap();
1996        crate::FileWorkspaceStore::new()
1997            .save_state(
1998                tmp.path(),
1999                &crate::workspace::Workspace {
2000                    mounts: vec![folder_mount("specs", mem_dir)],
2001                    settings: crate::workspace::WorkspaceSettings::default(),
2002                },
2003            )
2004            .unwrap();
2005
2006        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
2007        assert!(engine.pipeline_configs().bindings.is_empty());
2008
2009        // The JSON entry point (the FFI-facing shape) deserializes and lands.
2010        engine
2011            .add_projection_json(
2012                "specs",
2013                "graph",
2014                r#"{
2015                    "sources": [{ "name": "src", "type": "codebase", "pointer": "..",
2016                                  "scope": [{ "path": "**/*.rs", "mode": "allow" }] }],
2017                    "destination_mem": "specs"
2018                }"#,
2019                None,
2020            )
2021            .unwrap();
2022        // Snapshot refreshed in place.
2023        assert_eq!(engine.pipeline_configs().bindings.len(), 1);
2024        assert_eq!(engine.pipeline_configs().bindings[0].name, "graph");
2025        assert_eq!(
2026            engine.pipeline_configs().bindings[0].config.sources[0].name,
2027            "src"
2028        );
2029
2030        // A malformed payload is refused without touching the store.
2031        let err = engine
2032            .add_projection_json("specs", "bad", "{ not json", None)
2033            .unwrap_err();
2034        assert!(
2035            matches!(
2036                err,
2037                crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
2038            ),
2039            "got {err:?}"
2040        );
2041
2042        // Update patches over the stored record; delete removes and refreshes.
2043        engine
2044            .update_projection_json("specs", "graph", r#"{"intent":"i2"}"#, None)
2045            .unwrap();
2046        assert_eq!(
2047            engine.pipeline_configs().bindings[0]
2048                .config
2049                .intent
2050                .as_deref(),
2051            Some("i2")
2052        );
2053
2054        // Rename moves the record and refreshes the snapshot.
2055        engine
2056            .rename_projection("specs", "graph", "graph2", None)
2057            .unwrap();
2058        assert_eq!(engine.pipeline_configs().bindings[0].name, "graph2");
2059
2060        // The JSON read counterpart reflects the live store in the
2061        // `{bindings}`-only shape — no `mediums` / `facets` keys.
2062        let json = engine.pipeline_configs_json();
2063        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2064        assert!(parsed.get("mediums").is_none(), "no mediums key: {json}");
2065        assert!(parsed.get("facets").is_none(), "no facets key: {json}");
2066        let bindings = parsed["bindings"].as_array().unwrap();
2067        assert_eq!(bindings.len(), 1);
2068        assert_eq!(bindings[0]["name"], "graph2");
2069        assert_eq!(bindings[0]["config"]["sources"][0]["type"], "codebase");
2070
2071        engine.delete_projection("specs", "graph2", None).unwrap();
2072        assert!(engine.pipeline_configs().bindings.is_empty());
2073    }
2074
2075    /// The lean folder authoring path: a schema package authored at the
2076    /// fixed `<workspace>/.memstead/schemas/<name>@<version>/` location
2077    /// is resolved at boot, so a folder mem can pin a non-built-in
2078    /// schema. Before this wiring `from_workspace_root` loaded only
2079    /// built-ins, so the pin would refuse with `SCHEMA_NOT_FOUND`.
2080    #[test]
2081    fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
2082        use crate::engine::test_helpers::write_schema_files_with_default_type;
2083
2084        let tmp = TempDir::new().unwrap();
2085        let mem_dir = tmp.path().join("mem");
2086        std::fs::create_dir_all(&mem_dir).unwrap();
2087
2088        // Author a schema package at the fixed folder location.
2089        let authored_dir = tmp.path().join(".memstead").join("schemas");
2090        let manifest = r#"name: authored
2091version: 0.1.0
2092description: an authored-in-workspace test schema
2093when_to_use: tests
2094types:
2095  - doc
2096relationships:
2097  mode: strict
2098  definitions:
2099    - name: _default
2100      description: fallback
2101      default_weight: 1.0
2102community:
2103  resolution: 1.0
2104  seed: 42
2105"#;
2106        write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
2107
2108        // A folder mem pinning the authored (non-built-in) schema.
2109        let memstead = tmp.path().join(".memstead");
2110        std::fs::create_dir_all(&memstead).unwrap();
2111        std::fs::write(
2112            memstead.join("workspace.toml"),
2113            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2114        )
2115        .unwrap();
2116        let mount = Mount {
2117            mem: "specs".to_string(),
2118            schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2119            storage: MountStorage::Folder { path: mem_dir },
2120            capability: MountCapability::Write,
2121            lifecycle: MountLifecycle::Eager,
2122            cross_linkable: true,
2123            migration_target: None,
2124        };
2125        use crate::workspace_store::WorkspaceStoreAdapter;
2126        crate::FileWorkspaceStore::new()
2127            .save_state(
2128                tmp.path(),
2129                &crate::workspace::Workspace {
2130                    mounts: vec![mount],
2131                    settings: crate::workspace::WorkspaceSettings::default(),
2132                },
2133            )
2134            .unwrap();
2135
2136        // Boots cleanly — the authored pin resolved against the fixed
2137        // location rather than refusing as an unknown built-in.
2138        let engine = Engine::from_workspace_root(tmp.path())
2139            .expect("authored schema at .memstead/schemas/ must resolve at boot");
2140        assert_eq!(engine.mem_names(), vec!["specs"]);
2141    }
2142
2143    /// Authoring-drift health axis (plan 10): a STAMPED sealed schema
2144    /// reports a missing authoring package and (separately) a diverged
2145    /// one; an unmodified package, a cosmetic-only difference (editor
2146    /// header comment lines), and an unstamped seal produce NO
2147    /// finding; and the checks alter neither copy.
2148    #[test]
2149    fn health_reports_authoring_drift_for_stamped_schemas_only() {
2150        use crate::engine::test_helpers::write_schema_files_with_default_type;
2151
2152        let tmp = TempDir::new().unwrap();
2153        let mem_dir = tmp.path().join("mem");
2154        std::fs::create_dir_all(&mem_dir).unwrap();
2155        let manifest = r#"name: authored
2156version: 0.1.0
2157description: an authored-in-workspace test schema
2158when_to_use: tests
2159types:
2160  - doc
2161relationships:
2162  mode: strict
2163  definitions:
2164    - name: _default
2165      description: fallback
2166      default_weight: 1.0
2167community:
2168  resolution: 1.0
2169  seed: 42
2170"#;
2171        // Sealed copy at the fixed install location; authoring copy in
2172        // the working tree.
2173        let sealed_root = tmp.path().join(".memstead").join("schemas");
2174        write_schema_files_with_default_type(&sealed_root, "authored@0.1.0", manifest, &["doc"]);
2175        let author_root = tmp.path().join("author");
2176        write_schema_files_with_default_type(&author_root, "authored@0.1.0", manifest, &["doc"]);
2177        let authoring_dir = author_root.join("authored@0.1.0");
2178        let sealed_dir = sealed_root.join("authored@0.1.0");
2179        // The install-time stamp: the seal records where it came from.
2180        let stamp_path = sealed_dir.join(memstead_schema::INSTALL_PROVENANCE_FILE);
2181        std::fs::write(
2182            &stamp_path,
2183            serde_json::to_vec_pretty(&serde_json::json!({
2184                "authoring_path": authoring_dir.display().to_string(),
2185            }))
2186            .unwrap(),
2187        )
2188        .unwrap();
2189
2190        let memstead = tmp.path().join(".memstead");
2191        std::fs::write(
2192            memstead.join("workspace.toml"),
2193            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2194        )
2195        .unwrap();
2196        let mount = Mount {
2197            mem: "specs".to_string(),
2198            schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
2199            storage: MountStorage::Folder { path: mem_dir },
2200            capability: MountCapability::Write,
2201            lifecycle: MountLifecycle::Eager,
2202            cross_linkable: true,
2203            migration_target: None,
2204        };
2205        use crate::workspace_store::WorkspaceStoreAdapter;
2206        crate::FileWorkspaceStore::new()
2207            .save_state(
2208                tmp.path(),
2209                &crate::workspace::Workspace {
2210                    mounts: vec![mount],
2211                    settings: crate::workspace::WorkspaceSettings::default(),
2212                },
2213            )
2214            .unwrap();
2215        let engine = Engine::from_workspace_root(tmp.path()).expect("workspace boots");
2216        let drift_codes = |e: &Engine| -> Vec<String> {
2217            e.health()
2218                .warnings
2219                .iter()
2220                .filter(|w| w.code().starts_with("SCHEMA_AUTHORING_SOURCE_"))
2221                .map(|w| w.code().to_string())
2222                .collect()
2223        };
2224
2225        // Unmodified authoring package: no finding, and the check
2226        // touched neither copy.
2227        let sealed_before = std::fs::read(sealed_dir.join("schema.yaml")).unwrap();
2228        let author_before = std::fs::read(authoring_dir.join("schema.yaml")).unwrap();
2229        assert_eq!(drift_codes(&engine), Vec::<String>::new());
2230        assert_eq!(
2231            std::fs::read(sealed_dir.join("schema.yaml")).unwrap(),
2232            sealed_before,
2233            "health must not touch the sealed copy"
2234        );
2235        assert_eq!(
2236            std::fs::read(authoring_dir.join("schema.yaml")).unwrap(),
2237            author_before,
2238            "health must not touch the authoring copy"
2239        );
2240
2241        // Cosmetic-only difference (the CLI-injected editor-header
2242        // line + a comment): still no finding — parsed equivalence,
2243        // never raw bytes.
2244        std::fs::write(
2245            authoring_dir.join("schema.yaml"),
2246            format!(
2247                "# yaml-language-server: $schema=../../.memstead/meta-schemas/schema-manifest.json\n# cosmetic comment\n{manifest}"
2248            ),
2249        )
2250        .unwrap();
2251        assert_eq!(drift_codes(&engine), Vec::<String>::new());
2252
2253        // Semantic change: DIVERGED, naming schema, version, and the
2254        // pinning mems.
2255        std::fs::write(
2256            authoring_dir.join("schema.yaml"),
2257            manifest.replace(
2258                "an authored-in-workspace test schema",
2259                "a semantically different description",
2260            ),
2261        )
2262        .unwrap();
2263        let warnings = engine.health().warnings;
2264        let diverged = warnings
2265            .iter()
2266            .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED")
2267            .expect("semantic change must surface as DIVERGED");
2268        let d = serde_json::to_value(diverged).unwrap();
2269        assert_eq!(d["details"]["schema_ref"], "authored@0.1.0");
2270        assert_eq!(d["details"]["mems"], serde_json::json!(["specs"]));
2271
2272        // Authoring package gone: the DIFFERENT finding — MISSING.
2273        std::fs::remove_dir_all(&authoring_dir).unwrap();
2274        let warnings = engine.health().warnings;
2275        let missing = warnings
2276            .iter()
2277            .find(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_MISSING")
2278            .expect("vanished authoring package must surface as MISSING");
2279        let m = serde_json::to_value(missing).unwrap();
2280        assert_eq!(m["details"]["schema_ref"], "authored@0.1.0");
2281        assert_eq!(m["details"]["mems"], serde_json::json!(["specs"]));
2282        assert_eq!(
2283            m["details"]["stamped_path"],
2284            authoring_dir.display().to_string()
2285        );
2286        assert!(
2287            !warnings
2288                .iter()
2289                .any(|w| w.code() == "SCHEMA_AUTHORING_SOURCE_DIVERGED"),
2290            "missing and diverged are distinct findings"
2291        );
2292
2293        // No stamp → no finding, even with the package still gone.
2294        std::fs::remove_file(&stamp_path).unwrap();
2295        assert_eq!(drift_codes(&engine), Vec::<String>::new());
2296    }
2297
2298    /// Plan 12: `full_refresh` makes an out-of-band schema install and
2299    /// an out-of-band mem registration usable warm — additively.
2300    /// Removals are skipped and reported; a failed mount is reported
2301    /// per-item and does not abort the rest.
2302    #[test]
2303    fn full_refresh_is_additive_and_reports_skipped_removals() {
2304        use crate::engine::test_helpers::write_schema_files_with_default_type;
2305        use crate::workspace_store::WorkspaceStoreAdapter;
2306
2307        let tmp = TempDir::new().unwrap();
2308        let root = tmp.path();
2309        let mem_a = root.join("mem-a");
2310        std::fs::create_dir_all(&mem_a).unwrap();
2311        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2312        std::fs::write(
2313            root.join(".memstead").join("workspace.toml"),
2314            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2315        )
2316        .unwrap();
2317        let mount = |mem: &str, dir: &Path, schema: &str, version: semver::Version| Mount {
2318            mem: mem.to_string(),
2319            schema: Some(SchemaRef::new(schema, version)),
2320            storage: MountStorage::Folder {
2321                path: dir.to_path_buf(),
2322            },
2323            capability: MountCapability::Write,
2324            lifecycle: MountLifecycle::Eager,
2325            cross_linkable: true,
2326            migration_target: None,
2327        };
2328        let save = |mounts: Vec<Mount>| {
2329            crate::FileWorkspaceStore::new()
2330                .save_state(
2331                    root,
2332                    &crate::workspace::Workspace {
2333                        mounts,
2334                        settings: crate::workspace::WorkspaceSettings::default(),
2335                    },
2336                )
2337                .unwrap();
2338        };
2339        save(vec![mount(
2340            "specs",
2341            &mem_a,
2342            "default",
2343            semver::Version::new(1, 0, 0),
2344        )]);
2345        let mut engine = Engine::from_workspace_root(root).expect("workspace boots");
2346        assert_eq!(engine.mem_names(), vec!["specs"]);
2347
2348        // --- Out of band, while the "server" runs: install a schema
2349        // and register a mem pinned to it. ---
2350        let manifest = r#"name: authored
2351version: 0.1.0
2352description: an out-of-band installed schema
2353when_to_use: tests
2354types:
2355  - doc
2356relationships:
2357  mode: strict
2358  definitions:
2359    - name: _default
2360      description: fallback
2361      default_weight: 1.0
2362community:
2363  resolution: 1.0
2364  seed: 42
2365"#;
2366        write_schema_files_with_default_type(
2367            &root.join(".memstead").join("schemas"),
2368            "authored@0.1.0",
2369            manifest,
2370            &["doc"],
2371        );
2372        let mem_b = root.join("mem-b");
2373        std::fs::create_dir_all(&mem_b).unwrap();
2374        save(vec![
2375            mount("specs", &mem_a, "default", semver::Version::new(1, 0, 0)),
2376            mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2377        ]);
2378
2379        // Refusal complement, pre-refresh: the running engine still
2380        // refuses — the refresh is what changes the outcome.
2381        let (actor, client) = cli_actor();
2382        let mut pre = crate::engine::test_helpers::empty_create_args("notes", "Too Early");
2383        pre.entity_type = "doc".to_string();
2384        pre.sections = indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2385        let err = engine
2386            .create_entity(pre.clone(), actor, Some(&client), None)
2387            .unwrap_err();
2388        assert_eq!(err.code(), "UNKNOWN_MEM", "{err:?}");
2389        assert!(
2390            !engine
2391                .workspace_schemas()
2392                .iter()
2393                .any(|s| s.id().0 == "authored"),
2394            "schema catalogue is fixed pre-refresh"
2395        );
2396
2397        // --- Full refresh: both become usable, warm. ---
2398        let report = engine.full_refresh();
2399        assert_eq!(report.schemas_added, vec!["authored@0.1.0".to_string()]);
2400        assert_eq!(report.mems_mounted, vec!["notes".to_string()]);
2401        assert!(report.schema_removals_skipped.is_empty(), "{report:?}");
2402        assert!(report.mem_removals_skipped.is_empty(), "{report:?}");
2403        assert!(report.failures.is_empty(), "{report:?}");
2404        engine
2405            .create_entity(pre, actor, Some(&client), None)
2406            .expect("newly mounted mem accepts writes after the refresh");
2407
2408        // --- Removals do NOT take effect: drop `specs` from the
2409        // manifest and delete the schema package from its source. ---
2410        std::fs::remove_dir_all(
2411            root.join(".memstead")
2412                .join("schemas")
2413                .join("authored@0.1.0"),
2414        )
2415        .unwrap();
2416        save(vec![mount(
2417            "notes",
2418            &mem_b,
2419            "authored",
2420            semver::Version::new(0, 1, 0),
2421        )]);
2422        let report = engine.full_refresh();
2423        assert_eq!(report.mem_removals_skipped, vec!["specs".to_string()]);
2424        assert_eq!(
2425            report.schema_removals_skipped,
2426            vec!["authored@0.1.0".to_string()]
2427        );
2428        assert!(report.schemas_added.is_empty());
2429        assert!(report.mems_mounted.is_empty());
2430        // Both stay live: the unregistered mem still accepts writes,
2431        // the removed schema version still resolves for its mem.
2432        engine
2433            .create_entity(
2434                crate::engine::test_helpers::empty_create_args("specs", "Still Here"),
2435                actor,
2436                Some(&client),
2437                None,
2438            )
2439            .expect("skipped-removal mem stays writable");
2440        let mut into_notes =
2441            crate::engine::test_helpers::empty_create_args("notes", "Still Resolvable");
2442        into_notes.entity_type = "doc".to_string();
2443        into_notes.sections =
2444            indexmap::IndexMap::from_iter([("body".to_string(), "body".to_string())]);
2445        engine
2446            .create_entity(into_notes, actor, Some(&client), None)
2447            .expect("removed-from-source schema stays resolvable");
2448
2449        // --- Per-item failure: a manifest mount whose path is a FILE
2450        // fails alone; the rest of the refresh proceeds. ---
2451        let broken = root.join("broken-mem");
2452        std::fs::write(&broken, b"not a directory").unwrap();
2453        save(vec![
2454            mount("notes", &mem_b, "authored", semver::Version::new(0, 1, 0)),
2455            mount("broken", &broken, "default", semver::Version::new(1, 0, 0)),
2456        ]);
2457        let report = engine.full_refresh();
2458        assert!(
2459            report.failures.iter().any(|f| f.item == "mount:broken"),
2460            "failed mount must be reported per-item: {report:?}"
2461        );
2462        assert!(
2463            !report.mems_mounted.contains(&"broken".to_string()),
2464            "a failed mount never surfaces as newly available"
2465        );
2466        assert!(
2467            engine
2468                .get_entity(&crate::EntityId::new("broken", "anything"))
2469                .is_none()
2470                && engine.mem_names().contains(&"notes"),
2471            "other mounts unaffected"
2472        );
2473    }
2474
2475    #[test]
2476    fn from_workspace_root_propagates_mem_management_settings() {
2477        // workspace.toml carries [mem_management] rules; the file
2478        // adapter parses them into Workspace.settings; from_workspace_root
2479        // calls Engine::set_settings so the engine surface reflects them.
2480        // End-to-end check that the carriers, parser, and plumbing connect.
2481        let tmp = TempDir::new().unwrap();
2482        let mem_dir = tmp.path().join("mem");
2483        std::fs::create_dir_all(&mem_dir).unwrap();
2484
2485        let memstead = tmp.path().join(".memstead");
2486        std::fs::create_dir_all(&memstead).unwrap();
2487        std::fs::write(
2488            memstead.join("workspace.toml"),
2489            r#"format = "memstead-git-branch-2"
2490
2491[persistence_adapter]
2492name = "file-two-layer"
2493
2494[[mem_management.create]]
2495pattern = "exec-*"
2496schemas = ["default@1.0.0"]
2497
2498[[mem_management.delete]]
2499pattern = "exec-*"
2500"#,
2501        )
2502        .unwrap();
2503        use crate::workspace_store::WorkspaceStoreAdapter;
2504        let store = crate::FileWorkspaceStore::new();
2505        store
2506            .save_state(
2507                tmp.path(),
2508                &crate::workspace::Workspace {
2509                    mounts: vec![folder_mount("specs", mem_dir)],
2510                    settings: crate::workspace::WorkspaceSettings::default(),
2511                },
2512            )
2513            .unwrap();
2514
2515        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
2516        let s = engine.settings();
2517        assert_eq!(s.mem_create_rules.len(), 1);
2518        assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
2519        assert_eq!(
2520            s.mem_create_rules[0].schemas,
2521            vec!["default@1.0.0".to_string()]
2522        );
2523        assert_eq!(s.mem_delete_rules.len(), 1);
2524        assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
2525    }
2526
2527    /// Deliberate replacement of the historical wholesale-abort test
2528    /// (`from_mounts_rejects_unknown_schema_pin_with_typed_error`,
2529    /// agent-trust plan 04): an unresolvable pin no longer fails the
2530    /// workspace — the mem is QUARANTINED with the same typed
2531    /// `SCHEMA_NOT_FOUND` reason (nothing is weakened, the blast
2532    /// radius shrinks), operations naming it refuse `MEM_QUARANTINED`,
2533    /// and the roster surfaces on health.
2534    #[test]
2535    fn from_mounts_quarantines_unknown_schema_pin() {
2536        let tmp = TempDir::new().unwrap();
2537        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
2538        let mount = Mount {
2539            mem: "specs".to_string(),
2540            schema: Some(SchemaRef::new(
2541                "totally-not-a-schema",
2542                semver::Version::new(1, 0, 0),
2543            )),
2544            storage: MountStorage::Folder {
2545                path: tmp.path().to_path_buf(),
2546            },
2547            capability: MountCapability::Write,
2548            lifecycle: MountLifecycle::Eager,
2549            cross_linkable: true,
2550            migration_target: None,
2551        };
2552        let engine = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2553            .expect("a broken mem quarantines, never fails the workspace");
2554
2555        let roster = engine.quarantined_mems();
2556        assert_eq!(roster.len(), 1);
2557        assert_eq!(roster[0].mount.mem, "specs");
2558        assert_eq!(roster[0].reason_code, "SCHEMA_NOT_FOUND");
2559        assert!(
2560            roster[0].reason_message.contains("totally-not-a-schema"),
2561            "reason carries the failing pin: {}",
2562            roster[0].reason_message
2563        );
2564        // The mem serves nothing: it is not on the mount roster …
2565        assert!(engine.mounts().iter().all(|m| m.mem != "specs"));
2566        // … and lookups refuse with the typed quarantine code, not
2567        // UNKNOWN_MEM.
2568        let err = engine.unknown_mem_error("specs");
2569        assert_eq!(err.code(), "MEM_QUARANTINED");
2570        assert!(
2571            err.to_string().contains("SCHEMA_NOT_FOUND"),
2572            "quarantine refusal carries the underlying reason: {err}"
2573        );
2574        // Health carries the roster without an include gate.
2575        let health = engine.health();
2576        assert_eq!(health.quarantined.len(), 1);
2577        assert_eq!(health.quarantined[0].reason_code, "SCHEMA_NOT_FOUND");
2578    }
2579
2580    /// Criterion 5 (agent-trust plan 04): quarantine → repair →
2581    /// reload returns the mem to service in the same engine instance;
2582    /// the roster entry disappears. The repair here is the same
2583    /// value-level config-pin rewrite `memstead mem set-schema`
2584    /// performs below boot (plan 03).
2585    #[test]
2586    fn reload_returns_repaired_mem_from_quarantine() {
2587        let tmp = TempDir::new().unwrap();
2588        let dir = tmp.path().to_path_buf();
2589        std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2590        let config_path = dir.join(".memstead").join("config.json");
2591        std::fs::write(&config_path, r#"{ "schema": "ghost@1.0.0" }"#).unwrap();
2592        let writer = FilesystemMemWriter::new(dir.clone());
2593        let mount = Mount {
2594            mem: "specs".to_string(),
2595            schema: None,
2596            storage: MountStorage::Folder { path: dir },
2597            capability: MountCapability::Write,
2598            lifecycle: MountLifecycle::Eager,
2599            cross_linkable: true,
2600            migration_target: None,
2601        };
2602        let mut engine =
2603            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2604        assert_eq!(engine.quarantined_mems().len(), 1);
2605        // Un-repaired reload keeps the quarantine (refreshed reason,
2606        // typed refusal).
2607        let err = engine.reload_one_mem("specs").unwrap_err();
2608        assert_eq!(err.code(), "MEM_QUARANTINED");
2609        assert_eq!(engine.quarantined_mems().len(), 1);
2610
2611        // Repair: repin the config to a resolvable schema (what
2612        // `mem set-schema` does below boot), then reload.
2613        std::fs::write(&config_path, r#"{ "schema": "default@1.0.0" }"#).unwrap();
2614        engine
2615            .reload_one_mem("specs")
2616            .expect("repaired mem re-attaches on reload");
2617        assert!(
2618            engine.quarantined_mems().is_empty(),
2619            "roster entry disappears after re-attach"
2620        );
2621        // …and the mem serves again in the same process.
2622        let mut sections = indexmap::IndexMap::new();
2623        sections.insert("identity".to_string(), "back".to_string());
2624        sections.insert("purpose".to_string(), "post-repair service".to_string());
2625        engine
2626            .create_entity_with_ctx(
2627                crate::engine::CreateEntityArgs {
2628                    anchors: Vec::new(),
2629                    mem: "specs".to_string(),
2630                    title: "Back".to_string(),
2631                    entity_type: "spec".to_string(),
2632                    sections,
2633                    metadata: indexmap::IndexMap::new(),
2634                    relations: Vec::new(),
2635                    dry_run: false,
2636                },
2637                &crate::vcs::CommitContext::internal(),
2638            )
2639            .expect("reattached mem serves writes");
2640    }
2641
2642    /// Criterion 2 complement (agent-trust plan 04): a healthy mem
2643    /// whose entity body wiki-links INTO a quarantined mem loads
2644    /// normally — the link degrades like any dangling cross-mem link
2645    /// (stub target), no cascade failure.
2646    #[test]
2647    fn cross_mem_link_into_quarantined_mem_degrades_without_cascade() {
2648        let tmp = TempDir::new().unwrap();
2649        let healthy_dir = tmp.path().join("healthy");
2650        std::fs::create_dir_all(&healthy_dir).unwrap();
2651        std::fs::write(
2652            healthy_dir.join("linker.md"),
2653            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n\
2654             # Linker\n\n## Identity\n\nsee [[badpin:target]] for detail.\n",
2655        )
2656        .unwrap();
2657        let badpin_dir = tmp.path().join("badpin");
2658        std::fs::create_dir_all(&badpin_dir).unwrap();
2659        let mount = |mem: &str, dir: std::path::PathBuf, pin: &str| {
2660            (
2661                Mount {
2662                    mem: mem.to_string(),
2663                    schema: Some(SchemaRef::new(pin, semver::Version::new(1, 0, 0))),
2664                    storage: MountStorage::Folder { path: dir.clone() },
2665                    capability: MountCapability::Write,
2666                    lifecycle: MountLifecycle::Eager,
2667                    cross_linkable: true,
2668                    migration_target: None,
2669                },
2670                Box::new(FilesystemMemWriter::new(dir)) as Box<dyn MemBackend>,
2671            )
2672        };
2673        let engine = Engine::from_mounts(vec![
2674            mount("healthy", healthy_dir, "default"),
2675            mount("badpin", badpin_dir, "ghost"),
2676        ])
2677        .expect("boot survives the cross-mem link into the quarantined mem");
2678        assert_eq!(engine.quarantined_mems().len(), 1);
2679        // The linking entity loaded; its target degrades to a stub /
2680        // dangling link — no cascade, no partial-truth serving of the
2681        // quarantined mem.
2682        let linker = engine
2683            .get_entity(&crate::EntityId::new("healthy", "linker"))
2684            .expect("linking entity loads");
2685        assert_eq!(linker.entity_type, "spec");
2686        assert!(
2687            engine
2688                .get_entity(&crate::EntityId::new("badpin", "target"))
2689                .is_none_or(|e| e.stub),
2690            "the quarantined-side target is at most a stub, never real data"
2691        );
2692    }
2693
2694    /// Agent-trust plan 06, criterion 3 complement: a workspace where
2695    /// one mem pins an authored schema still on the retired
2696    /// `propagating_relationships` key boots — that mem quarantines
2697    /// with the rename error as its reason (never workspace-fatal),
2698    /// while healthy mems load and serve.
2699    #[test]
2700    fn old_key_authored_schema_quarantines_pinning_mem_never_workspace() {
2701        let tmp = TempDir::new().unwrap();
2702        let root = tmp.path();
2703        // Workspace marker + two folder mems.
2704        std::fs::create_dir_all(root.join(".memstead").join("state")).unwrap();
2705        std::fs::write(
2706            root.join(".memstead").join("workspace.toml"),
2707            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2708        )
2709        .unwrap();
2710        for m in ["healthy", "oldkey"] {
2711            std::fs::create_dir_all(root.join(m)).unwrap();
2712        }
2713        std::fs::write(
2714            root.join(".memstead").join("state").join("mounts.json"),
2715            r#"{ "format": "memstead-mounts-3", "mounts": [
2716                { "mem": "healthy", "schema": "default@1.1.0", "storage": { "type": "folder", "path": "healthy" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true },
2717                { "mem": "oldkey", "schema": "fieldschema@0.1.0", "storage": { "type": "folder", "path": "oldkey" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
2718            ] }"#,
2719        )
2720        .unwrap();
2721        // The authored package, still on the retired key.
2722        let pkg = root
2723            .join(".memstead")
2724            .join("schemas")
2725            .join("fieldschema@0.1.0");
2726        std::fs::create_dir_all(pkg.join("types")).unwrap();
2727        std::fs::write(
2728            pkg.join("schema.yaml"),
2729            "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",
2730        )
2731        .unwrap();
2732        std::fs::write(
2733            pkg.join("types").join("thing.yaml"),
2734            "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",
2735        )
2736        .unwrap();
2737
2738        let engine = Engine::from_workspace_root(root)
2739            .expect("the old-key schema quarantines its mem, never the workspace");
2740        assert!(engine.mounts().iter().any(|m| m.mem == "healthy"));
2741        let q = engine
2742            .quarantine_reason("oldkey")
2743            .expect("oldkey mem is quarantined");
2744        assert_eq!(q.reason_code, "SCHEMA_LOAD_FAILED");
2745        assert!(
2746            q.reason_message.contains("no_self_loop_relationships"),
2747            "quarantine reason is the rename error naming the new key: {}",
2748            q.reason_message
2749        );
2750    }
2751
2752    /// Agent-trust plan 06, criterion 2: a mem pinned to the new
2753    /// ingest@0.3.0 reports its edge-less entry entities as leaf
2754    /// population, zero false orphans; the prior version (0.2.0) is
2755    /// unchanged — the same entity still counts as an orphan there.
2756    #[test]
2757    fn ingest_0_3_entries_are_leaves_prior_version_unchanged() {
2758        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";
2759        let boot = |pin: &str| {
2760            let tmp = TempDir::new().unwrap();
2761            let dir = tmp.path().to_path_buf();
2762            std::fs::create_dir_all(dir.join(".memstead")).unwrap();
2763            std::fs::write(
2764                dir.join(".memstead").join("config.json"),
2765                format!("{{ \"schema\": \"{pin}\" }}"),
2766            )
2767            .unwrap();
2768            std::fs::write(dir.join("gap.md"), entry_md).unwrap();
2769            let writer = FilesystemMemWriter::new(dir.clone());
2770            let mount = Mount {
2771                mem: "proc".to_string(),
2772                schema: None,
2773                storage: MountStorage::Folder { path: dir },
2774                capability: MountCapability::Write,
2775                lifecycle: MountLifecycle::Eager,
2776                cross_linkable: true,
2777                migration_target: None,
2778            };
2779            let engine =
2780                Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
2781                    .unwrap();
2782            (engine.health(), tmp)
2783        };
2784
2785        let (health_new, _t1) = boot("ingest@0.3.0");
2786        assert_eq!(
2787            health_new.orphan_count, 0,
2788            "0.3.0 entry types are leaves — zero false orphans"
2789        );
2790        assert_eq!(
2791            health_new
2792                .leaf_entities_by_type
2793                .get("ingest@0.3.0:coverage_gap"),
2794            Some(&1),
2795            "the population stays visible: {:?}",
2796            health_new.leaf_entities_by_type
2797        );
2798
2799        let (health_old, _t2) = boot("ingest@0.2.0");
2800        assert_eq!(
2801            health_old.orphan_count, 1,
2802            "the prior version's behaviour is unchanged"
2803        );
2804        assert!(health_old.leaf_entities_by_type.is_empty());
2805    }
2806
2807    /// A workspace mixing one broken mem with healthy siblings boots,
2808    /// serves the healthy mems fully, and refuses typed on the
2809    /// quarantined one — the plenum shape (one bad pin, thirteen
2810    /// healthy hostages) can no longer occur. Drives the pin-failure
2811    /// and missing-pin variants in one fixture.
2812    #[test]
2813    fn broken_mem_quarantines_while_healthy_siblings_serve() {
2814        let tmp = TempDir::new().unwrap();
2815        let make_mount = |mem: &str, pin: Option<SchemaRef>| {
2816            let dir = tmp.path().join(mem);
2817            std::fs::create_dir_all(&dir).unwrap();
2818            let writer = FilesystemMemWriter::new(dir.clone());
2819            (
2820                Mount {
2821                    mem: mem.to_string(),
2822                    schema: pin,
2823                    storage: MountStorage::Folder { path: dir },
2824                    capability: MountCapability::Write,
2825                    lifecycle: MountLifecycle::Eager,
2826                    cross_linkable: true,
2827                    migration_target: None,
2828                },
2829                Box::new(writer) as Box<dyn MemBackend>,
2830            )
2831        };
2832        let healthy = make_mount(
2833            "healthy",
2834            Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2835        );
2836        let bad_pin = make_mount(
2837            "badpin",
2838            Some(SchemaRef::new("ghost", semver::Version::new(1, 0, 0))),
2839        );
2840        let missing_pin = make_mount("nopin", None);
2841        // Backend-failure variant: the mount's storage path is a FILE,
2842        // so the backend's entity walk fails at read time.
2843        let bad_io_path = tmp.path().join("badio");
2844        std::fs::write(&bad_io_path, "not a directory").unwrap();
2845        let bad_io = (
2846            Mount {
2847                mem: "badio".to_string(),
2848                schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
2849                storage: MountStorage::Folder {
2850                    path: bad_io_path.clone(),
2851                },
2852                capability: MountCapability::Write,
2853                lifecycle: MountLifecycle::Eager,
2854                cross_linkable: true,
2855                migration_target: None,
2856            },
2857            Box::new(FilesystemMemWriter::new(bad_io_path)) as Box<dyn MemBackend>,
2858        );
2859        let mut engine = Engine::from_mounts(vec![healthy, bad_pin, missing_pin, bad_io])
2860            .expect("mixed workspace boots");
2861
2862        // Roster: both broken mems, each with its own typed reason.
2863        let codes: std::collections::HashMap<String, String> = engine
2864            .quarantined_mems()
2865            .iter()
2866            .map(|q| (q.mount.mem.clone(), q.reason_code.clone()))
2867            .collect();
2868        assert_eq!(
2869            codes.get("badpin").map(String::as_str),
2870            Some("SCHEMA_NOT_FOUND")
2871        );
2872        assert_eq!(
2873            codes.get("nopin").map(String::as_str),
2874            Some("MEM_CONFIG_INCOMPLETE")
2875        );
2876        assert!(
2877            codes.contains_key("badio"),
2878            "backend read failure quarantines too: {codes:?}"
2879        );
2880
2881        // The healthy mem is fully writable.
2882        let mut sections = indexmap::IndexMap::new();
2883        sections.insert("identity".to_string(), "alive".to_string());
2884        sections.insert("purpose".to_string(), "proof of service".to_string());
2885        let created = engine
2886            .create_entity_with_ctx(
2887                crate::engine::CreateEntityArgs {
2888                    anchors: Vec::new(),
2889                    mem: "healthy".to_string(),
2890                    title: "Alive".to_string(),
2891                    entity_type: "spec".to_string(),
2892                    sections,
2893                    metadata: indexmap::IndexMap::new(),
2894                    relations: Vec::new(),
2895                    dry_run: false,
2896                },
2897                &crate::vcs::CommitContext::internal(),
2898            )
2899            .expect("healthy mem serves writes");
2900        assert_eq!(created.id.to_string(), "healthy--alive");
2901
2902        // Writes against a quarantined mem refuse with the typed code.
2903        let mut sections = indexmap::IndexMap::new();
2904        sections.insert("identity".to_string(), "x".to_string());
2905        let err = engine
2906            .create_entity_with_ctx(
2907                crate::engine::CreateEntityArgs {
2908                    anchors: Vec::new(),
2909                    mem: "badpin".to_string(),
2910                    title: "Nope".to_string(),
2911                    entity_type: "spec".to_string(),
2912                    sections,
2913                    metadata: indexmap::IndexMap::new(),
2914                    relations: Vec::new(),
2915                    dry_run: false,
2916                },
2917                &crate::vcs::CommitContext::internal(),
2918            )
2919            .unwrap_err();
2920        assert_eq!(err.code(), "MEM_QUARANTINED");
2921    }
2922
2923    /// Schema-pin authority: the mem's own per-mem config is the
2924    /// authoritative settled pin. Here the config pins a resolvable
2925    /// schema (`software@0.1.0`) while the workspace mount expects an
2926    /// unresolvable one — boot succeeds (proving the config pin won,
2927    /// not the mount's) and surfaces a `SchemaPinMismatch` warning
2928    /// naming both pins.
2929    #[test]
2930    fn mem_config_schema_is_authoritative_over_mount_pin() {
2931        let tmp = TempDir::new().unwrap();
2932        let mem_dir = tmp.path().to_path_buf();
2933        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2934        std::fs::write(
2935            mem_dir.join(".memstead").join("config.json"),
2936            r#"{"schema":"software@0.1.0"}"#,
2937        )
2938        .unwrap();
2939        let writer = FilesystemMemWriter::new(mem_dir.clone());
2940        let mount = Mount {
2941            mem: "specs".to_string(),
2942            schema: Some(SchemaRef::new(
2943                "totally-not-a-schema",
2944                semver::Version::new(9, 9, 9),
2945            )),
2946            storage: MountStorage::Folder { path: mem_dir },
2947            capability: MountCapability::Write,
2948            lifecycle: MountLifecycle::Eager,
2949            cross_linkable: true,
2950            migration_target: None,
2951        };
2952        let engine = Engine::from_mounts(vec![(
2953            mount,
2954            Box::new(writer) as Box<dyn MemBackend>,
2955        )])
2956        .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
2957
2958        let mismatch = engine
2959            .load_warnings()
2960            .iter()
2961            .find_map(|w| match w {
2962                WarningHint::SchemaPinMismatch {
2963                    mem,
2964                    config_pin,
2965                    mount_pin,
2966                } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
2967                _ => None,
2968            })
2969            .expect("SchemaPinMismatch warning must surface naming both pins");
2970        assert_eq!(mismatch.0, "specs");
2971        assert_eq!(mismatch.1, "software@0.1.0");
2972        assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
2973    }
2974
2975    #[test]
2976    fn from_workspace_root_quarantines_git_branch_mount_on_lean() {
2977        let tmp = TempDir::new().unwrap();
2978        let memstead = tmp.path().join(".memstead");
2979        std::fs::create_dir_all(&memstead).unwrap();
2980        std::fs::write(
2981            memstead.join("workspace.toml"),
2982            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2983        )
2984        .unwrap();
2985        // Hand-craft a state/mounts.json carrying a git-branch mount —
2986        // the lean boot path can't instantiate that backend.
2987        let state_dir = memstead.join("state");
2988        std::fs::create_dir_all(&state_dir).unwrap();
2989        std::fs::write(
2990            state_dir.join("mounts.json"),
2991            r#"{
2992                "format": "memstead-mounts-3",
2993                "mounts": [
2994                    {
2995                        "mem": "specs",
2996                        "schema": "default@1.0.0",
2997                        "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
2998                        "capability": "write",
2999                        "lifecycle": "eager",
3000                        "cross_linkable": true
3001                    }
3002                ]
3003            }"#,
3004        )
3005        .unwrap();
3006        // Deliberate replacement of the historical wholesale-abort
3007        // assertion (agent-trust plan 04): the lean binary meeting a
3008        // git-branch mount QUARANTINES that mem (typed
3009        // UNSUPPORTED_WORKSPACE_SHAPE reason) instead of refusing the
3010        // whole workspace — the judgment is unchanged, the blast
3011        // radius shrinks to the one mount the lean flavour cannot
3012        // serve.
3013        let engine = Engine::from_workspace_root(tmp.path())
3014            .expect("lean boot quarantines the git-branch mount, never fails the workspace");
3015        let roster = engine.quarantined_mems();
3016        assert_eq!(roster.len(), 1);
3017        assert_eq!(roster[0].mount.mem, "specs");
3018        assert_eq!(roster[0].reason_code, "UNSUPPORTED_WORKSPACE_SHAPE");
3019        assert_eq!(engine.unknown_mem_error("specs").code(), "MEM_QUARANTINED");
3020    }
3021
3022    #[test]
3023    fn from_workspace_root_roots_standalone_folder_mem() {
3024        // Standalone collapse: a bare folder mem — `.memstead/config.json`
3025        // pinning a schema, no `workspace.toml` — boots as a one-mount
3026        // workspace instead of refusing with NotInitialised.
3027        let tmp = TempDir::new().unwrap();
3028        let root = tmp.path();
3029        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3030        std::fs::write(
3031            root.join(".memstead").join("config.json"),
3032            r#"{"schema":"default@1.0.0"}"#,
3033        )
3034        .unwrap();
3035        // A collapsed single-mem folder keeps its `.md` files at the root.
3036        std::fs::write(
3037            root.join("hello.md"),
3038            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
3039        )
3040        .unwrap();
3041
3042        let engine = Engine::from_workspace_root(root)
3043            .expect("a bare folder mem must root as a one-mount workspace");
3044        assert_eq!(engine.status().mem_count, 1, "exactly one mount");
3045        assert!(
3046            engine.status().entity_count >= 1,
3047            "the standalone mem's entity must load"
3048        );
3049    }
3050
3051    #[test]
3052    fn from_workspace_root_still_rejects_truly_empty_dir() {
3053        // Refusal complement: a directory with neither `workspace.toml` nor a
3054        // `.memstead/config.json` is not a mem — it still refuses, so the
3055        // standalone path never masks a genuinely uninitialised directory.
3056        let tmp = TempDir::new().unwrap();
3057        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
3058        assert!(
3059            matches!(err, crate::BootError::NotInitialised(_)),
3060            "got {err:?}"
3061        );
3062    }
3063
3064    /// A workspace whose installed schema violates the heading
3065    /// round-trip rule still boots and serves reads; the violation
3066    /// surfaces as a `SCHEMA_HEADING_ROUNDTRIP_VIOLATION` load warning
3067    /// (merged into health), never as a boot failure — refusing at
3068    /// boot would brick every workspace that installed such a schema
3069    /// before the install gate existed.
3070    #[test]
3071    fn boot_keeps_loading_violating_schema_and_surfaces_health_finding() {
3072        let tmp = TempDir::new().unwrap();
3073        let schemas_dir = tmp.path().join("schemas");
3074        let pkg = schemas_dir.join("debate");
3075        std::fs::create_dir_all(pkg.join("types")).unwrap();
3076        std::fs::write(
3077            pkg.join("schema.yaml"),
3078            r#"name: debate
3079version: 0.1.0
3080description: sealed-violator fixture
3081when_to_use: tests
3082types:
3083  - question
3084relationships:
3085  mode: strict
3086  definitions:
3087    - name: PART_OF
3088      description: hier
3089      default_weight: 3.0
3090    - name: _default
3091      description: fallback
3092      default_weight: 1.0
3093community:
3094  resolution: 1.0
3095  seed: 42
3096"#,
3097        )
3098        .unwrap();
3099        std::fs::write(
3100            pkg.join("types").join("question.yaml"),
3101            r#"name: question
3102description: t
3103when_to_use: tests
3104sections:
3105  - key: answers
3106    heading: Answers argued
3107    required: true
3108    search_weight: 10.0
3109    write_rules: []
3110  - key: notes
3111    heading: Notes
3112    required: false
3113    search_weight: 3.0
3114    catch_all: true
3115    write_rules: []
3116metadata_fields: []
3117title_weight: 100.0
3118text_fields:
3119  - answers
3120  - notes
3121hierarchy_relationship: PART_OF
3122no_self_loop_relationships: []
3123updatable_fields:
3124  - title
3125  - answers
3126  - notes
3127health_required_fields:
3128  - answers
3129staleness_threshold_days: 90
3130write_rules: []
3131"#,
3132        )
3133        .unwrap();
3134
3135        let mem_dir = tmp.path().join("mem");
3136        std::fs::create_dir_all(&mem_dir).unwrap();
3137        std::fs::write(
3138            mem_dir.join("q.md"),
3139            "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n",
3140        )
3141        .unwrap();
3142
3143        let writer = FilesystemMemWriter::new(mem_dir.clone());
3144        let mount = Mount {
3145            mem: "debate-mem".to_string(),
3146            schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3147            storage: MountStorage::Folder { path: mem_dir },
3148            capability: MountCapability::Write,
3149            lifecycle: MountLifecycle::Eager,
3150            cross_linkable: true,
3151            migration_target: None,
3152        };
3153        let engine = Engine::from_mounts_with_schemas_dir(
3154            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3155            Some(&schemas_dir),
3156        )
3157        .expect("a violating sealed schema must keep loading, never refuse boot");
3158
3159        // Reads still serve.
3160        assert!(
3161            engine.status().entity_count >= 1,
3162            "entities load despite the schema violation"
3163        );
3164
3165        // The violation is a health finding with the full tuple.
3166        let hits: Vec<_> = engine
3167            .load_warnings()
3168            .iter()
3169            .filter_map(|w| match w {
3170                WarningHint::SchemaHeadingRoundtripViolation {
3171                    mem,
3172                    schema_ref,
3173                    violations,
3174                } => Some((mem.clone(), schema_ref.clone(), violations.clone())),
3175                _ => None,
3176            })
3177            .collect();
3178        assert_eq!(
3179            hits.len(),
3180            1,
3181            "exactly one schema-level finding; all warnings = {:?}",
3182            engine.load_warnings()
3183        );
3184        let (mem, schema_ref, violations) = &hits[0];
3185        assert_eq!(mem, "debate-mem");
3186        assert_eq!(schema_ref, "debate@0.1.0");
3187        assert_eq!(violations.len(), 1);
3188        assert_eq!(violations[0].type_name, "question");
3189        assert_eq!(violations[0].key, "answers");
3190        assert_eq!(violations[0].heading, "Answers argued");
3191        assert_eq!(violations[0].derived_key, "answers_argued");
3192    }
3193
3194    /// The other half of "still serves reads AND writes": a mem pinned
3195    /// to a sealed heading-round-trip-violating schema accepts writes.
3196    /// The update commits (refusal complement: it is NOT refused), the
3197    /// schema-level health finding persists after the write, and the
3198    /// write-path `SECTION_HEADING_DIVERGENCE` warning fires where its
3199    /// condition holds (the file carries a heading that derives to the
3200    /// written key while the schema declares a different heading text).
3201    #[test]
3202    fn sealed_violator_mem_still_serves_writes() {
3203        // Same fixture as the read test above.
3204        let tmp = TempDir::new().unwrap();
3205        let schemas_dir = tmp.path().join("schemas");
3206        let pkg = schemas_dir.join("debate");
3207        std::fs::create_dir_all(pkg.join("types")).unwrap();
3208        std::fs::write(
3209            pkg.join("schema.yaml"),
3210            r#"name: debate
3211version: 0.1.0
3212description: sealed-violator fixture
3213when_to_use: tests
3214types:
3215  - question
3216relationships:
3217  mode: strict
3218  definitions:
3219    - name: PART_OF
3220      description: hier
3221      default_weight: 3.0
3222    - name: _default
3223      description: fallback
3224      default_weight: 1.0
3225community:
3226  resolution: 1.0
3227  seed: 42
3228"#,
3229        )
3230        .unwrap();
3231        std::fs::write(
3232            pkg.join("types").join("question.yaml"),
3233            r#"name: question
3234description: t
3235when_to_use: tests
3236sections:
3237  - key: answers
3238    heading: Answers argued
3239    required: true
3240    search_weight: 10.0
3241    write_rules: []
3242  - key: notes
3243    heading: Notes
3244    required: false
3245    search_weight: 3.0
3246    catch_all: true
3247    write_rules: []
3248metadata_fields: []
3249title_weight: 100.0
3250text_fields:
3251  - answers
3252  - notes
3253hierarchy_relationship: PART_OF
3254no_self_loop_relationships: []
3255updatable_fields:
3256  - title
3257  - answers
3258  - notes
3259health_required_fields:
3260  - answers
3261staleness_threshold_days: 90
3262write_rules: []
3263"#,
3264        )
3265        .unwrap();
3266
3267        let mem_dir = tmp.path().join("mem");
3268        std::fs::create_dir_all(&mem_dir).unwrap();
3269        // The file's own heading "Answers" derives to the key
3270        // `answers`, differing from the schema's declared
3271        // "Answers argued" — the divergence-warning condition.
3272        std::fs::write(
3273            mem_dir.join("q.md"),
3274            "---\ntype: question\n---\n# Q\n\n## Answers\n\nTwo answers.\n",
3275        )
3276        .unwrap();
3277
3278        let writer = FilesystemMemWriter::new(mem_dir.clone());
3279        let mount = Mount {
3280            mem: "debate-mem".to_string(),
3281            schema: Some(SchemaRef::new("debate", semver::Version::new(0, 1, 0))),
3282            storage: MountStorage::Folder { path: mem_dir },
3283            capability: MountCapability::Write,
3284            lifecycle: MountLifecycle::Eager,
3285            cross_linkable: true,
3286            migration_target: None,
3287        };
3288        let mut engine = Engine::from_mounts_with_schemas_dir(
3289            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3290            Some(&schemas_dir),
3291        )
3292        .expect("a violating sealed schema must keep loading");
3293
3294        let id = crate::EntityId::new("debate-mem", "q");
3295        let hash = engine.get_entity(&id).unwrap().content_hash.clone();
3296        let mut sections = indexmap::IndexMap::new();
3297        sections.insert("answers".to_string(), "Updated answers body.".to_string());
3298        let outcome = engine
3299            .update_entity(
3300                crate::engine::UpdateEntityArgs {
3301                    anchors: Vec::new(),
3302                    anchors_unset: Vec::new(),
3303                    id: id.clone(),
3304                    expected_hash: Some(hash),
3305                    sections,
3306                    append_sections: indexmap::IndexMap::new(),
3307                    patch_sections: indexmap::IndexMap::new(),
3308                    metadata: indexmap::IndexMap::new(),
3309                    metadata_unset: Vec::new(),
3310                    declare_relations: Vec::new(),
3311                    dry_run: false,
3312                    relations_unset: Vec::new(),
3313                },
3314                crate::vcs::Actor::Cli,
3315                None,
3316                None,
3317            )
3318            .expect("a write against a sealed-violator mem must NOT be refused");
3319        assert!(!outcome.commit_sha.is_empty(), "the write commits");
3320        assert!(
3321            outcome
3322                .warnings
3323                .iter()
3324                .any(|w| w.code() == "SECTION_HEADING_DIVERGENCE"),
3325            "the write-path divergence warning fires where its condition holds: {:?}",
3326            outcome.warnings
3327        );
3328
3329        // The schema-level finding persists after the write.
3330        assert!(
3331            engine.load_warnings().iter().any(|w| matches!(
3332                w,
3333                WarningHint::SchemaHeadingRoundtripViolation { mem, .. } if mem == "debate-mem"
3334            )),
3335            "the health finding persists across writes"
3336        );
3337        // The written content is durably on disk and survives the
3338        // reparse — under the catch-all, because the violating schema's
3339        // declared heading cannot round-trip to the written key. That
3340        // fork is exactly what the divergence warning announced (and
3341        // what the persisting health finding tells the operator to fix
3342        // at the schema); the "serves writes" guarantee is that the
3343        // write lands and nothing refuses, not that a broken schema
3344        // routes content correctly.
3345        let entity = engine.get_entity(&id).unwrap();
3346        assert!(
3347            entity
3348                .sections
3349                .values()
3350                .any(|s| s.contains("Updated answers body.")),
3351            "written content survives the round-trip (in the catch-all): {:?}",
3352            entity.sections
3353        );
3354    }
3355
3356    // ---- Engine::reload_one_mem -----------------------------------
3357}