Skip to main content

memstead_base/engine/
query.rs

1//! Engine read paths — accessors and queries.
2//!
3//! Read-only methods on `Engine`: store / schema / mount accessors,
4//! per-mem path helpers (`gitdir_for` / `worktree_for`), aggregated
5//! views (`communities`, `orphans`, `stubs`, `most_connected`,
6//! `missing_required_outgoing`), per-mem summaries (`health`,
7//! `stats`, `context`), search (`list`, `search`,
8//! `search_indexes`), and the bytes-level read wrappers
9//! (`list_entities`, `read_entity`, `read_provenance`). Capability and
10//! cross-mem link gating live here too — they're consulted by
11//! handlers before any mutation reaches the backend.
12
13use std::cell::OnceCell;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use memstead_schema::Schema;
19
20use crate::engine_fallback_type;
21use crate::entity::{Entity, EntityId};
22use crate::graph::{LouvainOutput, community::detect_communities};
23use crate::mem::MemRouterSnapshot;
24use crate::ops::{ContextResult, Direction, NeighborInfo, SearchResult, SearchScope, WarningHint};
25use crate::provenance::Provenance;
26#[cfg(not(target_arch = "wasm32"))]
27use crate::search_index::{MemIndex, build_all};
28use crate::store::Store;
29use crate::workspace::{MountCapability, MountStorage, WorkspaceSettings};
30
31use super::{BackendFactory, Engine, EngineError, MountedBackend};
32
33impl Engine {
34    /// In-memory store populated at construction time from every
35    /// mount's backend. Read-only at this point in the rebuild —
36    /// mutation paths land in a later session.
37    pub fn store(&self) -> &Store {
38        &self.store
39    }
40
41    /// Per-mem schema, keyed by mount's mem name. Each entry is the
42    /// schema resolved from that mount's pin at boot, so the map holds
43    /// genuinely heterogeneous schemas in a multi-schema workspace.
44    pub fn schemas(&self) -> &HashMap<String, Arc<Schema>> {
45        &self.schemas
46    }
47
48    /// Workspace-authored schemas loaded from
49    /// `WorkspaceSettings.schemas_dir` at construction. Distinct from
50    /// [`Self::schemas`] (per-mem, only schemas pinned by a mount):
51    /// this slice carries every workspace-loaded schema regardless of
52    /// whether a mem pins it. Used by `memstead_overview` to enumerate
53    /// schemas referenced by `mem_create_rules.schemas[]` but not
54    /// pinned by any mem — agents see what could be pinned without
55    /// looking up the workspace.toml directly.
56    pub fn workspace_schemas(&self) -> &[Arc<Schema>] {
57        &self.workspace_schemas
58    }
59
60    /// Embedded built-in schemas loaded once at boot. Handlers
61    /// resolving a schema pin by `<name>@<version>` (MCP's `memstead_schema`,
62    /// `memstead_overview` rendering) walk mem-pinned, workspace, and
63    /// built-in catalogues in order — built-ins are the catch-all when
64    /// no mem or workspace dir pins the schema. Workspace schemas
65    /// shadow built-ins on `(name, version)` collision; resolve from
66    /// `workspace_schemas()` first.
67    pub fn builtin_schemas(&self) -> &[Arc<Schema>] {
68        &self.builtin_schemas
69    }
70
71    /// Classify a schema's trust origin — the single authority every read
72    /// surface consults before serving a schema's instruction-prose.
73    ///
74    /// A schema is [`OriginClass::FirstParty`] iff it is an engine built-in
75    /// **or** pinned by a writable mount in this workspace. Built-ins are
76    /// compiled into the binary — unforgeable. A non-built-in schema earns
77    /// first-party status only once the operator *adopts* it by writably
78    /// mounting a mem that pins it: writing into a mem is the act that
79    /// legitimately needs a schema's authoring prose (`system_message`,
80    /// `write_rules`, …), and the mount's writable posture is set by the
81    /// consumer's own config — a publisher cannot forge it.
82    ///
83    /// Everything else is [`OriginClass::ThirdParty`]: a schema present in
84    /// the catalogue but pinned only by read-only mounts (a registry-
85    /// installed read-mem or an adopted foreign folder/clone), or one the
86    /// engine cannot vouch for at all. Its prose is served structural-only
87    /// so a stranger's free-text never reaches a consuming agent as
88    /// instructions. This classifies by the mount graph — never by scanning
89    /// the schema's content, which a publisher controls — and `ThirdParty`
90    /// is the safe default for any ambiguous origin.
91    ///
92    /// Note a read-only mount pinning a *built-in* schema (e.g. a registry
93    /// mem on `default@1.0.0`) resolves to the consumer's own clean copy
94    /// and stays first-party — the de-framing targets only foreign,
95    /// non-built-in schemas that no writable mem has adopted.
96    pub fn schema_origin(&self, schema: &Arc<Schema>) -> crate::render::OriginClass {
97        use crate::render::OriginClass;
98        let (name, version) = schema.id();
99        // Built-in schemas are compiled in — first-party, unforgeable.
100        let is_builtin = self.builtin_schemas.iter().any(|s| {
101            let id = s.id();
102            id.0 == name && id.1 == version
103        });
104        if is_builtin {
105            return OriginClass::FirstParty;
106        }
107        // Adoption signal: some writable mount pins this exact schema, so
108        // the operator authors against it here.
109        let canon = format!("{name}@{version}");
110        let pinned_by_writable = self.mounts().iter().any(|m| {
111            m.schema.as_ref().map(|s| s.to_string()).as_deref() == Some(canon.as_str())
112                && self.mem_router().is_writable(&m.mem)
113        });
114        if pinned_by_writable {
115            OriginClass::FirstParty
116        } else {
117            OriginClass::ThirdParty
118        }
119    }
120
121    /// Classify a mem's *data* trust origin — the authority every read
122    /// surface consults before serving an entity's content (bodies,
123    /// snippets, titles). A writable mount is [`OriginClass::FirstParty`]:
124    /// its content is authored in this workspace. Anything else — a
125    /// read-only mount (a registry-installed read-mem or an adopted
126    /// foreign folder/clone) or an unknown mem — is
127    /// [`OriginClass::ThirdParty`], so the consuming agent/host treats the
128    /// content as quoted, untrusted data.
129    ///
130    /// This reads the mount's already-decided writable/read-only posture
131    /// (fixed at adopt/mount time) — it never scans content, and the class
132    /// is set by the consumer's mount config, so a publisher cannot forge
133    /// first-party. Distinct from [`Self::schema_origin`], which governs a
134    /// schema's instruction-prose: the data channel and the
135    /// instruction channel are separate vectors with separate authorities.
136    pub fn mem_origin_class(&self, mem: &str) -> crate::render::OriginClass {
137        if self.mem_router().is_writable(mem) {
138            crate::render::OriginClass::FirstParty
139        } else {
140            crate::render::OriginClass::ThirdParty
141        }
142    }
143
144    /// Per-file errors collected during load. Non-fatal: the engine
145    /// continues with whatever did parse. Empty when every backend's
146    /// content parses cleanly.
147    pub fn load_errors(&self) -> &[(PathBuf, String)] {
148        &self.load_errors
149    }
150
151    /// Workspace-level operator policy (mem create/delete rules,
152    /// cross-mem links). Defaults to empty; populated via
153    /// [`Engine::set_settings`] after construction. Surfaced for MCP
154    /// handlers (`memstead_health { include_config: true }`,
155    /// `memstead_overview`'s lifecycle-namespaces section) and other
156    /// consumers that need to read workspace policy.
157    pub fn settings(&self) -> &WorkspaceSettings {
158        &self.settings
159    }
160
161    /// The pipeline configs (Medium / Facet / Projection / Ingest) loaded
162    /// from the workspace store at boot — the read-only queryable surface
163    /// the loader exposes. Empty for engines not booted from a workspace
164    /// root, or for a workspace that declares no pipelines. The ingest
165    /// skill, future MCP tools, and the macOS app consume this structured
166    /// form rather than re-reading the JSON folders.
167    pub fn pipeline_configs(&self) -> &crate::pipeline_store::PipelineConfigs {
168        &self.pipeline_configs
169    }
170
171    /// The pipeline configs serialized as a JSON string — the read
172    /// counterpart of the `add_*_json` edit entry points. Serialization-
173    /// boundary callers (UniFFI, where serde does not live) get the
174    /// four-primitive store in one call and deserialize on their side.
175    /// Shape: `{ "mediums": [{ mem, name, config }], "facets": [...],
176    /// "projections": [...], "ingests": [{ name, config }] }`. Serializing
177    /// these plain records cannot fail; an empty store still yields the
178    /// fallback empty object.
179    pub fn pipeline_configs_json(&self) -> String {
180        serde_json::to_string(&self.pipeline_configs).unwrap_or_else(|_| {
181            "{\"mediums\":[],\"facets\":[],\"projections\":[],\"ingests\":[]}".to_string()
182        })
183    }
184
185    /// Overwrite the in-memory pipeline configs. The workspace-root boot
186    /// paths call this after [`crate::pipeline_store::load_pipeline_configs`];
187    /// exposed so the full boot helper (a separate crate) can populate the
188    /// same surface.
189    pub fn set_pipeline_configs(&mut self, configs: crate::pipeline_store::PipelineConfigs) {
190        self.pipeline_configs = configs;
191    }
192
193    /// Build a [`WarningHint::NoteMissing`] when the workspace has
194    /// `[mutations].require_notes = true` and the caller omitted (or
195    /// passed a blank/whitespace-only) `note`; `None` otherwise.
196    ///
197    /// This is the single enforcement point for the `require_notes`
198    /// provenance nudge. Every mutation that accepts a `note` calls it
199    /// on its commit-landing path and pushes the result onto the
200    /// outcome's `warnings`, so both the CLI and the MCP transports
201    /// inherit identical behaviour from the engine response rather than
202    /// each re-deriving the policy at its own boundary (the drift that
203    /// left the policy decorative on the CLI). `tool` becomes the
204    /// warning's `details.tool` — callers pass the engine-level verb
205    /// (`create_entity`, `update_entity`, `relate_entity`,
206    /// `delete_entity`, `rename_entity`, `create_mem`,
207    /// `delete_mem`), matching the commit `Tool:` provenance trailer.
208    /// The mutation still commits — the policy nudges, it never blocks.
209    pub fn note_missing_warning(&self, tool: &str, note: Option<&str>) -> Option<WarningHint> {
210        if !self.settings.mutations.require_notes.unwrap_or(false) {
211            return None;
212        }
213        let has_note = note.map(|n| !n.trim().is_empty()).unwrap_or(false);
214        if has_note {
215            return None;
216        }
217        Some(WarningHint::NoteMissing {
218            tool: tool.to_string(),
219        })
220    }
221
222    /// Backend factory currently installed on this engine. Returned by
223    /// value because [`BackendFactory`] is a function pointer (`Copy`).
224    /// Used by [`crate::mem_management::create_mem`] to materialise
225    /// the backend for a freshly-registered mount; consumers that need
226    /// to instantiate a backend ad-hoc can call this directly.
227    pub fn backend_factory(&self) -> BackendFactory {
228        self.backend_factory
229    }
230
231    /// Git-branch ops bundle currently installed on this engine.
232    /// `None` on lean-flavor engines that don't see mem-repo
233    /// mounts. Returned by value because [`super::GitBranchOps`] is
234    /// `Copy`. `create_mem` reaches for
235    /// the bundle to drive `prune_residue` against an unmounted
236    /// gitdir when the `ForceOverwrite` recovery action is selected.
237    pub fn git_branch_ops(&self) -> Option<super::GitBranchOps> {
238        self.git_branch_ops
239    }
240
241    /// Convenience: look up a parsed entity by id. Returns `None` for
242    /// unknown ids, including stub entries created for unresolved
243    /// inline-link targets — callers that want to distinguish real
244    /// from stub branch on `Entity::stub`.
245    pub fn get_entity(&self, id: &EntityId) -> Option<&Entity> {
246        self.store.get(id)
247    }
248
249    /// Mem names the engine knows about, in declaration order.
250    /// Cheap; useful for callers that need to enumerate before
251    /// dispatching by mem.
252    pub fn mem_names(&self) -> Vec<&str> {
253        self.mounts.iter().map(|m| m.mount.mem.as_str()).collect()
254    }
255
256    /// Public-shape mount record for `mem`, or `None` for an unknown
257    /// mem.
258    ///
259    /// Surfaces the operator-facing
260    /// [`crate::workspace::Mount`] (mem name, schema pin, storage
261    /// reference, capability, lifecycle, cross_linkable) so MCP / CLI
262    /// handlers can branch on backend-specific shapes via
263    /// [`crate::workspace::MountStorage`] when they need accessors
264    /// that don't make sense on every backend (e.g. gitdir / branch
265    /// for `memstead_health { include_config: true }`'s git-class
266    /// payload). Backends that want the equivalent of full's
267    /// `engine.gitdir_for(mem)` match
268    /// `engine.mount(mem).map(|m| &m.storage)` against
269    /// `MountStorage::GitBranch { gitdir, branch }` and walk
270    /// directly — keeps the engine surface backend-neutral.
271    ///
272    /// Counterpart to [`Self::mem_names`] which lists every mount.
273    pub fn mount(&self, mem: &str) -> Option<&crate::workspace::Mount> {
274        self.mounts
275            .iter()
276            .find(|m| m.mount.mem == mem)
277            .map(|m| &m.mount)
278    }
279
280    /// Orphan count attributed to each mem's pinned schema, over the
281    /// given `orphan_ids` (the caller pre-filters them by any mem scope).
282    /// Lets a health surface show that ingest-mem isolates (orphans by
283    /// design) and code-mem debt land in different schema buckets rather
284    /// than one blended, misleading total. Mems with no settled pin
285    /// bucket under the empty string.
286    pub fn orphans_by_schema(
287        &self,
288        orphan_ids: &[EntityId],
289    ) -> std::collections::BTreeMap<String, usize> {
290        let mut by_schema = std::collections::BTreeMap::new();
291        for id in orphan_ids {
292            let schema = self
293                .store()
294                .get(id)
295                .and_then(|e| self.mount(&e.mem))
296                .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
297                .unwrap_or_default();
298            *by_schema.entry(schema).or_insert(0) += 1;
299        }
300        by_schema
301    }
302
303    /// Community count attributed to each schema across `mems`: a cluster
304    /// counts toward every schema whose mems it touches, so these figures
305    /// can sum above the global community count — the same "touches"
306    /// semantic as the mem-scoped count. Per-schema dedup keeps a cluster
307    /// touching two mems of one schema from being counted twice.
308    pub fn communities_by_schema(
309        &self,
310        mems: &[String],
311    ) -> std::collections::BTreeMap<String, usize> {
312        let louvain = self.communities();
313        let mut buckets: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
314            std::collections::BTreeMap::new();
315        for name in mems {
316            let schema = self
317                .mount(name)
318                .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
319                .unwrap_or_default();
320            let clusters = crate::graph::community::clusters_in_mem(self.store(), louvain, name);
321            buckets.entry(schema).or_default().extend(clusters);
322        }
323        buckets
324            .into_iter()
325            .map(|(schema, set)| (schema, set.len()))
326            .collect()
327    }
328
329    /// All mounts the engine knows about, in declaration order.
330    /// Counterpart to [`Self::mem_names`] when the caller needs
331    /// the full mount shape (e.g. to enumerate by storage variant).
332    pub fn mounts(&self) -> Vec<&crate::workspace::Mount> {
333        self.mounts.iter().map(|m| &m.mount).collect()
334    }
335
336    /// Names of mems whose mount declares
337    /// [`crate::workspace::MountCapability::Write`], in declaration
338    /// order. Convenience over `mounts().iter().filter(...).map(...)`
339    /// for handlers that gate by writable status (`memstead_health`,
340    /// `memstead_overview`'s mem roster, the lifecycle tools'
341    /// candidate list). Read-only mounts (archive backends) are
342    /// excluded.
343    pub fn writable_mem_names(&self) -> Vec<&str> {
344        self.mounts
345            .iter()
346            .filter(|m| m.mount.capability == MountCapability::Write)
347            .map(|m| m.mount.mem.as_str())
348            .collect()
349    }
350
351    /// The default writable mem — the target a mutation lands in when
352    /// it omits `mem`. `None` when no writable mem is mounted.
353    ///
354    /// Defined as the **first writable mount in declaration order**, i.e.
355    /// the seed / earliest-created writable mem. This is a *stable*
356    /// designation, not a function of the current name set: new mems
357    /// register via `register_writable_mem`, which pushes onto the end
358    /// of the mount list (and `mounts.json` preserves that order across
359    /// reboots), so creating an additional mem never moves the default
360    /// — even one whose name sorts ahead alphabetically. Deleting the
361    /// current default promotes the next-earliest writable mem; that is
362    /// the only thing that shifts it. Both the MCP `resolve_mem` and the
363    /// CLI's omitted-`--mem` path resolve through here so the two
364    /// surfaces always agree (the
365    /// pre-fix MCP path read `writable_mems().iter().next()` off an
366    /// unordered `HashSet`, which silently retargeted writes when a second
367    /// mem appeared).
368    pub fn default_writable_mem(&self) -> Option<&str> {
369        self.mounts
370            .iter()
371            .find(|m| m.mount.capability == MountCapability::Write)
372            .map(|m| m.mount.mem.as_str())
373    }
374
375    /// On-disk folder path for a folder-backed mount, or `None` for
376    /// any other backend (git-branch, archive) or unknown mem.
377    /// Convenience over `engine.mount(mem).map(|m| &m.storage)` +
378    /// matching on `MountStorage::Folder { path }`. Used by
379    /// handlers that need a filesystem path for a folder mem
380    /// (e.g. `memstead_health { include_config: true }`'s
381    /// `mems[].vcs.worktree` field for folder mounts).
382    pub fn folder_path_for_mem(&self, mem: &str) -> Option<&Path> {
383        match self.mount(mem).map(|m| &m.storage) {
384            Some(crate::workspace::MountStorage::Folder { path }) => Some(path.as_path()),
385            _ => None,
386        }
387    }
388
389    /// Runtime snapshot of writable / visible mems. Handlers that
390    /// need the writable roster (`memstead_health`'s `writable_mems` /
391    /// `read_mems`), per-mem origin tag (`include_config:
392    /// true`'s `mems[].origin`), or visibility check
393    /// (`memstead_overview`'s mem list, the lifecycle tools' collision
394    /// guard) consume the router here. Returned by reference — the
395    /// `Arc` is held on the engine; callers that need a clonable
396    /// handle can `Arc::clone` the engine's field directly when that
397    /// surface arrives.
398    pub fn mem_router(&self) -> &MemRouterSnapshot {
399        &self.mem_router
400    }
401
402    /// Resolve the gitdir for a writable mem. Used by `memstead_health
403    /// { include_config: true }` to surface per-mem `vcs.gitdir`
404    /// so outer-repo auto-commit hooks can `git -C <gitdir>` per
405    /// mem without hardcoding the layout.
406    ///
407    /// - `EngineError::UnknownMem` when the name does not resolve.
408    /// - `EngineError::Mem` when the mount's storage is not
409    ///   git-branch-backed (folder, archive — they have no gitdir).
410    pub fn gitdir_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
411        let m = self
412            .mount(mem_name)
413            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
414        match &m.storage {
415            MountStorage::GitBranch { gitdir, .. } => Ok(gitdir.clone()),
416            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
417                Err(EngineError::Mem(format!(
418                    "mem '{mem_name}' has no resolved gitdir"
419                )))
420            }
421        }
422    }
423
424    /// Resolve the worktree for a writable mem. Used by
425    /// `memstead_health { include_config: true }` to surface per-mem
426    /// `vcs.worktree`.
427    ///
428    /// - `EngineError::UnknownMem` when the name does not resolve.
429    /// - `EngineError::Mem` when the mount's backend has no
430    ///   worktree concept (git-branch with no working tree, archive).
431    ///
432    /// Folder mounts surface their on-disk path. Git-branch mounts
433    /// follow the `dir: Some(...)` composition pattern: when the
434    /// workspace root contains a folder named after the mem with a
435    /// `.memstead/config.json` marker, that folder is the worktree
436    /// (disk-shape composition). Otherwise — pure mem-repo-backed
437    /// — return Err.
438    pub fn worktree_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
439        let m = self
440            .mount(mem_name)
441            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
442        match &m.storage {
443            MountStorage::Folder { path } => Ok(path.clone()),
444            MountStorage::GitBranch { .. } => {
445                if let Some(root) = self.workspace_root.as_deref() {
446                    let candidate = root.join(mem_name);
447                    if candidate
448                        .join(crate::mem::MEM_META_DIR)
449                        .join("config.json")
450                        .is_file()
451                    {
452                        return Ok(candidate.canonicalize().unwrap_or(candidate));
453                    }
454                }
455                Err(EngineError::Mem(format!(
456                    "mem '{mem_name}' has no working tree (mem-repo-backed)"
457                )))
458            }
459            MountStorage::Archive { .. } => Err(EngineError::Mem(format!(
460                "mem '{mem_name}' is archive-backed and has no worktree"
461            ))),
462            MountStorage::InMemory => Err(EngineError::Mem(format!(
463                "mem '{mem_name}' is in-memory and has no worktree"
464            ))),
465        }
466    }
467
468    /// Per-mem `.memstead/config.json` payload, when available. Used
469    /// by `memstead_health { include_config: true }` to surface the
470    /// opaque `write_guidance` map and the catch-all `extra` fields
471    /// per mem.
472    ///
473    /// Folder-backed mounts return `Some(&MemConfig)` when
474    /// `<path>/.memstead/config.json` parsed cleanly at construction.
475    /// Git-branch and archive backends return `None` until the
476    /// read-from-storage-backend path lifts (the V1 unified engine
477    /// loads configs only from folder layouts; the file lives
478    /// inside the gitdir / archive for the other backends and
479    /// needs a backend-level read primitive).
480    ///
481    /// Unknown mem names return `None` (no error variant — the
482    /// accessor is intentionally lenient because memstead_health emits
483    /// an empty detail block per missing config rather than
484    /// aborting the call).
485    pub fn mem_config_for(&self, mem: &str) -> Option<&memstead_schema::config::MemConfig> {
486        self.mounts
487            .iter()
488            .find(|m| m.mount.mem == mem)
489            .and_then(|m| m.mem_config.as_ref())
490    }
491
492    /// The authoring-provenance payload an installed mem carries, read
493    /// from the archive's `.memstead/provenance.json` at construction.
494    /// `None` when the mem carries none (a pre-provenance archive, a
495    /// runtime-created mem, or a backend that does not surface one) —
496    /// the read path reports provenance as absent. Unknown mem names
497    /// return `None`.
498    pub fn archive_provenance_for(&self, mem: &str) -> Option<&memstead_schema::ArchiveProvenance> {
499        self.mounts
500            .iter()
501            .find(|m| m.mount.mem == mem)
502            .and_then(|m| m.archive_provenance.as_ref())
503    }
504
505    /// Iterate `(mem_name, &MemConfig)` for every mount whose
506    /// mem-config payload loaded at construction. Used by callers
507    /// that walk every writable mount's config (`memstead health`'s
508    /// per-mem dump, the workspace-dump CLI). The yielded `&str` is
509    /// the authoritative mem leaf from the mount record.
510    ///
511    /// Folder-backed mounts yield when their `.memstead/config.json`
512    /// parsed cleanly. Git-branch and archive backends are silent in
513    /// V1 (the same deferred-read-from-storage gap that
514    /// [`Self::mem_config_for`] documents).
515    pub fn mem_configs_named(
516        &self,
517    ) -> impl Iterator<Item = (&str, &memstead_schema::config::MemConfig)> {
518        self.mounts
519            .iter()
520            .filter_map(|m| m.mem_config.as_ref().map(|c| (m.mount.mem.as_str(), c)))
521    }
522
523    /// Resolved `Arc<Schema>` for a writable mem by name. `None`
524    /// when the name is not a registered mount.
525    ///
526    /// Cheap — `Arc::clone` over the per-mem schema map. Resolved
527    /// schemas are stored in `HashMap<String, Arc<Schema>>` so the
528    /// lookup is a single hash hit + clone.
529    pub fn schema_for(&self, mem: &str) -> Option<std::sync::Arc<memstead_schema::Schema>> {
530        self.schemas.get(mem).cloned()
531    }
532
533    /// Cached current branch-tip cursor (typically a 40-char hex
534    /// SHA for git-branch backends; `None` for fresh mems or
535    /// backends that don't track a head — folder / archive).
536    ///
537    /// The value is the per-mount `last_known_head`, seeded at
538    /// construction by `backend.current_head()` and refreshed by
539    /// [`Self::reload_if_stale`] / mutation paths after a
540    /// successful commit.
541    ///
542    /// - `EngineError::UnknownMem` when the name does not resolve.
543    pub fn mem_head_sha(&self, mem_name: &str) -> Result<Option<String>, EngineError> {
544        let m = self
545            .mounts
546            .iter()
547            .find(|m| m.mount.mem == mem_name)
548            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
549        Ok(m.last_known_head.clone())
550    }
551
552    /// Whether a sibling writer has advanced this mem's backend past
553    /// the engine's cached `last_known_head` — a read-only drift probe
554    /// that does **not** reload (unlike [`Self::reload_if_stale`]). One
555    /// `backend.current_head()` read compared against the cached cursor;
556    /// the comparison clears once the engine re-reads (a `reload` /
557    /// `reload_if_stale` refreshes `last_known_head` to the live tip).
558    ///
559    /// Only git-branch backends track a head, so folder / archive /
560    /// in-memory mounts always report `false`. A backend that errors on
561    /// the probe (transient refdb hiccup) reports `false` rather than
562    /// surfacing the error — drift is advisory, and the next real
563    /// operation's reload path is the authoritative sync.
564    ///
565    /// - `EngineError::UnknownMem` when the name does not resolve.
566    pub fn mem_drifted(&self, mem_name: &str) -> Result<bool, EngineError> {
567        let m = self
568            .mounts
569            .iter()
570            .find(|m| m.mount.mem == mem_name)
571            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
572        let live = m.backend.current_head().ok().flatten();
573        Ok(live != m.last_known_head)
574    }
575
576    /// Workspace root the engine booted from, when one is known.
577    /// `None` for engines built directly from a mount list (tests,
578    /// ad-hoc consumers). Set by [`Self::from_workspace_root`] and
579    /// the full counterpart.
580    pub fn workspace_root(&self) -> Option<&Path> {
581        self.workspace_root.as_deref()
582    }
583
584    /// Typed warnings surfaced during mem load — drift findings
585    /// the loader pipeline collects per entity. Empty for V1; the
586    /// accessor surfaces them so handlers can merge into health
587    /// summaries uniformly.
588    pub fn load_warnings(&self) -> &[WarningHint] {
589        &self.load_warnings
590    }
591
592    // ---------------------------------------------------------------
593    // Read-side delegates onto the kernel ops/graph functions.
594    //
595    // The mem-router engine exposed each of these directly so the
596    // MCP layer could call them without reaching into the store. The
597    // unified engine mirrors that surface so the MCP migration is a
598    // straight rename rather than a re-architecture.
599    //
600    // Multi-mem cache strategy: per-mem community detection and
601    // per-mem search indexes are unnecessary at this layer — the
602    // engine-wide store already carries every mount's edges; Louvain
603    // and tantivy run once across the union. `mem_schemas` for
604    // health/search is the engine's existing `schemas` field as-is.
605    // ---------------------------------------------------------------
606
607    /// Lazy community-detection cache. First call runs Louvain
608    /// against the current store using one pinned schema for
609    /// `community.{resolution, seed}` and the per-rel weights.
610    /// Subsequent calls return the cached result. Mutations invalidate
611    /// the cache via [`Self::invalidate_communities`].
612    ///
613    /// One detection run per engine. The partition is workspace-global,
614    /// so it needs a single source for the Louvain parameters; that
615    /// source is the schema of the lexicographically-first mem name —
616    /// a stable key, so the partition is deterministic across processes
617    /// even when mounts pin heterogeneous schemas. For a single-schema
618    /// workspace every mem's schema is identical, so the choice of
619    /// key is immaterial there.
620    pub fn communities(&self) -> &LouvainOutput {
621        self.community_memo.get_or_init(|| {
622            // Select the parameter schema by a stable key (smallest
623            // mem name) rather than unordered-map iteration, so the
624            // partition does not vary between processes. Fall back to
625            // the builtin default for the empty-mounts case (caller
626            // still gets a valid empty Louvain result against an empty
627            // store).
628            let schema = self
629                .schemas
630                .iter()
631                .min_by(|a, b| a.0.cmp(b.0))
632                .map(|(_, s)| s.clone())
633                .unwrap_or_else(Schema::builtin_default);
634            let manifest = &schema.manifest;
635            let resolution = manifest.community.resolution;
636            let seed = manifest.community.seed;
637            let schema_for_weights = schema.clone();
638            detect_communities(&self.store, resolution, seed, move |rel_type| {
639                schema_for_weights
640                    .manifest
641                    .relationships
642                    .definitions
643                    .iter()
644                    .find(|d| d.name == rel_type)
645                    .map(|d| d.default_weight as f64)
646                    .unwrap_or(1.0)
647            })
648        })
649    }
650
651    /// Drop the cached community detection result.
652    pub fn invalidate_communities(&mut self) {
653        self.community_memo = OnceCell::new();
654    }
655
656    /// Real entities with no incoming or outgoing edges.
657    pub fn orphans(&self) -> Vec<EntityId> {
658        crate::graph::query::find_orphans(&self.store)
659    }
660
661    /// Stub entities with their referencer ids.
662    pub fn stubs(&self) -> Vec<(EntityId, Vec<EntityId>)> {
663        crate::graph::query::find_stubs(&self.store)
664    }
665
666    /// Top `limit` entities by total degree.
667    pub fn most_connected(&self, limit: usize) -> Vec<crate::graph::query::Connectivity> {
668        crate::graph::query::most_connected(&self.store, limit)
669    }
670
671    /// Entities whose type's `required_outgoing` blocks are not yet
672    /// satisfied. `mem_filter = None` scans every mem; `Some(v)`
673    /// scans only that mem.
674    pub fn missing_required_outgoing(
675        &self,
676        mem_filter: Option<&str>,
677    ) -> Vec<crate::ops::health::MissingRequiredOutgoingReport> {
678        crate::ops::health::collect_missing_required_outgoing(
679            &self.store,
680            mem_filter,
681            &self.schemas,
682        )
683    }
684
685    /// Conformance-axis integrity findings for one mem — which
686    /// entities a write would refuse under the effective schema, and
687    /// why. `target_schema = None` lints against the mem's current
688    /// pin; `Some(ref)` lints against that schema instead (resolved
689    /// among mem-pinned, workspace, and built-in schemas).
690    pub fn conformance_findings(
691        &self,
692        mem: &str,
693        target_schema: Option<&memstead_schema::SchemaRef>,
694    ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
695        let pinned = self
696            .schemas
697            .get(mem)
698            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))?;
699        let effective: Arc<Schema> = match target_schema {
700            None => pinned.clone(),
701            Some(target) => self.resolve_schema_by_ref(target).ok_or_else(|| {
702                let consulted: Vec<_> = self
703                    .workspace_schemas
704                    .iter()
705                    .chain(self.builtin_schemas.iter())
706                    .cloned()
707                    .collect();
708                EngineError::SchemaNotFound {
709                    mem: mem.to_string(),
710                    pin: target.as_display(),
711                    sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
712                        &target.name,
713                        &target.version,
714                        &consulted,
715                    ),
716                }
717            })?,
718        };
719        Ok(crate::ops::integrity::conformance_findings(
720            &self.store,
721            mem,
722            &effective,
723            &self.schemas,
724        ))
725    }
726
727    /// Resolve an exact `name@version` ref against every schema this
728    /// engine can see: mem-pinned, workspace-authored, built-in.
729    /// `None` when no loaded schema matches.
730    pub(crate) fn resolve_schema_by_ref(
731        &self,
732        target: &memstead_schema::SchemaRef,
733    ) -> Option<Arc<Schema>> {
734        self.schemas
735            .values()
736            .chain(self.workspace_schemas.iter())
737            .chain(self.builtin_schemas.iter())
738            .find(|s| {
739                let (name, version) = s.id();
740                name == target.name && version == target.version
741            })
742            .cloned()
743    }
744
745    /// The mem's `Mount.schema` expectation assertion, when set.
746    /// `None` for unknown mems *and* for mems whose mount carries no
747    /// assertion (the authoritative pin then lives in the backend
748    /// config; the resolved active schema, not this, is the effective pin).
749    pub fn schema_pin(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
750        self.mounts
751            .iter()
752            .find(|m| m.mount.mem == mem)
753            .and_then(|m| m.mount.schema.clone())
754    }
755
756    /// The mem's in-flight migration target, when dual-pin state is
757    /// active. `None` for settled or unknown mems.
758    pub fn migration_target(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
759        self.mounts
760            .iter()
761            .find(|m| m.mount.mem == mem)
762            .and_then(|m| m.mount.migration_target.clone())
763    }
764
765    /// Consistency-axis integrity findings for one mem — the
766    /// pre-existing graph-coherence categories (dangling links, stubs)
767    /// projected into the `{ id, axis, code, detail }` finding shape.
768    pub fn consistency_findings(
769        &self,
770        mem: &str,
771    ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
772        if !self.schemas.contains_key(mem) {
773            return Err(EngineError::UnknownMem(mem.to_string()));
774        }
775        Ok(crate::ops::integrity::consistency_findings(
776            &self.store,
777            mem,
778        ))
779    }
780
781    /// Engine-wide health summary across every mount.
782    pub fn health(&self) -> crate::ops::HealthSummary {
783        let fallback = engine_fallback_type();
784        let mut summary =
785            crate::ops::health::compute_health(&self.store, fallback.as_ref(), &self.schemas);
786        // Merge in load-time drift warnings so every caller of
787        // Engine::health — MCP handler, Swift FFI, direct CLI —
788        // sees the SuspiciousNestedPrefix / DuplicateSectionHeading
789        // findings without reaching into private engine state. The
790        // MCP handler further appends request-scoped warnings on
791        // top. Mirrors full's merge.
792        if !self.load_warnings.is_empty() {
793            let mut merged = self.load_warnings.clone();
794            merged.append(&mut summary.warnings);
795            summary.warnings = merged;
796        }
797        // Surface OUTER_REPO_NOT_IGNORING_MEM_REPO when the
798        // workspace is embedded inside a git repository whose
799        // .gitignore does not list `mem-repo/`. Skipped when
800        // workspace_root is unset (engine built ad-hoc from a mount
801        // list).
802        if let Some(root) = self.workspace_root.as_deref()
803            && let Some(outer) = crate::workspace_root::find_enclosing_git_repo(root)
804            && !crate::workspace_root::outer_repo_ignores_mem_repo(&outer, root)
805        {
806            summary
807                .warnings
808                .push(WarningHint::OuterRepoNotIgnoringMemRepo {
809                    outer_repo_root: outer.display().to_string(),
810                    workspace_root: root.display().to_string(),
811                });
812        }
813        summary
814    }
815
816    /// Engine-wide [`crate::ops::Stats`] across every mount.
817    pub fn stats(&self) -> crate::ops::Stats {
818        let mut types_in_use: Vec<String> = self
819            .store
820            .all_entities()
821            .filter(|e| !e.stub && !e.entity_type.is_empty())
822            .map(|e| e.entity_type.clone())
823            .collect();
824        types_in_use.sort();
825        types_in_use.dedup();
826
827        let mut edge_types: HashMap<String, usize> = HashMap::new();
828        for id in self.store.all_ids() {
829            for edge in self.store.outgoing(id) {
830                *edge_types.entry(edge.rel_type.clone()).or_insert(0) += 1;
831            }
832        }
833
834        crate::ops::Stats {
835            entity_count: self.store.all_entities().filter(|e| !e.stub).count(),
836            edge_count: self.store.edge_count(),
837            edge_types,
838            community_count: self.communities().count,
839            mem_count: self.mounts.len(),
840            types_in_use,
841        }
842    }
843
844    /// Build a [`ContextResult`] for `id`: the community cluster id
845    /// (or `None` when the entity is a stub or not present), plus the
846    /// outgoing + incoming neighbour lists.
847    pub fn context(&self, id: &EntityId) -> Option<ContextResult> {
848        let entity = self.store.get(id)?;
849        let community = self
850            .communities()
851            .entity_cluster_map
852            .get(id.as_ref())
853            .cloned();
854        let mut neighbors = Vec::new();
855        for edge in self.store.outgoing(id) {
856            if let Some(target) = self.store.get(&edge.target) {
857                neighbors.push(NeighborInfo {
858                    id: target.id.clone(),
859                    title: target.title.clone(),
860                    relationship: edge.rel_type.clone(),
861                    direction: Direction::Outgoing,
862                });
863            }
864        }
865        for edge in self.store.incoming(id) {
866            if let Some(source) = self.store.get(&edge.from) {
867                neighbors.push(NeighborInfo {
868                    id: source.id.clone(),
869                    title: source.title.clone(),
870                    relationship: edge.rel_type.clone(),
871                    direction: Direction::Incoming,
872                });
873            }
874        }
875        Some(ContextResult {
876            entity_id: entity.id.clone(),
877            community,
878            neighbors,
879        })
880    }
881
882    /// Lazily-built per-mem search index map. The map carries one
883    /// entry per writable mem. Build cost scales with entity count;
884    /// expect hundreds-of-ms for thousand-entity workspaces. Not
885    /// available on `wasm32` targets — search lives behind the bridge
886    /// (see [`Self::search`] for the typed refuse).
887    #[cfg(not(target_arch = "wasm32"))]
888    pub fn search_indexes(&self) -> &HashMap<String, MemIndex> {
889        self.search_indexes_memo
890            .get_or_init(|| build_all(&self.store, &self.schemas))
891    }
892
893    /// Drop the cached per-mem search index map. No-op on `wasm32`
894    /// where no index exists; the method stays present so mutation
895    /// hooks can call it unconditionally.
896    pub fn invalidate_search_indexes(&mut self) {
897        #[cfg(not(target_arch = "wasm32"))]
898        {
899            self.search_indexes_memo = OnceCell::new();
900        }
901    }
902
903    /// Filter the in-memory store by metadata only (no text match).
904    #[cfg(not(target_arch = "wasm32"))]
905    pub fn list(&self, scope: &SearchScope) -> crate::ops::ListResult {
906        let fallback = engine_fallback_type();
907        crate::ops::search::list(&self.store, scope, fallback.as_ref(), &self.schemas)
908    }
909
910    /// Run a search against the lazily-built index map. Returns
911    /// [`EngineError::SearchUnavailable`] on `wasm32` targets — browser
912    /// consumers route search to the bridge; the local
913    /// engine never builds a tantivy index in WASM. Native targets get
914    /// the same shape as before, wrapped in `Ok`.
915    pub fn search(&self, scope: &SearchScope) -> Result<SearchResult, EngineError> {
916        #[cfg(target_arch = "wasm32")]
917        {
918            let _ = scope;
919            return Err(EngineError::SearchUnavailable);
920        }
921        #[cfg(not(target_arch = "wasm32"))]
922        {
923            let fallback = engine_fallback_type();
924            Ok(crate::ops::search::search(
925                &self.store,
926                scope,
927                fallback.as_ref(),
928                self.search_indexes(),
929                &self.schemas,
930            ))
931        }
932    }
933
934    /// All mem-relative entity paths under `mem`. Delegates to
935    /// the backend's `list_entities`. Order is backend-defined.
936    pub fn list_entities(&self, mem: &str) -> Result<Vec<PathBuf>, EngineError> {
937        let m = self.find_mount(mem)?;
938        m.backend.list_entities().map_err(EngineError::Backend)
939    }
940
941    /// Raw bytes for a single entity (`Ok(None)` if absent).
942    pub fn read_entity(&self, mem: &str, rel_path: &Path) -> Result<Option<Vec<u8>>, EngineError> {
943        let m = self.find_mount(mem)?;
944        m.backend
945            .read_entity(rel_path)
946            .map_err(EngineError::Backend)
947    }
948
949    /// Provenance entries for `mem` since `cursor`. Cursor shape is
950    /// backend-specific (RFC-3339 timestamp for folder, commit SHA for
951    /// git-branch); `None` means "from the beginning".
952    pub fn read_provenance(
953        &self,
954        mem: &str,
955        cursor: Option<&str>,
956    ) -> Result<Vec<Provenance>, EngineError> {
957        let m = self.find_mount(mem)?;
958        m.backend
959            .read_provenance(cursor)
960            .map_err(EngineError::Backend)
961    }
962
963    /// Capability declared on the mount for `mem`. Surfaced for
964    /// callers that need to gate before dispatching a write — the
965    /// engine itself does not yet enforce capability (mutation paths
966    /// land in a later session).
967    pub fn capability(&self, mem: &str) -> Result<crate::workspace::MountCapability, EngineError> {
968        let m = self.find_mount(mem)?;
969        Ok(m.mount.capability)
970    }
971
972    /// Returns `true` when `from`'s source mem is mounted with
973    /// [`crate::workspace::MountCapability::ReadOnly`]. Returns
974    /// `false` for Write-Mems and for mems whose mount is absent
975    /// from the router (no mount → no ReadOnly assertion can be
976    /// made; the absence is treated as not-ReadOnly so consumers
977    /// don't trip on transient lookup misses).
978    ///
979    /// Plan body §"Single edge source in the store" specifies this
980    /// helper as the derived-on-demand alternative to adding a new
981    /// field on [`crate::store::Edge`]. Strict-invariant validators
982    /// and surfaces that want to highlight cross-mount references
983    /// call this rather than pattern-matching on a per-edge marker.
984    /// The information is fully derivable from the current mount
985    /// roster, so no new state needs to live on the edge itself.
986    pub fn edge_is_from_readonly(&self, from: &EntityId) -> bool {
987        match self.capability(from.mem()) {
988            Ok(crate::workspace::MountCapability::ReadOnly) => true,
989            Ok(crate::workspace::MountCapability::Write) | Err(_) => false,
990        }
991    }
992
993    /// Whether a cross-mem edge from `from_mem` to `to_mem` is
994    /// permitted under the current [`crate::WorkspaceSettings`]
995    /// cross-mem link policy.
996    ///
997    /// Resolution rules (matches full's `mem_router` semantics):
998    /// 1. Same-mem edge (`from_mem == to_mem`) → always
999    ///    allowed; the policy gates *cross*-mem edges only.
1000    /// 2. Explicit `cross_mem_links[from_mem]`:
1001    ///    - `"*"` (wildcard) → allowed regardless of target.
1002    ///    - `["a", ...]` (allowlist) → allowed iff `to_mem` is in
1003    ///      the list.
1004    /// 3. Per-create-rule `default_cross_links` synthesis — if
1005    ///    rule (1) didn't grant permission and `from_mem` matches
1006    ///    a `[[mem_management.create]]` rule whose
1007    ///    `default_cross_links` is set, the synthesised value
1008    ///    contributes:
1009    ///    - `"*"` → allowed regardless of target.
1010    ///    - `["a", ...]` → allowed iff `to_mem` is in the list.
1011    /// 4. Otherwise → denied (default-deny posture).
1012    ///
1013    /// The synthesis layer compiles a [`crate::mem_management::CreateRuleSet`]
1014    /// lazily on first call and caches it; [`Self::set_settings`]
1015    /// invalidates the cache. Compilation failure (malformed glob
1016    /// in a rule) logs a warning and the synthesis layer is silently
1017    /// skipped — the resolver still returns `true` from explicit
1018    /// policy alone, so a half-broken config doesn't lock out edges
1019    /// the operator did intend to allow. Operators who want hard
1020    /// validation pre-compile via
1021    /// [`crate::mem_management::CreateRuleSet::new`] before
1022    /// calling [`Self::set_settings`].
1023    ///
1024    /// The MCP `memstead_relate` handler's cross-mem gate consumes
1025    /// this method directly.
1026    pub fn cross_mem_link_allowed(&self, from_mem: &str, to_mem: &str) -> bool {
1027        use memstead_schema::workspace_config::CrossLinkValue;
1028        if from_mem == to_mem {
1029            return true;
1030        }
1031
1032        // Step 1: explicit cross_mem_links policy.
1033        if let Some(value) = self.settings.cross_mem_links.get(from_mem) {
1034            match value {
1035                CrossLinkValue::Wildcard => return true,
1036                CrossLinkValue::List(targets) => {
1037                    if targets.iter().any(|t| t == to_mem) {
1038                        return true;
1039                    }
1040                    // Fall through to synthesis check — a List that
1041                    // doesn't include the target may still allow it
1042                    // via per-rule default_cross_links union.
1043                }
1044            }
1045        }
1046
1047        // Step 2: per-create-rule default_cross_links synthesis.
1048        let rule_set = self.create_rule_set_memo.get_or_init(|| {
1049            crate::mem_management::CreateRuleSet::new(
1050                self.settings.mem_create_rules.clone(),
1051            )
1052            .unwrap_or_else(|err| {
1053                tracing::warn!(
1054                    error = %err,
1055                    "cross_mem_link_allowed: failed to compile mem_create_rules — synthesis disabled (resolver falls back to explicit-policy-only)"
1056                );
1057                crate::mem_management::CreateRuleSet::default()
1058            })
1059        });
1060
1061        // Compose the same `<mem_path>/<name>` candidate the create-rule
1062        // composer matched against. The rule globs are keyed on the composed
1063        // lifecycle path (e.g. `memstead/project`, compiled with
1064        // `literal_separator`), not the bare leaf name — matching
1065        // `from_mem` alone silently misses, so synthesis denied a link
1066        // that `memstead_overview` rendered as rule-granted (the
1067        // leaf-vs-composed-path divergence). Flat-layout mems (no
1068        // hierarchical path) keep the bare leaf, matching their bare rule.
1069        let candidate = match self.mount(from_mem).and_then(|m| m.mem_path()) {
1070            Some(path) => format!("{path}/{from_mem}"),
1071            None => from_mem.to_string(),
1072        };
1073        if let Some(matched) = rule_set.first_match(std::path::Path::new(&candidate))
1074            && let Some(synth) = matched.default_cross_links.as_ref()
1075        {
1076            return match synth {
1077                CrossLinkValue::Wildcard => true,
1078                CrossLinkValue::List(targets) => targets.iter().any(|t| t == to_mem),
1079            };
1080        }
1081
1082        false
1083    }
1084
1085    pub(super) fn find_mount(&self, mem: &str) -> Result<&MountedBackend, EngineError> {
1086        self.mounts
1087            .iter()
1088            .find(|m| m.mount.mem == mem)
1089            .ok_or_else(|| EngineError::UnknownMem(mem.to_string()))
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use std::path::Path;
1096
1097    use tempfile::TempDir;
1098
1099    use crate::backend::{BackendError, MemBackend};
1100    use crate::engine::test_helpers::*;
1101    use crate::engine::{Engine, EngineError, RelateEntityArgs};
1102    use crate::entity::EntityId;
1103    use crate::ops::{Direction, SearchScope, WarningHint};
1104    use crate::provenance::Provenance;
1105    use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
1106
1107    use crate::vcs::CommitContext;
1108    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1109
1110    /// `schema_origin` is the trust-classification authority: a built-in
1111    /// (or workspace-authored) schema is first-party; a schema whose
1112    /// `(name, version)` is in neither catalogue is third-party — the safe
1113    /// default for an origin the engine cannot vouch for.
1114    #[test]
1115    fn schema_origin_classifies_builtin_first_party_and_unknown_third_party() {
1116        use std::sync::Arc;
1117
1118        use crate::render::OriginClass;
1119
1120        let tmp = TempDir::new().unwrap();
1121        let engine = Engine::from_mounts(vec![(
1122            folder_mount("specs", tmp.path().to_path_buf()),
1123            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
1124        )])
1125        .unwrap();
1126
1127        // A built-in schema (the catalogue the engine resolved against).
1128        let builtin = engine.builtin_schemas()[0].clone();
1129        assert_eq!(
1130            engine.schema_origin(&builtin),
1131            OriginClass::FirstParty,
1132            "a built-in schema is first-party"
1133        );
1134
1135        // A schema whose version is in no catalogue — a stand-in for a
1136        // schema that entered from outside the workspace. Same name, a
1137        // version the engine never loaded.
1138        let foreign = Arc::new(memstead_schema::Schema {
1139            manifest: builtin.manifest.clone(),
1140            version: semver::Version::new(99, 0, 0),
1141            types: builtin.types.clone(),
1142        });
1143        assert_eq!(
1144            engine.schema_origin(&foreign),
1145            OriginClass::ThirdParty,
1146            "a schema in neither catalogue classifies third-party (safe default)"
1147        );
1148    }
1149
1150    /// `mem_origin_class` classifies a writable mount first-party (its
1151    /// content is authored in this workspace) and a read-only mount
1152    /// third-party (registry-installed read-mem or adopted foreign
1153    /// folder/clone — quoted, untrusted data). An unknown mem is
1154    /// third-party (the safe default).
1155    #[test]
1156    fn mem_origin_class_writable_first_party_readonly_third_party() {
1157        use crate::render::OriginClass;
1158
1159        let tmp = TempDir::new().unwrap();
1160        // Writable folder mem.
1161        let writable_dir = tmp.path().join("writable");
1162        std::fs::create_dir_all(&writable_dir).unwrap();
1163        let writer = FilesystemMemWriter::new(writable_dir.clone());
1164
1165        // Read-only archive mem.
1166        let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
1167        let archive_path = build_archive(tmp.path(), "ext", &[("ext.md", body.as_bytes())]);
1168
1169        let engine = Engine::from_mounts(vec![
1170            (
1171                folder_mount("local", writable_dir),
1172                Box::new(writer) as Box<dyn MemBackend>,
1173            ),
1174            (
1175                archive_mount("external", archive_path.clone()),
1176                Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1177            ),
1178        ])
1179        .unwrap();
1180
1181        assert_eq!(
1182            engine.mem_origin_class("local"),
1183            OriginClass::FirstParty,
1184            "a writable mount is first-party"
1185        );
1186        assert_eq!(
1187            engine.mem_origin_class("external"),
1188            OriginClass::ThirdParty,
1189            "a read-only mount is third-party"
1190        );
1191        assert_eq!(
1192            engine.mem_origin_class("no-such-mem"),
1193            OriginClass::ThirdParty,
1194            "an unknown mem is third-party (safe default)"
1195        );
1196    }
1197
1198    /// The adopt-gate: a non-built-in schema is first-party only once a
1199    /// writable mount pins it (the operator authors against it here).
1200    /// Pinned only by a read-only mount — a registry read-mem or an
1201    /// adopted foreign folder/clone — it stays third-party, so
1202    /// `memstead_schema` serves it structural-only.
1203    #[test]
1204    fn schema_origin_third_party_until_pinned_by_a_writable_mount() {
1205        use memstead_schema::SchemaRef;
1206
1207        use crate::render::OriginClass;
1208
1209        let manifest = r#"name: trust-test
1210version: 0.1.0
1211description: adopt-gate test schema
1212when_to_use: tests
1213types:
1214  - doc
1215relationships:
1216  mode: strict
1217  definitions:
1218    - name: _default
1219      description: fallback
1220      default_weight: 1.0
1221community:
1222  resolution: 1.0
1223  seed: 42
1224"#;
1225        let pin = SchemaRef::new("trust-test", semver::Version::new(0, 1, 0));
1226
1227        let mk_engine = |cap: MountCapability| -> Engine {
1228            let tmp = TempDir::new().unwrap();
1229            let schemas_dir = tmp.path().join("schemas");
1230            std::fs::create_dir_all(&schemas_dir).unwrap();
1231            write_schema_files_with_default_type(&schemas_dir, "trust-test", manifest, &["doc"]);
1232            let mem_dir = tmp.path().join("mem");
1233            std::fs::create_dir_all(&mem_dir).unwrap();
1234            let mount = Mount {
1235                mem: "v".to_string(),
1236                schema: Some(pin.clone()),
1237                storage: MountStorage::Folder {
1238                    path: mem_dir.clone(),
1239                },
1240                capability: cap,
1241                lifecycle: MountLifecycle::Eager,
1242                cross_linkable: true,
1243                migration_target: None,
1244            };
1245            let backend = Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>;
1246            // Keep `tmp` alive for the engine's lifetime by leaking it —
1247            // the test process is short-lived and the folder must outlast
1248            // the closure.
1249            std::mem::forget(tmp);
1250            Engine::from_mounts_with_schemas_dir(vec![(mount, backend)], Some(&schemas_dir))
1251                .unwrap()
1252        };
1253
1254        // Read-only mount: the foreign schema is never adopted → third-party.
1255        let ro = mk_engine(MountCapability::ReadOnly);
1256        let schema = ro.schemas().get("v").expect("schema resolved").clone();
1257        assert_eq!(
1258            ro.schema_origin(&schema),
1259            OriginClass::ThirdParty,
1260            "a non-built-in schema pinned only by a read-only mount is third-party"
1261        );
1262
1263        // Writable mount pinning the same schema: adopted → first-party.
1264        let rw = mk_engine(MountCapability::Write);
1265        let schema = rw.schemas().get("v").expect("schema resolved").clone();
1266        assert_eq!(
1267            rw.schema_origin(&schema),
1268            OriginClass::FirstParty,
1269            "a writable mount pinning the schema adopts it → first-party"
1270        );
1271    }
1272
1273    /// Consumer read path: an installed (archive-backed) mem that ships
1274    /// a `.memstead/provenance.json` payload surfaces per-entity authoring
1275    /// provenance through `archive_provenance_for`. A noted entity carries
1276    /// its rationale; an entity authored without a note is absent from the
1277    /// payload and reads as provenance-absent (no fabricated value); the
1278    /// `history` disposition records that full history is not shipped.
1279    #[test]
1280    fn archive_provenance_surfaces_per_entity_and_reports_absence() {
1281        use memstead_schema::History;
1282
1283        let tmp = TempDir::new().unwrap();
1284        let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
1285        let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
1286        let beta = b"---\ntype: spec\n---\n# Beta\n\n## Identity\n\nb\n\n## Purpose\n\np\n";
1287        // alpha noted; beta deliberately absent from the payload.
1288        let provenance = br#"{"format":1,"history":"summarised","entities":{"alpha":{"rationale":"why alpha exists","kind":"create","timestamp":"2026-06-24T00:00:00Z","actor":"agent"}}}"#;
1289        let archive = build_archive(
1290            tmp.path(),
1291            "seed",
1292            &[
1293                (".memstead/config.json", config),
1294                ("alpha.md", alpha),
1295                ("beta.md", beta),
1296                (".memstead/provenance.json", provenance),
1297            ],
1298        );
1299        let engine = Engine::from_mounts(vec![(
1300            archive_mount("seed", archive.clone()),
1301            Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
1302        )])
1303        .unwrap();
1304
1305        let prov = engine
1306            .archive_provenance_for("seed")
1307            .expect("provenance payload read from the archive");
1308        assert_eq!(
1309            prov.history,
1310            History::Summarised,
1311            "history-not-shipped is observable"
1312        );
1313        assert_eq!(
1314            prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
1315            Some("why alpha exists"),
1316            "noted entity surfaces its rationale"
1317        );
1318        assert!(
1319            prov.entity("beta").is_none(),
1320            "unnoted entity is absent (reported absent, not fabricated)"
1321        );
1322    }
1323
1324    /// A pre-provenance archive (no `.memstead/provenance.json`) reads as
1325    /// provenance uniformly absent — the additive contract: a newer engine
1326    /// installing an old archive reports no provenance, never an error.
1327    #[test]
1328    fn archive_without_provenance_reports_absent() {
1329        let tmp = TempDir::new().unwrap();
1330        let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
1331        let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
1332        let archive = build_archive(
1333            tmp.path(),
1334            "seed",
1335            &[(".memstead/config.json", config), ("alpha.md", alpha)],
1336        );
1337        let engine = Engine::from_mounts(vec![(
1338            archive_mount("seed", archive.clone()),
1339            Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
1340        )])
1341        .unwrap();
1342        assert!(
1343            engine.archive_provenance_for("seed").is_none(),
1344            "an archive without a provenance payload reports provenance absent"
1345        );
1346    }
1347
1348    #[test]
1349    fn folder_mount_routes_reads_to_filesystem_backend() {
1350        let tmp = TempDir::new().unwrap();
1351        let mem_dir = tmp.path().to_path_buf();
1352        let writer = FilesystemMemWriter::new(mem_dir.clone());
1353        // MemWriter and MemBackend share method names; the
1354        // module-top `use` brings both into scope. Seed via fully-
1355        // qualified MemWriter calls so dot-syntax stays unambiguous.
1356        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"alpha")
1357            .unwrap();
1358        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1359            .unwrap();
1360
1361        let engine = Engine::from_mounts(vec![(
1362            folder_mount("specs", mem_dir),
1363            Box::new(writer) as Box<dyn MemBackend>,
1364        )])
1365        .unwrap();
1366
1367        let mut paths: Vec<String> = engine
1368            .list_entities("specs")
1369            .unwrap()
1370            .into_iter()
1371            .map(|p| p.to_string_lossy().into_owned())
1372            .collect();
1373        paths.sort();
1374        assert_eq!(paths, vec!["a.md".to_string()]);
1375
1376        assert_eq!(
1377            engine.read_entity("specs", Path::new("a.md")).unwrap(),
1378            Some(b"alpha".to_vec())
1379        );
1380    }
1381
1382    #[test]
1383    fn heterogeneous_mounts_route_to_correct_backend() {
1384        let tmp = TempDir::new().unwrap();
1385
1386        // Folder mem.
1387        let folder_dir = tmp.path().join("folder-mem");
1388        std::fs::create_dir_all(&folder_dir).unwrap();
1389        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1390        <FilesystemMemWriter as MemWriter>::write_entity(
1391            &folder_writer,
1392            Path::new("local.md"),
1393            b"local",
1394        )
1395        .unwrap();
1396        <FilesystemMemWriter as MemWriter>::commit(
1397            &folder_writer,
1398            "seed",
1399            &CommitContext::internal(),
1400        )
1401        .unwrap();
1402
1403        // Archive mem.
1404        let archive_path = build_archive(
1405            tmp.path(),
1406            "external",
1407            &[("ext.md", b"external"), ("dir/nested.md", b"nested")],
1408        );
1409
1410        let engine = Engine::from_mounts(vec![
1411            (
1412                folder_mount("local", folder_dir),
1413                Box::new(folder_writer) as Box<dyn MemBackend>,
1414            ),
1415            (
1416                archive_mount("external", archive_path.clone()),
1417                Box::new(ArchiveBackend::new(archive_path)),
1418            ),
1419        ])
1420        .unwrap();
1421
1422        // Routes correctly by mem name.
1423        assert_eq!(engine.mem_names(), vec!["local", "external"]);
1424        assert_eq!(
1425            engine.read_entity("local", Path::new("local.md")).unwrap(),
1426            Some(b"local".to_vec())
1427        );
1428        assert_eq!(
1429            engine.read_entity("external", Path::new("ext.md")).unwrap(),
1430            Some(b"external".to_vec())
1431        );
1432        assert_eq!(
1433            engine
1434                .read_entity("external", Path::new("dir/nested.md"))
1435                .unwrap(),
1436            Some(b"nested".to_vec())
1437        );
1438        // Cross-routing: reading a path from the wrong mem → None
1439        // (the backend doesn't have it), not an error.
1440        assert_eq!(
1441            engine.read_entity("local", Path::new("ext.md")).unwrap(),
1442            None
1443        );
1444        assert_eq!(
1445            engine
1446                .read_entity("external", Path::new("local.md"))
1447                .unwrap(),
1448            None
1449        );
1450    }
1451
1452    #[test]
1453    fn edge_is_from_readonly_classifies_every_edge_by_source_mount_capability() {
1454        // `engine.edge_is_from_readonly` is the derived-on-demand
1455        // alternative to adding a per-edge marker: construct a mixed
1456        // workspace (one Write-Mem + one ReadOnly archive with
1457        // cross-mem wiki-links) and walk every edge in the store,
1458        // asserting each edge's source-mount capability.
1459        let tmp = TempDir::new().unwrap();
1460
1461        // Write folder mem `local` with a spec-shaped entity that
1462        // declares an explicit cross-mem relation into the archive
1463        // (under the alias model edges originate from `## Relationships`).
1464        let folder_dir = tmp.path().join("local-mem");
1465        std::fs::create_dir_all(&folder_dir).unwrap();
1466        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1467        let local_md = b"---\ntype: spec\n---\n# Note\n\n## Identity\n\nsee [[external:archived]] for prior context.\n\n## Relationships\n\n- **REFERENCES**: [[external:archived]]\n";
1468        <FilesystemMemWriter as MemWriter>::write_entity(
1469            &folder_writer,
1470            Path::new("note.md"),
1471            local_md,
1472        )
1473        .unwrap();
1474        <FilesystemMemWriter as MemWriter>::commit(
1475            &folder_writer,
1476            "seed",
1477            &CommitContext::internal(),
1478        )
1479        .unwrap();
1480
1481        // ReadOnly archive mem `external` with a spec-shaped entity
1482        // declaring an explicit cross-mem relation back to the local
1483        // note.
1484        let archive_md = b"---\ntype: spec\n---\n# Archived\n\n## Identity\n\nrefers back to [[local:note]] for the current revision.\n\n## Relationships\n\n- **REFERENCES**: [[local:note]]\n";
1485        let archive_path = build_archive(tmp.path(), "external", &[("archived.md", archive_md)]);
1486
1487        let engine = Engine::from_mounts(vec![
1488            (
1489                folder_mount("local", folder_dir),
1490                Box::new(folder_writer) as Box<dyn MemBackend>,
1491            ),
1492            (
1493                archive_mount("external", archive_path.clone()),
1494                Box::new(ArchiveBackend::new(archive_path)),
1495            ),
1496        ])
1497        .unwrap();
1498
1499        // Sanity: both entities are real, both mems are mounted.
1500        let local_id = EntityId::new("local", "note");
1501        let archived_id = EntityId::new("external", "archived");
1502        assert!(engine.get_entity(&local_id).is_some());
1503        assert!(engine.get_entity(&archived_id).is_some());
1504        assert!(matches!(
1505            engine.capability("local").unwrap(),
1506            MountCapability::Write
1507        ));
1508        assert!(matches!(
1509            engine.capability("external").unwrap(),
1510            MountCapability::ReadOnly
1511        ));
1512
1513        // Walk every edge in the store. For each (from, edge) pair,
1514        // `edge_is_from_readonly(from)` must return true iff the
1515        // source mount's capability is ReadOnly. The fixture's two
1516        // wiki-links produce one edge from each mem — both halves
1517        // exercise both branches of the helper.
1518        let mut seen_write_edge = false;
1519        let mut seen_readonly_edge = false;
1520        for from in engine.store().all_ids().cloned().collect::<Vec<_>>() {
1521            for _edge in engine.store().outgoing(&from) {
1522                let is_ro = engine.edge_is_from_readonly(&from);
1523                match engine.capability(from.mem()).unwrap() {
1524                    MountCapability::Write => {
1525                        assert!(
1526                            !is_ro,
1527                            "edge from write mem {} reported as ReadOnly",
1528                            from.mem()
1529                        );
1530                        seen_write_edge = true;
1531                    }
1532                    MountCapability::ReadOnly => {
1533                        assert!(
1534                            is_ro,
1535                            "edge from readonly mem {} reported as Write",
1536                            from.mem()
1537                        );
1538                        seen_readonly_edge = true;
1539                    }
1540                }
1541            }
1542        }
1543        assert!(
1544            seen_write_edge,
1545            "fixture must produce at least one edge from a write mem"
1546        );
1547        assert!(
1548            seen_readonly_edge,
1549            "fixture must produce at least one edge from a readonly mem"
1550        );
1551
1552        // Helper also reports `false` for mems absent from the
1553        // router — no mount → no ReadOnly assertion can be made.
1554        let phantom = EntityId::new("missing-mem", "phantom");
1555        assert!(
1556            !engine.edge_is_from_readonly(&phantom),
1557            "absent mount must not be reported as ReadOnly"
1558        );
1559    }
1560
1561    // ---- Engine::changes_since wrapper ------------------------------
1562
1563    #[test]
1564    fn cross_mem_link_allowed_same_mem_always_true() {
1565        // Self-edges (from == to) bypass the cross-mem policy
1566        // entirely — the policy gates *cross*-mem edges only.
1567        let tmp = TempDir::new().unwrap();
1568        let engine = build_demo_engine(&tmp);
1569        assert!(engine.cross_mem_link_allowed("specs", "specs"));
1570        // Even when the mem doesn't exist (not enrolled in
1571        // settings.cross_mem_links), same-mem returns true —
1572        // the engine doesn't validate mem existence here, just the
1573        // policy.
1574        assert!(engine.cross_mem_link_allowed("anywhere", "anywhere"));
1575    }
1576
1577    #[test]
1578    fn cross_mem_link_allowed_absent_denies_by_default() {
1579        // No entry in cross_mem_links for `from_mem` → denied.
1580        // Default-deny is the V1 posture; operators opt in.
1581        let tmp = TempDir::new().unwrap();
1582        let engine = build_demo_engine(&tmp);
1583        assert!(!engine.cross_mem_link_allowed("specs", "engine"));
1584        assert!(!engine.cross_mem_link_allowed("missing", "anywhere"));
1585    }
1586
1587    #[test]
1588    fn cross_mem_link_allowed_wildcard_admits_any_target() {
1589        use memstead_schema::workspace_config::CrossLinkValue;
1590        let tmp = TempDir::new().unwrap();
1591        let mut engine = build_demo_engine(&tmp);
1592        let mut settings = crate::workspace::WorkspaceSettings::default();
1593        settings
1594            .cross_mem_links
1595            .insert("specs".to_string(), CrossLinkValue::Wildcard);
1596        engine.set_settings(settings);
1597        assert!(engine.cross_mem_link_allowed("specs", "engine"));
1598        assert!(engine.cross_mem_link_allowed("specs", "macos"));
1599        assert!(engine.cross_mem_link_allowed("specs", "any-other"));
1600        // Reverse direction is independent — no policy entry for
1601        // engine→specs means denied.
1602        assert!(!engine.cross_mem_link_allowed("engine", "specs"));
1603    }
1604
1605    #[test]
1606    fn cross_mem_link_allowed_allowlist_enforces_membership() {
1607        use memstead_schema::workspace_config::CrossLinkValue;
1608        let tmp = TempDir::new().unwrap();
1609        let mut engine = build_demo_engine(&tmp);
1610        let mut settings = crate::workspace::WorkspaceSettings::default();
1611        settings.cross_mem_links.insert(
1612            "specs".to_string(),
1613            CrossLinkValue::List(vec!["engine".to_string(), "macos".to_string()]),
1614        );
1615        engine.set_settings(settings);
1616        assert!(engine.cross_mem_link_allowed("specs", "engine"));
1617        assert!(engine.cross_mem_link_allowed("specs", "macos"));
1618        assert!(!engine.cross_mem_link_allowed("specs", "external"));
1619    }
1620
1621    #[test]
1622    fn cross_mem_link_allowed_synthesises_from_matching_create_rule_wildcard() {
1623        // No explicit cross_mem_links entry, but a create rule
1624        // matches `from_mem` and carries default_cross_links = "*".
1625        // Synthesis grants permission to any target.
1626        use memstead_schema::workspace_config::CrossLinkValue;
1627        let tmp = TempDir::new().unwrap();
1628        let mut engine = build_demo_engine(&tmp);
1629        let mut settings = crate::workspace::WorkspaceSettings::default();
1630        settings
1631            .mem_create_rules
1632            .push(crate::workspace::CreateRuleSetting {
1633                pattern: "exec-*".to_string(),
1634                schemas: vec!["default".to_string()],
1635                default_cross_links: Some(CrossLinkValue::Wildcard),
1636            });
1637        engine.set_settings(settings);
1638        // No explicit policy; synthesis grants permission for any
1639        // target because the rule's value is Wildcard.
1640        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1641        assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
1642        // Mem that doesn't match any rule → still denied.
1643        assert!(!engine.cross_mem_link_allowed("orphan", "specs"));
1644    }
1645
1646    /// #42: synthesis matches a hierarchical mem by composing the same
1647    /// `<mem_path>/<name>` candidate the create-rule glob is keyed on,
1648    /// not the bare leaf. Before the fix, `from_mem = "project"` could
1649    /// never match a `memstead/*` rule (the leaf-vs-composed-path
1650    /// divergence), so enforcement denied a link `memstead_overview`
1651    /// rendered as rule-granted.
1652    #[test]
1653    fn cross_mem_link_allowed_synthesises_for_hierarchical_mem() {
1654        use memstead_schema::workspace_config::CrossLinkValue;
1655        let tmp = TempDir::new().unwrap();
1656        let mem_dir = tmp.path().to_path_buf();
1657        // Mount `project` with a hierarchical branch so its `mem_path()`
1658        // is "memstead" and the composed candidate is "memstead/project".
1659        // The Folder backend handles loading; only the Mount's storage
1660        // feeds `mem_path()`.
1661        let mount = Mount {
1662            mem: "project".into(),
1663            schema: Some(pin("default")),
1664            storage: MountStorage::GitBranch {
1665                gitdir: mem_dir.join(".git"),
1666                branch: "memstead/project".into(),
1667            },
1668            capability: MountCapability::Write,
1669            lifecycle: MountLifecycle::Eager,
1670            cross_linkable: true,
1671            migration_target: None,
1672        };
1673        let writer = FilesystemMemWriter::new(mem_dir.clone());
1674        let mut engine =
1675            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1676        let mut settings = crate::workspace::WorkspaceSettings::default();
1677        settings
1678            .mem_create_rules
1679            .push(crate::workspace::CreateRuleSetting {
1680                pattern: "memstead/*".to_string(),
1681                schemas: vec!["default".to_string()],
1682                default_cross_links: Some(CrossLinkValue::List(vec!["engine".to_string()])),
1683            });
1684        engine.set_settings(settings);
1685        assert!(
1686            engine.cross_mem_link_allowed("project", "engine"),
1687            "synthesis must match via the composed `memstead/project` candidate"
1688        );
1689        assert!(
1690            !engine.cross_mem_link_allowed("project", "macos"),
1691            "a target outside the rule's default_cross_links is still denied"
1692        );
1693    }
1694
1695    #[test]
1696    fn cross_mem_link_allowed_synthesises_from_matching_create_rule_list() {
1697        // Create rule's default_cross_links is a list — synthesis
1698        // grants permission to listed targets only.
1699        use memstead_schema::workspace_config::CrossLinkValue;
1700        let tmp = TempDir::new().unwrap();
1701        let mut engine = build_demo_engine(&tmp);
1702        let mut settings = crate::workspace::WorkspaceSettings::default();
1703        settings
1704            .mem_create_rules
1705            .push(crate::workspace::CreateRuleSetting {
1706                pattern: "exec-*".to_string(),
1707                schemas: vec!["default".to_string()],
1708                default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
1709            });
1710        engine.set_settings(settings);
1711        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1712        // Target not in the synthesised list → denied.
1713        assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
1714    }
1715
1716    #[test]
1717    fn cross_mem_link_allowed_explicit_policy_wins_over_synthesis() {
1718        // Explicit cross_mem_links wildcard fires first; the
1719        // synthesis layer is never consulted (and would deny).
1720        use memstead_schema::workspace_config::CrossLinkValue;
1721        let tmp = TempDir::new().unwrap();
1722        let mut engine = build_demo_engine(&tmp);
1723        let mut settings = crate::workspace::WorkspaceSettings::default();
1724        settings
1725            .cross_mem_links
1726            .insert("exec-foo".to_string(), CrossLinkValue::Wildcard);
1727        // The synthesis layer would deny `exec-foo → engine` (no
1728        // matching rule), but explicit policy returns true first.
1729        engine.set_settings(settings);
1730        assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
1731    }
1732
1733    #[test]
1734    fn cross_mem_link_allowed_synthesis_unions_into_explicit_list() {
1735        // Explicit list = ["specs"]; create rule synthesises = ["macos"].
1736        // Effective allowed targets: union ({specs, macos}).
1737        use memstead_schema::workspace_config::CrossLinkValue;
1738        let tmp = TempDir::new().unwrap();
1739        let mut engine = build_demo_engine(&tmp);
1740        let mut settings = crate::workspace::WorkspaceSettings::default();
1741        settings.cross_mem_links.insert(
1742            "exec-foo".to_string(),
1743            CrossLinkValue::List(vec!["specs".to_string()]),
1744        );
1745        settings
1746            .mem_create_rules
1747            .push(crate::workspace::CreateRuleSetting {
1748                pattern: "exec-*".to_string(),
1749                schemas: vec!["default".to_string()],
1750                default_cross_links: Some(CrossLinkValue::List(vec!["macos".to_string()])),
1751            });
1752        engine.set_settings(settings);
1753        // Explicit allowlist contains specs → allowed.
1754        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1755        // Synthesis layer adds macos → allowed.
1756        assert!(engine.cross_mem_link_allowed("exec-foo", "macos"));
1757        // Neither layer allows engine → denied.
1758        assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
1759    }
1760
1761    #[test]
1762    fn cross_mem_link_allowed_set_settings_invalidates_compiled_rule_cache() {
1763        // After set_settings, a fresh policy must be reflected on the
1764        // next call — the lazy memo can't return stale rules.
1765        use memstead_schema::workspace_config::CrossLinkValue;
1766        let tmp = TempDir::new().unwrap();
1767        let mut engine = build_demo_engine(&tmp);
1768
1769        // First settings: a rule allows exec-* → specs via synthesis.
1770        let mut s1 = crate::workspace::WorkspaceSettings::default();
1771        s1.mem_create_rules
1772            .push(crate::workspace::CreateRuleSetting {
1773                pattern: "exec-*".to_string(),
1774                schemas: vec!["default".to_string()],
1775                default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
1776            });
1777        engine.set_settings(s1);
1778        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
1779
1780        // Replace settings: the rule no longer carries
1781        // default_cross_links. Cache must invalidate so the next
1782        // call sees the new policy.
1783        let mut s2 = crate::workspace::WorkspaceSettings::default();
1784        s2.mem_create_rules
1785            .push(crate::workspace::CreateRuleSetting {
1786                pattern: "exec-*".to_string(),
1787                schemas: vec!["default".to_string()],
1788                default_cross_links: None,
1789            });
1790        engine.set_settings(s2);
1791        assert!(!engine.cross_mem_link_allowed("exec-foo", "specs"));
1792    }
1793
1794    #[test]
1795    fn cross_mem_link_allowed_malformed_glob_falls_back_to_explicit_policy() {
1796        // Malformed pattern in a create rule causes CreateRuleSet
1797        // compilation to fail; the resolver logs and disables
1798        // synthesis, but explicit cross_mem_links still works.
1799        use memstead_schema::workspace_config::CrossLinkValue;
1800        let tmp = TempDir::new().unwrap();
1801        let mut engine = build_demo_engine(&tmp);
1802        let mut settings = crate::workspace::WorkspaceSettings::default();
1803        settings
1804            .mem_create_rules
1805            .push(crate::workspace::CreateRuleSetting {
1806                pattern: "[unclosed".to_string(),
1807                schemas: vec!["default".to_string()],
1808                default_cross_links: Some(CrossLinkValue::Wildcard),
1809            });
1810        // Explicit policy still works.
1811        settings
1812            .cross_mem_links
1813            .insert("specs".to_string(), CrossLinkValue::Wildcard);
1814        engine.set_settings(settings);
1815        // Explicit policy: specs → engine allowed.
1816        assert!(engine.cross_mem_link_allowed("specs", "engine"));
1817        // Synthesis disabled (compilation failed); rule's would-be
1818        // wildcard doesn't apply.
1819        assert!(!engine.cross_mem_link_allowed("orphan", "anything"));
1820    }
1821
1822    #[test]
1823    fn cross_mem_link_allowed_empty_list_denies_all_cross_mem_targets() {
1824        // [cross_mem_links] specs = [] is the explicit
1825        // "intentionally locked down" shape — same effect as
1826        // default-deny but operator-acknowledged.
1827        use memstead_schema::workspace_config::CrossLinkValue;
1828        let tmp = TempDir::new().unwrap();
1829        let mut engine = build_demo_engine(&tmp);
1830        let mut settings = crate::workspace::WorkspaceSettings::default();
1831        settings
1832            .cross_mem_links
1833            .insert("specs".to_string(), CrossLinkValue::List(Vec::new()));
1834        engine.set_settings(settings);
1835        // Same-mem still passes — policy only gates cross-mem.
1836        assert!(engine.cross_mem_link_allowed("specs", "specs"));
1837        // Cross-mem denied to every target.
1838        assert!(!engine.cross_mem_link_allowed("specs", "engine"));
1839        assert!(!engine.cross_mem_link_allowed("specs", "anything"));
1840    }
1841
1842    #[test]
1843    fn from_mounts_load_warnings_merge_into_health_summary() {
1844        let tmp = TempDir::new().unwrap();
1845        let mem_dir = tmp.path().to_path_buf();
1846        let body = "---\ntype: spec\n---\n# Dup2\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
1847        std::fs::write(mem_dir.join("dup2.md"), body).unwrap();
1848
1849        let writer = FilesystemMemWriter::new(mem_dir.clone());
1850        let engine = Engine::from_mounts(vec![(
1851            folder_mount("specs", mem_dir),
1852            Box::new(writer) as Box<dyn MemBackend>,
1853        )])
1854        .unwrap();
1855
1856        let summary = engine.health();
1857        assert!(
1858            summary
1859                .warnings
1860                .iter()
1861                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
1862            "health() must merge load_warnings into summary.warnings: {:?}",
1863            summary.warnings,
1864        );
1865    }
1866
1867    #[test]
1868    fn workspace_root_accessor_is_none_for_engine_built_from_mounts() {
1869        let tmp = TempDir::new().unwrap();
1870        let mem_dir = tmp.path().to_path_buf();
1871        let writer = FilesystemMemWriter::new(mem_dir.clone());
1872        let engine = Engine::from_mounts(vec![(
1873            folder_mount("specs", mem_dir),
1874            Box::new(writer) as Box<dyn MemBackend>,
1875        )])
1876        .unwrap();
1877        assert!(
1878            engine.workspace_root().is_none(),
1879            "from_mounts has no workspace path",
1880        );
1881        assert!(engine.load_warnings().is_empty());
1882    }
1883
1884    #[test]
1885    fn health_omits_outer_repo_warning_when_workspace_root_unset() {
1886        let tmp = TempDir::new().unwrap();
1887        let mem_dir = tmp.path().to_path_buf();
1888        let writer = FilesystemMemWriter::new(mem_dir.clone());
1889        let engine = Engine::from_mounts(vec![(
1890            folder_mount("specs", mem_dir),
1891            Box::new(writer) as Box<dyn MemBackend>,
1892        )])
1893        .unwrap();
1894        let health = engine.health();
1895        assert!(
1896            !health
1897                .warnings
1898                .iter()
1899                .any(|w| matches!(w, WarningHint::OuterRepoNotIgnoringMemRepo { .. })),
1900            "outer-repo check must skip when workspace_root is None",
1901        );
1902    }
1903
1904    #[test]
1905    fn writable_mem_names_filters_by_capability() {
1906        let tmp = TempDir::new().unwrap();
1907        let mem_dir = tmp.path().to_path_buf();
1908        let writer = FilesystemMemWriter::new(mem_dir.clone());
1909        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1910
1911        let engine = Engine::from_mounts(vec![
1912            (
1913                folder_mount("writable", mem_dir),
1914                Box::new(writer) as Box<dyn MemBackend>,
1915            ),
1916            (
1917                archive_mount("sealed", archive_path.clone()),
1918                Box::new(ArchiveBackend::new(archive_path)),
1919            ),
1920        ])
1921        .unwrap();
1922
1923        // Only the writable mount surfaces; the archive (read-only)
1924        // is filtered out.
1925        let names = engine.writable_mem_names();
1926        assert_eq!(names, vec!["writable"]);
1927    }
1928
1929    /// The default writable mem is
1930    /// the FIRST writable mount in declaration order — the stable seed,
1931    /// not the alphabetically-first name. `test` is declared first;
1932    /// `other` sorts ahead alphabetically but is declared second, so it
1933    /// is NOT the default. This is the invariant that stops a second
1934    /// mem from silently retargeting omitted-`mem` writes.
1935    #[test]
1936    fn default_writable_mem_is_declaration_first_not_alphabetical() {
1937        let tmp = TempDir::new().unwrap();
1938        let test_dir = tmp.path().join("test");
1939        let other_dir = tmp.path().join("other");
1940        std::fs::create_dir_all(&test_dir).unwrap();
1941        std::fs::create_dir_all(&other_dir).unwrap();
1942
1943        let engine = Engine::from_mounts(vec![
1944            (
1945                folder_mount("test", test_dir.clone()),
1946                Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
1947            ),
1948            (
1949                folder_mount("other", other_dir.clone()),
1950                Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
1951            ),
1952        ])
1953        .unwrap();
1954
1955        assert_eq!(
1956            engine.default_writable_mem(),
1957            Some("test"),
1958            "default must be the declaration-first writable mem, not the alphabetically-first",
1959        );
1960    }
1961
1962    /// Reverse declaration order to prove the default tracks declaration
1963    /// order rather than a fixed name: with `other` declared first it
1964    /// becomes the default. Together with the test above this pins the
1965    /// lean as mount order, not name sort.
1966    #[test]
1967    fn default_writable_mem_follows_declaration_order() {
1968        let tmp = TempDir::new().unwrap();
1969        let other_dir = tmp.path().join("other");
1970        let test_dir = tmp.path().join("test");
1971        std::fs::create_dir_all(&other_dir).unwrap();
1972        std::fs::create_dir_all(&test_dir).unwrap();
1973
1974        let engine = Engine::from_mounts(vec![
1975            (
1976                folder_mount("other", other_dir.clone()),
1977                Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
1978            ),
1979            (
1980                folder_mount("test", test_dir.clone()),
1981                Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
1982            ),
1983        ])
1984        .unwrap();
1985
1986        assert_eq!(engine.default_writable_mem(), Some("other"));
1987    }
1988
1989    /// A read-only-only workspace has no default writable mem.
1990    #[test]
1991    fn default_writable_mem_none_without_writable_mount() {
1992        let tmp = TempDir::new().unwrap();
1993        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1994        let engine = Engine::from_mounts(vec![(
1995            archive_mount("sealed", archive_path.clone()),
1996            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1997        )])
1998        .unwrap();
1999        assert_eq!(engine.default_writable_mem(), None);
2000    }
2001
2002    #[test]
2003    fn folder_path_for_mem_returns_path_for_folder_mounts_only() {
2004        let tmp = TempDir::new().unwrap();
2005        let mem_dir = tmp.path().join("specs");
2006        std::fs::create_dir_all(&mem_dir).unwrap();
2007        let writer = FilesystemMemWriter::new(mem_dir.clone());
2008        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2009
2010        let engine = Engine::from_mounts(vec![
2011            (
2012                folder_mount("specs", mem_dir.clone()),
2013                Box::new(writer) as Box<dyn MemBackend>,
2014            ),
2015            (
2016                archive_mount("sealed", archive_path.clone()),
2017                Box::new(ArchiveBackend::new(archive_path)),
2018            ),
2019        ])
2020        .unwrap();
2021
2022        // Folder mount returns its path.
2023        assert_eq!(engine.folder_path_for_mem("specs"), Some(mem_dir.as_path()),);
2024        // Archive mount returns None — caller branches on storage type.
2025        assert_eq!(engine.folder_path_for_mem("sealed"), None);
2026        // Unknown mem returns None — same as Engine::mount.
2027        assert_eq!(engine.folder_path_for_mem("missing"), None);
2028    }
2029
2030    #[test]
2031    fn mount_accessor_returns_public_mount_shape() {
2032        // Build a heterogeneous engine and verify Engine::mount /
2033        // Engine::mounts surface the operator-facing Mount records.
2034        // Handlers branch on MountStorage variants through this
2035        // accessor (replacing full's gitdir_for / worktree_for /
2036        // mem_head_sha / mem_config_for direct-engine
2037        // accessors).
2038        let tmp = TempDir::new().unwrap();
2039        let mem_dir = tmp.path().to_path_buf();
2040        let writer = FilesystemMemWriter::new(mem_dir.clone());
2041        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2042
2043        let engine = Engine::from_mounts(vec![
2044            (
2045                folder_mount("writable", mem_dir.clone()),
2046                Box::new(writer) as Box<dyn MemBackend>,
2047            ),
2048            (
2049                archive_mount("sealed", archive_path.clone()),
2050                Box::new(ArchiveBackend::new(archive_path.clone())),
2051            ),
2052        ])
2053        .unwrap();
2054
2055        // Known mems: each returns a Mount whose storage variant
2056        // matches what the caller passed at construction.
2057        let folder = engine.mount("writable").expect("known mem");
2058        assert!(matches!(folder.storage, MountStorage::Folder { .. }));
2059        assert_eq!(folder.capability, MountCapability::Write);
2060
2061        let archive = engine.mount("sealed").expect("known mem");
2062        match &archive.storage {
2063            MountStorage::Archive { path } => assert_eq!(path, &archive_path),
2064            other => panic!("expected Archive storage, got {other:?}"),
2065        }
2066        assert_eq!(archive.capability, MountCapability::ReadOnly);
2067
2068        // Unknown mem — None, no panic, no error.
2069        assert!(engine.mount("missing").is_none());
2070
2071        // Engine::mounts enumerates every mount in declaration order.
2072        let mounts = engine.mounts();
2073        assert_eq!(mounts.len(), 2);
2074        assert_eq!(mounts[0].mem, "writable");
2075        assert_eq!(mounts[1].mem, "sealed");
2076    }
2077
2078    #[test]
2079    fn mem_router_writable_set_matches_writable_mount_capability() {
2080        // Build an engine with one writable folder mount and one
2081        // read-only archive mount; the router's writable set must
2082        // equal the writable mount's name only.
2083        let tmp = TempDir::new().unwrap();
2084        let mem_dir = tmp.path().join("specs");
2085        std::fs::create_dir_all(&mem_dir).unwrap();
2086        let writer = FilesystemMemWriter::new(mem_dir.clone());
2087        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2088
2089        let engine = Engine::from_mounts(vec![
2090            (
2091                folder_mount("specs", mem_dir.clone()),
2092                Box::new(writer) as Box<dyn MemBackend>,
2093            ),
2094            (
2095                archive_mount("ext", archive_path.clone()),
2096                Box::new(ArchiveBackend::new(archive_path)),
2097            ),
2098        ])
2099        .unwrap();
2100
2101        let router = engine.mem_router();
2102        assert!(router.is_writable("specs"));
2103        assert!(!router.is_writable("ext"));
2104        assert!(router.is_visible("specs"));
2105        assert!(router.is_visible("ext"));
2106        let writable: std::collections::HashSet<&String> = router.writable_mems().iter().collect();
2107        assert_eq!(writable.len(), 1);
2108        assert!(writable.contains(&"specs".to_string()));
2109    }
2110
2111    #[test]
2112    fn mem_router_origin_is_explicit_toml_for_workspace_mounts() {
2113        // Every mount built via `from_mounts` lands as
2114        // `MemOrigin::ExplicitToml` — the file-adapter origin.
2115        // `RuntimeCreated` is reserved for `memstead_mem_create`
2116        // runtime registrations once that handler migrates onto
2117        // the unified engine.
2118        let tmp = TempDir::new().unwrap();
2119        let mem_dir = tmp.path().join("specs");
2120        std::fs::create_dir_all(&mem_dir).unwrap();
2121        let writer = FilesystemMemWriter::new(mem_dir.clone());
2122
2123        let engine = Engine::from_mounts(vec![(
2124            folder_mount("specs", mem_dir),
2125            Box::new(writer) as Box<dyn MemBackend>,
2126        )])
2127        .unwrap();
2128
2129        let origin = engine
2130            .mem_router()
2131            .origin_for_mem("specs")
2132            .expect("known mem");
2133        assert_eq!(origin.kind(), "explicit");
2134    }
2135
2136    #[test]
2137    fn mem_router_dir_for_writable_folder_mount_matches_storage_path() {
2138        // Folder-backed writable mounts surface the storage path
2139        // via `dir_for_mem`. Handlers consuming the router for
2140        // per-mem path resolution rely on this.
2141        let tmp = TempDir::new().unwrap();
2142        let mem_dir = tmp.path().join("specs");
2143        std::fs::create_dir_all(&mem_dir).unwrap();
2144        let writer = FilesystemMemWriter::new(mem_dir.clone());
2145
2146        let engine = Engine::from_mounts(vec![(
2147            folder_mount("specs", mem_dir.clone()),
2148            Box::new(writer) as Box<dyn MemBackend>,
2149        )])
2150        .unwrap();
2151
2152        assert_eq!(
2153            engine.mem_router().dir_for_mem("specs"),
2154            Some(mem_dir.as_path()),
2155        );
2156        assert_eq!(engine.mem_router().dir_for_mem("unknown"), None);
2157    }
2158
2159    #[test]
2160    fn mem_router_archive_path_for_read_only_archive_mount() {
2161        // Read-only archive mounts register via `add_read_only` so
2162        // `archive_path_for_mem` resolves the archive's on-disk
2163        // location.
2164        let tmp = TempDir::new().unwrap();
2165        let mem_dir = tmp.path().join("specs");
2166        std::fs::create_dir_all(&mem_dir).unwrap();
2167        let writer = FilesystemMemWriter::new(mem_dir.clone());
2168        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2169
2170        let engine = Engine::from_mounts(vec![
2171            (
2172                folder_mount("specs", mem_dir),
2173                Box::new(writer) as Box<dyn MemBackend>,
2174            ),
2175            (
2176                archive_mount("ext", archive_path.clone()),
2177                Box::new(ArchiveBackend::new(archive_path.clone())),
2178            ),
2179        ])
2180        .unwrap();
2181
2182        let router = engine.mem_router();
2183        assert_eq!(
2184            router.archive_path_for_mem("ext"),
2185            Some(archive_path.as_path()),
2186        );
2187        // Writable folder mount has no archive path.
2188        assert_eq!(router.archive_path_for_mem("specs"), None);
2189    }
2190
2191    #[test]
2192    fn read_mem_config_via_backend_trait_folder_reads_bytes() {
2193        // Direct trait call against FilesystemMemWriter. Verifies
2194        // the backend-side primitive returns the raw bytes the
2195        // engine then parses.
2196        let tmp = TempDir::new().unwrap();
2197        let mem_dir = tmp.path().to_path_buf();
2198        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2199        let body = br#"{
2200            "format": 1,
2201            "schema": "default@1.0.0",
2202            "writeGuidance": { "tone": "neutral" }
2203        }"#;
2204        std::fs::write(mem_dir.join(".memstead").join("config.json"), body).unwrap();
2205
2206        let writer = FilesystemMemWriter::new(mem_dir);
2207        let result = MemBackend::read_mem_config(&writer).unwrap();
2208        let bytes = result.expect("config bytes must surface");
2209        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2210        assert_eq!(parsed["schema"], "default@1.0.0");
2211    }
2212
2213    #[test]
2214    fn read_mem_config_via_backend_trait_folder_missing_returns_none() {
2215        let tmp = TempDir::new().unwrap();
2216        let mem_dir = tmp.path().to_path_buf();
2217        let writer = FilesystemMemWriter::new(mem_dir);
2218        let result = MemBackend::read_mem_config(&writer).unwrap();
2219        assert!(result.is_none());
2220    }
2221
2222    #[test]
2223    fn read_mem_config_via_backend_trait_archive_reads_bytes() {
2224        // Build an archive containing .memstead/config.json and verify
2225        // the ArchiveBackend impl returns its bytes.
2226        let tmp = TempDir::new().unwrap();
2227        let archive_path = tmp.path().join("seed.mem");
2228        let body = br#"{
2229            "format": 1,
2230            "schema": "default@1.0.0",
2231            "writeGuidance": { "tone": "archive" }
2232        }"#;
2233        {
2234            let file = std::fs::File::create(&archive_path).unwrap();
2235            let mut writer = zip::ZipWriter::new(file);
2236            writer
2237                .start_file(
2238                    ".memstead/config.json",
2239                    zip::write::SimpleFileOptions::default(),
2240                )
2241                .unwrap();
2242            use std::io::Write;
2243            writer.write_all(body).unwrap();
2244            writer.finish().unwrap();
2245        }
2246
2247        let backend = ArchiveBackend::new(archive_path);
2248        let result = MemBackend::read_mem_config(&backend).unwrap();
2249        let bytes = result.expect("config bytes must surface");
2250        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2251        assert_eq!(parsed["writeGuidance"]["tone"], "archive");
2252    }
2253
2254    #[test]
2255    fn mem_config_for_returns_none_when_no_config_file_present() {
2256        // Folder backend without a `.memstead/config.json` file. The
2257        // accessor must lenient — return None, not error.
2258        let tmp = TempDir::new().unwrap();
2259        let mem_dir = tmp.path().to_path_buf();
2260        let writer = FilesystemMemWriter::new(mem_dir.clone());
2261        let engine = Engine::from_mounts(vec![(
2262            folder_mount("specs", mem_dir),
2263            Box::new(writer) as Box<dyn MemBackend>,
2264        )])
2265        .unwrap();
2266        assert!(engine.mem_config_for("specs").is_none());
2267    }
2268
2269    #[test]
2270    fn mem_config_for_returns_some_when_config_file_present() {
2271        // Drop a valid `.memstead/config.json` into the mem dir,
2272        // build the engine, and assert the accessor surfaces a
2273        // MemConfig with the right shape (write_guidance entries
2274        // round-trip).
2275        let tmp = TempDir::new().unwrap();
2276        let mem_dir = tmp.path().to_path_buf();
2277        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2278        let config_body = r#"{
2279            "format": 1,
2280            "schema": "default@1.0.0",
2281            "writeGuidance": {
2282                "tone": "neutral",
2283                "voice": "active"
2284            }
2285        }"#;
2286        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
2287
2288        let writer = FilesystemMemWriter::new(mem_dir.clone());
2289        let engine = Engine::from_mounts(vec![(
2290            folder_mount("specs", mem_dir),
2291            Box::new(writer) as Box<dyn MemBackend>,
2292        )])
2293        .unwrap();
2294
2295        let cfg = engine
2296            .mem_config_for("specs")
2297            .expect("mem_config should load");
2298        assert_eq!(cfg.write_guidance.len(), 2);
2299        assert_eq!(
2300            cfg.write_guidance.get("tone").and_then(|v| v.as_str()),
2301            Some("neutral"),
2302        );
2303        assert_eq!(
2304            cfg.write_guidance.get("voice").and_then(|v| v.as_str()),
2305            Some("active"),
2306        );
2307    }
2308
2309    #[test]
2310    fn mem_config_for_unknown_mem_returns_none() {
2311        // Lenient accessor — unknown names get None, not Err.
2312        let tmp = TempDir::new().unwrap();
2313        let mem_dir = tmp.path().to_path_buf();
2314        let writer = FilesystemMemWriter::new(mem_dir.clone());
2315        let engine = Engine::from_mounts(vec![(
2316            folder_mount("specs", mem_dir),
2317            Box::new(writer) as Box<dyn MemBackend>,
2318        )])
2319        .unwrap();
2320        assert!(engine.mem_config_for("missing").is_none());
2321    }
2322
2323    #[test]
2324    fn mem_config_for_archive_mount_returns_none() {
2325        // Archive backends carry mem_config = None in V1 (the
2326        // read-from-storage path is deferred to a follow-up).
2327        let tmp = TempDir::new().unwrap();
2328        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2329        let engine = Engine::from_mounts(vec![(
2330            archive_mount("ext", archive_path.clone()),
2331            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2332        )])
2333        .unwrap();
2334        assert!(engine.mem_config_for("ext").is_none());
2335    }
2336
2337    #[test]
2338    fn mem_configs_named_iterates_only_mounts_with_config() {
2339        // Two folder mounts; one has a config file, one doesn't.
2340        // The iterator yields exactly the configured one — verifies
2341        // the filter_map shape and that the name comes from the
2342        // mount record (authoritative), not the config body.
2343        let tmp = TempDir::new().unwrap();
2344        let with_config = tmp.path().join("specs");
2345        let without_config = tmp.path().join("memos");
2346        std::fs::create_dir_all(with_config.join(".memstead")).unwrap();
2347        std::fs::create_dir_all(&without_config).unwrap();
2348        let config_body = r#"{
2349            "format": 1,
2350            "schema": "default@1.0.0",
2351            "writeGuidance": { "tone": "neutral" }
2352        }"#;
2353        std::fs::write(
2354            with_config.join(".memstead").join("config.json"),
2355            config_body,
2356        )
2357        .unwrap();
2358
2359        let engine = Engine::from_mounts(vec![
2360            (
2361                folder_mount("specs", with_config.clone()),
2362                Box::new(FilesystemMemWriter::new(with_config)) as Box<dyn MemBackend>,
2363            ),
2364            (
2365                folder_mount("memos", without_config.clone()),
2366                Box::new(FilesystemMemWriter::new(without_config)) as Box<dyn MemBackend>,
2367            ),
2368        ])
2369        .unwrap();
2370
2371        let yielded: Vec<(&str, usize)> = engine
2372            .mem_configs_named()
2373            .map(|(name, cfg)| (name, cfg.write_guidance.len()))
2374            .collect();
2375        assert_eq!(yielded, vec![("specs", 1)]);
2376    }
2377
2378    #[test]
2379    fn schema_for_returns_some_for_known_mem_and_none_for_unknown() {
2380        // Every mount registers a schema (resolved from its pin at
2381        // boot). Lookup by mem name surfaces the same Arc that
2382        // mutations resolve internally; unknown names return None.
2383        let tmp = TempDir::new().unwrap();
2384        let mem_dir = tmp.path().to_path_buf();
2385        let writer = FilesystemMemWriter::new(mem_dir.clone());
2386        let engine = Engine::from_mounts(vec![(
2387            folder_mount("specs", mem_dir),
2388            Box::new(writer) as Box<dyn MemBackend>,
2389        )])
2390        .unwrap();
2391        assert!(engine.schema_for("specs").is_some());
2392        assert!(engine.schema_for("missing").is_none());
2393    }
2394
2395    #[test]
2396    fn gitdir_for_unknown_mem_returns_unknown_mem() {
2397        let tmp = TempDir::new().unwrap();
2398        let mem_dir = tmp.path().to_path_buf();
2399        let writer = FilesystemMemWriter::new(mem_dir.clone());
2400        let engine = Engine::from_mounts(vec![(
2401            folder_mount("specs", mem_dir),
2402            Box::new(writer) as Box<dyn MemBackend>,
2403        )])
2404        .unwrap();
2405        let err = engine.gitdir_for("missing").unwrap_err();
2406        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2407    }
2408
2409    #[test]
2410    fn gitdir_for_folder_mount_returns_no_gitdir_error() {
2411        // Folder mounts do not have a gitdir — full's contract surfaces
2412        // a mem-level error, not UnknownMem. Mirror that here.
2413        let tmp = TempDir::new().unwrap();
2414        let mem_dir = tmp.path().to_path_buf();
2415        let writer = FilesystemMemWriter::new(mem_dir.clone());
2416        let engine = Engine::from_mounts(vec![(
2417            folder_mount("specs", mem_dir),
2418            Box::new(writer) as Box<dyn MemBackend>,
2419        )])
2420        .unwrap();
2421        let err = engine.gitdir_for("specs").unwrap_err();
2422        match err {
2423            EngineError::Mem(msg) => assert!(msg.contains("no resolved gitdir")),
2424            other => panic!("expected EngineError::Mem, got {other:?}"),
2425        }
2426    }
2427
2428    #[test]
2429    fn worktree_for_folder_mount_returns_storage_path() {
2430        let tmp = TempDir::new().unwrap();
2431        let mem_dir = tmp.path().to_path_buf();
2432        let writer = FilesystemMemWriter::new(mem_dir.clone());
2433        let engine = Engine::from_mounts(vec![(
2434            folder_mount("specs", mem_dir.clone()),
2435            Box::new(writer) as Box<dyn MemBackend>,
2436        )])
2437        .unwrap();
2438        let worktree = engine.worktree_for("specs").unwrap();
2439        assert_eq!(worktree, mem_dir);
2440    }
2441
2442    #[test]
2443    fn worktree_for_unknown_mem_returns_unknown_mem() {
2444        let tmp = TempDir::new().unwrap();
2445        let mem_dir = tmp.path().to_path_buf();
2446        let writer = FilesystemMemWriter::new(mem_dir.clone());
2447        let engine = Engine::from_mounts(vec![(
2448            folder_mount("specs", mem_dir),
2449            Box::new(writer) as Box<dyn MemBackend>,
2450        )])
2451        .unwrap();
2452        let err = engine.worktree_for("missing").unwrap_err();
2453        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2454    }
2455
2456    #[test]
2457    fn worktree_for_archive_mount_returns_archive_backed_error() {
2458        let tmp = TempDir::new().unwrap();
2459        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2460        let engine = Engine::from_mounts(vec![(
2461            archive_mount("ext", archive_path.clone()),
2462            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2463        )])
2464        .unwrap();
2465        let err = engine.worktree_for("ext").unwrap_err();
2466        match err {
2467            EngineError::Mem(msg) => assert!(msg.contains("archive-backed")),
2468            other => panic!("expected EngineError::Mem, got {other:?}"),
2469        }
2470    }
2471
2472    #[test]
2473    fn mem_head_sha_for_folder_mount_is_none() {
2474        // Folder backend doesn't track a head; current_head() returns
2475        // Ok(None) at construction; mem_head_sha returns Ok(None).
2476        let tmp = TempDir::new().unwrap();
2477        let mem_dir = tmp.path().to_path_buf();
2478        let writer = FilesystemMemWriter::new(mem_dir.clone());
2479        let engine = Engine::from_mounts(vec![(
2480            folder_mount("specs", mem_dir),
2481            Box::new(writer) as Box<dyn MemBackend>,
2482        )])
2483        .unwrap();
2484        let head = engine.mem_head_sha("specs").unwrap();
2485        assert_eq!(head, None);
2486    }
2487
2488    #[test]
2489    fn mem_head_sha_unknown_mem_returns_unknown_mem() {
2490        let tmp = TempDir::new().unwrap();
2491        let mem_dir = tmp.path().to_path_buf();
2492        let writer = FilesystemMemWriter::new(mem_dir.clone());
2493        let engine = Engine::from_mounts(vec![(
2494            folder_mount("specs", mem_dir),
2495            Box::new(writer) as Box<dyn MemBackend>,
2496        )])
2497        .unwrap();
2498        let err = engine.mem_head_sha("missing").unwrap_err();
2499        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
2500    }
2501
2502    #[test]
2503    fn capability_surfaces_per_mount() {
2504        let tmp = TempDir::new().unwrap();
2505        let mem_dir = tmp.path().to_path_buf();
2506        let writer = FilesystemMemWriter::new(mem_dir.clone());
2507        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2508
2509        let engine = Engine::from_mounts(vec![
2510            (
2511                folder_mount("writable", mem_dir),
2512                Box::new(writer) as Box<dyn MemBackend>,
2513            ),
2514            (
2515                archive_mount("read-only", archive_path.clone()),
2516                Box::new(ArchiveBackend::new(archive_path)),
2517            ),
2518        ])
2519        .unwrap();
2520
2521        assert_eq!(
2522            engine.capability("writable").unwrap(),
2523            MountCapability::Write
2524        );
2525        assert_eq!(
2526            engine.capability("read-only").unwrap(),
2527            MountCapability::ReadOnly
2528        );
2529        assert!(matches!(
2530            engine.capability("missing"),
2531            Err(EngineError::UnknownMem(_))
2532        ));
2533    }
2534
2535    #[test]
2536    fn read_provenance_routes_through_backend() {
2537        let tmp = TempDir::new().unwrap();
2538        let mem_dir = tmp.path().to_path_buf();
2539        let writer = FilesystemMemWriter::new(mem_dir.clone());
2540
2541        // Append a provenance record via the backend trait directly,
2542        // then read it back through the engine.
2543        let backend_handle: &dyn MemBackend = &writer;
2544        backend_handle
2545            .append_provenance(&Provenance::new(
2546                std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
2547                crate::ProvenanceKind::Create,
2548                Some("v:e".into()),
2549                crate::vcs::Actor::Cli,
2550                None,
2551                Some("first".into()),
2552            ))
2553            .unwrap();
2554
2555        let engine = Engine::from_mounts(vec![(
2556            folder_mount("specs", mem_dir),
2557            Box::new(writer) as Box<dyn MemBackend>,
2558        )])
2559        .unwrap();
2560
2561        let records = engine.read_provenance("specs", None).unwrap();
2562        assert_eq!(records.len(), 1);
2563        assert_eq!(records[0].kind, crate::ProvenanceKind::Create);
2564        assert_eq!(records[0].entity.as_deref(), Some("v:e"));
2565        assert_eq!(records[0].note.as_deref(), Some("first"));
2566    }
2567
2568    #[test]
2569    fn archive_mount_returns_sealed_indirectly_through_backend_layer() {
2570        // The engine doesn't yet expose mutation methods, but an
2571        // archive backend held on a Mount with ReadOnly capability is
2572        // still a `&dyn MemBackend` whose write methods return
2573        // Sealed. This test locks the trait routing — when the engine
2574        // gains write methods in a later session, capability gating +
2575        // backend Sealed errors must agree.
2576        let tmp = TempDir::new().unwrap();
2577        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
2578        let backend = ArchiveBackend::new(archive_path);
2579        match MemBackend::write_entity(&backend, Path::new("x.md"), b"x") {
2580            Err(BackendError::Sealed) => {}
2581            other => panic!("expected Sealed, got {other:?}"),
2582        }
2583    }
2584
2585    // ---- Read-side delegates ----------------------------------------
2586    //
2587    // These tests pin the surface that the MCP migration consumes
2588    // (stats, health, context, communities, search, list, orphans,
2589    // stubs, most_connected, missing_required_outgoing). They run
2590    // against a folder-mount engine with a small fixture of created
2591    // entities and one relate edge — enough to exercise both the
2592    // graph-query path and the cache-invalidation hooks.
2593
2594    fn build_demo_engine(tmp: &TempDir) -> Engine {
2595        let mem_dir = tmp.path().to_path_buf();
2596        let writer = FilesystemMemWriter::new(mem_dir.clone());
2597        let mut engine = Engine::from_mounts(vec![(
2598            folder_mount("specs", mem_dir),
2599            Box::new(writer) as Box<dyn MemBackend>,
2600        )])
2601        .unwrap();
2602        let (actor, client) = cli_actor();
2603        let source = engine
2604            .create_entity(
2605                empty_create_args("specs", "Source One"),
2606                actor,
2607                Some(&client),
2608                None,
2609            )
2610            .unwrap();
2611        let target = engine
2612            .create_entity(
2613                empty_create_args("specs", "Target Two"),
2614                actor,
2615                Some(&client),
2616                None,
2617            )
2618            .unwrap();
2619        engine
2620            .create_entity(
2621                empty_create_args("specs", "Lonely Three"),
2622                actor,
2623                Some(&client),
2624                None,
2625            )
2626            .unwrap();
2627        engine
2628            .relate_entity(
2629                RelateEntityArgs {
2630                    source: source.id.clone(),
2631                    expected_hash: Some(source.content_hash.clone()),
2632                    rel_type: "USES".to_string(),
2633                    target: target.id.clone(),
2634                    remove: false,
2635                    description: None,
2636                },
2637                actor,
2638                Some(&client),
2639                None,
2640            )
2641            .unwrap();
2642        engine
2643    }
2644
2645    #[test]
2646    fn stats_reports_per_engine_counts() {
2647        let tmp = TempDir::new().unwrap();
2648        let engine = build_demo_engine(&tmp);
2649        let stats = engine.stats();
2650        assert_eq!(stats.entity_count, 3);
2651        assert_eq!(stats.edge_count, 1);
2652        assert_eq!(stats.mem_count, 1);
2653        assert_eq!(stats.types_in_use, vec!["spec".to_string()]);
2654        assert_eq!(stats.edge_types.get("USES"), Some(&1));
2655    }
2656
2657    #[test]
2658    fn orphans_lists_unconnected_real_entities() {
2659        let tmp = TempDir::new().unwrap();
2660        let engine = build_demo_engine(&tmp);
2661        let orphans = engine.orphans();
2662        assert_eq!(orphans.len(), 1);
2663        assert_eq!(orphans[0].as_ref(), "specs--lonely-three");
2664    }
2665
2666    /// #49: the orphan/community headlines can be attributed per pinned
2667    /// schema. Single-mem here, so one bucket — but it proves the
2668    /// attribution keys by `schema_of(mem)` and that the per-schema
2669    /// counts sum to the raw total (which a health surface keeps verbatim).
2670    #[test]
2671    fn schema_breakdowns_attribute_to_mem_pin() {
2672        let tmp = TempDir::new().unwrap();
2673        let engine = build_demo_engine(&tmp);
2674
2675        let orphans = engine.orphans();
2676        let orphans_by_schema = engine.orphans_by_schema(&orphans);
2677        assert_eq!(
2678            orphans_by_schema.values().sum::<usize>(),
2679            orphans.len(),
2680            "per-schema orphan counts must sum to the raw total"
2681        );
2682        assert_eq!(orphans_by_schema.len(), 1, "one mem ⇒ one schema bucket");
2683        let (schema, count) = orphans_by_schema.iter().next().unwrap();
2684        assert!(!schema.is_empty(), "specs mem is pinned: {schema:?}");
2685        assert_eq!(*count, 1);
2686
2687        // communities_by_schema buckets the demo mem's clusters under the
2688        // same pin; with one schema, its values sum to the global count.
2689        let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
2690        let communities_by_schema = engine.communities_by_schema(&mems);
2691        assert_eq!(communities_by_schema.len(), 1);
2692        assert_eq!(
2693            communities_by_schema.values().sum::<usize>(),
2694            engine.communities().count,
2695        );
2696    }
2697
2698    #[test]
2699    fn stubs_lists_unresolved_link_targets() {
2700        let tmp = TempDir::new().unwrap();
2701        let mem_dir = tmp.path().to_path_buf();
2702        let writer = FilesystemMemWriter::new(mem_dir.clone());
2703        let mut engine = Engine::from_mounts(vec![(
2704            folder_mount("specs", mem_dir),
2705            Box::new(writer) as Box<dyn MemBackend>,
2706        )])
2707        .unwrap();
2708        let (actor, client) = cli_actor();
2709        let source = engine
2710            .create_entity(
2711                empty_create_args("specs", "Holder"),
2712                actor,
2713                Some(&client),
2714                None,
2715            )
2716            .unwrap();
2717        // Relate to a non-existent target — relate_entity creates a
2718        // stub for the target so the edge can land.
2719        engine
2720            .relate_entity(
2721                RelateEntityArgs {
2722                    source: source.id.clone(),
2723                    expected_hash: Some(source.content_hash.clone()),
2724                    rel_type: "USES".to_string(),
2725                    target: EntityId::new("specs", "ghost"),
2726                    remove: false,
2727                    description: None,
2728                },
2729                actor,
2730                Some(&client),
2731                None,
2732            )
2733            .unwrap();
2734        let stubs = engine.stubs();
2735        assert!(
2736            stubs.iter().any(|(id, _)| id.as_ref() == "specs--ghost"),
2737            "expected ghost stub: {stubs:?}"
2738        );
2739    }
2740
2741    #[test]
2742    fn most_connected_orders_by_degree() {
2743        let tmp = TempDir::new().unwrap();
2744        let engine = build_demo_engine(&tmp);
2745        let top = engine.most_connected(5);
2746        assert_eq!(top.len(), 3);
2747        // Source and Target each have one edge; Lonely has zero.
2748        let zero_degree: Vec<_> = top
2749            .iter()
2750            .filter(|c| c.total == 0)
2751            .map(|c| c.id.as_ref().to_string())
2752            .collect();
2753        assert_eq!(zero_degree, vec!["specs--lonely-three".to_string()]);
2754    }
2755
2756    #[test]
2757    fn health_returns_per_engine_summary() {
2758        let tmp = TempDir::new().unwrap();
2759        let engine = build_demo_engine(&tmp);
2760        let health = engine.health();
2761        // `memstead_create` refuses on missing required sections, so
2762        // entities built through `empty_create_args` carry the
2763        // helper-seeded `identity` + `purpose` bodies and no longer
2764        // surface as missing-fields. Health remains the read-side
2765        // tolerance surface for legacy on-disk drift — covered by
2766        // the loader-tolerance tests that hand-craft pre-strict
2767        // markdown files.
2768        assert!(
2769            health
2770                .missing_fields
2771                .iter()
2772                .all(|r| r.id.as_ref() != "specs--source-one"),
2773            "post-strict-create fixture must not surface as missing-fields; got {:?}",
2774            health.missing_fields,
2775        );
2776    }
2777
2778    #[test]
2779    fn context_carries_neighbors_and_community() {
2780        let tmp = TempDir::new().unwrap();
2781        let engine = build_demo_engine(&tmp);
2782        let source_id = EntityId::new("specs", "source-one");
2783        let ctx = engine.context(&source_id).unwrap();
2784        assert_eq!(ctx.entity_id, source_id);
2785        assert_eq!(ctx.neighbors.len(), 1);
2786        assert_eq!(ctx.neighbors[0].relationship, "USES");
2787        assert!(matches!(ctx.neighbors[0].direction, Direction::Outgoing));
2788    }
2789
2790    #[test]
2791    fn communities_caches_louvain_until_invalidated() {
2792        let tmp = TempDir::new().unwrap();
2793        let mut engine = build_demo_engine(&tmp);
2794        // Population reflects the current store at first call.
2795        let entities_before = engine.communities().entity_cluster_map.len();
2796        // Cache hit — repeat call returns same data.
2797        assert_eq!(
2798            engine.communities().entity_cluster_map.len(),
2799            entities_before
2800        );
2801        // Mutation invalidates the cache; next call re-runs against
2802        // the post-mutation store and includes the new entity.
2803        let (actor, client) = cli_actor();
2804        engine
2805            .create_entity(
2806                empty_create_args("specs", "Disturber"),
2807                actor,
2808                Some(&client),
2809                None,
2810            )
2811            .unwrap();
2812        let entities_after = engine.communities().entity_cluster_map.len();
2813        assert_eq!(
2814            entities_after,
2815            entities_before + 1,
2816            "create_entity should have invalidated community cache and added the new entity"
2817        );
2818    }
2819
2820    #[test]
2821    fn list_filters_by_metadata_only() {
2822        let tmp = TempDir::new().unwrap();
2823        let engine = build_demo_engine(&tmp);
2824        let scope = SearchScope {
2825            entity_type: Some("spec".to_string()),
2826            ..Default::default()
2827        };
2828        let result = engine.list(&scope);
2829        // Three real spec entities created; stubs / non-spec types absent.
2830        assert_eq!(result.hits.len(), 3);
2831    }
2832
2833    #[test]
2834    fn list_applies_schema_declared_filter_on_non_default_schema_mem() {
2835        // A mem pinned to `planning` (non-default schema). The
2836        // `decision` type declares `status` with `filterable: equality`.
2837        // Pre-fix, filter dispatch consulted only the built-in default
2838        // schema via `type_by_name`, missed `status`, silently bypassed
2839        // the filter, and emitted the misleading "unknown filter key"
2840        // warning. Post-fix, the filter is honored and no warning fires.
2841        let tmp = TempDir::new().unwrap();
2842        let mem_dir = tmp.path().to_path_buf();
2843        let writer = FilesystemMemWriter::new(mem_dir.clone());
2844        let mount = Mount {
2845            mem: "planning".to_string(),
2846            schema: Some(memstead_schema::SchemaRef::new(
2847                "planning",
2848                semver::Version::new(0, 1, 0),
2849            )),
2850            storage: MountStorage::Folder { path: mem_dir },
2851            capability: MountCapability::Write,
2852            lifecycle: MountLifecycle::Eager,
2853            cross_linkable: true,
2854            migration_target: None,
2855        };
2856        let mut engine =
2857            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
2858        let (actor, client) = cli_actor();
2859
2860        // Two decisions with different status values; required fields
2861        // (decision/context/consequences sections, decided_on, deciders)
2862        // get placeholder defaults — the test only cares about the
2863        // status field's filterability.
2864        for (title, status) in &[("Skip Postgres", "accepted"), ("Use SQLite", "proposed")] {
2865            let mut metadata = indexmap::IndexMap::new();
2866            metadata.insert("status".to_string(), status.to_string());
2867            metadata.insert("deciders".to_string(), "alice".to_string());
2868            metadata.insert("decided_on".to_string(), "2026-05-19".to_string());
2869            let args = crate::engine::CreateEntityArgs {
2870                mem: "planning".to_string(),
2871                title: title.to_string(),
2872                entity_type: "decision".to_string(),
2873                sections: indexmap::IndexMap::from_iter([
2874                    ("decision".to_string(), "We chose this.".to_string()),
2875                    ("context".to_string(), "Single-user dev.".to_string()),
2876                    ("consequences".to_string(), "Lose multi-writer.".to_string()),
2877                ]),
2878                metadata,
2879                relations: Vec::new(),
2880                dry_run: false,
2881            };
2882            engine
2883                .create_entity(args, actor, Some(&client), None)
2884                .unwrap();
2885        }
2886
2887        // Filter on the schema-declared filterable field.
2888        let scope = SearchScope {
2889            entity_type: Some("decision".to_string()),
2890            filters: std::collections::HashMap::from([(
2891                "status".to_string(),
2892                "accepted".to_string(),
2893            )]),
2894            ..Default::default()
2895        };
2896        let result = engine.list(&scope);
2897        assert_eq!(
2898            result.hits.len(),
2899            1,
2900            "filter on schema-declared field must select only matching entities"
2901        );
2902        assert_eq!(result.hits[0].title, "Skip Postgres");
2903        assert!(
2904            result.warnings.is_empty(),
2905            "no warning should fire when the filter is declared by the mem's pinned schema: {:?}",
2906            result.warnings
2907        );
2908    }
2909
2910    #[test]
2911    fn search_returns_results_against_built_index() {
2912        let tmp = TempDir::new().unwrap();
2913        let engine = build_demo_engine(&tmp);
2914        let scope = SearchScope {
2915            query: Some(crate::ops::Query {
2916                any: vec!["source".to_string()],
2917                ..Default::default()
2918            }),
2919            ..Default::default()
2920        };
2921        let result = engine.search(&scope).expect("native search returns Ok");
2922        assert!(result.total >= 1, "expected ≥1 hit for source: {result:?}");
2923        assert!(
2924            result
2925                .hits
2926                .iter()
2927                .any(|h| h.id.as_ref() == "specs--source-one"),
2928            "expected source-one in hits: {result:?}"
2929        );
2930    }
2931
2932    // ---- Engine::from_workspace_root (lean boot path) --------------
2933}