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())
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 = load_workspace_schemas(schemas_dir)?;
65        Self::from_mounts_inner(mounts, extra_schemas)
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 = load_workspace_schemas(schemas_dir)?;
82        local.append(&mut extra);
83        Self::from_mounts_inner(mounts, local)
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    ) -> Result<Self, EngineError> {
90        let mut seen: std::collections::HashSet<String> =
91            std::collections::HashSet::with_capacity(mounts.len());
92        let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
93        for (mount, backend) in mounts {
94            if !seen.insert(mount.mem.clone()) {
95                return Err(EngineError::DuplicateMem(mount.mem));
96            }
97            // Seed the per-mount drift baseline. A backend that
98            // doesn't track HEAD (folder, archive) returns Ok(None)
99            // — drift detection is then a no-op for the mount. A
100            // probe failure during init falls back to None so a
101            // later successful probe can establish the baseline.
102            let last_known_head = backend.current_head().ok().flatten();
103            // Load the per-mem `.memstead/config.json` via the
104            // backend trait. Each backend resolves its own
105            // canonical location (folder: `<root>/.memstead/config.json`;
106            // archive: inside the zip; git-branch:
107            // `__MEMSTEAD:mems/<leaf>/config.json`). Read failures
108            // or missing files surface as
109            // `None` — `memstead_health` accommodates the missing-config
110            // case (handler emits empty `writeGuidance` + `extra`).
111            let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
112                let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
113                memstead_schema::config::parse_mem_config(&value).ok()
114            });
115            // Read the optional authoring-provenance payload the archive
116            // carries (`.memstead/provenance.json`). A malformed payload is
117            // downgraded to `None` (the member is additive — a parse
118            // failure means "provenance absent", not "mount failed").
119            let archive_provenance =
120                backend
121                    .read_archive_provenance()
122                    .ok()
123                    .flatten()
124                    .and_then(|bytes| {
125                        memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
126                    });
127            mounted.push(MountedBackend {
128                mount,
129                backend,
130                last_known_head,
131                mem_config,
132                archive_provenance,
133            });
134        }
135
136        // Walk each backend, parse entries, populate one shared Store.
137        // Resolve each mount's schema pin against the built-in schema
138        // catalogue. The schema-registry resolver (which would also
139        // honor workspace-authored schemas living inside the storage
140        // backend) lands as a separate plan; this resolution closes
141        // the gap for the built-in catalogue so a workspace pinning
142        // a non-default built-in (e.g. `software`, `memory`) surfaces
143        // the right schema rather than silently downgrading to
144        // `default`.
145        let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
146            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
147        // Workspace-authored schemas resolve first (override builtins
148        // on (name, version) collision); builtins fill the rest.
149        let workspace_schemas = extra_schemas.clone();
150        let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
151            Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
152        catalogue.extend(extra_schemas);
153        catalogue.extend(builtin_schemas_only.clone());
154        let builtin_schemas = catalogue;
155        let mut store = Store::new();
156        let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
157        let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
158        let fallback = engine_fallback_type();
159
160        // Derive the mem roster + last-segment suffixes ONCE so the
161        // per-mount load loop hands the same view to every
162        // `LoadCollector`. `known_suffixes` is the input the
163        // nested-prefix detector compares against; the full
164        // `mem_names` list feeds the two-pass cross-mem resolver
165        // in `push_entities_into_store`.
166        let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
167        let known_suffixes: Vec<String> = mem_names
168            .iter()
169            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
170            .collect();
171        let mut load_warnings: Vec<WarningHint> = Vec::new();
172
173        for m in &mounted {
174            // Schema-pin authority: the mem's own per-mem config is
175            // the authoritative settled pin, so a copied or cloned mem
176            // resolves its schema from its own backend without consulting
177            // this workspace's `mounts.json`. `Mount.schema` (the mount
178            // record's pin) is the fallback when the config carries no
179            // schema, and an expectation assertion when it does — a
180            // disagreement surfaces a `SchemaPinMismatch` warning rather
181            // than silently preferring either.
182            let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
183            let mount_pin = m.mount.schema.as_ref();
184            // `Mount.schema` is an optional expectation assertion: warn
185            // only when it is set *and* disagrees with the authoritative
186            // config pin.
187            if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
188                && cfg != mp
189            {
190                load_warnings.push(WarningHint::SchemaPinMismatch {
191                    mem: m.mount.mem.clone(),
192                    config_pin: cfg.as_display(),
193                    mount_pin: mp.as_display(),
194                });
195            }
196            // Authoritative pin first (the backend config), then the
197            // mount assertion as fallback when the config carries none.
198            let settled_pin = config_pin.or(mount_pin);
199            // Dual-pin: a mem mid-migration validates against the
200            // migration target, not the settled pin.
201            let effective_pin = m
202                .mount
203                .migration_target
204                .as_ref()
205                .or(settled_pin)
206                .ok_or_else(|| EngineError::MemConfigIncomplete {
207                    mem: m.mount.mem.clone(),
208                    missing_fields: vec!["schema".to_string()],
209                })?;
210            let schema = SchemaResolver::new(&builtin_schemas)
211                .resolve(effective_pin)
212                .map_err(|sources| EngineError::SchemaNotFound {
213                    mem: m.mount.mem.clone(),
214                    pin: effective_pin.as_display(),
215                    sources,
216                })?;
217            schemas.insert(m.mount.mem.clone(), schema.clone());
218
219            let (entries, read_errors) = collect_source_entries(m.backend.as_ref())?;
220            let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
221            // Wire the LoadCollector so the parser/store-builder
222            // pipeline forwards typed drift warnings
223            // (`SuspiciousNestedPrefix`, `DuplicateSectionHeading`,
224            // `InlineWikiLinkAutoStubbed`) into `load_warnings`.
225            // Mutation paths still pass `None` to stay silent.
226            push_entities_into_store(
227                &mut store,
228                load_result.entities,
229                fallback.as_ref(),
230                Some(crate::entity::store_builder::LoadCollector {
231                    warnings: &mut load_warnings,
232                    known_suffixes: &known_suffixes,
233                    mem_names: &mem_names,
234                }),
235            );
236            load_errors.extend(load_result.errors);
237        }
238
239        // Parse-time relation validation runs after every mount's
240        // entities are loaded so cross-mem target types are
241        // resolvable. Hand-edits, external tooling, and the macOS
242        // app's editor surface can inject relations that bypass
243        // `memstead_relate`; this is the only place those get caught.
244        // Mutation paths pre-validate before writing, so they
245        // never trip the warning post-load.
246        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
247            mounted
248                .iter()
249                .map(|m| (m.mount.mem.clone(), m.mount.capability))
250                .collect();
251        crate::entity::store_builder::validate_loaded_relations(
252            &mut store,
253            &schemas,
254            &mount_caps,
255            &mut load_warnings,
256        );
257
258        // Stamp `EdgeSource::BodyLink` on edges whose rel-type matches
259        // the source mem's `alias_target_rel_type` pointer. Runs
260        // after `validate_loaded_relations` so the surviving relation
261        // set is schema-clean before the labeling pass.
262        crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
263
264        // Derive the runtime mem router from the mount list.
265        // Mirrors full's `Engine::from_init` step that registers every
266        // mount with `MemRouterSnapshot` so handlers reach a
267        // consistent writable/visible roster regardless of which
268        // backend serves the mem.
269        let mem_router = build_mem_router_from_mounts(&mounted);
270
271        Ok(Self {
272            mounts: mounted,
273            store,
274            schemas,
275            workspace_schemas,
276            builtin_schemas: builtin_schemas_only,
277            load_errors,
278            community_memo: OnceCell::new(),
279            #[cfg(not(target_arch = "wasm32"))]
280            search_indexes_memo: OnceCell::new(),
281            settings: WorkspaceSettings::default(),
282            create_rule_set_memo: OnceCell::new(),
283            declared_origins: HashMap::new(),
284            workspace_root: None,
285            load_warnings,
286            pipeline_configs: crate::pipeline_store::PipelineConfigs::default(),
287            mem_router: Arc::new(mem_router),
288            backend_factory: crate::workspace_store::instantiate_lean_backend,
289            git_branch_ops: None,
290            event_subscribers: Arc::new(std::sync::Mutex::new(
291                crate::engine::events::SubscriberRegistry::new(),
292            )),
293            pending_mem_changed: Vec::new(),
294        })
295    }
296
297    /// Boot an engine from a workspace root using only lean-flavour
298    /// backends (folder + archive). The MCP filesystem server, the
299    /// CLI's lean dispatcher, and the macOS UniFFI consumer all reach
300    /// the new engine through this entry point — replacing per-flavour
301    /// init code with one call.
302    ///
303    /// Loads the workspace through [`crate::FileWorkspaceStore`],
304    /// instantiates each mount's backend via
305    /// [`crate::instantiate_lean_backend`], and constructs the
306    /// engine via [`Engine::from_mounts`].
307    ///
308    /// Errors:
309    /// - [`Layout::Empty`](crate::Layout) → [`BootError::NotInitialised`]
310    /// - any mount declaring [`crate::workspace::MountStorage::GitBranch`]
311    ///   → [`BootError::Instantiate`] wrapping
312    ///   [`crate::InstantiateError::GitBranchRequiresMemRepoFeature`]
313    /// - underlying store / engine failures lift through the
314    ///   `#[from]` conversions
315    pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
316        use crate::workspace_store::{
317            FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
318            instantiate_lean_backend,
319        };
320
321        let workspace = match detect_layout(workspace_root) {
322            // Standalone collapse: a bare folder mem (`.memstead/config.json`,
323            // no `workspace.toml`) roots as a one-mount workspace rather than
324            // refusing — the lone-mem boot path is the unified one.
325            Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
326                Some(ws) => ws,
327                None => {
328                    return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
329                }
330            },
331            Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
332        };
333
334        let settings = workspace.settings.clone();
335        let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
336            Vec::with_capacity(workspace.mounts.len());
337        for mount in workspace.mounts {
338            let backend = instantiate_lean_backend(&mount)?;
339            mounts.push((mount, backend));
340        }
341        // Folder-backend authoring path: authored schema packages live
342        // at the fixed `<workspace>/.memstead/schemas/<name>@<version>/`
343        // location — the folder analogue of the git-branch backend's
344        // `__MEMSTEAD:schemas/` ref. Read them through the folder
345        // `SchemaSource` (which no-ops when the directory is absent, so a
346        // workspace that authored no schemas resolves exactly as before —
347        // built-ins only). This is the lean flavour's schema-authoring
348        // path, which it lacked.
349        use crate::schema_source::SchemaSource as _;
350        let local = crate::schema_source::FolderSchemaSource::for_workspace(workspace_root)
351            .read_schemas()
352            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
353        let mut engine = Engine::from_mounts_inner(mounts, local)?;
354        engine.set_settings(settings);
355        engine.workspace_root = Some(workspace_root.to_path_buf());
356        // Load the workspace store's pipeline configs (Medium / Facet /
357        // Projection / Ingest) and expose them read-only. A malformed
358        // config surfaces a typed `StoreError::Parse` naming the file —
359        // early validation of operator-edited configs (the loader's stated
360        // value). Absent primitive directories resolve to empty.
361        // Authored pipeline configs (Medium / Facet / Projection / Ingest)
362        // from the workspace store. The legacy `scopes|projections|ingests/`
363        // JSON folders are no longer read at boot — the migration-window
364        // compatibility shim retired once the bundled pipelines migrated
365        // (2026-06-14). `memstead projection migrate` is the only path from
366        // old-shape configs into the store. A malformed config surfaces a
367        // typed `StoreError::Parse` naming the file.
368        // The engine's stored `pipeline_configs` is the four-primitive
369        // (legacy) shape — it backs the referential-integrity edit layer and
370        // the macOS `pipeline_configs_json` surface, both of which still speak
371        // Projection + flat-Ingest. The **live** brief / selection path reloads
372        // the version-gated binding shape fresh (`load_pipeline_configs`); boot
373        // uses the legacy reader so a not-yet-migrated dependent (the editor)
374        // keeps working. A migrated (v1) workspace reads its bindings lossily
375        // as projections here (version/operations ignored), which the edit
376        // layer and JSON surface tolerate.
377        engine.set_pipeline_configs(crate::pipeline_store::load_legacy_pipeline_configs(
378            workspace_root,
379        )?);
380        // Publish the authoring meta-schemas into `.memstead/meta-schemas/`
381        // so an editor validates authored schema YAML against them
382        // (resolved by each package's `# yaml-language-server:` directive).
383        // Best-effort — a read-only workspace still boots.
384        let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
385        Ok(engine)
386    }
387}
388
389/// Derive a [`MemRouterSnapshot`] from the engine's resolved mount
390/// list. Mirrors full's `Engine::from_init` mount-register loop so the
391/// runtime router carries the same writable/visible roster regardless
392/// of which backend serves each mem.
393///
394/// One pass over the mounts:
395/// - Writable mounts ([`MountCapability::Write`]) register via
396///   `add_writable` with the storage's worktree path. Folder mounts
397///   surface `MountStorage::Folder.path`; git-branch mounts surface
398///   `None` (the mem content lives only inside the gitdir).
399///   Archive mounts should never be writable; if one slips through,
400///   it registers with `dir: None`.
401/// - Read-only folder / git-branch mounts also register via
402///   `add_writable` with `dir: None`, then are *visible-only* —
403///   `is_writable` returns `false` because we follow up with a
404///   `remove_writable` (no-op for archives because archives are
405///   registered as `add_read_only`).
406///
407/// Actually we keep it simple: writable mounts go through
408/// `add_writable`; read-only mounts go through `add_read_only` with
409/// a synthesized archive-style path. For folder/git-branch read-only
410/// mounts we use the path the storage offers as the archive_path
411/// argument — semantically wrong but the router treats
412/// `add_read_only` data as opaque for visibility tracking. The two
413/// callers that care (`archive_path_for_mem`, `dir_for_mem`)
414/// branch on backend type at the handler level rather than reading
415/// these synthesized paths.
416///
417/// Origin is `MemOrigin::ExplicitToml` for every mount built from
418/// `Workspace.mounts` — the file-adapter case. `RuntimeCreated`
419/// origins land when `memstead_mem_create` migrates onto the unified
420/// engine and produces fresh runtime registrations.
421fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
422    let mut router = MemRouterSnapshot::new();
423    for m in mounts {
424        match m.mount.capability {
425            MountCapability::Write => {
426                let dir: Option<PathBuf> = match &m.mount.storage {
427                    MountStorage::Folder { path } => Some(path.clone()),
428                    MountStorage::GitBranch { .. } => None,
429                    MountStorage::Archive { .. } => None,
430                    // In-memory mounts have no on-disk working dir —
431                    // they register writable with `dir: None`, the same
432                    // shape mem-repo-backed mounts use.
433                    MountStorage::InMemory => None,
434                };
435                router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
436            }
437            MountCapability::ReadOnly => match &m.mount.storage {
438                MountStorage::Archive { path } => {
439                    router.add_read_only(m.mount.mem.clone(), path.clone());
440                }
441                MountStorage::Folder { path } => {
442                    router.add_read_only(m.mount.mem.clone(), path.clone());
443                }
444                MountStorage::GitBranch { gitdir, .. } => {
445                    router.add_read_only(m.mount.mem.clone(), gitdir.clone());
446                }
447                // A read-only in-memory mount has no on-disk read
448                // source to register. The engine never produces this
449                // configuration (in-memory mounts are created writable
450                // for ephemeral sessions); handled here only to keep
451                // the match total.
452                MountStorage::InMemory => {}
453            },
454        }
455    }
456    router
457}
458
459/// Public re-export of [`resolve_builtin_schema_pin`] for lifecycle
460/// orchestrators in `memstead-engine`. Mirrors full's
461/// `resolve_mem_schema` against the built-in catalogue;
462/// workspace-schema-registry resolution lifts later.
463pub fn resolve_builtin_schema_pin_pub(
464    pin: &memstead_schema::SchemaRef,
465    catalogue: &[Arc<memstead_schema::Schema>],
466) -> Option<Arc<memstead_schema::Schema>> {
467    resolve_builtin_schema_pin(pin, catalogue)
468}
469
470/// The engine's schema-pin resolver — the single named entry point a
471/// load path resolves a `name@version` pin through. Consults schema
472/// sources in a fixed order: **local storage** (the mem's own storage
473/// backend — folder `.memstead/schemas/` or the git-branch
474/// `__MEMSTEAD:schemas/` ref, layered first into the catalogue so it
475/// wins on `(name, version)` collision), **built-in** (compiled into the
476/// binary), **remote** (memstead.io, reserved, not implemented). The
477/// order is fixed in code — local-over-built-in by the catalogue's
478/// insertion precedence, remote always last. On a miss it yields the
479/// per-source [`SchemaSourceDiagnostic`] trail the `SCHEMA_NOT_FOUND`
480/// envelope carries.
481///
482/// Holds a borrowed view of the merged catalogue (`local ⧺ built-in`)
483/// the boot / register paths assemble, so resolution allocates nothing.
484pub struct SchemaResolver<'a> {
485    catalogue: &'a [Arc<memstead_schema::Schema>],
486}
487
488impl<'a> SchemaResolver<'a> {
489    /// Wrap the merged resolution catalogue (workspace-authored schemas
490    /// layered over the built-in set, local winning on collision).
491    pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
492        Self { catalogue }
493    }
494
495    /// Resolve a pin to its schema, or the fixed-order source
496    /// diagnostics on a miss (fed straight into
497    /// `EngineError::SchemaNotFound`'s `sources`).
498    pub fn resolve(
499        &self,
500        pin: &memstead_schema::SchemaRef,
501    ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
502    {
503        resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
504            crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
505                &pin.name,
506                &pin.version,
507                self.catalogue,
508            )
509        })
510    }
511}
512
513/// Walk `schemas_dir` and load every immediate subdirectory as a
514/// workspace-authored schema. Each subdirectory must contain a
515/// `schema.yaml` manifest (and optional `types/*.yaml`) — silently
516/// skips entries that don't carry the manifest. `pub(crate)` so the
517/// folder `SchemaSource` reads through the same walker the boot path uses.
518pub(crate) fn load_workspace_schemas(
519    schemas_dir: Option<&Path>,
520) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
521    let Some(dir) = schemas_dir else {
522        return Ok(Vec::new());
523    };
524    if !dir.is_dir() {
525        return Ok(Vec::new());
526    }
527    let entries = match std::fs::read_dir(dir) {
528        Ok(e) => e,
529        Err(_) => return Ok(Vec::new()),
530    };
531    let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
532    for entry in entries.flatten() {
533        let path = entry.path();
534        if !path.is_dir() {
535            continue;
536        }
537        if !path.join("schema.yaml").is_file() {
538            continue;
539        }
540        let schema = memstead_schema::load_schema_from_dir(&path)
541            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
542        schemas.push(Arc::new(schema));
543    }
544    Ok(schemas)
545}
546
547pub(super) fn resolve_builtin_schema_pin(
548    pin: &memstead_schema::SchemaRef,
549    catalogue: &[Arc<memstead_schema::Schema>],
550) -> Option<Arc<memstead_schema::Schema>> {
551    catalogue
552        .iter()
553        .find(|s| {
554            let id = s.id();
555            id.0 == pin.name && id.1 == pin.version
556        })
557        .cloned()
558}
559
560pub(super) fn collect_source_entries(
561    backend: &dyn MemBackend,
562) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
563    let paths = backend.list_entities()?;
564    let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
565    let mut errors: Vec<SourceReadError> = Vec::new();
566    for path in paths {
567        match backend.read_entity(&path) {
568            Ok(Some(bytes)) => match String::from_utf8(bytes) {
569                Ok(content) => entries.push(SourceEntry {
570                    relative_path: path.to_string_lossy().into_owned(),
571                    source_path: path.clone(),
572                    content,
573                }),
574                Err(e) => errors.push(SourceReadError {
575                    source_path: path,
576                    error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
577                }),
578            },
579            Ok(None) => {
580                // Listed-but-absent: list/read race. Skip silently.
581            }
582            Err(e) => errors.push(SourceReadError {
583                source_path: path,
584                error: std::io::Error::other(e.to_string()),
585            }),
586        }
587    }
588    Ok((entries, errors))
589}
590
591#[cfg(test)]
592mod tests {
593
594    use std::path::Path;
595
596    use memstead_schema::SchemaRef;
597    use tempfile::TempDir;
598
599    use crate::backend::MemBackend;
600    use crate::engine::test_helpers::*;
601    use crate::engine::{Engine, EngineError};
602    use crate::ops::WarningHint;
603    use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
604    use crate::vcs::CommitContext;
605    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
606
607    /// The `SchemaResolver` resolves a pin against the catalogue and, on
608    /// a miss, yields the fixed-order (`local_storage` → `builtin` →
609    /// `remote`) source diagnostics the `SCHEMA_NOT_FOUND` envelope carries.
610    #[test]
611    fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
612        let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
613        let resolver = super::SchemaResolver::new(&catalogue);
614
615        let ok: SchemaRef = "default@1.0.0".parse().unwrap();
616        assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
617
618        let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
619        let sources = resolver.resolve(&miss).unwrap_err();
620        let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
621        assert_eq!(labels, ["local_storage", "builtin", "remote"]);
622        assert!(sources.iter().all(|s| !s.pinned_version_match));
623    }
624
625    #[test]
626    fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
627        let engine = Engine::from_mounts(Vec::new()).unwrap();
628        assert!(engine.mem_names().is_empty());
629        match engine.list_entities("missing") {
630            Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
631            other => panic!("expected UnknownMem, got {other:?}"),
632        }
633    }
634
635    #[test]
636    fn duplicate_mem_names_rejected_at_construction() {
637        let tmp = TempDir::new().unwrap();
638        let writer1: Box<dyn MemBackend> =
639            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
640        let writer2: Box<dyn MemBackend> =
641            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
642        let err = Engine::from_mounts(vec![
643            (folder_mount("specs", tmp.path().to_path_buf()), writer1),
644            (folder_mount("specs", tmp.path().to_path_buf()), writer2),
645        ])
646        .unwrap_err();
647        assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
648    }
649
650    #[test]
651    fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
652        // A markdown file with the same `## Identity` heading twice
653        // should cause the parser to emit a typed
654        // `DuplicateSectionHeading` warning. With the
655        // LoadCollector wiring, that warning lands on
656        // `engine.load_warnings()`.
657        let tmp = TempDir::new().unwrap();
658        let mem_dir = tmp.path().to_path_buf();
659        let body =
660            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
661        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
662
663        let writer = FilesystemMemWriter::new(mem_dir.clone());
664        let engine = Engine::from_mounts(vec![(
665            folder_mount("specs", mem_dir),
666            Box::new(writer) as Box<dyn MemBackend>,
667        )])
668        .unwrap();
669
670        let warnings = engine.load_warnings();
671        assert!(
672            warnings
673                .iter()
674                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
675            "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
676        );
677    }
678
679    /// Parse-time relation validation drops relations whose `rel_type`
680    /// is not declared in the source mem's strict-mode schema and
681    /// emits `PARSED_RELATION_INVALID { reason: "unknown_rel_type" }`.
682    /// The entity itself loads normally; only the bad relation goes
683    /// missing from the in-memory store.
684    #[test]
685    fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
686        let tmp = TempDir::new().unwrap();
687        let mem_dir = tmp.path().to_path_buf();
688        // Hand-authored markdown with a `## Relationships` entry whose
689        // type isn't declared in the default schema (strict mode).
690        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
691        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
692        std::fs::write(mem_dir.join("target.md"), target).unwrap();
693        std::fs::write(mem_dir.join("source.md"), source).unwrap();
694
695        let writer = FilesystemMemWriter::new(mem_dir.clone());
696        let engine = Engine::from_mounts(vec![(
697            folder_mount("specs", mem_dir),
698            Box::new(writer) as Box<dyn MemBackend>,
699        )])
700        .unwrap();
701
702        let source_id = crate::entity::EntityId::new("specs", "source");
703        let target_id = crate::entity::EntityId::new("specs", "target");
704        let source_entity = engine.get_entity(&source_id).expect("source loaded");
705        // The offending relation does not survive into the entity's
706        // in-memory relationships list.
707        assert!(
708            source_entity.relationships.is_empty(),
709            "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
710            source_entity.relationships,
711        );
712        // Nor into the store's edge index.
713        let outgoing: Vec<_> = engine
714            .store()
715            .outgoing(&source_id)
716            .iter()
717            .filter(|e| e.rel_type == "MADE_UP_TYPE")
718            .collect();
719        assert!(
720            outgoing.is_empty(),
721            "MADE_UP_TYPE edge must be dropped from the store"
722        );
723        // The warning surfaces with the correct payload.
724        let parsed_invalid: Vec<_> = engine
725            .load_warnings()
726            .iter()
727            .filter_map(|w| match w {
728                WarningHint::ParsedRelationInvalid {
729                    entity_id,
730                    rel_type,
731                    target,
732                    reason,
733                    origin,
734                    recovery,
735                } => Some((
736                    entity_id.clone(),
737                    rel_type.clone(),
738                    target.clone(),
739                    reason.clone(),
740                    origin.clone(),
741                    recovery.clone(),
742                )),
743                _ => None,
744            })
745            .collect();
746        assert_eq!(
747            parsed_invalid.len(),
748            1,
749            "expected one warning, got {parsed_invalid:?}"
750        );
751        assert_eq!(parsed_invalid[0].0, source_id);
752        assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
753        assert_eq!(parsed_invalid[0].2, target_id);
754        assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
755        assert_eq!(parsed_invalid[0].4, "writable");
756        // Writable-origin warnings carry the abstract recovery action.
757        let recovery = parsed_invalid[0]
758            .5
759            .as_ref()
760            .expect("writable-origin warning must carry recovery");
761        assert_eq!(
762            recovery.kind,
763            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
764        );
765        assert_eq!(recovery.source_id, parsed_invalid[0].0);
766        assert_eq!(recovery.target_id, parsed_invalid[0].2);
767        assert_eq!(recovery.rel_type, parsed_invalid[0].1);
768    }
769
770    /// Hand-edited markdown can inject a cycle in an `acyclic: true`
771    /// rel-type's subgraph — the mutation surface's `would_cycle`
772    /// guard never fires for that path. The boot validator's
773    /// second pass finds the back-edge and drops it with
774    /// `reason: "cycle"`. The entity itself loads normally; one of
775    /// the two cycle-closing edges goes missing from the in-memory
776    /// store; the other survives.
777    #[test]
778    fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
779        let tmp = TempDir::new().unwrap();
780        let mem_dir = tmp.path().to_path_buf();
781        // Mutual PART_OF — acyclic in the default schema. The
782        // wiki-link grammar admits both as well-formed cross-
783        // references, so only the cycle pass can catch this.
784        let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
785        let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
786        std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
787        std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
788
789        let writer = FilesystemMemWriter::new(mem_dir.clone());
790        let engine = Engine::from_mounts(vec![(
791            folder_mount("specs", mem_dir),
792            Box::new(writer) as Box<dyn MemBackend>,
793        )])
794        .unwrap();
795
796        let alpha_id = crate::entity::EntityId::new("specs", "alpha");
797        let beta_id = crate::entity::EntityId::new("specs", "beta");
798
799        // Both entities are real — only the relation in the cycle
800        // gets dropped.
801        assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
802        assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
803
804        // Exactly one of the two PART_OF edges survives — the cycle
805        // is broken by dropping a single back-edge.
806        let surviving: Vec<_> = engine
807            .store()
808            .all_entities()
809            .flat_map(|e| {
810                engine
811                    .store()
812                    .outgoing(&e.id)
813                    .iter()
814                    .filter(|edge| edge.rel_type == "PART_OF")
815                    .map(|edge| (e.id.clone(), edge.target.clone()))
816                    .collect::<Vec<_>>()
817            })
818            .collect();
819        assert_eq!(
820            surviving.len(),
821            1,
822            "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
823        );
824
825        // The warning surfaces with `reason: "cycle"` and names the
826        // dropped pair.
827        let cycle_drops: Vec<_> = engine
828            .load_warnings()
829            .iter()
830            .filter_map(|w| match w {
831                WarningHint::ParsedRelationInvalid {
832                    entity_id,
833                    rel_type,
834                    target,
835                    reason,
836                    ..
837                } if reason == "cycle" => {
838                    Some((entity_id.clone(), rel_type.clone(), target.clone()))
839                }
840                _ => None,
841            })
842            .collect();
843        assert_eq!(
844            cycle_drops.len(),
845            1,
846            "exactly one cycle warning must fire, got {cycle_drops:?}",
847        );
848        // The dropped edge is one of the two PART_OF entries.
849        let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
850        assert_eq!(dropped_rel_type, "PART_OF");
851        let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
852        let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
853        assert!(
854            is_alpha_to_beta || is_beta_to_alpha,
855            "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
856        );
857        // And the surviving edge isn't the same as the dropped one.
858        assert_ne!(
859            (&surviving[0].0, &surviving[0].1),
860            (dropped_from, dropped_to),
861            "surviving edge must differ from the dropped one",
862        );
863    }
864
865    /// Shape-invalid relations on a writable-origin mount get dropped
866    /// with `reason: "shape"`; the warning carries a
867    /// `remove_explicit_relation` recovery hint whose ids and rel-type
868    /// mirror the warning's top-level fields. Same envelope shape as
869    /// the `unknown_rel_type` reason — `reason` discriminates the
870    /// cause; `recovery.kind` discriminates the action. Uses a
871    /// synthetic schema with `source_types` / `target_types`
872    /// constraints because the default schema's rel-types are
873    /// unconstrained.
874    #[test]
875    fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
876        use crate::engine::test_helpers::write_schema_files_with_default_type;
877
878        let tmp = TempDir::new().unwrap();
879        let schemas_dir = tmp.path().join("schemas");
880        std::fs::create_dir_all(&schemas_dir).unwrap();
881        // A schema declaring a single rel-type whose shape only
882        // admits `actor -> doc`. The source markdown below uses
883        // `doc -> doc`, which trips the shape validator.
884        let manifest = r#"name: shape-test
885version: 0.1.0
886description: shape-constraint schema
887when_to_use: tests
888types:
889  - doc
890  - actor
891relationships:
892  mode: strict
893  definitions:
894    - name: OWNS
895      description: actor owns doc
896      default_weight: 1.0
897      source_types: [actor]
898      target_types: [doc]
899    - name: _default
900      description: fallback
901      default_weight: 1.0
902community:
903  resolution: 1.0
904  seed: 42
905"#;
906        write_schema_files_with_default_type(
907            &schemas_dir,
908            "shape-test",
909            manifest,
910            &["doc", "actor"],
911        );
912
913        let mem_dir = tmp.path().join("mem");
914        std::fs::create_dir_all(&mem_dir).unwrap();
915        // Source is type `doc`; target is also type `doc`. The
916        // declared `OWNS` rel-type expects `actor -> doc`, so the
917        // shape check rejects this pair at load.
918        let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
919        let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
920        std::fs::write(mem_dir.join("target.md"), target).unwrap();
921        std::fs::write(mem_dir.join("source.md"), source).unwrap();
922
923        let writer = FilesystemMemWriter::new(mem_dir.clone());
924        let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
925        let mount = Mount {
926            mem: "specs".to_string(),
927            schema: Some(pin),
928            storage: MountStorage::Folder { path: mem_dir },
929            capability: MountCapability::Write,
930            lifecycle: MountLifecycle::Eager,
931            cross_linkable: true,
932            migration_target: None,
933        };
934        let engine = Engine::from_mounts_with_schemas_dir(
935            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
936            Some(&schemas_dir),
937        )
938        .unwrap();
939
940        let source_id = crate::entity::EntityId::new("specs", "source");
941        let target_id = crate::entity::EntityId::new("specs", "target");
942
943        let shape_drops: Vec<_> = engine
944            .load_warnings()
945            .iter()
946            .filter_map(|w| match w {
947                WarningHint::ParsedRelationInvalid {
948                    entity_id,
949                    rel_type,
950                    target,
951                    reason,
952                    origin,
953                    recovery,
954                } if reason == "shape" => Some((
955                    entity_id.clone(),
956                    rel_type.clone(),
957                    target.clone(),
958                    origin.clone(),
959                    recovery.clone(),
960                )),
961                _ => None,
962            })
963            .collect();
964        assert_eq!(
965            shape_drops.len(),
966            1,
967            "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
968            engine.load_warnings(),
969        );
970        let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
971            shape_drops.into_iter().next().unwrap();
972        assert_eq!(drop_from, source_id);
973        assert_eq!(drop_type, "OWNS");
974        assert_eq!(drop_to, target_id);
975        assert_eq!(drop_origin, "writable");
976        // Recovery mirrors the warning's top-level fields and names
977        // the abstract `remove_explicit_relation` action.
978        let recovery = drop_recovery.expect("writable origin must carry recovery");
979        assert_eq!(
980            recovery.kind,
981            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
982        );
983        assert_eq!(recovery.source_id, source_id);
984        assert_eq!(recovery.target_id, target_id);
985        assert_eq!(recovery.rel_type, "OWNS");
986    }
987
988    /// Read-only-origin warnings omit the recovery hint — the engine
989    /// cannot rewrite a read-only mount's markdown, so no abstract
990    /// action is available. The message field still names the
991    /// operator-level path (uninstall the archive or accept the
992    /// drift); structured consumers branch on `recovery.is_none()`.
993    #[test]
994    fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
995        let tmp = TempDir::new().unwrap();
996        // Archive content with a `MADE_UP_TYPE` row that the schema
997        // does not declare — parses to a `PARSED_RELATION_INVALID`
998        // with `reason: "unknown_rel_type"` on a read-only mount.
999        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
1000        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
1001        let archive_path = build_archive(
1002            tmp.path(),
1003            "ext",
1004            &[
1005                ("target.md", target.as_bytes()),
1006                ("source.md", source.as_bytes()),
1007            ],
1008        );
1009
1010        let engine = Engine::from_mounts(vec![(
1011            archive_mount("external", archive_path.clone()),
1012            Box::new(ArchiveBackend::new(archive_path)),
1013        )])
1014        .unwrap();
1015
1016        let invalid: Vec<_> = engine
1017            .load_warnings()
1018            .iter()
1019            .filter_map(|w| match w {
1020                WarningHint::ParsedRelationInvalid {
1021                    rel_type,
1022                    reason,
1023                    origin,
1024                    recovery,
1025                    ..
1026                } => Some((
1027                    rel_type.clone(),
1028                    reason.clone(),
1029                    origin.clone(),
1030                    recovery.clone(),
1031                )),
1032                _ => None,
1033            })
1034            .collect();
1035        assert_eq!(
1036            invalid.len(),
1037            1,
1038            "expected one parse-time drop on the readonly mount, got {invalid:?}",
1039        );
1040        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1041        assert_eq!(invalid[0].1, "unknown_rel_type");
1042        assert_eq!(invalid[0].2, "readonly");
1043        assert!(
1044            invalid[0].3.is_none(),
1045            "readonly-origin warning must omit the recovery hint, got {:?}",
1046            invalid[0].3,
1047        );
1048    }
1049
1050    #[test]
1051    fn load_on_init_populates_store_from_folder_mount() {
1052        // Real markdown content: minimal but parses cleanly against
1053        // the builtin default schema.
1054        let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1055
1056        let tmp = TempDir::new().unwrap();
1057        let mem_dir = tmp.path().to_path_buf();
1058        let writer = FilesystemMemWriter::new(mem_dir.clone());
1059        <FilesystemMemWriter as MemWriter>::write_entity(
1060            &writer,
1061            Path::new("hello.md"),
1062            body.as_bytes(),
1063        )
1064        .unwrap();
1065        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1066            .unwrap();
1067
1068        let engine = Engine::from_mounts(vec![(
1069            folder_mount("specs", mem_dir),
1070            Box::new(writer) as Box<dyn MemBackend>,
1071        )])
1072        .unwrap();
1073
1074        // Store is populated.
1075        assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1076        let id = crate::EntityId::new("specs", "hello");
1077        let entity = engine.get_entity(&id).expect("entity must be present");
1078        assert_eq!(entity.title, "Hello");
1079        assert_eq!(entity.entity_type, "spec");
1080        assert!(engine.load_errors().is_empty());
1081        // Schema map carries one entry per mount.
1082        assert_eq!(engine.schemas().len(), 1);
1083        assert!(engine.schemas().contains_key("specs"));
1084    }
1085
1086    #[test]
1087    fn load_on_init_populates_store_from_archive_mount() {
1088        let body =
1089            "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1090
1091        let tmp = TempDir::new().unwrap();
1092        let archive_path =
1093            build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1094
1095        let engine = Engine::from_mounts(vec![(
1096            archive_mount("external", archive_path.clone()),
1097            Box::new(ArchiveBackend::new(archive_path)),
1098        )])
1099        .unwrap();
1100
1101        let id = crate::EntityId::new("external", "from-archive");
1102        let entity = engine.get_entity(&id).expect("entity must be present");
1103        assert_eq!(entity.title, "From Archive");
1104        assert!(engine.load_errors().is_empty());
1105    }
1106
1107    #[test]
1108    fn load_on_init_populates_store_from_heterogeneous_mounts() {
1109        let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1110        let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1111
1112        let tmp = TempDir::new().unwrap();
1113
1114        let folder_dir = tmp.path().join("folder-mem");
1115        std::fs::create_dir_all(&folder_dir).unwrap();
1116        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1117        <FilesystemMemWriter as MemWriter>::write_entity(
1118            &folder_writer,
1119            Path::new("local.md"),
1120            folder_body.as_bytes(),
1121        )
1122        .unwrap();
1123        <FilesystemMemWriter as MemWriter>::commit(
1124            &folder_writer,
1125            "seed",
1126            &CommitContext::internal(),
1127        )
1128        .unwrap();
1129
1130        let archive_path = build_archive(
1131            tmp.path(),
1132            "external",
1133            &[("external.md", archive_body.as_bytes())],
1134        );
1135
1136        let engine = Engine::from_mounts(vec![
1137            (
1138                folder_mount("local", folder_dir),
1139                Box::new(folder_writer) as Box<dyn MemBackend>,
1140            ),
1141            (
1142                archive_mount("external", archive_path.clone()),
1143                Box::new(ArchiveBackend::new(archive_path)),
1144            ),
1145        ])
1146        .unwrap();
1147
1148        // Both mems' entities live in one shared store.
1149        assert_eq!(engine.store().len(), 2);
1150        assert!(
1151            engine
1152                .get_entity(&crate::EntityId::new("local", "local"))
1153                .is_some()
1154        );
1155        assert!(
1156            engine
1157                .get_entity(&crate::EntityId::new("external", "external"))
1158                .is_some()
1159        );
1160    }
1161
1162    #[test]
1163    fn load_on_init_collects_per_file_parse_errors_without_failing() {
1164        // One good file + one with malformed frontmatter — the parser
1165        // produces an error for the malformed file but the good one
1166        // still loads.
1167        let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1168        let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1169
1170        let tmp = TempDir::new().unwrap();
1171        let mem_dir = tmp.path().to_path_buf();
1172        let writer = FilesystemMemWriter::new(mem_dir.clone());
1173        <FilesystemMemWriter as MemWriter>::write_entity(
1174            &writer,
1175            Path::new("good.md"),
1176            good.as_bytes(),
1177        )
1178        .unwrap();
1179        <FilesystemMemWriter as MemWriter>::write_entity(
1180            &writer,
1181            Path::new("bad.md"),
1182            bad.as_bytes(),
1183        )
1184        .unwrap();
1185        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1186            .unwrap();
1187
1188        let engine = Engine::from_mounts(vec![(
1189            folder_mount("specs", mem_dir),
1190            Box::new(writer) as Box<dyn MemBackend>,
1191        )])
1192        .unwrap();
1193
1194        // The good entity is in the store; construction did not fail.
1195        assert!(
1196            engine
1197                .get_entity(&crate::EntityId::new("specs", "good"))
1198                .is_some(),
1199            "good.md must parse and reach the store"
1200        );
1201        // Either bad.md surfaces as a load error, or it parses
1202        // permissively — both are acceptable outcomes here. The
1203        // contract under test is "construction does not fail on a
1204        // single bad file".
1205        let bad_known_to_engine = engine
1206            .get_entity(&crate::EntityId::new("specs", "bad"))
1207            .is_some()
1208            || !engine.load_errors().is_empty();
1209        assert!(
1210            bad_known_to_engine,
1211            "bad.md must either parse or surface in load_errors"
1212        );
1213    }
1214
1215    #[test]
1216    fn empty_mount_list_yields_empty_store() {
1217        let engine = Engine::from_mounts(Vec::new()).unwrap();
1218        assert!(engine.store().is_empty());
1219        assert!(engine.schemas().is_empty());
1220        assert!(engine.load_errors().is_empty());
1221    }
1222
1223    // ---- Engine::create_entity --------------------------------------
1224
1225    #[test]
1226    fn from_workspace_root_errors_for_empty_layout() {
1227        let tmp = TempDir::new().unwrap();
1228        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1229        match err {
1230            crate::BootError::NotInitialised(p) => {
1231                assert_eq!(p, tmp.path());
1232            }
1233            other => panic!("expected NotInitialised, got {other:?}"),
1234        }
1235    }
1236
1237    #[test]
1238    fn from_workspace_root_loads_new_two_layer_layout() {
1239        let tmp = TempDir::new().unwrap();
1240        let mem_dir = tmp.path().join("mem");
1241        std::fs::create_dir_all(&mem_dir).unwrap();
1242        std::fs::write(
1243            mem_dir.join("hello.md"),
1244            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1245        )
1246        .unwrap();
1247
1248        let memstead = tmp.path().join(".memstead");
1249        std::fs::create_dir_all(&memstead).unwrap();
1250        std::fs::write(
1251            memstead.join("workspace.toml"),
1252            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1253        )
1254        .unwrap();
1255        // Save the mount via the file adapter so the JSON shape matches
1256        // the wire format the loader expects.
1257        use crate::workspace_store::WorkspaceStoreAdapter;
1258        let store = crate::FileWorkspaceStore::new();
1259        store
1260            .save_state(
1261                tmp.path(),
1262                &crate::workspace::Workspace {
1263                    mounts: vec![folder_mount("specs", mem_dir)],
1264                    settings: crate::workspace::WorkspaceSettings::default(),
1265                },
1266            )
1267            .unwrap();
1268
1269        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1270        assert_eq!(engine.mem_names(), vec!["specs"]);
1271        let entity = engine
1272            .get_entity(&crate::EntityId::new("specs", "hello"))
1273            .expect("seeded entity must load through from_workspace_root");
1274        assert_eq!(entity.title, "Hello");
1275    }
1276
1277    /// AC ("engine-side pipeline loader is activated"): with a workspace
1278    /// store carrying one Medium, one Facet, one Projection, one Ingest,
1279    /// the engine on boot enumerates all four through its read-only
1280    /// queryable surface in the post-refactor shape.
1281    #[test]
1282    fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1283        use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, Projection};
1284        use crate::pipeline_store::{LegacyIngest, LegacyIngestMode};
1285        let tmp = TempDir::new().unwrap();
1286        let mem_dir = tmp.path().join("mem");
1287        std::fs::create_dir_all(&mem_dir).unwrap();
1288
1289        let memstead = tmp.path().join(".memstead");
1290        std::fs::create_dir_all(&memstead).unwrap();
1291        std::fs::write(
1292            memstead.join("workspace.toml"),
1293            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1294        )
1295        .unwrap();
1296        use crate::workspace_store::WorkspaceStoreAdapter;
1297        crate::FileWorkspaceStore::new()
1298            .save_state(
1299                tmp.path(),
1300                &crate::workspace::Workspace {
1301                    mounts: vec![folder_mount("specs", mem_dir)],
1302                    settings: crate::workspace::WorkspaceSettings::default(),
1303                },
1304            )
1305            .unwrap();
1306
1307        // One of each primitive in the store.
1308        crate::pipeline_store::write_medium(
1309            tmp.path(),
1310            "specs",
1311            "src",
1312            &Medium {
1313                name: "src".to_string(),
1314                medium_type: MediumType::Codebase,
1315                pointer: "..".to_string(),
1316                change_detection: None,
1317            },
1318        )
1319        .unwrap();
1320        crate::pipeline_store::write_facet(
1321            tmp.path(),
1322            "specs",
1323            "view",
1324            &Facet {
1325                name: "view".to_string(),
1326                medium: "src".to_string(),
1327                scope: Vec::new(),
1328                engagement: None,
1329                preparation: None,
1330            },
1331        )
1332        .unwrap();
1333        crate::pipeline_store::write_projection(
1334            tmp.path(),
1335            "specs",
1336            "graph",
1337            &Projection {
1338                intent: None,
1339                source_facets: vec!["view".to_string()],
1340                reference_mems: Vec::new(),
1341                destination_mem: "specs".to_string(),
1342                rules: None,
1343            },
1344        )
1345        .unwrap();
1346        crate::pipeline_store::write_ingest(
1347            tmp.path(),
1348            "specs-graph",
1349            &LegacyIngest {
1350                projection: "specs/graph".to_string(),
1351                mode: LegacyIngestMode::Discovery,
1352                trigger: IngestTrigger::Loop,
1353                batch_size: 10,
1354                deny_paths: Vec::new(),
1355                post_actions: None,
1356            },
1357        )
1358        .unwrap();
1359
1360        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1361        let pc = engine.pipeline_configs();
1362        assert_eq!(pc.mediums.len(), 1, "one medium enumerated");
1363        assert_eq!(pc.mediums[0].config.medium_type, MediumType::Codebase);
1364        assert_eq!(pc.facets.len(), 1, "one facet enumerated");
1365        assert_eq!(pc.facets[0].config.medium, "src");
1366        assert_eq!(pc.projections.len(), 1, "one projection enumerated");
1367        assert_eq!(pc.projections[0].config.destination_mem, "specs");
1368        assert_eq!(pc.ingests.len(), 1, "one ingest enumerated");
1369        assert_eq!(pc.ingests[0].name, "specs-graph");
1370        assert_eq!(pc.ingests[0].config.mode, LegacyIngestMode::Discovery);
1371    }
1372
1373    /// Live per-anchor state (criteria 1, 9 — path-medium subset): a
1374    /// single-medium `path` mem observes working-tree existence at the current
1375    /// HEAD. Absent artifact ⇒ `orphaned`; present + non-hash class ⇒
1376    /// `resolves`; present + hash-bearing class ⇒ `recheck` (never a
1377    /// fabricated `drifted` — the prepared-content hash adjudication is E3b's).
1378    #[test]
1379    fn entity_anchors_resolve_live_state_for_path_medium() {
1380        use crate::anchor::{AnchorInput, AnchorState};
1381        use crate::pipeline::{Medium, MediumType};
1382        use crate::vcs::Actor;
1383        use crate::workspace_store::WorkspaceStoreAdapter;
1384        use indexmap::IndexMap;
1385
1386        let tmp = TempDir::new().unwrap();
1387        let mem_dir = tmp.path().join("mem");
1388        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1389        std::fs::write(
1390            mem_dir.join(".memstead").join("config.json"),
1391            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1392        )
1393        .unwrap();
1394
1395        let memstead = tmp.path().join(".memstead");
1396        std::fs::create_dir_all(&memstead).unwrap();
1397        std::fs::write(
1398            memstead.join("workspace.toml"),
1399            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1400        )
1401        .unwrap();
1402        crate::FileWorkspaceStore::new()
1403            .save_state(
1404                tmp.path(),
1405                &crate::workspace::Workspace {
1406                    mounts: vec![folder_mount("specs", mem_dir.clone())],
1407                    settings: crate::workspace::WorkspaceSettings::default(),
1408                },
1409            )
1410            .unwrap();
1411
1412        // A single `path` medium rooted at `<workspace>/src`; a file exists
1413        // there, so `present.rs` observes present and `gone.rs` observes
1414        // absent.
1415        crate::pipeline_store::write_medium(
1416            tmp.path(),
1417            "specs",
1418            "src",
1419            &Medium {
1420                name: "src".to_string(),
1421                medium_type: MediumType::Codebase,
1422                pointer: "src".to_string(),
1423                change_detection: None,
1424            },
1425        )
1426        .unwrap();
1427        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1428        std::fs::write(tmp.path().join("src").join("present.rs"), "fn main() {}").unwrap();
1429
1430        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1431
1432        let anchor = |artifact: &str, class: &str, hash: Option<&str>| AnchorInput {
1433            artifact: Some(artifact.to_string()),
1434            grain: Some("file".to_string()),
1435            class: Some(class.to_string()),
1436            hash: hash.map(str::to_string),
1437            hash_stability: Some("stable".to_string()),
1438            ..Default::default()
1439        };
1440        let mut sections = IndexMap::new();
1441        sections.insert("identity".to_string(), "Covers src.".to_string());
1442        sections.insert("purpose".to_string(), "Track sources.".to_string());
1443        let created = engine
1444            .create_entity(
1445                crate::CreateEntityArgs {
1446                    mem: "specs".to_string(),
1447                    title: "Covers".to_string(),
1448                    entity_type: "spec".to_string(),
1449                    sections,
1450                    metadata: IndexMap::new(),
1451                    relations: Vec::new(),
1452                    anchors: vec![
1453                        anchor("present.rs", "anchored", Some("h1")), // present + hash → recheck
1454                        anchor("present.rs", "informed-by", None), // present + non-hash → resolves
1455                        anchor("gone.rs", "anchored", Some("h2")), // absent → orphaned
1456                    ],
1457                    dry_run: false,
1458                },
1459                Actor::Agent,
1460                None,
1461                None,
1462            )
1463            .unwrap();
1464
1465        let resolved = engine.entity_anchors_resolved(&created.id);
1466        assert_eq!(resolved.len(), 3);
1467        let state_of = |artifact: &str, class: crate::anchor::AnchorProvenanceClass| {
1468            resolved
1469                .iter()
1470                .find(|r| r.anchor.artifact == artifact && r.anchor.class == class)
1471                .and_then(|r| r.state)
1472        };
1473        assert_eq!(
1474            state_of("present.rs", crate::anchor::AnchorProvenanceClass::Anchored),
1475            Some(AnchorState::Recheck),
1476            "present hash-bearing anchor cannot adjudicate the prepared hash here → recheck"
1477        );
1478        assert_eq!(
1479            state_of(
1480                "present.rs",
1481                crate::anchor::AnchorProvenanceClass::InformedBy
1482            ),
1483            Some(AnchorState::Resolves),
1484            "present non-hash anchor resolves on existence"
1485        );
1486        assert_eq!(
1487            state_of("gone.rs", crate::anchor::AnchorProvenanceClass::Anchored),
1488            Some(AnchorState::Orphaned),
1489            "absent artifact is orphaned"
1490        );
1491    }
1492
1493    /// The engine edit surface: a wrapper edit (`add_medium`) routes through
1494    /// the pipeline-edit layer, writes the store, and refreshes the in-memory
1495    /// snapshot in place (no `reload()`), and referential integrity (a facet
1496    /// pinning the medium) is enforced through the engine method.
1497    #[test]
1498    fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
1499        use crate::pipeline::{Facet, Medium, MediumType};
1500        use crate::workspace_store::WorkspaceStoreAdapter;
1501
1502        let tmp = TempDir::new().unwrap();
1503        let mem_dir = tmp.path().join("mem");
1504        std::fs::create_dir_all(&mem_dir).unwrap();
1505        let memstead = tmp.path().join(".memstead");
1506        std::fs::create_dir_all(&memstead).unwrap();
1507        std::fs::write(
1508            memstead.join("workspace.toml"),
1509            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1510        )
1511        .unwrap();
1512        crate::FileWorkspaceStore::new()
1513            .save_state(
1514                tmp.path(),
1515                &crate::workspace::Workspace {
1516                    mounts: vec![folder_mount("specs", mem_dir)],
1517                    settings: crate::workspace::WorkspaceSettings::default(),
1518                },
1519            )
1520            .unwrap();
1521
1522        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1523        assert!(engine.pipeline_configs().mediums.is_empty());
1524
1525        engine
1526            .add_medium(
1527                "specs",
1528                "src",
1529                &Medium {
1530                    name: "src".to_string(),
1531                    medium_type: MediumType::Codebase,
1532                    pointer: "..".to_string(),
1533                    change_detection: None,
1534                },
1535                None,
1536            )
1537            .unwrap();
1538        // Snapshot refreshed in place.
1539        assert_eq!(engine.pipeline_configs().mediums.len(), 1);
1540        assert_eq!(engine.pipeline_configs().mediums[0].name, "src");
1541
1542        engine
1543            .add_facet(
1544                "specs",
1545                "view",
1546                &Facet {
1547                    name: "view".to_string(),
1548                    medium: "src".to_string(),
1549                    scope: Vec::new(),
1550                    engagement: None,
1551                    preparation: None,
1552                },
1553                None,
1554            )
1555            .unwrap();
1556        // Deleting a referenced medium is refused through the engine surface.
1557        let err = engine.delete_medium("specs", "src", None).unwrap_err();
1558        assert!(
1559            matches!(
1560                err,
1561                crate::pipeline_edit::PipelineEditError::Referenced { .. }
1562            ),
1563            "got {err:?}"
1564        );
1565        assert_eq!(
1566            engine.pipeline_configs().mediums.len(),
1567            1,
1568            "refused delete left the medium in place"
1569        );
1570
1571        // The JSON entry point (the FFI-facing shape) deserializes and lands.
1572        engine
1573            .add_medium_json(
1574                "specs",
1575                "docs",
1576                r#"{"name":"docs","type":"filesystem","pointer":"./docs"}"#,
1577                None,
1578            )
1579            .unwrap();
1580        assert!(
1581            engine
1582                .pipeline_configs()
1583                .mediums
1584                .iter()
1585                .any(|m| m.name == "docs" && m.config.medium_type == MediumType::Filesystem),
1586            "add_medium_json should land a filesystem medium"
1587        );
1588        // A malformed payload is refused without touching the store.
1589        let err = engine
1590            .add_medium_json("specs", "bad", "{ not json", None)
1591            .unwrap_err();
1592        assert!(
1593            matches!(
1594                err,
1595                crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
1596            ),
1597            "got {err:?}"
1598        );
1599
1600        // The JSON read counterpart reflects the live store.
1601        let json = engine.pipeline_configs_json();
1602        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1603        let mediums = parsed["mediums"].as_array().unwrap();
1604        assert_eq!(mediums.len(), 2, "src + docs");
1605        assert!(
1606            mediums
1607                .iter()
1608                .any(|m| m["name"] == "docs" && m["config"]["type"] == "filesystem"),
1609            "pipeline_configs_json should carry the docs medium: {json}"
1610        );
1611    }
1612
1613    /// The lean folder authoring path: a schema package authored at the
1614    /// fixed `<workspace>/.memstead/schemas/<name>@<version>/` location
1615    /// is resolved at boot, so a folder mem can pin a non-built-in
1616    /// schema. Before this wiring `from_workspace_root` loaded only
1617    /// built-ins, so the pin would refuse with `SCHEMA_NOT_FOUND`.
1618    #[test]
1619    fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
1620        use crate::engine::test_helpers::write_schema_files_with_default_type;
1621
1622        let tmp = TempDir::new().unwrap();
1623        let mem_dir = tmp.path().join("mem");
1624        std::fs::create_dir_all(&mem_dir).unwrap();
1625
1626        // Author a schema package at the fixed folder location.
1627        let authored_dir = tmp.path().join(".memstead").join("schemas");
1628        let manifest = r#"name: authored
1629version: 0.1.0
1630description: an authored-in-workspace test schema
1631when_to_use: tests
1632types:
1633  - doc
1634relationships:
1635  mode: strict
1636  definitions:
1637    - name: _default
1638      description: fallback
1639      default_weight: 1.0
1640community:
1641  resolution: 1.0
1642  seed: 42
1643"#;
1644        write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
1645
1646        // A folder mem pinning the authored (non-built-in) schema.
1647        let memstead = tmp.path().join(".memstead");
1648        std::fs::create_dir_all(&memstead).unwrap();
1649        std::fs::write(
1650            memstead.join("workspace.toml"),
1651            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1652        )
1653        .unwrap();
1654        let mount = Mount {
1655            mem: "specs".to_string(),
1656            schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
1657            storage: MountStorage::Folder { path: mem_dir },
1658            capability: MountCapability::Write,
1659            lifecycle: MountLifecycle::Eager,
1660            cross_linkable: true,
1661            migration_target: None,
1662        };
1663        use crate::workspace_store::WorkspaceStoreAdapter;
1664        crate::FileWorkspaceStore::new()
1665            .save_state(
1666                tmp.path(),
1667                &crate::workspace::Workspace {
1668                    mounts: vec![mount],
1669                    settings: crate::workspace::WorkspaceSettings::default(),
1670                },
1671            )
1672            .unwrap();
1673
1674        // Boots cleanly — the authored pin resolved against the fixed
1675        // location rather than refusing as an unknown built-in.
1676        let engine = Engine::from_workspace_root(tmp.path())
1677            .expect("authored schema at .memstead/schemas/ must resolve at boot");
1678        assert_eq!(engine.mem_names(), vec!["specs"]);
1679    }
1680
1681    #[test]
1682    fn from_workspace_root_propagates_mem_management_settings() {
1683        // workspace.toml carries [mem_management] rules; the file
1684        // adapter parses them into Workspace.settings; from_workspace_root
1685        // calls Engine::set_settings so the engine surface reflects them.
1686        // End-to-end check that the carriers, parser, and plumbing connect.
1687        let tmp = TempDir::new().unwrap();
1688        let mem_dir = tmp.path().join("mem");
1689        std::fs::create_dir_all(&mem_dir).unwrap();
1690
1691        let memstead = tmp.path().join(".memstead");
1692        std::fs::create_dir_all(&memstead).unwrap();
1693        std::fs::write(
1694            memstead.join("workspace.toml"),
1695            r#"format = "memstead-git-branch-2"
1696
1697[persistence_adapter]
1698name = "file-two-layer"
1699
1700[[mem_management.create]]
1701pattern = "exec-*"
1702schemas = ["default@1.0.0"]
1703
1704[[mem_management.delete]]
1705pattern = "exec-*"
1706"#,
1707        )
1708        .unwrap();
1709        use crate::workspace_store::WorkspaceStoreAdapter;
1710        let store = crate::FileWorkspaceStore::new();
1711        store
1712            .save_state(
1713                tmp.path(),
1714                &crate::workspace::Workspace {
1715                    mounts: vec![folder_mount("specs", mem_dir)],
1716                    settings: crate::workspace::WorkspaceSettings::default(),
1717                },
1718            )
1719            .unwrap();
1720
1721        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1722        let s = engine.settings();
1723        assert_eq!(s.mem_create_rules.len(), 1);
1724        assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
1725        assert_eq!(
1726            s.mem_create_rules[0].schemas,
1727            vec!["default@1.0.0".to_string()]
1728        );
1729        assert_eq!(s.mem_delete_rules.len(), 1);
1730        assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
1731    }
1732
1733    #[test]
1734    fn from_mounts_rejects_unknown_schema_pin_with_typed_error() {
1735        let tmp = TempDir::new().unwrap();
1736        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1737        let mount = Mount {
1738            mem: "specs".to_string(),
1739            schema: Some(SchemaRef::new(
1740                "totally-not-a-schema",
1741                semver::Version::new(1, 0, 0),
1742            )),
1743            storage: MountStorage::Folder {
1744                path: tmp.path().to_path_buf(),
1745            },
1746            capability: MountCapability::Write,
1747            lifecycle: MountLifecycle::Eager,
1748            cross_linkable: true,
1749            migration_target: None,
1750        };
1751        let err = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
1752            .unwrap_err();
1753        match err {
1754            EngineError::SchemaNotFound { mem, pin, sources } => {
1755                assert_eq!(mem, "specs");
1756                assert_eq!(pin, "totally-not-a-schema@1.0.0");
1757                // The diagnostics name all three sources in fixed order;
1758                // none carries the unknown pin, and remote is reserved.
1759                let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1760                assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1761                assert!(sources.iter().all(|s| !s.pinned_version_match));
1762                assert_eq!(
1763                    sources
1764                        .iter()
1765                        .find(|s| s.source == "remote")
1766                        .unwrap()
1767                        .status,
1768                    Some("not_configured"),
1769                );
1770            }
1771            other => panic!("expected SchemaNotFound, got {other:?}"),
1772        }
1773    }
1774
1775    /// Schema-pin authority: the mem's own per-mem config is the
1776    /// authoritative settled pin. Here the config pins a resolvable
1777    /// schema (`software@0.1.0`) while the workspace mount expects an
1778    /// unresolvable one — boot succeeds (proving the config pin won,
1779    /// not the mount's) and surfaces a `SchemaPinMismatch` warning
1780    /// naming both pins.
1781    #[test]
1782    fn mem_config_schema_is_authoritative_over_mount_pin() {
1783        let tmp = TempDir::new().unwrap();
1784        let mem_dir = tmp.path().to_path_buf();
1785        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1786        std::fs::write(
1787            mem_dir.join(".memstead").join("config.json"),
1788            r#"{"schema":"software@0.1.0"}"#,
1789        )
1790        .unwrap();
1791        let writer = FilesystemMemWriter::new(mem_dir.clone());
1792        let mount = Mount {
1793            mem: "specs".to_string(),
1794            schema: Some(SchemaRef::new(
1795                "totally-not-a-schema",
1796                semver::Version::new(9, 9, 9),
1797            )),
1798            storage: MountStorage::Folder { path: mem_dir },
1799            capability: MountCapability::Write,
1800            lifecycle: MountLifecycle::Eager,
1801            cross_linkable: true,
1802            migration_target: None,
1803        };
1804        let engine = Engine::from_mounts(vec![(
1805            mount,
1806            Box::new(writer) as Box<dyn MemBackend>,
1807        )])
1808        .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
1809
1810        let mismatch = engine
1811            .load_warnings()
1812            .iter()
1813            .find_map(|w| match w {
1814                WarningHint::SchemaPinMismatch {
1815                    mem,
1816                    config_pin,
1817                    mount_pin,
1818                } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
1819                _ => None,
1820            })
1821            .expect("SchemaPinMismatch warning must surface naming both pins");
1822        assert_eq!(mismatch.0, "specs");
1823        assert_eq!(mismatch.1, "software@0.1.0");
1824        assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
1825    }
1826
1827    #[test]
1828    fn from_workspace_root_rejects_git_branch_mount_with_typed_error() {
1829        let tmp = TempDir::new().unwrap();
1830        let memstead = tmp.path().join(".memstead");
1831        std::fs::create_dir_all(&memstead).unwrap();
1832        std::fs::write(
1833            memstead.join("workspace.toml"),
1834            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1835        )
1836        .unwrap();
1837        // Hand-craft a state/mounts.json carrying a git-branch mount —
1838        // the lean boot path can't instantiate that backend.
1839        let state_dir = memstead.join("state");
1840        std::fs::create_dir_all(&state_dir).unwrap();
1841        std::fs::write(
1842            state_dir.join("mounts.json"),
1843            r#"{
1844                "format": "memstead-mounts-3",
1845                "mounts": [
1846                    {
1847                        "mem": "specs",
1848                        "schema": "default@1.0.0",
1849                        "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
1850                        "capability": "write",
1851                        "lifecycle": "eager",
1852                        "cross_linkable": true
1853                    }
1854                ]
1855            }"#,
1856        )
1857        .unwrap();
1858        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1859        match err {
1860            crate::BootError::Instantiate(
1861                crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
1862            ) => {
1863                assert_eq!(mem, "specs");
1864            }
1865            other => panic!("expected Instantiate(GitBranchRequiresMemRepoFeature), got {other:?}"),
1866        }
1867    }
1868
1869    #[test]
1870    fn from_workspace_root_roots_standalone_folder_mem() {
1871        // Standalone collapse: a bare folder mem — `.memstead/config.json`
1872        // pinning a schema, no `workspace.toml` — boots as a one-mount
1873        // workspace instead of refusing with NotInitialised.
1874        let tmp = TempDir::new().unwrap();
1875        let root = tmp.path();
1876        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1877        std::fs::write(
1878            root.join(".memstead").join("config.json"),
1879            r#"{"schema":"default@1.0.0"}"#,
1880        )
1881        .unwrap();
1882        // A collapsed single-mem folder keeps its `.md` files at the root.
1883        std::fs::write(
1884            root.join("hello.md"),
1885            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
1886        )
1887        .unwrap();
1888
1889        let engine = Engine::from_workspace_root(root)
1890            .expect("a bare folder mem must root as a one-mount workspace");
1891        assert_eq!(engine.status().mem_count, 1, "exactly one mount");
1892        assert!(
1893            engine.status().entity_count >= 1,
1894            "the standalone mem's entity must load"
1895        );
1896    }
1897
1898    #[test]
1899    fn from_workspace_root_still_rejects_truly_empty_dir() {
1900        // Refusal complement: a directory with neither `workspace.toml` nor a
1901        // `.memstead/config.json` is not a mem — it still refuses, so the
1902        // standalone path never masks a genuinely uninitialised directory.
1903        let tmp = TempDir::new().unwrap();
1904        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1905        assert!(
1906            matches!(err, crate::BootError::NotInitialised(_)),
1907            "got {err:?}"
1908        );
1909    }
1910
1911    // ---- Engine::reload_one_mem -----------------------------------
1912}