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