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 the CLI: that surface
157    /// operates 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    /// Resolve a VISIBLE mem to its folder-storage root on disk.
175    ///
176    /// Unknown or quarantined names refuse `UNKNOWN_MEM` (the same
177    /// visibility gate `search` and the conflicts door apply); a
178    /// visible mount whose storage is not folder-backed returns
179    /// `Ok(None)` so callers branch on backend applicability without
180    /// inventing a typed error. Read-only mounts resolve too — this is
181    /// a read accessor, not a write gate. Generic by design: any
182    /// consumer that needs a folder mem's disk root (per-mem changelog
183    /// readers, export tooling, future doors) gets the same answer.
184    pub fn folder_mem_root(&self, mem: &str) -> Result<Option<PathBuf>, EngineError> {
185        let mount = self
186            .mounts
187            .iter()
188            .find(|m| m.mount.mem == mem)
189            .ok_or_else(|| self.unknown_mem_error(mem))?;
190        if self.quarantine_reason(mem).is_some() {
191            return Err(self.unknown_mem_error(mem));
192        }
193        match &mount.mount.storage {
194            crate::workspace::MountStorage::Folder { path } => Ok(Some(path.clone())),
195            _ => Ok(None),
196        }
197    }
198
199    /// Workspace-level operator policy (mem create/delete rules,
200    /// cross-mem links). Defaults to empty; populated via
201    /// [`Engine::set_settings`] after construction. Surfaced for MCP
202    /// handlers (`memstead_health { include_config: true }`,
203    /// `memstead_overview`'s lifecycle-namespaces section) and other
204    /// consumers that need to read workspace policy.
205    pub fn settings(&self) -> &WorkspaceSettings {
206        &self.settings
207    }
208
209    /// The pipeline configs — the v2 single-record binding store — loaded
210    /// from the workspace at boot: the read-only queryable surface the
211    /// loader exposes. Empty for engines not booted from a workspace root,
212    /// or for a workspace that declares no pipelines. The ingest skill
213    /// and future MCP tools consume this structured form
214    /// rather than re-reading the JSON folders.
215    pub fn pipeline_configs(&self) -> &crate::pipeline_store::BindingConfigs {
216        &self.pipeline_configs
217    }
218
219    /// The pipeline configs serialized as a JSON string — the read
220    /// counterpart of the `add_projection_json` edit entry point.
221    /// Serialization-boundary callers (where serde does not live)
222    /// get the store in one call and deserialize on their side.
223    ///
224    /// Shape: `{ "bindings": [{ mem, name, config }] }` — the v2
225    /// single-record store (`config` carries the whole binding: inline
226    /// `sources`, `operations`, everything). The `mediums` / `facets` /
227    /// `ingests` keys are **gone** with their record kinds. This reads the
228    /// live binding store fresh (like the brief path) rather than the
229    /// in-memory snapshot, so an edit shows back immediately. A missing
230    /// root or a legacy/unreadable store yields the fallback empty object.
231    pub fn pipeline_configs_json(&self) -> String {
232        let empty = || "{\"bindings\":[]}".to_string();
233        let Some(root) = self.workspace_root() else {
234            return empty();
235        };
236        match crate::pipeline_store::load_pipeline_configs(root) {
237            Ok(configs) => serde_json::to_string(&configs).unwrap_or_else(|_| empty()),
238            Err(_) => empty(),
239        }
240    }
241
242    /// Overwrite the in-memory pipeline configs. The workspace-root boot
243    /// paths call this after [`crate::pipeline_store::load_pipeline_configs`];
244    /// exposed so the full boot helper (a separate crate) can populate the
245    /// same surface.
246    pub fn set_pipeline_configs(&mut self, configs: crate::pipeline_store::BindingConfigs) {
247        self.pipeline_configs = configs;
248    }
249
250    /// Build a [`WarningHint::NoteMissing`] when the workspace has
251    /// `[mutations].require_notes = true` and the caller omitted (or
252    /// passed a blank/whitespace-only) `note`; `None` otherwise.
253    ///
254    /// This is the single enforcement point for the `require_notes`
255    /// provenance nudge. Every mutation that accepts a `note` calls it
256    /// on its commit-landing path and pushes the result onto the
257    /// outcome's `warnings`, so both the CLI and the MCP transports
258    /// inherit identical behaviour from the engine response rather than
259    /// each re-deriving the policy at its own boundary (the drift that
260    /// left the policy decorative on the CLI). `tool` becomes the
261    /// warning's `details.tool` — callers pass the engine-level verb
262    /// (`create_entity`, `update_entity`, `relate_entity`,
263    /// `delete_entity`, `rename_entity`, `create_mem`,
264    /// `delete_mem`), matching the commit `Tool:` provenance trailer.
265    /// The mutation still commits — the policy nudges, it never blocks.
266    pub fn note_missing_warning(&self, tool: &str, note: Option<&str>) -> Option<WarningHint> {
267        if !self.settings.mutations.require_notes.unwrap_or(false) {
268            return None;
269        }
270        let has_note = note.map(|n| !n.trim().is_empty()).unwrap_or(false);
271        if has_note {
272            return None;
273        }
274        Some(WarningHint::NoteMissing {
275            tool: tool.to_string(),
276        })
277    }
278
279    /// Backend factory currently installed on this engine. Returned by
280    /// value because [`BackendFactory`] is a function pointer (`Copy`).
281    /// Used by [`crate::mem_management::create_mem`] to materialise
282    /// the backend for a freshly-registered mount; consumers that need
283    /// to instantiate a backend ad-hoc can call this directly.
284    pub fn backend_factory(&self) -> BackendFactory {
285        self.backend_factory
286    }
287
288    /// Git-branch ops bundle currently installed on this engine.
289    /// `None` on lean-flavor engines that don't see mem-repo
290    /// mounts. Returned by value because [`super::GitBranchOps`] is
291    /// `Copy`. `create_mem` reaches for
292    /// the bundle to drive `prune_residue` against an unmounted
293    /// gitdir when the `ForceOverwrite` recovery action is selected.
294    pub fn git_branch_ops(&self) -> Option<super::GitBranchOps> {
295        self.git_branch_ops
296    }
297
298    /// Convenience: look up a parsed entity by id. Returns `None` for
299    /// unknown ids, including stub entries created for unresolved
300    /// inline-link targets — callers that want to distinguish real
301    /// from stub branch on `Entity::stub`.
302    pub fn get_entity(&self, id: &EntityId) -> Option<&Entity> {
303        self.store.get(id)
304    }
305
306    /// The stored provenance anchors for `id`, read from its mem's
307    /// anchors sidecar. Empty for an entity with none, an unknown mem, or
308    /// a backend that does not persist anchors (a pre-anchor archive / any
309    /// sealed read-only mount). Additive read surface (E3a): the
310    /// resolution *model* lives in [`crate::anchor`]
311    /// ([`crate::anchor::resolve_anchor`] / [`crate::anchor::compose_entity_anchors`]);
312    /// the live per-anchor *state* (which requires observing the source
313    /// artifacts through the medium/preparation pipeline) is E3b's concern.
314    /// The anchors sidecar's parse error for `mem`, if it has one.
315    ///
316    /// The anchor readers below degrade a malformed sidecar to "no anchors",
317    /// which keeps a read path alive but makes a corrupt file
318    /// indistinguishable from an empty one. For a *reader* that is the right
319    /// trade; for anything that draws a conclusion from the absence of
320    /// anchors it is not — a fidelity pass would report every artifact
321    /// uncovered and call it a finding, when the truth is that it could not
322    /// read the file. Callers that need that distinction ask here first and
323    /// refuse. `None` means the sidecar is absent (legitimately no anchors
324    /// yet) or parses cleanly; the binding store draws the same distinction
325    /// with its quarantine path.
326    pub fn anchors_sidecar_error(&self, mem: &str) -> Option<String> {
327        let mount = self.mounts.iter().find(|m| m.mount.mem == mem)?;
328        // Three distinct ways to be unreadable, and only one of them is a
329        // parse error. `.ok().flatten()` would collapse the first into
330        // "absent", which is the very confusion this exists to prevent.
331        let bytes = match mount.backend.read_anchors_sidecar() {
332            // A backend error — permission denied, an IO fault. The file may
333            // be perfectly well-formed; we simply could not look at it.
334            Err(e) => return Some(format!("could not read the sidecar: {e}")),
335            // Genuinely absent: a mem with no anchors yet. Not an error.
336            Ok(None) => return None,
337            Ok(Some(b)) => b,
338        };
339        // An empty or whitespace-only file parses as "no anchors" by a
340        // deliberate tolerance in `from_bytes`. That tolerance is right for a
341        // reader and wrong here: a sidecar truncated to zero by an
342        // interrupted write is not a mem that never had anchors.
343        if bytes.iter().all(|b| b.is_ascii_whitespace()) {
344            return Some(
345                "the sidecar file is empty — an interrupted write leaves this state, and it is                  not the same as having no anchors; remove the file if the mem genuinely has none"
346                    .to_string(),
347            );
348        }
349        match crate::anchor::AnchorSidecar::from_bytes(&bytes) {
350            Ok(_) => None,
351            Err(e) => Some(e.to_string()),
352        }
353    }
354
355    pub fn entity_anchors(&self, id: &EntityId) -> Vec<crate::anchor::Anchor> {
356        let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == id.mem()) else {
357            return Vec::new();
358        };
359        let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
360            return Vec::new();
361        };
362        match crate::anchor::AnchorSidecar::from_bytes(&bytes) {
363            Ok(sc) => sc.get(id.as_ref()).to_vec(),
364            // Deliberate degrade-to-empty for the read path; a caller that
365            // must not confuse "unreadable" with "none" checks
366            // [`Self::anchors_sidecar_error`] first.
367            Err(_) => Vec::new(),
368        }
369    }
370
371    /// The stored anchors for `id`, each paired with its **live** resolution
372    /// state when the engine could observe the source artifact this pass.
373    ///
374    /// Additive over [`Self::entity_anchors`]: the durable data is unchanged;
375    /// `state` is the [`crate::anchor::resolve_anchor`] outcome against an
376    /// observation the engine produces here. A `path`-namespace anchor
377    /// (codebase / filesystem / git) is observed against the working tree at
378    /// the current HEAD; an `entity`-namespace anchor is observed against the
379    /// live graph by [`Self::observe_entity_anchor`]:
380    ///
381    /// - artifact absent ⇒ [`AnchorState::Orphaned`](crate::anchor::AnchorState::Orphaned);
382    /// - artifact present, non-hash class (`authored` / `informed-by`) ⇒
383    ///   [`Resolves`](crate::anchor::AnchorState::Resolves);
384    /// - artifact present, hash-bearing class (`anchored` / `derived`) ⇒ the
385    ///   prepared-content hash comparison decides:
386    ///   [`Resolves`](crate::anchor::AnchorState::Resolves) on a match,
387    ///   [`Drifted`](crate::anchor::AnchorState::Drifted) on a stable-medium
388    ///   mismatch, [`Recheck`](crate::anchor::AnchorState::Recheck) on an
389    ///   unstable medium or when a hash is unavailable on either side (a
390    ///   hash-less anchor, a `tree` grain, an unreadable artifact).
391    ///
392    /// For an `entity` grain the same table applies, read off the store
393    /// rather than the filesystem: the entity missing (or present only as a
394    /// stub) is `Orphaned`, and a hash-bearing class compares the canonical
395    /// rendered markdown.
396    ///
397    /// `state` is `None` (unobserved — never a fabricated state) when there is
398    /// no workspace root, when the grain is `url` (the engine never fetches;
399    /// a url anchor's hash is the registry's prepared form of the content
400    /// its observer supplied at write time), when an `entity` anchor's
401    /// source declares a preparation the registry does not know (the form
402    /// cannot be computed), or when an `entity` anchor's mem is **not
403    /// mounted** — an unmounted mem is not a mem of deleted entities, and
404    /// saying so would route a deletion proposal to prune.
405    pub fn entity_anchors_resolved(&self, id: &EntityId) -> Vec<ResolvedAnchor> {
406        let anchors = self.entity_anchors(id);
407        let source_roots = self.anchor_source_roots(id.mem());
408        anchors
409            .into_iter()
410            .map(|anchor| {
411                let observed = self.observe_anchor(&anchor, &source_roots);
412                let (state, observed_hash) = match observed {
413                    Some((state, hash)) => (Some(state), hash),
414                    None => (None, None),
415                };
416                ResolvedAnchor {
417                    anchor,
418                    state,
419                    observed_hash,
420                }
421            })
422            .collect()
423    }
424
425    /// Per-anchor observation — THE one resolution mechanism, shared by
426    /// binding-backed verify (`mem_anchors_resolved`, which the ingest
427    /// render/report/prune/findings paths consume), the per-entity read
428    /// (`entity_anchors_resolved`), and the standalone
429    /// `verify_mem_anchors` operation. Path-shaped grains
430    /// (`span`/`file`/`tree`) observe under the **decision-29 candidate
431    /// priority** (backlog-sweep plan 03a): the anchor's artifact path is
432    /// SOURCE-relative first — when its `source` name resolves through
433    /// `source_roots` to a declared pointer, the pointer-joined path is
434    /// authoritative — and workspace-relative only as the fallback, tried
435    /// when the source-join does not resolve. A path resolving under both
436    /// joins is decided by that priority, deterministically. An anchor
437    /// without a `source` (a hand-authored mem, a binding-less write)
438    /// observes workspace-relative exactly as before. A `url`
439    /// grain has no engine-side observation and returns `None` (the report
440    /// vocabulary's `unresolvable`) — the engine never fetches; its hash is
441    /// recorded from observation-supplied content at write time — as does a
442    /// workspace-root-less engine. An `entity`
443    /// grain is not a filesystem path but is not unobservable either — it
444    /// resolves against the live graph via
445    /// [`Self::observe_entity_anchor`], under the preparation its source
446    /// declares (touchpoint A of [`crate::preparation`]: the registry
447    /// decides the prepared form the artifact hashes as). This replaces the retired `single_path_medium_root` gate,
448    /// whose single-source assumption nulled every anchor of a mem with
449    /// zero or several bindings — the honest per-anchor answer supersedes
450    /// the all-or-nothing mem-level one.
451    fn observe_anchor(
452        &self,
453        anchor: &crate::anchor::Anchor,
454        source_roots: &std::collections::BTreeMap<String, AnchorSourceJoin>,
455    ) -> Option<(crate::anchor::AnchorState, Option<String>)> {
456        let join = anchor
457            .source
458            .as_deref()
459            .and_then(|name| source_roots.get(name));
460        // An `entity`-grain anchor points into a mem's graph, not a file
461        // tree. It has always returned `None` here — "unobserved this pass" —
462        // which meant it could never be drifted, never be orphaned, and always
463        // blocked prune. That is the bail the S1b pilot demonstrated: a
464        // deliberately stale anchor over a changed source entity went unflagged
465        // while the capability matrix claimed full parity.
466        if anchor.grain == crate::anchor::AnchorGrain::Entity {
467            return self.observe_entity_anchor(anchor, join.and_then(|j| j.preparation.as_deref()));
468        }
469        let root = self.workspace_root.as_deref()?;
470        observe_path_anchor(root, anchor, join)
471    }
472
473    /// Observe an `entity`-grain anchor against the live graph — the entity-
474    /// namespace counterpart of [`observe_path_anchor`], and the mechanism
475    /// that makes a graph-medium binding's drift real.
476    ///
477    /// The artifact is an entity id. Present/absent comes from the store, so
478    /// this works uniformly across backends — a git-branch mem has no
479    /// working-tree file to stat, which is exactly why observation cannot go
480    /// through the filesystem here. The compared form is the preparation
481    /// registry's **prepared form** for `preparation` (the anchor's source's
482    /// declared preparation, [`crate::preparation::entity_prepared_hash`]):
483    /// the **canonical rendered markdown** when the source declares none —
484    /// byte-for-byte today's form — or the load-bearing serialization under
485    /// `entity-load-bearing`; hashed with the same `prepared_content_hash`
486    /// the path arm uses, so an anchor's recorded hash means the same thing
487    /// in both namespaces. An identifier the registry does not know cannot
488    /// be prepared: the anchor is reported unobserved (`None`), never hashed
489    /// under a fabricated form.
490    ///
491    /// A stub is treated as absent: a stub is the engine's placeholder for an
492    /// unresolved reference, not the entity the anchor claims to pin. Scoring
493    /// it as present would let a dangling anchor resolve clean.
494    fn observe_entity_anchor(
495        &self,
496        anchor: &crate::anchor::Anchor,
497        preparation: Option<&str>,
498    ) -> Option<(crate::anchor::AnchorState, Option<String>)> {
499        let id = EntityId::canonical(&anchor.artifact);
500
501        // The mem the anchor points into must be MOUNTED before a store miss
502        // can mean anything. If it is not, every entity in it is missing from
503        // the store, and reporting `Orphaned` would say "the source deleted
504        // these" about entities sitting untouched on disk. `None` — genuinely
505        // unobserved — is the honest answer.
506        //
507        // This guard lives here, at the one observation site, and not at the
508        // callers. An earlier fix put it in `run_verify` alone; `prune` reaches
509        // anchor resolution by its own path (`mem_anchors_resolved`), so the
510        // sync brief went on proposing the deletion of every destination
511        // entity — a data-loss suggestion routed to the graph's only
512        // maintenance writer, from a mem merely being unmounted. A guard that
513        // protects one caller is not a guard on the behaviour.
514        if !self.mounts.iter().any(|m| m.mount.mem == id.mem()) {
515            return None;
516        }
517
518        let entity = self.store.get(&id).filter(|e| !e.stub);
519        let Some(entity) = entity else {
520            return Some((
521                crate::anchor::resolve_anchor(anchor, &crate::anchor::ArtifactObservation::Absent),
522                None,
523            ));
524        };
525        let current_hash = if anchor.class.is_hash_bearing() {
526            let type_def = self
527                .schema_for(id.mem())
528                .and_then(|schema| schema.get_type(&entity.entity_type));
529            Some(crate::preparation::entity_prepared_hash(
530                entity,
531                type_def.as_deref(),
532                preparation,
533            )?)
534        } else {
535            None
536        };
537        let observation = crate::anchor::ArtifactObservation::Present {
538            current_hash: current_hash.clone(),
539        };
540        Some((
541            crate::anchor::resolve_anchor(anchor, &observation),
542            current_hash,
543        ))
544    }
545
546    /// The `source name → join` map for `mem`'s bindings: the filesystem
547    /// roots that anchors written in the source dialect join onto (decision
548    /// 26: anchor artifact paths are source-relative first) and the
549    /// preparation each source declares (touchpoint A: what the registry
550    /// prepares the artifact as before hashing). Empty when the workspace
551    /// has no root, the pipeline store does not load, or `mem` has no
552    /// bindings — resolution then degrades to the workspace-relative dialect
553    /// alone with no preparation, which is exactly the hand-authored-mem
554    /// posture.
555    pub(crate) fn anchor_source_roots(
556        &self,
557        mem: &str,
558    ) -> std::collections::BTreeMap<String, AnchorSourceJoin> {
559        let mut roots = std::collections::BTreeMap::new();
560        let Some(root) = self.workspace_root.as_deref() else {
561            return roots;
562        };
563        let Ok(configs) = crate::pipeline_store::load_pipeline_configs(root) else {
564            return roots;
565        };
566        for record in configs.bindings.iter().filter(|r| r.mem == mem) {
567            for source in &record.config.sources {
568                roots
569                    .entry(source.name.clone())
570                    .or_insert_with(|| AnchorSourceJoin {
571                        pointer: source.pointer.clone(),
572                        preparation: source.preparation.clone(),
573                        source: source.clone(),
574                        deny_paths: record.config.deny_paths.clone(),
575                    });
576            }
577        }
578        roots
579    }
580
581    /// Reverse anchor lookup: every `(entity_id, anchor)` across all mems
582    /// whose anchor references `artifact_path`. This is the query the
583    /// rebuilt check-realization hook consumes — given the file an agent
584    /// just edited, which entities anchored to it. A `span`/`file`/`tree`
585    /// anchor references the path when its base path (locator suffix
586    /// `@commit` / `#span` stripped) equals the path, or — for a `tree`
587    /// grain — when the path lies under the tree. Path-shaped grains only;
588    /// `url` / `entity` anchors are matched by exact base equality.
589    pub fn anchors_referencing_artifact(
590        &self,
591        artifact_path: &str,
592    ) -> Vec<(EntityId, crate::anchor::Anchor)> {
593        let mut out = Vec::new();
594        for mount in &self.mounts {
595            let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
596                continue;
597            };
598            let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
599                continue;
600            };
601            // Source-dialect anchors (decision 26) reference the same
602            // artifact under its pointer-joined workspace form — match both.
603            let source_roots = self.anchor_source_roots(&mount.mount.mem);
604            for (eid, anchors) in &sc.entities {
605                for a in anchors {
606                    let joined = a
607                        .source
608                        .as_deref()
609                        .and_then(|name| source_roots.get(name))
610                        .map(|join| join_pointer(&join.pointer, anchor_base_path(&a.artifact)));
611                    if anchor_references_path(a, artifact_path)
612                        || joined.is_some_and(|j| {
613                            path_references(
614                                &j,
615                                a.grain == crate::anchor::AnchorGrain::Tree,
616                                artifact_path,
617                            )
618                        })
619                    {
620                        out.push((EntityId(eid.clone()), a.clone()));
621                    }
622                }
623            }
624        }
625        out
626    }
627
628    /// Every `(entity_id, resolved anchor)` in `mem`, read from its anchors
629    /// sidecar once and each paired with its **live** resolution state (the
630    /// same observation [`Self::entity_anchors_resolved`] produces per entity,
631    /// computed here mem-wide in a single sidecar read). Empty for an unknown
632    /// mem, a backend that persists no anchors, or a mem with none.
633    ///
634    /// Additive read surface: the durable data is unchanged; `state` is the
635    /// [`crate::anchor::resolve_anchor`] outcome against an observation the
636    /// engine produces — the working tree for a `path`-namespace anchor, the
637    /// live graph for an `entity` one — or `None` when unobserved (never
638    /// fabricated). The verify pipeline consumes it to adjudicate a mem's
639    /// anchors against the source; audit/health can reuse it.
640    pub fn mem_anchors_resolved(&self, mem: &str) -> Vec<(EntityId, ResolvedAnchor)> {
641        let Some(mount) = self.mounts.iter().find(|m| m.mount.mem == mem) else {
642            return Vec::new();
643        };
644        let Ok(Some(bytes)) = mount.backend.read_anchors_sidecar() else {
645            return Vec::new();
646        };
647        let Ok(sc) = crate::anchor::AnchorSidecar::from_bytes(&bytes) else {
648            return Vec::new();
649        };
650        let mut out = Vec::new();
651        let source_roots = self.anchor_source_roots(mem);
652        for (eid, anchors) in &sc.entities {
653            for anchor in anchors {
654                let observed = self.observe_anchor(anchor, &source_roots);
655                let (state, observed_hash) = match observed {
656                    Some((state, hash)) => (Some(state), hash),
657                    None => (None, None),
658                };
659                out.push((
660                    EntityId(eid.clone()),
661                    ResolvedAnchor {
662                        anchor: anchor.clone(),
663                        state,
664                        observed_hash,
665                    },
666                ));
667            }
668        }
669        out
670    }
671
672    /// Standalone anchor verification — "do my sources still say what I
673    /// recorded?" for one mem, regardless of how it was built. Walks the
674    /// mem's anchor sidecar through the shared per-anchor mechanism
675    /// ([`Self::observe_anchor`] via [`Self::mem_anchors_resolved`]) and
676    /// classifies every anchor into the report vocabulary: `resolved`
677    /// (source present, hash matches or non-hash class), `drifted`
678    /// (present, hash differs, stability `stable`), `recheck` (hash
679    /// differs under `unstable`, or a hash is missing on either side),
680    /// `unresolvable` (source absent, or a grain/medium the mechanism
681    /// does not reach — never fabricated into drift). Read-only on mem
682    /// content: pure sidecar read + filesystem observation, no commit on
683    /// any backend. A mem with no anchors returns an empty report.
684    pub fn verify_mem_anchors(&self, mem: &str) -> Result<MemAnchorVerification, EngineError> {
685        if !self.mem_router.is_visible(mem) {
686            return Err(self.unknown_mem_error(mem));
687        }
688        let mut report = MemAnchorVerification {
689            mem: mem.to_string(),
690            ..Default::default()
691        };
692        for (eid, resolved) in self.mem_anchors_resolved(mem) {
693            let state = match resolved.state {
694                Some(crate::anchor::AnchorState::Resolves) => {
695                    report.resolved += 1;
696                    "resolved"
697                }
698                Some(crate::anchor::AnchorState::Drifted) => {
699                    report.drifted += 1;
700                    "drifted"
701                }
702                Some(crate::anchor::AnchorState::Recheck) => {
703                    report.recheck += 1;
704                    "recheck"
705                }
706                Some(crate::anchor::AnchorState::Orphaned) | None => {
707                    report.unresolvable += 1;
708                    "unresolvable"
709                }
710            };
711            report.anchors.push(VerifiedAnchor {
712                entity_id: eid.to_string(),
713                artifact: resolved.anchor.artifact.clone(),
714                grain: resolved.anchor.grain.as_wire().to_string(),
715                class: resolved.anchor.class.as_wire().to_string(),
716                state: state.to_string(),
717                observed_hash: resolved.observed_hash,
718            });
719        }
720        Ok(report)
721    }
722
723    /// Mem names the engine knows about, in declaration order.
724    /// Cheap; useful for callers that need to enumerate before
725    /// dispatching by mem.
726    pub fn mem_names(&self) -> Vec<&str> {
727        self.mounts.iter().map(|m| m.mount.mem.as_str()).collect()
728    }
729
730    /// Derivation-staleness report for one mem (agent-trust plan 12):
731    /// every EXPLICIT edge whose rel-type the mem's schema declares
732    /// `derivation: true`, compared against its recorded baseline.
733    /// Baseline differs from the target's current hash → `stale`;
734    /// no baseline recorded (edge predates the declaration, or was
735    /// load-derived) → `unbaselined`, distinctly — never fabricated
736    /// as fresh or stale. Fresh edges are not reported. A mem whose
737    /// schema declares no derivation rel-types returns the empty
738    /// report; an unreadable sidecar reads as empty (every edge
739    /// unbaselined) rather than an error.
740    pub fn derivation_report(
741        &self,
742        mem: &str,
743    ) -> Result<Vec<crate::ops::health::DerivationFinding>, EngineError> {
744        if !self.mem_router.is_visible(mem) {
745            return Err(self.unknown_mem_error(mem));
746        }
747        let Some(schema) = self.schemas.get(mem) else {
748            return Ok(Vec::new());
749        };
750        let declared: std::collections::HashSet<&str> = schema
751            .manifest
752            .relationships
753            .definitions
754            .iter()
755            .filter(|d| d.derivation)
756            .map(|d| d.name.as_str())
757            .collect();
758        if declared.is_empty() {
759            return Ok(Vec::new());
760        }
761        let sidecar = self
762            .mounts
763            .iter()
764            .find(|m| m.mount.mem == mem)
765            .and_then(|m| {
766                m.backend
767                    .read_entity(Path::new(crate::derivation::DERIVATION_SIDECAR_PATH))
768                    .ok()
769                    .flatten()
770            })
771            .and_then(|bytes| crate::derivation::DerivationSidecar::from_bytes(&bytes).ok())
772            .unwrap_or_default();
773
774        let mut out = Vec::new();
775        let mut sources: Vec<&crate::entity::Entity> = self
776            .store
777            .all_entities()
778            .filter(|e| !e.stub && e.id.mem() == mem)
779            .collect();
780        sources.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
781        for entity in sources {
782            for edge in self.store.outgoing(&entity.id) {
783                if !declared.contains(edge.rel_type.as_str())
784                    || edge.source != crate::store::EdgeSource::Explicit
785                {
786                    continue;
787                }
788                let current = self
789                    .store
790                    .get(&edge.target)
791                    .map(|t| t.content_hash.clone())
792                    .unwrap_or_default();
793                match sidecar.get(entity.id.as_ref(), &edge.rel_type, edge.target.as_ref()) {
794                    None => out.push(crate::ops::health::DerivationFinding {
795                        source: entity.id.clone(),
796                        rel_type: edge.rel_type.clone(),
797                        target: edge.target.clone(),
798                        state: "unbaselined".to_string(),
799                        baseline: None,
800                        current,
801                    }),
802                    Some(baseline) if baseline != current => {
803                        out.push(crate::ops::health::DerivationFinding {
804                            source: entity.id.clone(),
805                            rel_type: edge.rel_type.clone(),
806                            target: edge.target.clone(),
807                            state: "stale".to_string(),
808                            baseline: Some(baseline.to_string()),
809                            current,
810                        })
811                    }
812                    Some(_) => {}
813                }
814            }
815        }
816        Ok(out)
817    }
818
819    /// Public-shape mount record for `mem`, or `None` for an unknown
820    /// mem.
821    ///
822    /// Surfaces the operator-facing
823    /// [`crate::workspace::Mount`] (mem name, schema pin, storage
824    /// reference, capability, lifecycle, cross_linkable) so MCP / CLI
825    /// handlers can branch on backend-specific shapes via
826    /// [`crate::workspace::MountStorage`] when they need accessors
827    /// that don't make sense on every backend (e.g. gitdir / branch
828    /// for `memstead_health { include_config: true }`'s git-class
829    /// payload). Backends that want the equivalent of full's
830    /// `engine.gitdir_for(mem)` match
831    /// `engine.mount(mem).map(|m| &m.storage)` against
832    /// `MountStorage::GitBranch { gitdir, branch }` and walk
833    /// directly — keeps the engine surface backend-neutral.
834    ///
835    /// Counterpart to [`Self::mem_names`] which lists every mount.
836    pub fn mount(&self, mem: &str) -> Option<&crate::workspace::Mount> {
837        self.mounts
838            .iter()
839            .find(|m| m.mount.mem == mem)
840            .map(|m| &m.mount)
841    }
842
843    /// Orphan count attributed to each mem's pinned schema, over the
844    /// given `orphan_ids` (the caller pre-filters them by any mem scope).
845    /// Lets a health surface show that ingest-mem isolates (orphans by
846    /// design) and code-mem debt land in different schema buckets rather
847    /// than one blended, misleading total. Mems with no settled pin
848    /// bucket under the empty string.
849    pub fn orphans_by_schema(
850        &self,
851        orphan_ids: &[EntityId],
852    ) -> std::collections::BTreeMap<String, usize> {
853        let mut by_schema = std::collections::BTreeMap::new();
854        for id in orphan_ids {
855            let schema = self
856                .store()
857                .get(id)
858                .and_then(|e| self.mount(&e.mem))
859                .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
860                .unwrap_or_default();
861            *by_schema.entry(schema).or_insert(0) += 1;
862        }
863        by_schema
864    }
865
866    /// Community count attributed to each schema across `mems`: a cluster
867    /// counts toward every schema whose mems it touches, so these figures
868    /// can sum above the global community count — the same "touches"
869    /// semantic as the mem-scoped count. Per-schema dedup keeps a cluster
870    /// touching two mems of one schema from being counted twice.
871    pub fn communities_by_schema(
872        &self,
873        mems: &[String],
874    ) -> std::collections::BTreeMap<String, usize> {
875        let louvain = self.communities();
876        let mut buckets: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
877            std::collections::BTreeMap::new();
878        for name in mems {
879            let schema = self
880                .mount(name)
881                .and_then(|m| m.schema.as_ref().map(|s| s.as_display()))
882                .unwrap_or_default();
883            let clusters = crate::graph::community::clusters_in_mem(self.store(), louvain, name);
884            buckets.entry(schema).or_default().extend(clusters);
885        }
886        buckets
887            .into_iter()
888            .map(|(schema, set)| (schema, set.len()))
889            .collect()
890    }
891
892    /// All mounts the engine knows about, in declaration order.
893    /// Counterpart to [`Self::mem_names`] when the caller needs
894    /// the full mount shape (e.g. to enumerate by storage variant).
895    pub fn mounts(&self) -> Vec<&crate::workspace::Mount> {
896        self.mounts.iter().map(|m| &m.mount).collect()
897    }
898
899    /// Names of mems whose mount declares
900    /// [`crate::workspace::MountCapability::Write`], in declaration
901    /// order. Convenience over `mounts().iter().filter(...).map(...)`
902    /// for handlers that gate by writable status (`memstead_health`,
903    /// `memstead_overview`'s mem roster, the lifecycle tools'
904    /// candidate list). Read-only mounts (archive backends) are
905    /// excluded.
906    pub fn writable_mem_names(&self) -> Vec<&str> {
907        self.mounts
908            .iter()
909            .filter(|m| m.mount.capability == MountCapability::Write)
910            .map(|m| m.mount.mem.as_str())
911            .collect()
912    }
913
914    /// The default writable mem — the target a mutation lands in when
915    /// it omits `mem`. `None` when no writable mem is mounted.
916    ///
917    /// Defined as the **first writable mount in declaration order**, i.e.
918    /// the seed / earliest-created writable mem. This is a *stable*
919    /// designation, not a function of the current name set: new mems
920    /// register via `register_writable_mem`, which pushes onto the end
921    /// of the mount list (and `mounts.json` preserves that order across
922    /// reboots), so creating an additional mem never moves the default
923    /// — even one whose name sorts ahead alphabetically. Deleting the
924    /// current default promotes the next-earliest writable mem; that is
925    /// the only thing that shifts it. Both the MCP `resolve_mem` and the
926    /// CLI's omitted-`--mem` path resolve through here so the two
927    /// surfaces always agree (the
928    /// pre-fix MCP path read `writable_mems().iter().next()` off an
929    /// unordered `HashSet`, which silently retargeted writes when a second
930    /// mem appeared).
931    pub fn default_writable_mem(&self) -> Option<&str> {
932        self.mounts
933            .iter()
934            .find(|m| m.mount.capability == MountCapability::Write)
935            .map(|m| m.mount.mem.as_str())
936    }
937
938    /// On-disk folder path for a folder-backed mount, or `None` for
939    /// any other backend (git-branch, archive) or unknown mem.
940    /// Convenience over `engine.mount(mem).map(|m| &m.storage)` +
941    /// matching on `MountStorage::Folder { path }`. Used by
942    /// handlers that need a filesystem path for a folder mem
943    /// (e.g. `memstead_health { include_config: true }`'s
944    /// `mems[].vcs.worktree` field for folder mounts).
945    pub fn folder_path_for_mem(&self, mem: &str) -> Option<&Path> {
946        match self.mount(mem).map(|m| &m.storage) {
947            Some(crate::workspace::MountStorage::Folder { path }) => Some(path.as_path()),
948            _ => None,
949        }
950    }
951
952    /// Runtime snapshot of writable / visible mems. Handlers that
953    /// need the writable roster (`memstead_health`'s `writable_mems` /
954    /// `read_mems`), per-mem origin tag (`include_config:
955    /// true`'s `mems[].origin`), or visibility check
956    /// (`memstead_overview`'s mem list, the lifecycle tools' collision
957    /// guard) consume the router here. Returned by reference — the
958    /// `Arc` is held on the engine; callers that need a clonable
959    /// handle can `Arc::clone` the engine's field directly when that
960    /// surface arrives.
961    pub fn mem_router(&self) -> &MemRouterSnapshot {
962        &self.mem_router
963    }
964
965    /// Resolve the gitdir for a writable mem. Used by `memstead_health
966    /// { include_config: true }` to surface per-mem `vcs.gitdir`
967    /// so outer-repo bookkeeping clients can `git -C <gitdir>` per
968    /// mem without hardcoding the layout.
969    ///
970    /// - `EngineError::UnknownMem` when the name does not resolve.
971    /// - `EngineError::Mem` when the mount's storage is not
972    ///   git-branch-backed (folder, archive — they have no gitdir).
973    pub fn gitdir_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
974        let m = self
975            .mount(mem_name)
976            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
977        match &m.storage {
978            MountStorage::GitBranch { gitdir, .. } => Ok(gitdir.clone()),
979            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
980                Err(EngineError::Mem(format!(
981                    "mem '{mem_name}' has no resolved gitdir"
982                )))
983            }
984        }
985    }
986
987    /// Resolve the worktree for a writable mem. Used by
988    /// `memstead_health { include_config: true }` to surface per-mem
989    /// `vcs.worktree`.
990    ///
991    /// - `EngineError::UnknownMem` when the name does not resolve.
992    /// - `EngineError::Mem` when the mount's backend has no
993    ///   worktree concept (git-branch with no working tree, archive).
994    ///
995    /// Folder mounts surface their on-disk path. Git-branch mounts
996    /// follow the `dir: Some(...)` composition pattern: when the
997    /// workspace root contains a folder named after the mem with a
998    /// `.memstead/config.json` marker, that folder is the worktree
999    /// (disk-shape composition). Otherwise — pure mem-repo-backed
1000    /// — return Err.
1001    pub fn worktree_for(&self, mem_name: &str) -> Result<PathBuf, EngineError> {
1002        let m = self
1003            .mount(mem_name)
1004            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1005        match &m.storage {
1006            MountStorage::Folder { path } => Ok(path.clone()),
1007            MountStorage::GitBranch { .. } => {
1008                if let Some(root) = self.workspace_root.as_deref() {
1009                    let candidate = root.join(mem_name);
1010                    if candidate
1011                        .join(crate::mem::MEM_META_DIR)
1012                        .join("config.json")
1013                        .is_file()
1014                    {
1015                        return Ok(candidate.canonicalize().unwrap_or(candidate));
1016                    }
1017                }
1018                Err(EngineError::Mem(format!(
1019                    "mem '{mem_name}' has no working tree (mem-repo-backed)"
1020                )))
1021            }
1022            MountStorage::Archive { .. } => Err(EngineError::Mem(format!(
1023                "mem '{mem_name}' is archive-backed and has no worktree"
1024            ))),
1025            MountStorage::InMemory => Err(EngineError::Mem(format!(
1026                "mem '{mem_name}' is in-memory and has no worktree"
1027            ))),
1028        }
1029    }
1030
1031    /// Per-mem `.memstead/config.json` payload, when available. Used
1032    /// by `memstead_health { include_config: true }` to surface the
1033    /// opaque `write_guidance` map and the catch-all `extra` fields
1034    /// per mem.
1035    ///
1036    /// Folder-backed mounts return `Some(&MemConfig)` when
1037    /// `<path>/.memstead/config.json` parsed cleanly at construction.
1038    /// Git-branch and archive backends return `None` until the
1039    /// read-from-storage-backend path lifts (the V1 unified engine
1040    /// loads configs only from folder layouts; the file lives
1041    /// inside the gitdir / archive for the other backends and
1042    /// needs a backend-level read primitive).
1043    ///
1044    /// Unknown mem names return `None` (no error variant — the
1045    /// accessor is intentionally lenient because memstead_health emits
1046    /// an empty detail block per missing config rather than
1047    /// aborting the call).
1048    pub fn mem_config_for(&self, mem: &str) -> Option<&memstead_schema::config::MemConfig> {
1049        self.mounts
1050            .iter()
1051            .find(|m| m.mount.mem == mem)
1052            .and_then(|m| m.mem_config.as_ref())
1053    }
1054
1055    /// The authoring-provenance payload an installed mem carries, read
1056    /// from the archive's `.memstead/provenance.json` at construction.
1057    /// `None` when the mem carries none (a pre-provenance archive, a
1058    /// runtime-created mem, or a backend that does not surface one) —
1059    /// the read path reports provenance as absent. Unknown mem names
1060    /// return `None`.
1061    pub fn archive_provenance_for(&self, mem: &str) -> Option<&memstead_schema::ArchiveProvenance> {
1062        self.mounts
1063            .iter()
1064            .find(|m| m.mount.mem == mem)
1065            .and_then(|m| m.archive_provenance.as_ref())
1066    }
1067
1068    /// Iterate `(mem_name, &MemConfig)` for every mount whose
1069    /// mem-config payload loaded at construction. Used by callers
1070    /// that walk every writable mount's config (`memstead health`'s
1071    /// per-mem dump, the workspace-dump CLI). The yielded `&str` is
1072    /// the authoritative mem leaf from the mount record.
1073    ///
1074    /// Folder-backed mounts yield when their `.memstead/config.json`
1075    /// parsed cleanly. Git-branch and archive backends are silent in
1076    /// V1 (the same deferred-read-from-storage gap that
1077    /// [`Self::mem_config_for`] documents).
1078    pub fn mem_configs_named(
1079        &self,
1080    ) -> impl Iterator<Item = (&str, &memstead_schema::config::MemConfig)> {
1081        self.mounts
1082            .iter()
1083            .filter_map(|m| m.mem_config.as_ref().map(|c| (m.mount.mem.as_str(), c)))
1084    }
1085
1086    /// Resolved `Arc<Schema>` for a writable mem by name. `None`
1087    /// when the name is not a registered mount.
1088    ///
1089    /// Cheap — `Arc::clone` over the per-mem schema map. Resolved
1090    /// schemas are stored in `HashMap<String, Arc<Schema>>` so the
1091    /// lookup is a single hash hit + clone.
1092    pub fn schema_for(&self, mem: &str) -> Option<std::sync::Arc<memstead_schema::Schema>> {
1093        self.schemas.get(mem).cloned()
1094    }
1095
1096    /// Cached current branch-tip cursor (typically a 40-char hex
1097    /// SHA for git-branch backends; `None` for fresh mems or
1098    /// backends that don't track a head — folder / archive).
1099    ///
1100    /// The value is the per-mount `last_known_head`, seeded at
1101    /// construction by `backend.current_head()` and refreshed by
1102    /// [`Self::reload_if_stale`] / mutation paths after a
1103    /// successful commit.
1104    ///
1105    /// - `EngineError::UnknownMem` when the name does not resolve.
1106    pub fn mem_head_sha(&self, mem_name: &str) -> Result<Option<String>, EngineError> {
1107        let m = self
1108            .mounts
1109            .iter()
1110            .find(|m| m.mount.mem == mem_name)
1111            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1112        Ok(m.last_known_head.clone())
1113    }
1114
1115    /// Whether a sibling writer has advanced this mem's backend past
1116    /// the engine's cached `last_known_head` — a read-only drift probe
1117    /// that does **not** reload (unlike [`Self::reload_if_stale`]). One
1118    /// `backend.current_head()` read compared against the cached cursor;
1119    /// the comparison clears once the engine re-reads (a `reload` /
1120    /// `reload_if_stale` refreshes `last_known_head` to the live tip).
1121    ///
1122    /// Only git-branch backends track a head, so folder / archive /
1123    /// in-memory mounts always report `false`. A backend that errors on
1124    /// the probe (transient refdb hiccup) reports `false` rather than
1125    /// surfacing the error — drift is advisory, and the next real
1126    /// operation's reload path is the authoritative sync.
1127    ///
1128    /// - `EngineError::UnknownMem` when the name does not resolve.
1129    pub fn mem_drifted(&self, mem_name: &str) -> Result<bool, EngineError> {
1130        let m = self
1131            .mounts
1132            .iter()
1133            .find(|m| m.mount.mem == mem_name)
1134            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
1135        let live = m.backend.current_head().ok().flatten();
1136        Ok(live != m.last_known_head)
1137    }
1138
1139    /// Workspace root the engine booted from, when one is known.
1140    /// `None` for engines built directly from a mount list (tests,
1141    /// ad-hoc consumers). Set by [`Self::from_workspace_root`] and
1142    /// the full counterpart.
1143    pub fn workspace_root(&self) -> Option<&Path> {
1144        self.workspace_root.as_deref()
1145    }
1146
1147    /// Typed warnings surfaced during mem load — drift findings
1148    /// the loader pipeline collects per entity. Empty for V1; the
1149    /// accessor surfaces them so handlers can merge into health
1150    /// summaries uniformly.
1151    pub fn load_warnings(&self) -> &[WarningHint] {
1152        &self.load_warnings
1153    }
1154
1155    /// The quarantine roster: mems that failed their mem-level boot
1156    /// step and serve nothing until repaired + reloaded. Empty on a
1157    /// fully healthy workspace. Surfaced on overview and health.
1158    pub fn quarantined_mems(&self) -> &[crate::engine::QuarantinedMem] {
1159        &self.quarantined
1160    }
1161
1162    /// The quarantine entry for `mem`, when it is quarantined.
1163    pub fn quarantine_reason(&self, mem: &str) -> Option<&crate::engine::QuarantinedMem> {
1164        self.quarantined.iter().find(|q| q.mount.mem == mem)
1165    }
1166
1167    /// Mems whose lazy entity load is still DEFERRED — on the mount
1168    /// roster with a resolved schema pin, but with no entities in the
1169    /// store yet. Read surfaces that render per-mem counts or
1170    /// distributions consult this: a count over a deferred mem's slice
1171    /// of the store is a count over nothing, and rendering it as a
1172    /// bare zero is the silent absence the lazy-mount contract forbids.
1173    /// Either trigger the load ([`Self::ensure_mems_loaded`]) or render
1174    /// the load state explicitly.
1175    pub fn deferred_mems(&self) -> Vec<&str> {
1176        self.mounts
1177            .iter()
1178            .filter(|m| m.deferred)
1179            .map(|m| m.mount.mem.as_str())
1180            .collect()
1181    }
1182
1183    /// Whether `mem` is a lazy mount whose entity load has not run yet.
1184    pub fn mem_is_deferred(&self, mem: &str) -> bool {
1185        self.mounts.iter().any(|m| m.deferred && m.mount.mem == mem)
1186    }
1187
1188    /// The typed error for a mem name that did not resolve to a
1189    /// serving mount: `MEM_QUARANTINED` (carrying the underlying boot
1190    /// failure and its repair command) when the mem is on the
1191    /// quarantine roster, `UNKNOWN_MEM` otherwise. Every lookup site
1192    /// that fails to find a mem routes here so a quarantined mem is
1193    /// never misreported as unknown — honest absence, with the reason.
1194    pub fn unknown_mem_error(&self, mem: &str) -> EngineError {
1195        match self.quarantine_reason(mem) {
1196            Some(q) => EngineError::MemQuarantined {
1197                mem: mem.to_string(),
1198                reason_code: q.reason_code.clone(),
1199                reason_message: q.reason_message.clone(),
1200            },
1201            None => EngineError::UnknownMem(mem.to_string()),
1202        }
1203    }
1204
1205    /// The workspace-level boot diagnosis a diagnostic-shell engine
1206    /// carries (`None` on ordinarily booted engines).
1207    pub fn boot_diagnosis(&self) -> Option<(&str, &str)> {
1208        self.boot_diagnosis
1209            .as_ref()
1210            .map(|(c, m)| (c.as_str(), m.as_str()))
1211    }
1212
1213    /// Build a mem-less diagnostic-shell engine for a workspace whose
1214    /// boot failed at the WORKSPACE level (nothing loadable — e.g. an
1215    /// unparseable store). It serves no mems and no entities; its one
1216    /// job is answering overview/health with the typed boot diagnosis
1217    /// so a session can always ask WHY the graph is gone — the MCP
1218    /// server serves this instead of exiting into `-32000 Connection
1219    /// closed` (degrade, never disappear).
1220    pub fn diagnostic_shell(reason_code: String, reason_message: String) -> Engine {
1221        let mut engine =
1222            Engine::from_mounts(Vec::new()).expect("an empty mount list always constructs");
1223        engine.boot_diagnosis = Some((reason_code, reason_message));
1224        engine
1225    }
1226
1227    /// Append boot-path quarantine entries recorded outside
1228    /// `from_mounts_inner` (backend-instantiation failures happen
1229    /// before the mount list reaches the engine constructor). Boot
1230    /// paths only — quarantine is a boot judgment, never a runtime
1231    /// mutation.
1232    pub fn extend_quarantine(&mut self, entries: Vec<crate::engine::QuarantinedMem>) {
1233        self.quarantined.extend(entries);
1234    }
1235
1236    // ---------------------------------------------------------------
1237    // Read-side delegates onto the kernel ops/graph functions.
1238    //
1239    // The mem-router engine exposed each of these directly so the
1240    // MCP layer could call them without reaching into the store. The
1241    // unified engine mirrors that surface so the MCP migration is a
1242    // straight rename rather than a re-architecture.
1243    //
1244    // Multi-mem cache strategy: per-mem community detection and
1245    // per-mem search indexes are unnecessary at this layer — the
1246    // engine-wide store already carries every mount's edges; Louvain
1247    // and tantivy run once across the union. `mem_schemas` for
1248    // health/search is the engine's existing `schemas` field as-is.
1249    // ---------------------------------------------------------------
1250
1251    /// Lazy community-detection cache. First call runs Louvain
1252    /// against the current store using one pinned schema for
1253    /// `community.{resolution, seed}` and the per-rel weights.
1254    /// Subsequent calls return the cached result. Mutations invalidate
1255    /// the cache via [`Self::invalidate_communities`].
1256    ///
1257    /// One detection run per engine. The partition is workspace-global,
1258    /// so it needs a single source for the Louvain parameters; that
1259    /// source is the schema of the lexicographically-first mem name —
1260    /// a stable key, so the partition is deterministic across processes
1261    /// even when mounts pin heterogeneous schemas. For a single-schema
1262    /// workspace every mem's schema is identical, so the choice of
1263    /// key is immaterial there.
1264    pub fn communities(&self) -> &LouvainOutput {
1265        // Generation sanity: a stored memo must match the live store
1266        // generation — the mutation hooks clear stale memos before any
1267        // read can arrive here. A mismatch would mean a mutation path
1268        // skipped its invalidation call (the exact silent-staleness
1269        // bug the generation exists to catch).
1270        if let Some((memo_key, _)) = self.community_memo.get() {
1271            debug_assert_eq!(
1272                *memo_key,
1273                self.derived_key(),
1274                "community memo key lags the engine — a mutation path missed invalidate_communities"
1275            );
1276        }
1277        &self
1278            .community_memo
1279            .get_or_init(|| (self.derived_key(), self.compute_communities()))
1280            .1
1281    }
1282
1283    /// The current validity key for derived-structure memos: store
1284    /// generation plus schemas epoch (see [`super::DerivedKey`]).
1285    pub fn derived_key(&self) -> super::DerivedKey {
1286        super::DerivedKey {
1287            store_generation: self.store.generation(),
1288            schemas_epoch: self.schemas_epoch,
1289        }
1290    }
1291
1292    fn compute_communities(&self) -> LouvainOutput {
1293        {
1294            // Select the parameter schema by a stable key (smallest
1295            // mem name) rather than unordered-map iteration, so the
1296            // partition does not vary between processes. Fall back to
1297            // the builtin default for the empty-mounts case (caller
1298            // still gets a valid empty Louvain result against an empty
1299            // store).
1300            let schema = self
1301                .schemas
1302                .iter()
1303                .min_by(|a, b| a.0.cmp(b.0))
1304                .map(|(_, s)| s.clone())
1305                .unwrap_or_else(Schema::builtin_default);
1306            let manifest = &schema.manifest;
1307            let resolution = manifest.community.resolution;
1308            let seed = manifest.community.seed;
1309            let schema_for_weights = schema.clone();
1310            detect_communities(&self.store, resolution, seed, move |rel_type| {
1311                schema_for_weights
1312                    .manifest
1313                    .relationships
1314                    .definitions
1315                    .iter()
1316                    .find(|d| d.name == rel_type)
1317                    .map(|d| d.default_weight as f64)
1318                    .unwrap_or(1.0)
1319            })
1320        }
1321    }
1322
1323    /// Drop the cached community detection result and the grounded
1324    /// labelling memo — unless the store still sits at the generation
1325    /// each memo was computed from (flywheel W8/01). The keep case is
1326    /// exactly the batch-rollback path: the restored snapshot restored
1327    /// the generation with it, so the memo describes the live state
1328    /// and recomputing it would be pure waste. Every real mutation
1329    /// bumps the generation first, so those clears behave as before.
1330    /// Coupling the labelling reset here means every site that already
1331    /// invalidates communities (all mutation paths, drift reload,
1332    /// quarantine attach/detach, apply-commit) invalidates the
1333    /// labelling too, so a stale label can never outlive the state
1334    /// change that moved it.
1335    pub fn invalidate_communities(&mut self) {
1336        let key = self.derived_key();
1337        if !matches!(self.community_memo.get(), Some((k, _)) if *k == key) {
1338            self.community_memo = OnceCell::new();
1339        }
1340        if !matches!(self.labelling_memo.get(), Some((k, _)) if *k == key) {
1341            self.labelling_memo = OnceCell::new();
1342        }
1343    }
1344
1345    /// The grounded labelling of one mem — `None` when its pinned
1346    /// schema declares no `relationships.labelling`. Computed on
1347    /// first access for every declaring mem, memoised until the next
1348    /// invalidation; generation-keyed like `community_memo`.
1349    pub fn mem_labelling(&self, mem: &str) -> Option<&crate::ops::labelling::MemLabelling> {
1350        // Generation sanity, same contract as `communities()`: a
1351        // stored memo must match the live derived key — a mismatch
1352        // means a mutation path missed invalidate_communities.
1353        if let Some((memo_key, _)) = self.labelling_memo.get() {
1354            debug_assert_eq!(
1355                *memo_key,
1356                self.derived_key(),
1357                "labelling memo key lags the engine — a mutation path missed invalidate_communities"
1358            );
1359        }
1360        let (_, map) = self.labelling_memo.get_or_init(|| {
1361            let mut out = std::collections::HashMap::new();
1362            for (mem_name, schema) in &self.schemas {
1363                if let Some(lab) = crate::ops::labelling::labelling_of(schema) {
1364                    out.insert(
1365                        mem_name.clone(),
1366                        crate::ops::labelling::compute_mem_labelling(
1367                            &self.store,
1368                            mem_name,
1369                            &lab.attack,
1370                        ),
1371                    );
1372                }
1373            }
1374            (self.derived_key(), out)
1375        });
1376        map.get(mem)
1377    }
1378
1379    /// One entity's served labelling view — `None` when the entity is
1380    /// a stub or its mem's schema declares no labelling; serving
1381    /// surfaces then keep their byte-identical payloads. The shape
1382    /// block is present exactly when the declaration carries a
1383    /// `support` walk.
1384    pub fn computed_labelling(
1385        &self,
1386        entity: &Entity,
1387    ) -> Option<crate::ops::labelling::LabellingView> {
1388        use crate::ops::labelling::{Label, compute_shape, labelling_of};
1389        if entity.stub {
1390            return None;
1391        }
1392        let schema = self.schemas.get(entity.mem.as_str())?;
1393        let lab = labelling_of(schema)?;
1394        let mem_lab = self.mem_labelling(entity.mem.as_str())?;
1395        let label = *mem_lab.labels.get(entity.id.0.as_str())?;
1396        let defeated_by = if label == Label::Defeated {
1397            mem_lab.accepted_attackers_of(entity.id.0.as_str())
1398        } else {
1399            Vec::new()
1400        };
1401        let undecided_by = if label == Label::Undecided {
1402            mem_lab.undecided_attackers_of(entity.id.0.as_str())
1403        } else {
1404            Vec::new()
1405        };
1406        let shape = lab.support.as_ref().map(|walk| {
1407            let label_of = |id: &EntityId| -> Option<Label> {
1408                self.mem_labelling(id.mem())
1409                    .and_then(|ml| ml.labels.get(id.0.as_str()).copied())
1410            };
1411            compute_shape(&self.store, &entity.id, walk, &label_of)
1412        });
1413        Some(crate::ops::labelling::LabellingView {
1414            label,
1415            defeated_by,
1416            undecided_by,
1417            shape,
1418        })
1419    }
1420
1421    /// The `labelling` health axis payload — per declaring mem:
1422    /// counts per label, the defeated list with its accepted
1423    /// attackers, the undecided list with its open attacker set, and
1424    /// the excluded cross-mem attack-edge count. One composer shared
1425    /// by the CLI health command and both MCP flavours.
1426    pub fn health_labelling_axis(&self, mem_filter: Option<&str>) -> serde_json::Value {
1427        use crate::ops::labelling::Label;
1428        let mut mems = serde_json::Map::new();
1429        let mut mem_names: Vec<&String> = self.schemas.keys().collect();
1430        mem_names.sort();
1431        for mem in mem_names {
1432            if let Some(v) = mem_filter
1433                && mem != v
1434            {
1435                continue;
1436            }
1437            let Some(ml) = self.mem_labelling(mem) else {
1438                continue;
1439            };
1440            let mut accepted = 0usize;
1441            let mut defeated: Vec<serde_json::Value> = Vec::new();
1442            let mut undecided: Vec<serde_json::Value> = Vec::new();
1443            for (id, label) in &ml.labels {
1444                match label {
1445                    Label::Accepted => accepted += 1,
1446                    Label::Defeated => defeated.push(serde_json::json!({
1447                        "id": id,
1448                        "defeated_by": ml.accepted_attackers_of(id),
1449                    })),
1450                    Label::Undecided => undecided.push(serde_json::json!({
1451                        "id": id,
1452                        "undecided_by": ml.undecided_attackers_of(id),
1453                    })),
1454                }
1455            }
1456            mems.insert(
1457                mem.clone(),
1458                serde_json::json!({
1459                    "counts": {
1460                        "accepted": accepted,
1461                        "defeated": defeated.len(),
1462                        "undecided": undecided.len(),
1463                    },
1464                    "defeated": defeated,
1465                    "undecided": undecided,
1466                    "cross_mem_edges_excluded": ml.cross_mem_edges_excluded,
1467                }),
1468            );
1469        }
1470        serde_json::Value::Object(mems)
1471    }
1472
1473    /// Real entities with no incoming or outgoing edges — leaf-declared
1474    /// types exempt (their edge-less entities are terminal by
1475    /// construction; see [`Self::leaf_population`]).
1476    pub fn orphans(&self) -> Vec<EntityId> {
1477        crate::graph::query::find_orphans_with_schemas(&self.store, &self.schemas)
1478    }
1479
1480    /// Count of real entities per leaf-declared type, keyed
1481    /// `<schema_ref>:<type>` — the visible population the orphan
1482    /// exemption covers.
1483    pub fn leaf_population(&self) -> std::collections::BTreeMap<String, usize> {
1484        crate::graph::query::leaf_population(&self.store, &self.schemas)
1485    }
1486
1487    /// Stub entities with their referencer ids.
1488    pub fn stubs(&self) -> Vec<(EntityId, Vec<EntityId>)> {
1489        crate::graph::query::find_stubs(&self.store)
1490    }
1491
1492    /// Top `limit` entities by total degree.
1493    pub fn most_connected(&self, limit: usize) -> Vec<crate::graph::query::Connectivity> {
1494        crate::graph::query::most_connected(&self.store, limit)
1495    }
1496
1497    /// Entities whose type's `required_outgoing` blocks are not yet
1498    /// satisfied. `mem_filter = None` scans every mem; `Some(v)`
1499    /// scans only that mem.
1500    pub fn missing_required_outgoing(
1501        &self,
1502        mem_filter: Option<&str>,
1503    ) -> Vec<crate::ops::health::MissingRequiredOutgoingReport> {
1504        crate::ops::health::collect_missing_required_outgoing(
1505            &self.store,
1506            mem_filter,
1507            &self.schemas,
1508        )
1509    }
1510
1511    /// Standing violations of declared `constraints` (the health
1512    /// `constraints` include) — every non-stub entity whose type
1513    /// declares constraints its current state violates, in
1514    /// deterministic `(mem, id)` order.
1515    pub fn constraint_findings(
1516        &self,
1517        mem_filter: Option<&str>,
1518    ) -> Vec<crate::ops::health::ConstraintFindingReport> {
1519        crate::ops::health::collect_constraint_findings(&self.store, mem_filter, &self.schemas)
1520    }
1521
1522    /// The evaluated aggregate signals for one entity — `None` when
1523    /// the mem has no schema, the type is unknown or a stub, or the
1524    /// type declares no signals; serving surfaces then keep their
1525    /// byte-identical payloads.
1526    pub fn computed_signals(
1527        &self,
1528        entity: &Entity,
1529    ) -> Option<Vec<crate::ops::signals::ComputedSignal>> {
1530        if entity.stub {
1531            return None;
1532        }
1533        let schema = self.schemas.get(entity.mem.as_str())?;
1534        let td = schema.types.get(entity.entity_type.as_str())?;
1535        if td.signals.is_empty() {
1536            return None;
1537        }
1538        Some(crate::ops::signals::compute_signals(
1539            &self.store,
1540            td,
1541            &entity.id,
1542        ))
1543    }
1544
1545    /// Every entity carrying at least one signal above `none` — the
1546    /// include-gated `signals` health axis.
1547    pub fn signal_reports(
1548        &self,
1549        mem_filter: Option<&str>,
1550    ) -> Vec<crate::ops::health::SignalReport> {
1551        crate::ops::health::collect_signal_reports(&self.store, mem_filter, &self.schemas)
1552    }
1553
1554    /// The `signals` health axis payload — the entity roster plus
1555    /// per-level counts. One composer shared by the CLI health
1556    /// command and both MCP flavours so the axis cannot drift
1557    /// between surfaces.
1558    pub fn health_signals_axis(&self, mem_filter: Option<&str>) -> serde_json::Value {
1559        use memstead_schema::SignalLevel;
1560        let reports = self.signal_reports(mem_filter);
1561        let mut notice = 0usize;
1562        let mut warn = 0usize;
1563        for r in &reports {
1564            for s in &r.signals {
1565                match s.level {
1566                    Some(SignalLevel::Notice) => notice += 1,
1567                    Some(SignalLevel::Warn) => warn += 1,
1568                    None => {}
1569                }
1570            }
1571        }
1572        serde_json::json!({
1573            "entities": reports,
1574            "counts": { "notice": notice, "warn": warn },
1575        })
1576    }
1577
1578    /// Defective section-format declarations the loaded schemas carry
1579    /// (lenient boot recorded them; install would have refused).
1580    pub fn schema_format_defects(&self) -> Vec<crate::ops::health::SchemaFormatDefect> {
1581        crate::ops::health::collect_schema_format_defects(&self.schemas)
1582    }
1583
1584    /// Conformance-axis integrity findings for one mem — which
1585    /// entities a write would refuse under the effective schema, and
1586    /// why. `target_schema = None` lints against the mem's current
1587    /// pin; `Some(ref)` lints against that schema instead (resolved
1588    /// among mem-pinned, workspace, and built-in schemas).
1589    pub fn conformance_findings(
1590        &self,
1591        mem: &str,
1592        target_schema: Option<&memstead_schema::SchemaRef>,
1593    ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
1594        let pinned = self
1595            .schemas
1596            .get(mem)
1597            .ok_or_else(|| self.unknown_mem_error(mem))?;
1598        let effective: Arc<Schema> = match target_schema {
1599            None => pinned.clone(),
1600            Some(target) => self.resolve_schema_by_ref(target).ok_or_else(|| {
1601                let consulted: Vec<_> = self
1602                    .workspace_schemas
1603                    .iter()
1604                    .chain(self.builtin_schemas.iter())
1605                    .cloned()
1606                    .collect();
1607                EngineError::SchemaNotFound {
1608                    mem: mem.to_string(),
1609                    pin: target.as_display(),
1610                    sources: crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
1611                        &target.name,
1612                        &target.version,
1613                        &consulted,
1614                    ),
1615                    install_hint: None,
1616                }
1617                .with_schema_install_probe(self.workspace_root())
1618            })?,
1619        };
1620        Ok(crate::ops::integrity::conformance_findings(
1621            &self.store,
1622            mem,
1623            &effective,
1624            &self.schemas,
1625        ))
1626    }
1627
1628    /// Resolve an exact `name@version` ref against every schema this
1629    /// engine can see: mem-pinned, workspace-authored, built-in.
1630    /// `None` when no loaded schema matches.
1631    pub(crate) fn resolve_schema_by_ref(
1632        &self,
1633        target: &memstead_schema::SchemaRef,
1634    ) -> Option<Arc<Schema>> {
1635        self.schemas
1636            .values()
1637            .chain(self.workspace_schemas.iter())
1638            .chain(self.builtin_schemas.iter())
1639            .find(|s| {
1640                let (name, version) = s.id();
1641                name == target.name && version == target.version
1642            })
1643            .cloned()
1644    }
1645
1646    /// The mem's `Mount.schema` expectation assertion, when set.
1647    /// `None` for unknown mems *and* for mems whose mount carries no
1648    /// assertion (the authoritative pin then lives in the backend
1649    /// config; the resolved active schema, not this, is the effective pin).
1650    pub fn schema_pin(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
1651        self.mounts
1652            .iter()
1653            .find(|m| m.mount.mem == mem)
1654            .and_then(|m| m.mount.schema.clone())
1655    }
1656
1657    /// The mem's in-flight migration target, when dual-pin state is
1658    /// active. `None` for settled or unknown mems.
1659    pub fn migration_target(&self, mem: &str) -> Option<memstead_schema::SchemaRef> {
1660        self.mounts
1661            .iter()
1662            .find(|m| m.mount.mem == mem)
1663            .and_then(|m| m.mount.migration_target.clone())
1664    }
1665
1666    /// Consistency-axis integrity findings for one mem — the
1667    /// pre-existing graph-coherence categories (dangling links, stubs)
1668    /// projected into the `{ id, axis, code, detail }` finding shape.
1669    pub fn consistency_findings(
1670        &self,
1671        mem: &str,
1672    ) -> Result<Vec<crate::ops::integrity::IntegrityFinding>, EngineError> {
1673        if !self.schemas.contains_key(mem) {
1674            return Err(self.unknown_mem_error(mem));
1675        }
1676        Ok(crate::ops::integrity::consistency_findings(
1677            &self.store,
1678            mem,
1679        ))
1680    }
1681
1682    /// Engine-wide health summary across every mount.
1683    pub fn health(&self) -> crate::ops::HealthSummary {
1684        self.health_inner(None)
1685    }
1686
1687    /// Health summary scoped to one visible mem. The scans and
1688    /// structural counts narrow to that mem; workspace-level facts
1689    /// (quarantine roster, boot diagnosis, workspace-scoped warnings)
1690    /// stay global — an agent scoping to one mem must still see them.
1691    /// A name that is quarantined or not on the visible roster refuses
1692    /// `UNKNOWN_MEM` — the same gate `search` applies to its `mem`
1693    /// filter, so "no such mem" and "healthy mem, nothing to report"
1694    /// can never be confused. `None` is the engine-wide sweep.
1695    pub fn health_scoped(
1696        &self,
1697        mem: Option<&str>,
1698    ) -> Result<crate::ops::HealthSummary, crate::EngineError> {
1699        if let Some(name) = mem
1700            && (self.quarantine_reason(name).is_some() || !self.mem_router.is_visible(name))
1701        {
1702            return Err(self.unknown_mem_error(name));
1703        }
1704        Ok(self.health_inner(mem))
1705    }
1706
1707    fn health_inner(&self, mem: Option<&str>) -> crate::ops::HealthSummary {
1708        let fallback = engine_fallback_type();
1709        let mut summary =
1710            crate::ops::health::compute_health(&self.store, fallback.as_ref(), &self.schemas, mem);
1711        // Merge in load-time drift warnings so every caller of
1712        // Engine::health — MCP handler, Swift FFI, direct CLI —
1713        // sees the SuspiciousNestedPrefix / DuplicateSectionHeading
1714        // findings without reaching into private engine state. The
1715        // MCP handler further appends request-scoped warnings on
1716        // top. Mirrors full's merge.
1717        if !self.load_warnings.is_empty() {
1718            let mut merged = self.load_warnings.clone();
1719            merged.append(&mut summary.warnings);
1720            summary.warnings = merged;
1721        }
1722        // Quarantine roster — a boot-honesty fact, present whenever
1723        // non-empty, never behind an include gate. Empty (and omitted
1724        // from the wire) on a healthy workspace.
1725        summary.quarantined = self
1726            .quarantined
1727            .iter()
1728            .map(|q| crate::ops::QuarantinedMemReport {
1729                mem: q.mount.mem.clone(),
1730                reason_code: q.reason_code.clone(),
1731                reason_message: q.reason_message.clone(),
1732            })
1733            .collect();
1734        // Per-file load failures ride the report unconditionally, like
1735        // the quarantine roster — each entry's message names the remedy
1736        // (the merge-conflict refusal names `memstead conflicts
1737        // resolve`), and a remedy only a library accessor carries is a
1738        // capability nobody finds at the moment it is needed.
1739        summary.load_errors = self
1740            .load_errors
1741            .iter()
1742            .map(|(path, msg)| crate::ops::LoadErrorReport {
1743                file: path.display().to_string(),
1744                error: msg.clone(),
1745            })
1746            .collect();
1747        summary.boot_diagnosis = self
1748            .boot_diagnosis
1749            .as_ref()
1750            .map(|(code, message)| serde_json::json!({ "code": code, "message": message }));
1751        // Surface OUTER_REPO_NOT_IGNORING_MEM_REPO when the
1752        // workspace is embedded inside a git repository whose
1753        // .gitignore does not list `mem-repo/`. Skipped when
1754        // workspace_root is unset (engine built ad-hoc from a mount
1755        // list).
1756        if let Some(root) = self.workspace_root.as_deref()
1757            && let Some(outer) = crate::workspace_root::find_enclosing_git_repo(root)
1758            && !crate::workspace_root::outer_repo_ignores_mem_repo(&outer, root)
1759        {
1760            summary
1761                .warnings
1762                .push(WarningHint::OuterRepoNotIgnoringMemRepo {
1763                    outer_repo_root: outer.display().to_string(),
1764                    workspace_root: root.display().to_string(),
1765                });
1766        }
1767        // Authoring-drift axis: for every pinned schema whose sealed
1768        // copy carries an install-provenance stamp, report a MISSING
1769        // authoring package (stamped path gone) or a DIVERGED one
1770        // (present but no longer parsed-equivalent to the seal).
1771        // Unstamped schemas — sealed pre-stamp, built-ins, archive
1772        // installs — produce no finding. Read-only on both copies.
1773        summary.warnings.extend(self.authoring_drift_findings());
1774        // Rot axis for the pins the drift axis skips: an UNSTAMPED
1775        // sealed package whose content no longer passes current-
1776        // language authoring validation gets its own low-tier hint —
1777        // the holding runs fine on the tolerant seal, but the package
1778        // (and the unlocatable authoring source it came from) is no
1779        // longer installable, and nothing else would say so before the
1780        // next install attempt. A parsing unstamped package stays
1781        // silent; stamped pins are the drift axis's business.
1782        summary.warnings.extend(self.unstamped_rot_findings());
1783        // Under a mem scope, mem-attributable warnings narrow to the
1784        // scoped mem; workspace- and request-scoped warnings return
1785        // `None` from `source_mem()` and stay visible regardless.
1786        // Mirrors the full flavour's compose filter.
1787        if let Some(v) = mem {
1788            summary
1789                .warnings
1790                .retain(|w| w.source_mem().is_none_or(|wv| wv == v));
1791        }
1792        summary
1793    }
1794
1795    /// Compute the authoring-drift findings for every stamped pinned
1796    /// schema. See the call site in [`Self::health`] for the axis
1797    /// contract; returns an empty list when no workspace root is set
1798    /// (ad-hoc mount-list engines have no authoring tree to check).
1799    fn authoring_drift_findings(&self) -> Vec<WarningHint> {
1800        let Some(root) = self.workspace_root.as_deref() else {
1801            return Vec::new();
1802        };
1803        // Group pinning mems by (name, version) — BTreeMap for a
1804        // deterministic finding order.
1805        let mut pins: std::collections::BTreeMap<(String, String), Vec<String>> =
1806            std::collections::BTreeMap::new();
1807        for (mem, schema) in &self.schemas {
1808            let (name, version) = schema.id();
1809            pins.entry((name.to_string(), version.to_string()))
1810                .or_default()
1811                .push(mem.clone());
1812        }
1813        let mut out = Vec::new();
1814        for ((name, version), mut mems) in pins {
1815            mems.sort();
1816            let Some(stamped_path) = self.read_install_provenance(root, &name, &version) else {
1817                continue;
1818            };
1819            let schema_ref = format!("{name}@{version}");
1820            let authoring = std::path::Path::new(&stamped_path);
1821            if !authoring.is_dir() {
1822                out.push(WarningHint::SchemaAuthoringSourceMissing {
1823                    schema_ref,
1824                    stamped_path,
1825                    mems,
1826                });
1827                continue;
1828            }
1829            let sealed = self
1830                .schemas
1831                .get(&mems[0])
1832                .expect("mems collected from self.schemas keys")
1833                .clone();
1834            match memstead_schema::load_schema_from_dir(authoring) {
1835                Err(e) => out.push(WarningHint::SchemaAuthoringSourceDiverged {
1836                    schema_ref,
1837                    stamped_path,
1838                    mems,
1839                    detail: format!("the authoring package no longer loads: {e}"),
1840                }),
1841                Ok(authored) => {
1842                    if schema_parsed_fingerprint(&authored) != schema_parsed_fingerprint(&sealed) {
1843                        out.push(WarningHint::SchemaAuthoringSourceDiverged {
1844                            schema_ref,
1845                            stamped_path,
1846                            mems,
1847                            detail: "the parsed authoring package differs from the sealed copy \
1848                                     the engine runs on"
1849                                .to_string(),
1850                        });
1851                    }
1852                }
1853            }
1854        }
1855        out
1856    }
1857
1858    /// Compute the rot findings for every UNSTAMPED pinned schema: read
1859    /// the sealed package's content back (folder seal directory, or the
1860    /// `__MEMSTEAD:schemas/` ref via the ops bundle) and run the
1861    /// authoring-tier check over it. A pin with a stamp is skipped (the
1862    /// divergence axis owns it); a pin with no readable sealed package
1863    /// — built-ins resolving from the embedded catalogue — is skipped
1864    /// too (nothing on disk can rot). See the call site in
1865    /// [`Self::health`] for the axis contract.
1866    fn unstamped_rot_findings(&self) -> Vec<WarningHint> {
1867        let Some(root) = self.workspace_root.as_deref() else {
1868            return Vec::new();
1869        };
1870        let mut pins: std::collections::BTreeMap<(String, String), Vec<String>> =
1871            std::collections::BTreeMap::new();
1872        for (mem, schema) in &self.schemas {
1873            let (name, version) = schema.id();
1874            pins.entry((name.to_string(), version.to_string()))
1875                .or_default()
1876                .push(mem.clone());
1877        }
1878        let mut out = Vec::new();
1879        for ((name, version), mut mems) in pins {
1880            mems.sort();
1881            if self
1882                .read_install_provenance(root, &name, &version)
1883                .is_some()
1884            {
1885                continue; // stamped — the divergence axis checks it
1886            }
1887            let schema_ref = format!("{name}@{version}");
1888            // Folder seal: the sealed package is a real directory the
1889            // authoring loader can probe directly.
1890            let sealed_dir = root.join(".memstead").join("schemas").join(&schema_ref);
1891            let detail: Option<String> = if sealed_dir.join("schema.yaml").is_file() {
1892                memstead_schema::load_schema_from_dir(&sealed_dir)
1893                    .err()
1894                    .map(|e| e.to_string())
1895            } else if let Some((manifest, types)) = self.read_sealed_package_yamls(&name, &version)
1896            {
1897                memstead_schema::loader::check_package_reauthorable(&manifest, &types)
1898                    .err()
1899                    .map(|e| e.to_string())
1900            } else {
1901                None // no sealed copy anywhere — embedded builtin
1902            };
1903            if let Some(detail) = detail {
1904                out.push(WarningHint::SchemaUnstampedSourceRot {
1905                    schema_ref,
1906                    mems,
1907                    detail,
1908                });
1909            }
1910        }
1911        out
1912    }
1913
1914    /// Read a sealed package's `schema.yaml` + `types/*.yaml` back from
1915    /// the `__MEMSTEAD:schemas/` ref, reconstructing the type-file names
1916    /// from the pinned parsed schema's type roster (seal-time authoring
1917    /// enforces stem == declared type name). `None` when the ops bundle
1918    /// or the package is absent — the embedded-builtin state.
1919    fn read_sealed_package_yamls(
1920        &self,
1921        name: &str,
1922        version: &str,
1923    ) -> Option<(String, Vec<(String, String)>)> {
1924        let ops = self.git_branch_ops()?;
1925        let root = self.workspace_root.as_deref()?;
1926        let gitdir = self
1927            .mounts
1928            .iter()
1929            .find_map(|m| match &m.mount.storage {
1930                crate::workspace::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
1931                _ => None,
1932            })
1933            .or_else(|| {
1934                let g = root.join("mem-repo").join(".git");
1935                g.is_dir().then_some(g)
1936            })?;
1937        let read = |rel: &str| -> Option<String> {
1938            (ops.read_schema_file)(&gitdir, name, version, rel)
1939                .ok()
1940                .flatten()
1941                .and_then(|bytes| String::from_utf8(bytes).ok())
1942        };
1943        let manifest = read("schema.yaml")?;
1944        let schema = self
1945            .schemas
1946            .values()
1947            .find(|s| {
1948                let (n, v) = s.id();
1949                n == name && v.to_string() == version
1950            })?
1951            .clone();
1952        let mut type_names: Vec<String> = schema.types.keys().cloned().collect();
1953        type_names.sort();
1954        let mut types = Vec::new();
1955        for t in type_names {
1956            if let Some(body) = read(&format!("types/{t}.yaml")) {
1957                types.push((t, body));
1958            }
1959        }
1960        Some((manifest, types))
1961    }
1962
1963    /// Read the install-provenance stamp for a sealed schema package,
1964    /// checking the folder location first
1965    /// (`.memstead/schemas/<name>@<version>/`) and falling back to the
1966    /// `__MEMSTEAD:schemas/` ref via the git-branch ops bundle when
1967    /// wired. `None` when no stamp exists anywhere — the normal state
1968    /// for pre-stamp seals, built-ins, and archive installs.
1969    fn read_install_provenance(&self, root: &Path, name: &str, version: &str) -> Option<String> {
1970        let folder_stamp = root
1971            .join(".memstead")
1972            .join("schemas")
1973            .join(format!("{name}@{version}"))
1974            .join(memstead_schema::INSTALL_PROVENANCE_FILE);
1975        let bytes = if folder_stamp.is_file() {
1976            std::fs::read(&folder_stamp).ok()
1977        } else {
1978            let ops = self.git_branch_ops()?;
1979            let gitdir = self
1980                .mounts
1981                .iter()
1982                .find_map(|m| match &m.mount.storage {
1983                    crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
1984                        Some(gitdir.clone())
1985                    }
1986                    _ => None,
1987                })
1988                .or_else(|| {
1989                    let g = root.join("mem-repo").join(".git");
1990                    g.is_dir().then_some(g)
1991                })?;
1992            (ops.read_schema_file)(
1993                &gitdir,
1994                name,
1995                version,
1996                memstead_schema::INSTALL_PROVENANCE_FILE,
1997            )
1998            .ok()
1999            .flatten()
2000        }?;
2001        let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2002        v.get("authoring_path")?.as_str().map(String::from)
2003    }
2004
2005    /// Engine-wide [`crate::ops::Status`] across every mount — the graph
2006    /// counts behind `memstead status` (renamed from `stats` with the
2007    /// command, D11; fields unchanged).
2008    pub fn status(&self) -> crate::ops::Status {
2009        let mut types_in_use: Vec<String> = self
2010            .store
2011            .all_entities()
2012            .filter(|e| !e.stub && !e.entity_type.is_empty())
2013            .map(|e| e.entity_type.clone())
2014            .collect();
2015        types_in_use.sort();
2016        types_in_use.dedup();
2017
2018        let mut edge_types: HashMap<String, usize> = HashMap::new();
2019        for id in self.store.all_ids() {
2020            for edge in self.store.outgoing(id) {
2021                *edge_types.entry(edge.rel_type.clone()).or_insert(0) += 1;
2022            }
2023        }
2024
2025        crate::ops::Status {
2026            entity_count: self.store.all_entities().filter(|e| !e.stub).count(),
2027            edge_count: self.store.edge_count(),
2028            edge_types,
2029            community_count: self.communities().count,
2030            mem_count: self.mounts.len(),
2031            types_in_use,
2032        }
2033    }
2034
2035    /// Build a [`ContextResult`] for `id`: the community cluster id
2036    /// (or `None` when the entity is a stub or not present), plus the
2037    /// outgoing + incoming neighbour lists.
2038    pub fn context(&self, id: &EntityId) -> Option<ContextResult> {
2039        let entity = self.store.get(id)?;
2040        let community = self
2041            .communities()
2042            .entity_cluster_map
2043            .get(id.as_ref())
2044            .cloned();
2045        let mut neighbors = Vec::new();
2046        for edge in self.store.outgoing(id) {
2047            if let Some(target) = self.store.get(&edge.target) {
2048                neighbors.push(NeighborInfo {
2049                    id: target.id.clone(),
2050                    title: target.title.clone(),
2051                    relationship: edge.rel_type.clone(),
2052                    direction: Direction::Outgoing,
2053                });
2054            }
2055        }
2056        for edge in self.store.incoming(id) {
2057            if let Some(source) = self.store.get(&edge.from) {
2058                neighbors.push(NeighborInfo {
2059                    id: source.id.clone(),
2060                    title: source.title.clone(),
2061                    relationship: edge.rel_type.clone(),
2062                    direction: Direction::Incoming,
2063                });
2064            }
2065        }
2066        Some(ContextResult {
2067            entity_id: entity.id.clone(),
2068            community,
2069            neighbors,
2070        })
2071    }
2072
2073    /// Lazily-built per-mem search index map. The map carries one
2074    /// entry per writable mem. Build cost scales with entity count;
2075    /// expect hundreds-of-ms for thousand-entity workspaces. Not
2076    /// available on `wasm32` targets — search lives behind the bridge
2077    /// (see [`Self::search`] for the typed refuse).
2078    #[cfg(not(target_arch = "wasm32"))]
2079    pub fn search_indexes(&self) -> &HashMap<String, MemIndex> {
2080        if let Some((memo_key, _)) = self.search_indexes_memo.get() {
2081            debug_assert_eq!(
2082                *memo_key,
2083                self.derived_key(),
2084                "search memo key lags the engine — a mutation path missed invalidate_search_indexes"
2085            );
2086        }
2087        &self
2088            .search_indexes_memo
2089            .get_or_init(|| (self.derived_key(), build_all(&self.store, &self.schemas)))
2090            .1
2091    }
2092
2093    /// Drop the cached per-mem search index map. No-op on `wasm32`
2094    /// where no index exists; the method stays present so mutation
2095    /// hooks can call it unconditionally.
2096    /// Incrementally maintain the search-index memo for a known
2097    /// touched-id set (flywheel W8/01, criterion 1): replace or remove
2098    /// exactly the touched documents in place and advance the memo's
2099    /// key, instead of dropping the whole map. Semantics:
2100    ///
2101    /// - Memo empty → nothing to maintain; the next read builds fresh.
2102    /// - Memo current (key matches) → no-op (rollback already landed).
2103    /// - Schemas epoch moved → the index FIELD SET may have changed:
2104    ///   the named, scoped fallback — drop the memo for a full
2105    ///   rebuild. Never a silent widening: this is the one case the
2106    ///   plan names (schema-shape change).
2107    /// - Otherwise: per touched id, a real non-stub entity in the
2108    ///   store is re-indexed (delete-then-add on the id term), and an
2109    ///   absent or stub entry is removed — stubs stay excluded exactly
2110    ///   as the bulk build excludes them. Touched mems' writers
2111    ///   commit; any tantivy error falls back to dropping the memo
2112    ///   (warn-logged), never to serving a stale index.
2113    #[cfg(not(target_arch = "wasm32"))]
2114    pub(crate) fn maintain_search_indexes(&mut self, touched: &[crate::EntityId]) {
2115        let current = super::DerivedKey {
2116            store_generation: self.store.generation(),
2117            schemas_epoch: self.schemas_epoch,
2118        };
2119        let Some((memo_key, indexes)) = self.search_indexes_memo.get_mut() else {
2120            return;
2121        };
2122        if *memo_key == current {
2123            return;
2124        }
2125        if memo_key.schemas_epoch != current.schemas_epoch {
2126            self.search_indexes_memo = OnceCell::new();
2127            return;
2128        }
2129        let mut touched_mems: std::collections::HashSet<&str> = std::collections::HashSet::new();
2130        for id in touched {
2131            let Some(idx) = indexes.get_mut(id.mem()) else {
2132                continue;
2133            };
2134            let result = match self.store.get(id) {
2135                Some(entity) if !entity.stub => idx.index_entity(entity),
2136                _ => idx.remove_entity(id),
2137            };
2138            if let Err(e) = result {
2139                tracing::warn!(
2140                    id = id.as_ref(),
2141                    error = %e,
2142                    "incremental index maintenance failed; dropping the memo for a full rebuild"
2143                );
2144                self.search_indexes_memo = OnceCell::new();
2145                return;
2146            }
2147            touched_mems.insert(id.mem());
2148        }
2149        for (mem, idx) in indexes.iter_mut() {
2150            if !touched_mems.contains(mem.as_str()) {
2151                continue;
2152            }
2153            if let Err(e) = idx.commit() {
2154                tracing::warn!(
2155                    mem = mem.as_str(),
2156                    error = %e,
2157                    "incremental index commit failed; dropping the memo for a full rebuild"
2158                );
2159                self.search_indexes_memo = OnceCell::new();
2160                return;
2161            }
2162        }
2163        *memo_key = current;
2164    }
2165
2166    /// No-op shim on `wasm32`, mirroring `invalidate_search_indexes`
2167    /// so mutation paths call it unconditionally.
2168    #[cfg(target_arch = "wasm32")]
2169    pub(crate) fn maintain_search_indexes(&mut self, _touched: &[crate::EntityId]) {}
2170
2171    /// Unconditionally drop the search-index memo, regardless of its
2172    /// generation. The forced variant exists for embedders that want
2173    /// to release the index's memory in a long-lived process (or force
2174    /// a from-scratch rebuild for verification); the generation-checked
2175    /// [`Self::invalidate_search_indexes`] stays the mutation-path
2176    /// hook.
2177    pub fn drop_search_indexes(&mut self) {
2178        #[cfg(not(target_arch = "wasm32"))]
2179        {
2180            self.search_indexes_memo = OnceCell::new();
2181        }
2182    }
2183
2184    pub fn invalidate_search_indexes(&mut self) {
2185        #[cfg(not(target_arch = "wasm32"))]
2186        {
2187            // Same generation check as `invalidate_communities`: keep
2188            // the memo when the store still sits at its generation
2189            // (the batch-rollback case), clear otherwise.
2190            if let Some((memo_key, _)) = self.search_indexes_memo.get()
2191                && *memo_key == self.derived_key()
2192            {
2193                return;
2194            }
2195            self.search_indexes_memo = OnceCell::new();
2196        }
2197    }
2198
2199    /// Filter the in-memory store by metadata only (no text match).
2200    #[cfg(not(target_arch = "wasm32"))]
2201    pub fn list(&self, scope: &SearchScope) -> crate::ops::ListResult {
2202        let fallback = engine_fallback_type();
2203        crate::ops::search::list(&self.store, scope, fallback.as_ref(), &self.schemas)
2204    }
2205
2206    /// Run a search against the lazily-built index map. Returns
2207    /// [`EngineError::SearchUnavailable`] on `wasm32` targets — browser
2208    /// consumers route search to the bridge; the local
2209    /// engine never builds a tantivy index in WASM. Native targets get
2210    /// the same shape as before, wrapped in `Ok`.
2211    pub fn search(&self, scope: &SearchScope) -> Result<SearchResult, EngineError> {
2212        // A mem filter naming a quarantined OR nonexistent mem refuses
2213        // typed `UNKNOWN_MEM`, matching every other mem-naming surface. A
2214        // success with 0 hits (the old nonexistent-mem behaviour, with a
2215        // missing-index warning) is indistinguishable from a true empty
2216        // result — the one thing a typed surface must never be.
2217        if let Some(mem) = scope.mem.as_deref()
2218            && (self.quarantine_reason(mem).is_some() || !self.mem_router.is_visible(mem))
2219        {
2220            return Err(self.unknown_mem_error(mem));
2221        }
2222        #[cfg(target_arch = "wasm32")]
2223        {
2224            let _ = scope;
2225            return Err(EngineError::SearchUnavailable);
2226        }
2227        #[cfg(not(target_arch = "wasm32"))]
2228        {
2229            let fallback = engine_fallback_type();
2230            Ok(crate::ops::search::search(
2231                &self.store,
2232                scope,
2233                fallback.as_ref(),
2234                self.search_indexes(),
2235                &self.schemas,
2236            ))
2237        }
2238    }
2239
2240    /// All mem-relative entity paths under `mem`. Delegates to
2241    /// the backend's `list_entities`. Order is backend-defined.
2242    pub fn list_entities(&self, mem: &str) -> Result<Vec<PathBuf>, EngineError> {
2243        let m = self.find_mount(mem)?;
2244        m.backend.list_entities().map_err(EngineError::Backend)
2245    }
2246
2247    /// Raw bytes for a single entity (`Ok(None)` if absent).
2248    pub fn read_entity(&self, mem: &str, rel_path: &Path) -> Result<Option<Vec<u8>>, EngineError> {
2249        let m = self.find_mount(mem)?;
2250        m.backend
2251            .read_entity(rel_path)
2252            .map_err(EngineError::Backend)
2253    }
2254
2255    /// Provenance entries for `mem` since `cursor`. Cursor shape is
2256    /// backend-specific (RFC-3339 timestamp for folder, commit SHA for
2257    /// git-branch); `None` means "from the beginning".
2258    pub fn read_provenance(
2259        &self,
2260        mem: &str,
2261        cursor: Option<&str>,
2262    ) -> Result<Vec<Provenance>, EngineError> {
2263        let m = self.find_mount(mem)?;
2264        m.backend
2265            .read_provenance(cursor)
2266            .map_err(EngineError::Backend)
2267    }
2268
2269    /// Capability declared on the mount for `mem`. Surfaced for
2270    /// callers that need to gate before dispatching a write — the
2271    /// engine itself does not yet enforce capability (mutation paths
2272    /// land in a later session).
2273    pub fn capability(&self, mem: &str) -> Result<crate::workspace::MountCapability, EngineError> {
2274        let m = self.find_mount(mem)?;
2275        Ok(m.mount.capability)
2276    }
2277
2278    /// Returns `true` when `from`'s source mem is mounted with
2279    /// [`crate::workspace::MountCapability::ReadOnly`]. Returns
2280    /// `false` for Write-Mems and for mems whose mount is absent
2281    /// from the router (no mount → no ReadOnly assertion can be
2282    /// made; the absence is treated as not-ReadOnly so consumers
2283    /// don't trip on transient lookup misses).
2284    ///
2285    /// Plan body §"Single edge source in the store" specifies this
2286    /// helper as the derived-on-demand alternative to adding a new
2287    /// field on [`crate::store::Edge`]. Strict-invariant validators
2288    /// and surfaces that want to highlight cross-mount references
2289    /// call this rather than pattern-matching on a per-edge marker.
2290    /// The information is fully derivable from the current mount
2291    /// roster, so no new state needs to live on the edge itself.
2292    pub fn edge_is_from_readonly(&self, from: &EntityId) -> bool {
2293        match self.capability(from.mem()) {
2294            Ok(crate::workspace::MountCapability::ReadOnly) => true,
2295            Ok(crate::workspace::MountCapability::Write) | Err(_) => false,
2296        }
2297    }
2298
2299    /// Whether a cross-mem edge from `from_mem` to `to_mem` is
2300    /// permitted under the current [`crate::WorkspaceSettings`]
2301    /// cross-mem link policy.
2302    ///
2303    /// Resolution rules (matches full's `mem_router` semantics):
2304    /// 1. Same-mem edge (`from_mem == to_mem`) → always
2305    ///    allowed; the policy gates *cross*-mem edges only.
2306    /// 2. Explicit `cross_mem_links[from_mem]`:
2307    ///    - `"*"` (wildcard) → allowed regardless of target.
2308    ///    - `["a", ...]` (allowlist) → allowed iff `to_mem` is in
2309    ///      the list.
2310    /// 3. Per-create-rule `default_cross_links` synthesis — if
2311    ///    rule (1) didn't grant permission and `from_mem` matches
2312    ///    a `[[mem_management.create]]` rule whose
2313    ///    `default_cross_links` is set, the synthesised value
2314    ///    contributes:
2315    ///    - `"*"` → allowed regardless of target.
2316    ///    - `["a", ...]` → allowed iff `to_mem` is in the list.
2317    /// 4. Otherwise → denied (default-deny posture).
2318    ///
2319    /// The synthesis layer compiles a [`crate::mem_management::CreateRuleSet`]
2320    /// lazily on first call and caches it; [`Self::set_settings`]
2321    /// invalidates the cache. Compilation failure (malformed glob
2322    /// in a rule) logs a warning and the synthesis layer is silently
2323    /// skipped — the resolver still returns `true` from explicit
2324    /// policy alone, so a half-broken config doesn't lock out edges
2325    /// the operator did intend to allow. Operators who want hard
2326    /// validation pre-compile via
2327    /// [`crate::mem_management::CreateRuleSet::new`] before
2328    /// calling [`Self::set_settings`].
2329    ///
2330    /// The MCP `memstead_relate` handler's cross-mem gate consumes
2331    /// this method directly.
2332    pub fn cross_mem_link_allowed(&self, from_mem: &str, to_mem: &str) -> bool {
2333        use memstead_schema::workspace_config::CrossLinkValue;
2334        if from_mem == to_mem {
2335            return true;
2336        }
2337
2338        // Step 1: explicit cross_mem_links policy.
2339        if let Some(value) = self.settings.cross_mem_links.get(from_mem) {
2340            match value {
2341                CrossLinkValue::Wildcard => return true,
2342                CrossLinkValue::List(targets) => {
2343                    if targets.iter().any(|t| t == to_mem) {
2344                        return true;
2345                    }
2346                    // Fall through to synthesis check — a List that
2347                    // doesn't include the target may still allow it
2348                    // via per-rule default_cross_links union.
2349                }
2350            }
2351        }
2352
2353        // Step 2: per-create-rule default_cross_links synthesis.
2354        let rule_set = self.create_rule_set_memo.get_or_init(|| {
2355            crate::mem_management::CreateRuleSet::new(
2356                self.settings.mem_create_rules.clone(),
2357            )
2358            .unwrap_or_else(|err| {
2359                tracing::warn!(
2360                    error = %err,
2361                    "cross_mem_link_allowed: failed to compile mem_create_rules — synthesis disabled (resolver falls back to explicit-policy-only)"
2362                );
2363                crate::mem_management::CreateRuleSet::default()
2364            })
2365        });
2366
2367        // Compose the same `<mem_path>/<name>` candidate the create-rule
2368        // composer matched against. The rule globs are keyed on the composed
2369        // lifecycle path (e.g. `memstead/project`, compiled with
2370        // `literal_separator`), not the bare leaf name — matching
2371        // `from_mem` alone silently misses, so synthesis denied a link
2372        // that `memstead_overview` rendered as rule-granted (the
2373        // leaf-vs-composed-path divergence). Flat-layout mems (no
2374        // hierarchical path) keep the bare leaf, matching their bare rule.
2375        let candidate = match self.mount(from_mem).and_then(|m| m.mem_path()) {
2376            Some(path) => format!("{path}/{from_mem}"),
2377            None => from_mem.to_string(),
2378        };
2379        if let Some(matched) = rule_set.first_match(std::path::Path::new(&candidate))
2380            && let Some(synth) = matched.default_cross_links.as_ref()
2381        {
2382            return match synth {
2383                CrossLinkValue::Wildcard => true,
2384                CrossLinkValue::List(targets) => targets.iter().any(|t| t == to_mem),
2385            };
2386        }
2387
2388        false
2389    }
2390
2391    pub(super) fn find_mount(&self, mem: &str) -> Result<&MountedBackend, EngineError> {
2392        self.mounts
2393            .iter()
2394            .find(|m| m.mount.mem == mem)
2395            .ok_or_else(|| self.unknown_mem_error(mem))
2396    }
2397}
2398
2399/// The base path of an anchor artifact ref — the locator suffixes a
2400/// medium may append (`@<commit>`, `#<span>`) stripped so the reverse
2401/// lookup compares paths, not versioned/located refs.
2402pub(crate) fn anchor_base_path(artifact: &str) -> &str {
2403    let cut = artifact.find(['@', '#']).unwrap_or(artifact.len());
2404    &artifact[..cut]
2405}
2406
2407/// One mem's standalone anchor-verification report — the counts plus
2408/// the per-anchor rows, in sidecar order.
2409#[derive(Debug, Clone, Default, serde::Serialize)]
2410pub struct MemAnchorVerification {
2411    pub mem: String,
2412    /// Source present, hash matches (or a non-hash class whose source
2413    /// exists).
2414    pub resolved: usize,
2415    /// Source present, hash differs, stability `stable` — real drift.
2416    pub drifted: usize,
2417    /// Hash differs under `unstable` stability, or a hash is missing on
2418    /// either side — flagged for re-examination, never called drift.
2419    pub recheck: usize,
2420    /// Source absent, or a grain/medium the mechanism does not reach.
2421    pub unresolvable: usize,
2422    pub anchors: Vec<VerifiedAnchor>,
2423}
2424
2425/// One anchor's verification row.
2426#[derive(Debug, Clone, serde::Serialize)]
2427pub struct VerifiedAnchor {
2428    pub entity_id: String,
2429    pub artifact: String,
2430    pub grain: String,
2431    pub class: String,
2432    /// `resolved` | `drifted` | `recheck` | `unresolvable`.
2433    pub state: String,
2434    #[serde(skip_serializing_if = "Option::is_none")]
2435    pub observed_hash: Option<String>,
2436}
2437
2438/// Whether `anchor` references `path`. `tree`-grain anchors match `path`
2439/// itself and anything beneath the tree; every other grain matches by
2440/// exact base-path equality.
2441/// A stored anchor paired with its live resolution state, when observable.
2442/// See [`Engine::entity_anchors_resolved`] for how `state` is produced and
2443/// when it is `None` (unobserved, never fabricated).
2444#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
2445pub struct ResolvedAnchor {
2446    /// The durable anchor record (flattened on the wire so the resolved shape
2447    /// is the stored anchor plus a `state` field).
2448    #[serde(flatten)]
2449    pub anchor: crate::anchor::Anchor,
2450    /// The live resolution state, or `None` when the engine could not observe
2451    /// the source artifact this pass: a `url` grain, no workspace root, an
2452    /// ambiguous or absent path medium, or an `entity` grain whose mem is not
2453    /// mounted. That last case is load-bearing — an unmounted mem is not a mem
2454    /// of deleted entities, so it must read as unobserved rather than
2455    /// `Orphaned`, which prune would act on.
2456    #[serde(skip_serializing_if = "Option::is_none")]
2457    pub state: Option<crate::anchor::AnchorState>,
2458    /// The prepared-content hash the observation computed this pass —
2459    /// present only for a hash-bearing (`anchored` / `derived`) anchor whose
2460    /// artifact could be read: a `file` / `span` anchor resolving to a
2461    /// readable file, or an `entity` anchor whose mem is mounted (hashed over
2462    /// the canonical rendered markdown, so the value means the same thing in
2463    /// both namespaces). The verify pass's backfill leg records it onto a
2464    /// hash-less anchor. Engine-internal
2465    /// observation detail, deliberately not serialized: the wire shape stays
2466    /// the stored anchor plus `state`.
2467    #[serde(skip)]
2468    pub observed_hash: Option<String>,
2469}
2470
2471/// What an anchor's `source` name resolves to in its mem's bindings: the
2472/// declared pointer (the filesystem root a source-dialect artifact path
2473/// joins onto, decision 26) and the declared preparation (what the
2474/// preparation registry prepares the artifact as before hashing —
2475/// touchpoint A of [`crate::preparation`]).
2476#[derive(Debug, Clone, PartialEq, Eq)]
2477pub(crate) struct AnchorSourceJoin {
2478    /// The source's declared `pointer`.
2479    pub(crate) pointer: String,
2480    /// The source's declared `preparation`, if any.
2481    pub(crate) preparation: Option<String>,
2482    /// The declaring source itself — its scope is what a `tree` anchor's
2483    /// prepared form enumerates under a code-map preparation.
2484    pub(crate) source: crate::pipeline::Source,
2485    /// The binding's `deny_paths`, applied on top of the source scope.
2486    pub(crate) deny_paths: Vec<String>,
2487}
2488
2489/// Observe a single path-namespace anchor against `root` (its medium's
2490/// filesystem root) and resolve its live state plus — for a present
2491/// hash-bearing (`anchored` / `derived`) `file` / `span` anchor — the
2492/// artifact's **prepared-content hash**
2493/// ([`crate::anchor::prepared_content_hash`]). `None` when the anchor's
2494/// grain does not reference a filesystem path.
2495///
2496/// The computed hash is what lets [`crate::anchor::resolve_anchor`]
2497/// adjudicate `drifted` vs `resolves` deterministically against the recorded
2498/// hash. The prepared form is the registry's rule for the anchor's
2499/// source's preparation ([`crate::preparation::path_prepared_hash`]): a
2500/// `span` anchor hashes its whole containing file (the span locator selects
2501/// within it; the file is the hashed unit), except under a **delivery
2502/// preparation**, where a `<path>#<key>` span names one delivery unit (the
2503/// unit's own text is the hashed unit, and a key the file no longer yields
2504/// is an absent artifact); under a **code-map** preparation a file or span
2505/// hashes the interface digest, and a `tree` hashes the code map of every
2506/// scoped file under it. A `tree` grain under no code map has no prepared
2507/// form and observes no hash; a read failure likewise observes no hash —
2508/// those resolve `recheck`, never a fabricated `drifted`. Non-hash classes
2509/// (`authored` / `informed-by`) skip the read entirely, so an anchor-less or
2510/// hash-free mem pays no observation cost.
2511fn observe_path_anchor(
2512    root: &Path,
2513    anchor: &crate::anchor::Anchor,
2514    join: Option<&AnchorSourceJoin>,
2515) -> Option<(crate::anchor::AnchorState, Option<String>)> {
2516    use crate::anchor::AnchorGrain;
2517    match anchor.grain {
2518        AnchorGrain::Span | AnchorGrain::File | AnchorGrain::Tree => {}
2519        AnchorGrain::Url | AnchorGrain::Entity => return None,
2520    }
2521    let source_pointer = join.map(|j| j.pointer.as_str());
2522    let preparation = join.and_then(|j| j.preparation.as_deref());
2523    let base = anchor_base_path(&anchor.artifact);
2524    // Decision 29: the source-join is authoritative — an artifact path is
2525    // source-relative first (joined onto the declaring source's pointer,
2526    // which may deliberately leave the workspace root for out-of-root
2527    // pointers); the workspace-relative form is tried only when the
2528    // source-join does not resolve. A path resolving under both joins is
2529    // decided by this priority, deterministically.
2530    let path = source_pointer
2531        .map(|pointer| root.join(join_pointer(pointer, base)))
2532        .filter(|joined| joined.exists())
2533        .unwrap_or_else(|| root.join(base));
2534    if !path.exists() {
2535        return Some((
2536            crate::anchor::resolve_anchor(anchor, &crate::anchor::ArtifactObservation::Absent),
2537            None,
2538        ));
2539    }
2540    let current_hash = if !anchor.class.is_hash_bearing() {
2541        None
2542    } else if matches!(anchor.grain, AnchorGrain::File | AnchorGrain::Span) && path.is_file() {
2543        match std::fs::read(&path).ok().map(|bytes| {
2544            crate::preparation::path_prepared_hash(
2545                preparation,
2546                &anchor.artifact,
2547                anchor.grain,
2548                &bytes,
2549            )
2550        }) {
2551            Some(crate::preparation::PathPrepared::Hash(h)) => Some(h),
2552            Some(crate::preparation::PathPrepared::UnitAbsent) => {
2553                return Some((
2554                    crate::anchor::resolve_anchor(
2555                        anchor,
2556                        &crate::anchor::ArtifactObservation::Absent,
2557                    ),
2558                    None,
2559                ));
2560            }
2561            Some(crate::preparation::PathPrepared::NoHash) | None => None,
2562        }
2563    } else if anchor.grain == AnchorGrain::Tree
2564        && path.is_dir()
2565        && preparation == Some(crate::preparation::CODE_MAP)
2566        && let Some(join) = join
2567    {
2568        // The tree's code map: every scoped file under the tree, by the
2569        // declaring source's own scope and the binding's deny paths. The
2570        // path the anchor names is workspace-relative or source-relative;
2571        // the enumeration is workspace-relative, so compare the resolved
2572        // absolute paths.
2573        let files: Vec<(String, String)> =
2574            crate::ingest::cursor::enumerate_facet_files(&join.source, &join.deny_paths, root)
2575                .into_iter()
2576                .filter(|f| root.join(f).starts_with(&path))
2577                .filter_map(|f| {
2578                    std::fs::read(root.join(&f))
2579                        .ok()
2580                        .map(|bytes| (f, String::from_utf8_lossy(&bytes).into_owned()))
2581                })
2582                .collect();
2583        Some(crate::anchor::prepared_content_hash(
2584            crate::preparation::code_map_tree_digest(&files).as_bytes(),
2585        ))
2586    } else {
2587        None
2588    };
2589    let observation = crate::anchor::ArtifactObservation::Present {
2590        current_hash: current_hash.clone(),
2591    };
2592    Some((
2593        crate::anchor::resolve_anchor(anchor, &observation),
2594        current_hash,
2595    ))
2596}
2597
2598/// Deterministic fingerprint of a PARSED schema for the
2599/// authoring-drift equivalence check. Compares semantic content, never
2600/// raw bytes: YAML comments (the CLI-injected editor-header lines) and
2601/// whitespace vanish at parse time, and `Schema.types` — a `HashMap`
2602/// with nondeterministic iteration order — is rendered sorted by type
2603/// name so two loads of equivalent packages always fingerprint alike.
2604fn schema_parsed_fingerprint(schema: &memstead_schema::Schema) -> String {
2605    let mut keys: Vec<&String> = schema.types.keys().collect();
2606    keys.sort();
2607    let types: Vec<String> = keys
2608        .iter()
2609        .map(|k| format!("{k}={:?}", schema.types[k.as_str()]))
2610        .collect();
2611    format!(
2612        "{:?}|{}|{}",
2613        schema.manifest,
2614        schema.version,
2615        types.join(";")
2616    )
2617}
2618
2619fn anchor_references_path(anchor: &crate::anchor::Anchor, path: &str) -> bool {
2620    let base = anchor_base_path(&anchor.artifact);
2621    path_references(base, anchor.grain == crate::anchor::AnchorGrain::Tree, path)
2622}
2623
2624/// Whether `base` (a file path, or a tree root when `is_tree`) references
2625/// `path` — exact match, or containment for a tree.
2626fn path_references(base: &str, is_tree: bool, path: &str) -> bool {
2627    if base == path {
2628        return true;
2629    }
2630    if is_tree {
2631        let prefix = base.strip_suffix('/').unwrap_or(base);
2632        return path.starts_with(&format!("{prefix}/"));
2633    }
2634    false
2635}
2636
2637/// Join a source pointer and a source-relative artifact path into the
2638/// pointer-joined (workspace-relative) form — the decision-26 dialect
2639/// bridge. Plain string concatenation with a separator: the pointer is
2640/// workspace-relative (and may climb out via `..`), the artifact is
2641/// source-relative; no canonicalization here, the filesystem resolves it.
2642pub(crate) fn join_pointer(pointer: &str, base: &str) -> String {
2643    let pointer = pointer.trim_end_matches('/');
2644    if pointer.is_empty() || pointer == "." {
2645        base.to_string()
2646    } else {
2647        format!("{pointer}/{base}")
2648    }
2649}
2650
2651#[cfg(test)]
2652mod tests {
2653    use std::path::Path;
2654
2655    use tempfile::TempDir;
2656
2657    use crate::backend::{BackendError, MemBackend};
2658    use crate::engine::test_helpers::*;
2659    use crate::engine::{Engine, EngineError, RelateEntityArgs};
2660    use crate::entity::EntityId;
2661    use crate::ops::{Direction, SearchScope, WarningHint};
2662    use crate::provenance::Provenance;
2663    use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
2664
2665    use crate::vcs::CommitContext;
2666    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
2667
2668    /// `schema_origin` is the trust-classification authority: a built-in
2669    /// (or workspace-authored) schema is first-party; a schema whose
2670    /// `(name, version)` is in neither catalogue is third-party — the safe
2671    /// default for an origin the engine cannot vouch for.
2672    #[test]
2673    fn schema_origin_classifies_builtin_first_party_and_unknown_third_party() {
2674        use std::sync::Arc;
2675
2676        use crate::render::OriginClass;
2677
2678        let tmp = TempDir::new().unwrap();
2679        let engine = Engine::from_mounts(vec![(
2680            folder_mount("specs", tmp.path().to_path_buf()),
2681            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf())) as Box<dyn MemBackend>,
2682        )])
2683        .unwrap();
2684
2685        // A built-in schema (the catalogue the engine resolved against).
2686        let builtin = engine.builtin_schemas()[0].clone();
2687        assert_eq!(
2688            engine.schema_origin(&builtin),
2689            OriginClass::FirstParty,
2690            "a built-in schema is first-party"
2691        );
2692
2693        // A schema whose version is in no catalogue — a stand-in for a
2694        // schema that entered from outside the workspace. Same name, a
2695        // version the engine never loaded.
2696        let foreign = Arc::new(memstead_schema::Schema {
2697            manifest: builtin.manifest.clone(),
2698            version: semver::Version::new(99, 0, 0),
2699            types: builtin.types.clone(),
2700        });
2701        assert_eq!(
2702            engine.schema_origin(&foreign),
2703            OriginClass::ThirdParty,
2704            "a schema in neither catalogue classifies third-party (safe default)"
2705        );
2706    }
2707
2708    /// `mem_origin_class` classifies a writable mount first-party (its
2709    /// content is authored in this workspace) and a read-only mount
2710    /// third-party (registry-installed read-mem or adopted foreign
2711    /// folder/clone — quoted, untrusted data). An unknown mem is
2712    /// third-party (the safe default).
2713    #[test]
2714    fn mem_origin_class_writable_first_party_readonly_third_party() {
2715        use crate::render::OriginClass;
2716
2717        let tmp = TempDir::new().unwrap();
2718        // Writable folder mem.
2719        let writable_dir = tmp.path().join("writable");
2720        std::fs::create_dir_all(&writable_dir).unwrap();
2721        let writer = FilesystemMemWriter::new(writable_dir.clone());
2722
2723        // Read-only archive mem.
2724        let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
2725        let archive_path = build_archive(tmp.path(), "ext", &[("ext.md", body.as_bytes())]);
2726
2727        let engine = Engine::from_mounts(vec![
2728            (
2729                folder_mount("local", writable_dir),
2730                Box::new(writer) as Box<dyn MemBackend>,
2731            ),
2732            (
2733                archive_mount("external", archive_path.clone()),
2734                Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
2735            ),
2736        ])
2737        .unwrap();
2738
2739        assert_eq!(
2740            engine.mem_origin_class("local"),
2741            OriginClass::FirstParty,
2742            "a writable mount is first-party"
2743        );
2744        assert_eq!(
2745            engine.mem_origin_class("external"),
2746            OriginClass::ThirdParty,
2747            "a read-only mount is third-party"
2748        );
2749        assert_eq!(
2750            engine.mem_origin_class("no-such-mem"),
2751            OriginClass::ThirdParty,
2752            "an unknown mem is third-party (safe default)"
2753        );
2754    }
2755
2756    /// `declare_mem_origin` lets the embedding deployment vouch for one
2757    /// read-only mount as first-party (the curated hosted read tier),
2758    /// overriding the writability inference for that mem only — sibling
2759    /// read-only mounts keep the safe third-party default.
2760    #[test]
2761    fn declared_origin_overrides_inference_per_mem() {
2762        use crate::render::OriginClass;
2763
2764        let tmp = TempDir::new().unwrap();
2765        let body = "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nFrom an archive.\n";
2766        let vouched_path = build_archive(tmp.path(), "vouched", &[("v.md", body.as_bytes())]);
2767        let other_path = build_archive(tmp.path(), "other", &[("o.md", body.as_bytes())]);
2768
2769        let mut engine = Engine::from_mounts(vec![
2770            (
2771                archive_mount("vouched", vouched_path.clone()),
2772                Box::new(ArchiveBackend::new(vouched_path)) as Box<dyn MemBackend>,
2773            ),
2774            (
2775                archive_mount("other", other_path.clone()),
2776                Box::new(ArchiveBackend::new(other_path)) as Box<dyn MemBackend>,
2777            ),
2778        ])
2779        .unwrap();
2780
2781        engine.declare_mem_origin("vouched", OriginClass::FirstParty);
2782
2783        assert_eq!(
2784            engine.mem_origin_class("vouched"),
2785            OriginClass::FirstParty,
2786            "the deployment's declaration wins over the read-only inference"
2787        );
2788        assert_eq!(
2789            engine.mem_origin_class("other"),
2790            OriginClass::ThirdParty,
2791            "an undeclared sibling mount keeps the safe default"
2792        );
2793    }
2794
2795    /// The adopt-gate: a non-built-in schema is first-party only once a
2796    /// writable mount pins it (the operator authors against it here).
2797    /// Pinned only by a read-only mount — a registry read-mem or an
2798    /// adopted foreign folder/clone — it stays third-party, so
2799    /// `memstead_schema` serves it structural-only.
2800    #[test]
2801    fn schema_origin_third_party_until_pinned_by_a_writable_mount() {
2802        use memstead_schema::SchemaRef;
2803
2804        use crate::render::OriginClass;
2805
2806        let manifest = r#"name: trust-test
2807version: 0.1.0
2808description: adopt-gate test schema
2809when_to_use: tests
2810types:
2811  - doc
2812relationships:
2813  mode: strict
2814  definitions:
2815    - name: _default
2816      description: fallback
2817      default_weight: 1.0
2818community:
2819  resolution: 1.0
2820  seed: 42
2821"#;
2822        let pin = SchemaRef::new("trust-test", semver::Version::new(0, 1, 0));
2823
2824        let mk_engine = |cap: MountCapability| -> Engine {
2825            let tmp = TempDir::new().unwrap();
2826            let schemas_dir = tmp.path().join("schemas");
2827            std::fs::create_dir_all(&schemas_dir).unwrap();
2828            write_schema_files_with_default_type(&schemas_dir, "trust-test", manifest, &["doc"]);
2829            let mem_dir = tmp.path().join("mem");
2830            std::fs::create_dir_all(&mem_dir).unwrap();
2831            let mount = Mount {
2832                mem: "v".to_string(),
2833                schema: Some(pin.clone()),
2834                storage: MountStorage::Folder {
2835                    path: mem_dir.clone(),
2836                },
2837                capability: cap,
2838                lifecycle: MountLifecycle::Eager,
2839                cross_linkable: true,
2840                migration_target: None,
2841            };
2842            let backend = Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>;
2843            // Keep `tmp` alive for the engine's lifetime by leaking it —
2844            // the test process is short-lived and the folder must outlast
2845            // the closure.
2846            std::mem::forget(tmp);
2847            Engine::from_mounts_with_schemas_dir(vec![(mount, backend)], Some(&schemas_dir))
2848                .unwrap()
2849        };
2850
2851        // Read-only mount: the foreign schema is never adopted → third-party.
2852        let ro = mk_engine(MountCapability::ReadOnly);
2853        let schema = ro.schemas().get("v").expect("schema resolved").clone();
2854        assert_eq!(
2855            ro.schema_origin(&schema),
2856            OriginClass::ThirdParty,
2857            "a non-built-in schema pinned only by a read-only mount is third-party"
2858        );
2859
2860        // Writable mount pinning the same schema: adopted → first-party.
2861        let rw = mk_engine(MountCapability::Write);
2862        let schema = rw.schemas().get("v").expect("schema resolved").clone();
2863        assert_eq!(
2864            rw.schema_origin(&schema),
2865            OriginClass::FirstParty,
2866            "a writable mount pinning the schema adopts it → first-party"
2867        );
2868    }
2869
2870    /// Consumer read path: an installed (archive-backed) mem that ships
2871    /// a `.memstead/provenance.json` payload surfaces per-entity authoring
2872    /// provenance through `archive_provenance_for`. A noted entity carries
2873    /// its rationale; an entity authored without a note is absent from the
2874    /// payload and reads as provenance-absent (no fabricated value); the
2875    /// `history` disposition records that full history is not shipped.
2876    #[test]
2877    fn archive_provenance_surfaces_per_entity_and_reports_absence() {
2878        use memstead_schema::History;
2879
2880        let tmp = TempDir::new().unwrap();
2881        let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
2882        let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
2883        let beta = b"---\ntype: spec\n---\n# Beta\n\n## Identity\n\nb\n\n## Purpose\n\np\n";
2884        // alpha noted; beta deliberately absent from the payload.
2885        let provenance = br#"{"format":1,"history":"summarised","entities":{"alpha":{"rationale":"why alpha exists","kind":"create","timestamp":"2026-06-24T00:00:00Z","actor":"agent"}}}"#;
2886        let archive = build_archive(
2887            tmp.path(),
2888            "seed",
2889            &[
2890                (".memstead/config.json", config),
2891                ("alpha.md", alpha),
2892                ("beta.md", beta),
2893                (".memstead/provenance.json", provenance),
2894            ],
2895        );
2896        let engine = Engine::from_mounts(vec![(
2897            archive_mount("seed", archive.clone()),
2898            Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
2899        )])
2900        .unwrap();
2901
2902        let prov = engine
2903            .archive_provenance_for("seed")
2904            .expect("provenance payload read from the archive");
2905        assert_eq!(
2906            prov.history,
2907            History::Summarised,
2908            "history-not-shipped is observable"
2909        );
2910        assert_eq!(
2911            prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
2912            Some("why alpha exists"),
2913            "noted entity surfaces its rationale"
2914        );
2915        assert!(
2916            prov.entity("beta").is_none(),
2917            "unnoted entity is absent (reported absent, not fabricated)"
2918        );
2919    }
2920
2921    /// A pre-provenance archive (no `.memstead/provenance.json`) reads as
2922    /// provenance uniformly absent — the additive contract: a newer engine
2923    /// installing an old archive reports no provenance, never an error.
2924    #[test]
2925    fn archive_without_provenance_reports_absent() {
2926        let tmp = TempDir::new().unwrap();
2927        let config = br#"{"format":3,"name":"seed","version":"0.1.0","schema":"default@1.0.0"}"#;
2928        let alpha = b"---\ntype: spec\n---\n# Alpha\n\n## Identity\n\na\n\n## Purpose\n\np\n";
2929        let archive = build_archive(
2930            tmp.path(),
2931            "seed",
2932            &[(".memstead/config.json", config), ("alpha.md", alpha)],
2933        );
2934        let engine = Engine::from_mounts(vec![(
2935            archive_mount("seed", archive.clone()),
2936            Box::new(ArchiveBackend::new(archive)) as Box<dyn MemBackend>,
2937        )])
2938        .unwrap();
2939        assert!(
2940            engine.archive_provenance_for("seed").is_none(),
2941            "an archive without a provenance payload reports provenance absent"
2942        );
2943    }
2944
2945    #[test]
2946    fn folder_mount_routes_reads_to_filesystem_backend() {
2947        let tmp = TempDir::new().unwrap();
2948        let mem_dir = tmp.path().to_path_buf();
2949        let writer = FilesystemMemWriter::new(mem_dir.clone());
2950        // MemWriter and MemBackend share method names; the
2951        // module-top `use` brings both into scope. Seed via fully-
2952        // qualified MemWriter calls so dot-syntax stays unambiguous.
2953        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"alpha")
2954            .unwrap();
2955        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
2956            .unwrap();
2957
2958        let engine = Engine::from_mounts(vec![(
2959            folder_mount("specs", mem_dir),
2960            Box::new(writer) as Box<dyn MemBackend>,
2961        )])
2962        .unwrap();
2963
2964        let mut paths: Vec<String> = engine
2965            .list_entities("specs")
2966            .unwrap()
2967            .into_iter()
2968            .map(|p| p.to_string_lossy().into_owned())
2969            .collect();
2970        paths.sort();
2971        assert_eq!(paths, vec!["a.md".to_string()]);
2972
2973        assert_eq!(
2974            engine.read_entity("specs", Path::new("a.md")).unwrap(),
2975            Some(b"alpha".to_vec())
2976        );
2977    }
2978
2979    #[test]
2980    fn heterogeneous_mounts_route_to_correct_backend() {
2981        let tmp = TempDir::new().unwrap();
2982
2983        // Folder mem.
2984        let folder_dir = tmp.path().join("folder-mem");
2985        std::fs::create_dir_all(&folder_dir).unwrap();
2986        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
2987        <FilesystemMemWriter as MemWriter>::write_entity(
2988            &folder_writer,
2989            Path::new("local.md"),
2990            b"local",
2991        )
2992        .unwrap();
2993        <FilesystemMemWriter as MemWriter>::commit(
2994            &folder_writer,
2995            "seed",
2996            &CommitContext::internal(),
2997        )
2998        .unwrap();
2999
3000        // Archive mem.
3001        let archive_path = build_archive(
3002            tmp.path(),
3003            "external",
3004            &[("ext.md", b"external"), ("dir/nested.md", b"nested")],
3005        );
3006
3007        let engine = Engine::from_mounts(vec![
3008            (
3009                folder_mount("local", folder_dir),
3010                Box::new(folder_writer) as Box<dyn MemBackend>,
3011            ),
3012            (
3013                archive_mount("external", archive_path.clone()),
3014                Box::new(ArchiveBackend::new(archive_path)),
3015            ),
3016        ])
3017        .unwrap();
3018
3019        // Routes correctly by mem name.
3020        assert_eq!(engine.mem_names(), vec!["local", "external"]);
3021        assert_eq!(
3022            engine.read_entity("local", Path::new("local.md")).unwrap(),
3023            Some(b"local".to_vec())
3024        );
3025        assert_eq!(
3026            engine.read_entity("external", Path::new("ext.md")).unwrap(),
3027            Some(b"external".to_vec())
3028        );
3029        assert_eq!(
3030            engine
3031                .read_entity("external", Path::new("dir/nested.md"))
3032                .unwrap(),
3033            Some(b"nested".to_vec())
3034        );
3035        // Cross-routing: reading a path from the wrong mem → None
3036        // (the backend doesn't have it), not an error.
3037        assert_eq!(
3038            engine.read_entity("local", Path::new("ext.md")).unwrap(),
3039            None
3040        );
3041        assert_eq!(
3042            engine
3043                .read_entity("external", Path::new("local.md"))
3044                .unwrap(),
3045            None
3046        );
3047    }
3048
3049    #[test]
3050    fn edge_is_from_readonly_classifies_every_edge_by_source_mount_capability() {
3051        // `engine.edge_is_from_readonly` is the derived-on-demand
3052        // alternative to adding a per-edge marker: construct a mixed
3053        // workspace (one Write-Mem + one ReadOnly archive with
3054        // cross-mem wiki-links) and walk every edge in the store,
3055        // asserting each edge's source-mount capability.
3056        let tmp = TempDir::new().unwrap();
3057
3058        // Write folder mem `local` with a spec-shaped entity that
3059        // declares an explicit cross-mem relation into the archive
3060        // (under the alias model edges originate from `## Relationships`).
3061        let folder_dir = tmp.path().join("local-mem");
3062        std::fs::create_dir_all(&folder_dir).unwrap();
3063        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
3064        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";
3065        <FilesystemMemWriter as MemWriter>::write_entity(
3066            &folder_writer,
3067            Path::new("note.md"),
3068            local_md,
3069        )
3070        .unwrap();
3071        <FilesystemMemWriter as MemWriter>::commit(
3072            &folder_writer,
3073            "seed",
3074            &CommitContext::internal(),
3075        )
3076        .unwrap();
3077
3078        // ReadOnly archive mem `external` with a spec-shaped entity
3079        // declaring an explicit cross-mem relation back to the local
3080        // note.
3081        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";
3082        let archive_path = build_archive(tmp.path(), "external", &[("archived.md", archive_md)]);
3083
3084        let engine = Engine::from_mounts(vec![
3085            (
3086                folder_mount("local", folder_dir),
3087                Box::new(folder_writer) as Box<dyn MemBackend>,
3088            ),
3089            (
3090                archive_mount("external", archive_path.clone()),
3091                Box::new(ArchiveBackend::new(archive_path)),
3092            ),
3093        ])
3094        .unwrap();
3095
3096        // Sanity: both entities are real, both mems are mounted.
3097        let local_id = EntityId::new("local", "note");
3098        let archived_id = EntityId::new("external", "archived");
3099        assert!(engine.get_entity(&local_id).is_some());
3100        assert!(engine.get_entity(&archived_id).is_some());
3101        assert!(matches!(
3102            engine.capability("local").unwrap(),
3103            MountCapability::Write
3104        ));
3105        assert!(matches!(
3106            engine.capability("external").unwrap(),
3107            MountCapability::ReadOnly
3108        ));
3109
3110        // Walk every edge in the store. For each (from, edge) pair,
3111        // `edge_is_from_readonly(from)` must return true iff the
3112        // source mount's capability is ReadOnly. The fixture's two
3113        // wiki-links produce one edge from each mem — both halves
3114        // exercise both branches of the helper.
3115        let mut seen_write_edge = false;
3116        let mut seen_readonly_edge = false;
3117        for from in engine.store().all_ids().cloned().collect::<Vec<_>>() {
3118            for _edge in engine.store().outgoing(&from) {
3119                let is_ro = engine.edge_is_from_readonly(&from);
3120                match engine.capability(from.mem()).unwrap() {
3121                    MountCapability::Write => {
3122                        assert!(
3123                            !is_ro,
3124                            "edge from write mem {} reported as ReadOnly",
3125                            from.mem()
3126                        );
3127                        seen_write_edge = true;
3128                    }
3129                    MountCapability::ReadOnly => {
3130                        assert!(
3131                            is_ro,
3132                            "edge from readonly mem {} reported as Write",
3133                            from.mem()
3134                        );
3135                        seen_readonly_edge = true;
3136                    }
3137                }
3138            }
3139        }
3140        assert!(
3141            seen_write_edge,
3142            "fixture must produce at least one edge from a write mem"
3143        );
3144        assert!(
3145            seen_readonly_edge,
3146            "fixture must produce at least one edge from a readonly mem"
3147        );
3148
3149        // Helper also reports `false` for mems absent from the
3150        // router — no mount → no ReadOnly assertion can be made.
3151        let phantom = EntityId::new("missing-mem", "phantom");
3152        assert!(
3153            !engine.edge_is_from_readonly(&phantom),
3154            "absent mount must not be reported as ReadOnly"
3155        );
3156    }
3157
3158    // ---- Engine::changes_since wrapper ------------------------------
3159
3160    #[test]
3161    fn cross_mem_link_allowed_same_mem_always_true() {
3162        // Self-edges (from == to) bypass the cross-mem policy
3163        // entirely — the policy gates *cross*-mem edges only.
3164        let tmp = TempDir::new().unwrap();
3165        let engine = build_demo_engine(&tmp);
3166        assert!(engine.cross_mem_link_allowed("specs", "specs"));
3167        // Even when the mem doesn't exist (not enrolled in
3168        // settings.cross_mem_links), same-mem returns true —
3169        // the engine doesn't validate mem existence here, just the
3170        // policy.
3171        assert!(engine.cross_mem_link_allowed("anywhere", "anywhere"));
3172    }
3173
3174    #[test]
3175    fn cross_mem_link_allowed_absent_denies_by_default() {
3176        // No entry in cross_mem_links for `from_mem` → denied.
3177        // Default-deny is the V1 posture; operators opt in.
3178        let tmp = TempDir::new().unwrap();
3179        let engine = build_demo_engine(&tmp);
3180        assert!(!engine.cross_mem_link_allowed("specs", "engine"));
3181        assert!(!engine.cross_mem_link_allowed("missing", "anywhere"));
3182    }
3183
3184    #[test]
3185    fn cross_mem_link_allowed_wildcard_admits_any_target() {
3186        use memstead_schema::workspace_config::CrossLinkValue;
3187        let tmp = TempDir::new().unwrap();
3188        let mut engine = build_demo_engine(&tmp);
3189        let mut settings = crate::workspace::WorkspaceSettings::default();
3190        settings
3191            .cross_mem_links
3192            .insert("specs".to_string(), CrossLinkValue::Wildcard);
3193        engine.set_settings(settings);
3194        assert!(engine.cross_mem_link_allowed("specs", "engine"));
3195        assert!(engine.cross_mem_link_allowed("specs", "macos"));
3196        assert!(engine.cross_mem_link_allowed("specs", "any-other"));
3197        // Reverse direction is independent — no policy entry for
3198        // engine→specs means denied.
3199        assert!(!engine.cross_mem_link_allowed("engine", "specs"));
3200    }
3201
3202    #[test]
3203    fn cross_mem_link_allowed_allowlist_enforces_membership() {
3204        use memstead_schema::workspace_config::CrossLinkValue;
3205        let tmp = TempDir::new().unwrap();
3206        let mut engine = build_demo_engine(&tmp);
3207        let mut settings = crate::workspace::WorkspaceSettings::default();
3208        settings.cross_mem_links.insert(
3209            "specs".to_string(),
3210            CrossLinkValue::List(vec!["engine".to_string(), "macos".to_string()]),
3211        );
3212        engine.set_settings(settings);
3213        assert!(engine.cross_mem_link_allowed("specs", "engine"));
3214        assert!(engine.cross_mem_link_allowed("specs", "macos"));
3215        assert!(!engine.cross_mem_link_allowed("specs", "external"));
3216    }
3217
3218    #[test]
3219    fn cross_mem_link_allowed_synthesises_from_matching_create_rule_wildcard() {
3220        // No explicit cross_mem_links entry, but a create rule
3221        // matches `from_mem` and carries default_cross_links = "*".
3222        // Synthesis grants permission to any target.
3223        use memstead_schema::workspace_config::CrossLinkValue;
3224        let tmp = TempDir::new().unwrap();
3225        let mut engine = build_demo_engine(&tmp);
3226        let mut settings = crate::workspace::WorkspaceSettings::default();
3227        settings
3228            .mem_create_rules
3229            .push(crate::workspace::CreateRuleSetting {
3230                pattern: "exec-*".to_string(),
3231                schemas: vec!["default".to_string()],
3232                default_cross_links: Some(CrossLinkValue::Wildcard),
3233            });
3234        engine.set_settings(settings);
3235        // No explicit policy; synthesis grants permission for any
3236        // target because the rule's value is Wildcard.
3237        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3238        assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
3239        // Mem that doesn't match any rule → still denied.
3240        assert!(!engine.cross_mem_link_allowed("orphan", "specs"));
3241    }
3242
3243    /// #42: synthesis matches a hierarchical mem by composing the same
3244    /// `<mem_path>/<name>` candidate the create-rule glob is keyed on,
3245    /// not the bare leaf. Before the fix, `from_mem = "project"` could
3246    /// never match a `memstead/*` rule (the leaf-vs-composed-path
3247    /// divergence), so enforcement denied a link `memstead_overview`
3248    /// rendered as rule-granted.
3249    #[test]
3250    fn cross_mem_link_allowed_synthesises_for_hierarchical_mem() {
3251        use memstead_schema::workspace_config::CrossLinkValue;
3252        let tmp = TempDir::new().unwrap();
3253        let mem_dir = tmp.path().to_path_buf();
3254        // Mount `project` with a hierarchical branch so its `mem_path()`
3255        // is "memstead" and the composed candidate is "memstead/project".
3256        // The Folder backend handles loading; only the Mount's storage
3257        // feeds `mem_path()`.
3258        let mount = Mount {
3259            mem: "project".into(),
3260            schema: Some(pin("default")),
3261            storage: MountStorage::GitBranch {
3262                gitdir: mem_dir.join(".git"),
3263                branch: "memstead/project".into(),
3264            },
3265            capability: MountCapability::Write,
3266            lifecycle: MountLifecycle::Eager,
3267            cross_linkable: true,
3268            migration_target: None,
3269        };
3270        let writer = FilesystemMemWriter::new(mem_dir.clone());
3271        let mut engine =
3272            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3273        let mut settings = crate::workspace::WorkspaceSettings::default();
3274        settings
3275            .mem_create_rules
3276            .push(crate::workspace::CreateRuleSetting {
3277                pattern: "memstead/*".to_string(),
3278                schemas: vec!["default".to_string()],
3279                default_cross_links: Some(CrossLinkValue::List(vec!["engine".to_string()])),
3280            });
3281        engine.set_settings(settings);
3282        assert!(
3283            engine.cross_mem_link_allowed("project", "engine"),
3284            "synthesis must match via the composed `memstead/project` candidate"
3285        );
3286        assert!(
3287            !engine.cross_mem_link_allowed("project", "macos"),
3288            "a target outside the rule's default_cross_links is still denied"
3289        );
3290    }
3291
3292    #[test]
3293    fn cross_mem_link_allowed_synthesises_from_matching_create_rule_list() {
3294        // Create rule's default_cross_links is a list — synthesis
3295        // grants permission to listed targets only.
3296        use memstead_schema::workspace_config::CrossLinkValue;
3297        let tmp = TempDir::new().unwrap();
3298        let mut engine = build_demo_engine(&tmp);
3299        let mut settings = crate::workspace::WorkspaceSettings::default();
3300        settings
3301            .mem_create_rules
3302            .push(crate::workspace::CreateRuleSetting {
3303                pattern: "exec-*".to_string(),
3304                schemas: vec!["default".to_string()],
3305                default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
3306            });
3307        engine.set_settings(settings);
3308        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3309        // Target not in the synthesised list → denied.
3310        assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
3311    }
3312
3313    #[test]
3314    fn cross_mem_link_allowed_explicit_policy_wins_over_synthesis() {
3315        // Explicit cross_mem_links wildcard fires first; the
3316        // synthesis layer is never consulted (and would deny).
3317        use memstead_schema::workspace_config::CrossLinkValue;
3318        let tmp = TempDir::new().unwrap();
3319        let mut engine = build_demo_engine(&tmp);
3320        let mut settings = crate::workspace::WorkspaceSettings::default();
3321        settings
3322            .cross_mem_links
3323            .insert("exec-foo".to_string(), CrossLinkValue::Wildcard);
3324        // The synthesis layer would deny `exec-foo → engine` (no
3325        // matching rule), but explicit policy returns true first.
3326        engine.set_settings(settings);
3327        assert!(engine.cross_mem_link_allowed("exec-foo", "engine"));
3328    }
3329
3330    #[test]
3331    fn cross_mem_link_allowed_synthesis_unions_into_explicit_list() {
3332        // Explicit list = ["specs"]; create rule synthesises = ["macos"].
3333        // Effective allowed targets: union ({specs, macos}).
3334        use memstead_schema::workspace_config::CrossLinkValue;
3335        let tmp = TempDir::new().unwrap();
3336        let mut engine = build_demo_engine(&tmp);
3337        let mut settings = crate::workspace::WorkspaceSettings::default();
3338        settings.cross_mem_links.insert(
3339            "exec-foo".to_string(),
3340            CrossLinkValue::List(vec!["specs".to_string()]),
3341        );
3342        settings
3343            .mem_create_rules
3344            .push(crate::workspace::CreateRuleSetting {
3345                pattern: "exec-*".to_string(),
3346                schemas: vec!["default".to_string()],
3347                default_cross_links: Some(CrossLinkValue::List(vec!["macos".to_string()])),
3348            });
3349        engine.set_settings(settings);
3350        // Explicit allowlist contains specs → allowed.
3351        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3352        // Synthesis layer adds macos → allowed.
3353        assert!(engine.cross_mem_link_allowed("exec-foo", "macos"));
3354        // Neither layer allows engine → denied.
3355        assert!(!engine.cross_mem_link_allowed("exec-foo", "engine"));
3356    }
3357
3358    #[test]
3359    fn cross_mem_link_allowed_set_settings_invalidates_compiled_rule_cache() {
3360        // After set_settings, a fresh policy must be reflected on the
3361        // next call — the lazy memo can't return stale rules.
3362        use memstead_schema::workspace_config::CrossLinkValue;
3363        let tmp = TempDir::new().unwrap();
3364        let mut engine = build_demo_engine(&tmp);
3365
3366        // First settings: a rule allows exec-* → specs via synthesis.
3367        let mut s1 = crate::workspace::WorkspaceSettings::default();
3368        s1.mem_create_rules
3369            .push(crate::workspace::CreateRuleSetting {
3370                pattern: "exec-*".to_string(),
3371                schemas: vec!["default".to_string()],
3372                default_cross_links: Some(CrossLinkValue::List(vec!["specs".to_string()])),
3373            });
3374        engine.set_settings(s1);
3375        assert!(engine.cross_mem_link_allowed("exec-foo", "specs"));
3376
3377        // Replace settings: the rule no longer carries
3378        // default_cross_links. Cache must invalidate so the next
3379        // call sees the new policy.
3380        let mut s2 = crate::workspace::WorkspaceSettings::default();
3381        s2.mem_create_rules
3382            .push(crate::workspace::CreateRuleSetting {
3383                pattern: "exec-*".to_string(),
3384                schemas: vec!["default".to_string()],
3385                default_cross_links: None,
3386            });
3387        engine.set_settings(s2);
3388        assert!(!engine.cross_mem_link_allowed("exec-foo", "specs"));
3389    }
3390
3391    #[test]
3392    fn cross_mem_link_allowed_malformed_glob_falls_back_to_explicit_policy() {
3393        // Malformed pattern in a create rule causes CreateRuleSet
3394        // compilation to fail; the resolver logs and disables
3395        // synthesis, but explicit cross_mem_links still works.
3396        use memstead_schema::workspace_config::CrossLinkValue;
3397        let tmp = TempDir::new().unwrap();
3398        let mut engine = build_demo_engine(&tmp);
3399        let mut settings = crate::workspace::WorkspaceSettings::default();
3400        settings
3401            .mem_create_rules
3402            .push(crate::workspace::CreateRuleSetting {
3403                pattern: "[unclosed".to_string(),
3404                schemas: vec!["default".to_string()],
3405                default_cross_links: Some(CrossLinkValue::Wildcard),
3406            });
3407        // Explicit policy still works.
3408        settings
3409            .cross_mem_links
3410            .insert("specs".to_string(), CrossLinkValue::Wildcard);
3411        engine.set_settings(settings);
3412        // Explicit policy: specs → engine allowed.
3413        assert!(engine.cross_mem_link_allowed("specs", "engine"));
3414        // Synthesis disabled (compilation failed); rule's would-be
3415        // wildcard doesn't apply.
3416        assert!(!engine.cross_mem_link_allowed("orphan", "anything"));
3417    }
3418
3419    #[test]
3420    fn cross_mem_link_allowed_empty_list_denies_all_cross_mem_targets() {
3421        // [cross_mem_links] specs = [] is the explicit
3422        // "intentionally locked down" shape — same effect as
3423        // default-deny but operator-acknowledged.
3424        use memstead_schema::workspace_config::CrossLinkValue;
3425        let tmp = TempDir::new().unwrap();
3426        let mut engine = build_demo_engine(&tmp);
3427        let mut settings = crate::workspace::WorkspaceSettings::default();
3428        settings
3429            .cross_mem_links
3430            .insert("specs".to_string(), CrossLinkValue::List(Vec::new()));
3431        engine.set_settings(settings);
3432        // Same-mem still passes — policy only gates cross-mem.
3433        assert!(engine.cross_mem_link_allowed("specs", "specs"));
3434        // Cross-mem denied to every target.
3435        assert!(!engine.cross_mem_link_allowed("specs", "engine"));
3436        assert!(!engine.cross_mem_link_allowed("specs", "anything"));
3437    }
3438
3439    #[test]
3440    fn from_mounts_load_warnings_merge_into_health_summary() {
3441        let tmp = TempDir::new().unwrap();
3442        let mem_dir = tmp.path().to_path_buf();
3443        let body = "---\ntype: spec\n---\n# Dup2\n\n## Identity\n\na.\n\n## Identity\n\nb.\n";
3444        std::fs::write(mem_dir.join("dup2.md"), body).unwrap();
3445
3446        let writer = FilesystemMemWriter::new(mem_dir.clone());
3447        let engine = Engine::from_mounts(vec![(
3448            folder_mount("specs", mem_dir),
3449            Box::new(writer) as Box<dyn MemBackend>,
3450        )])
3451        .unwrap();
3452
3453        let summary = engine.health();
3454        assert!(
3455            summary
3456                .warnings
3457                .iter()
3458                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
3459            "health() must merge load_warnings into summary.warnings: {:?}",
3460            summary.warnings,
3461        );
3462    }
3463
3464    #[test]
3465    fn workspace_root_accessor_is_none_for_engine_built_from_mounts() {
3466        let tmp = TempDir::new().unwrap();
3467        let mem_dir = tmp.path().to_path_buf();
3468        let writer = FilesystemMemWriter::new(mem_dir.clone());
3469        // Newest default generation so the clean-boot assertion below
3470        // isn't tripped by the SCHEMA_GENERATIONS_BEHIND hint.
3471        let mut mount = folder_mount("specs", mem_dir);
3472        mount.schema = Some("default@1.3.0".parse().unwrap());
3473        let engine =
3474            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3475        assert!(
3476            engine.workspace_root().is_none(),
3477            "from_mounts has no workspace path",
3478        );
3479        // An entity-less mount reports itself empty; nothing else.
3480        assert!(
3481            engine
3482                .load_warnings()
3483                .iter()
3484                .all(|w| w.code() == "MOUNT_UNBACKED"),
3485            "{:?}",
3486            engine.load_warnings()
3487        );
3488    }
3489
3490    #[test]
3491    fn health_omits_outer_repo_warning_when_workspace_root_unset() {
3492        let tmp = TempDir::new().unwrap();
3493        let mem_dir = tmp.path().to_path_buf();
3494        let writer = FilesystemMemWriter::new(mem_dir.clone());
3495        let engine = Engine::from_mounts(vec![(
3496            folder_mount("specs", mem_dir),
3497            Box::new(writer) as Box<dyn MemBackend>,
3498        )])
3499        .unwrap();
3500        let health = engine.health();
3501        assert!(
3502            !health
3503                .warnings
3504                .iter()
3505                .any(|w| matches!(w, WarningHint::OuterRepoNotIgnoringMemRepo { .. })),
3506            "outer-repo check must skip when workspace_root is None",
3507        );
3508    }
3509
3510    #[test]
3511    fn writable_mem_names_filters_by_capability() {
3512        let tmp = TempDir::new().unwrap();
3513        let mem_dir = tmp.path().to_path_buf();
3514        let writer = FilesystemMemWriter::new(mem_dir.clone());
3515        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3516
3517        let engine = Engine::from_mounts(vec![
3518            (
3519                folder_mount("writable", mem_dir),
3520                Box::new(writer) as Box<dyn MemBackend>,
3521            ),
3522            (
3523                archive_mount("sealed", archive_path.clone()),
3524                Box::new(ArchiveBackend::new(archive_path)),
3525            ),
3526        ])
3527        .unwrap();
3528
3529        // Only the writable mount surfaces; the archive (read-only)
3530        // is filtered out.
3531        let names = engine.writable_mem_names();
3532        assert_eq!(names, vec!["writable"]);
3533    }
3534
3535    /// The default writable mem is
3536    /// the FIRST writable mount in declaration order — the stable seed,
3537    /// not the alphabetically-first name. `test` is declared first;
3538    /// `other` sorts ahead alphabetically but is declared second, so it
3539    /// is NOT the default. This is the invariant that stops a second
3540    /// mem from silently retargeting omitted-`mem` writes.
3541    #[test]
3542    fn default_writable_mem_is_declaration_first_not_alphabetical() {
3543        let tmp = TempDir::new().unwrap();
3544        let test_dir = tmp.path().join("test");
3545        let other_dir = tmp.path().join("other");
3546        std::fs::create_dir_all(&test_dir).unwrap();
3547        std::fs::create_dir_all(&other_dir).unwrap();
3548
3549        let engine = Engine::from_mounts(vec![
3550            (
3551                folder_mount("test", test_dir.clone()),
3552                Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
3553            ),
3554            (
3555                folder_mount("other", other_dir.clone()),
3556                Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3557            ),
3558        ])
3559        .unwrap();
3560
3561        assert_eq!(
3562            engine.default_writable_mem(),
3563            Some("test"),
3564            "default must be the declaration-first writable mem, not the alphabetically-first",
3565        );
3566    }
3567
3568    /// Reverse declaration order to prove the default tracks declaration
3569    /// order rather than a fixed name: with `other` declared first it
3570    /// becomes the default. Together with the test above this pins the
3571    /// lean as mount order, not name sort.
3572    #[test]
3573    fn default_writable_mem_follows_declaration_order() {
3574        let tmp = TempDir::new().unwrap();
3575        let other_dir = tmp.path().join("other");
3576        let test_dir = tmp.path().join("test");
3577        std::fs::create_dir_all(&other_dir).unwrap();
3578        std::fs::create_dir_all(&test_dir).unwrap();
3579
3580        let engine = Engine::from_mounts(vec![
3581            (
3582                folder_mount("other", other_dir.clone()),
3583                Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3584            ),
3585            (
3586                folder_mount("test", test_dir.clone()),
3587                Box::new(FilesystemMemWriter::new(test_dir)) as Box<dyn MemBackend>,
3588            ),
3589        ])
3590        .unwrap();
3591
3592        assert_eq!(engine.default_writable_mem(), Some("other"));
3593    }
3594
3595    /// A read-only-only workspace has no default writable mem.
3596    #[test]
3597    fn default_writable_mem_none_without_writable_mount() {
3598        let tmp = TempDir::new().unwrap();
3599        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3600        let engine = Engine::from_mounts(vec![(
3601            archive_mount("sealed", archive_path.clone()),
3602            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3603        )])
3604        .unwrap();
3605        assert_eq!(engine.default_writable_mem(), None);
3606    }
3607
3608    #[test]
3609    fn folder_path_for_mem_returns_path_for_folder_mounts_only() {
3610        let tmp = TempDir::new().unwrap();
3611        let mem_dir = tmp.path().join("specs");
3612        std::fs::create_dir_all(&mem_dir).unwrap();
3613        let writer = FilesystemMemWriter::new(mem_dir.clone());
3614        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3615
3616        let engine = Engine::from_mounts(vec![
3617            (
3618                folder_mount("specs", mem_dir.clone()),
3619                Box::new(writer) as Box<dyn MemBackend>,
3620            ),
3621            (
3622                archive_mount("sealed", archive_path.clone()),
3623                Box::new(ArchiveBackend::new(archive_path)),
3624            ),
3625        ])
3626        .unwrap();
3627
3628        // Folder mount returns its path.
3629        assert_eq!(engine.folder_path_for_mem("specs"), Some(mem_dir.as_path()),);
3630        // Archive mount returns None — caller branches on storage type.
3631        assert_eq!(engine.folder_path_for_mem("sealed"), None);
3632        // Unknown mem returns None — same as Engine::mount.
3633        assert_eq!(engine.folder_path_for_mem("missing"), None);
3634    }
3635
3636    #[test]
3637    fn mount_accessor_returns_public_mount_shape() {
3638        // Build a heterogeneous engine and verify Engine::mount /
3639        // Engine::mounts surface the operator-facing Mount records.
3640        // Handlers branch on MountStorage variants through this
3641        // accessor (replacing full's gitdir_for / worktree_for /
3642        // mem_head_sha / mem_config_for direct-engine
3643        // accessors).
3644        let tmp = TempDir::new().unwrap();
3645        let mem_dir = tmp.path().to_path_buf();
3646        let writer = FilesystemMemWriter::new(mem_dir.clone());
3647        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3648
3649        let engine = Engine::from_mounts(vec![
3650            (
3651                folder_mount("writable", mem_dir.clone()),
3652                Box::new(writer) as Box<dyn MemBackend>,
3653            ),
3654            (
3655                archive_mount("sealed", archive_path.clone()),
3656                Box::new(ArchiveBackend::new(archive_path.clone())),
3657            ),
3658        ])
3659        .unwrap();
3660
3661        // Known mems: each returns a Mount whose storage variant
3662        // matches what the caller passed at construction.
3663        let folder = engine.mount("writable").expect("known mem");
3664        assert!(matches!(folder.storage, MountStorage::Folder { .. }));
3665        assert_eq!(folder.capability, MountCapability::Write);
3666
3667        let archive = engine.mount("sealed").expect("known mem");
3668        match &archive.storage {
3669            MountStorage::Archive { path } => assert_eq!(path, &archive_path),
3670            other => panic!("expected Archive storage, got {other:?}"),
3671        }
3672        assert_eq!(archive.capability, MountCapability::ReadOnly);
3673
3674        // Unknown mem — None, no panic, no error.
3675        assert!(engine.mount("missing").is_none());
3676
3677        // Engine::mounts enumerates every mount in declaration order.
3678        let mounts = engine.mounts();
3679        assert_eq!(mounts.len(), 2);
3680        assert_eq!(mounts[0].mem, "writable");
3681        assert_eq!(mounts[1].mem, "sealed");
3682    }
3683
3684    #[test]
3685    fn mem_router_writable_set_matches_writable_mount_capability() {
3686        // Build an engine with one writable folder mount and one
3687        // read-only archive mount; the router's writable set must
3688        // equal the writable mount's name only.
3689        let tmp = TempDir::new().unwrap();
3690        let mem_dir = tmp.path().join("specs");
3691        std::fs::create_dir_all(&mem_dir).unwrap();
3692        let writer = FilesystemMemWriter::new(mem_dir.clone());
3693        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3694
3695        let engine = Engine::from_mounts(vec![
3696            (
3697                folder_mount("specs", mem_dir.clone()),
3698                Box::new(writer) as Box<dyn MemBackend>,
3699            ),
3700            (
3701                archive_mount("ext", archive_path.clone()),
3702                Box::new(ArchiveBackend::new(archive_path)),
3703            ),
3704        ])
3705        .unwrap();
3706
3707        let router = engine.mem_router();
3708        assert!(router.is_writable("specs"));
3709        assert!(!router.is_writable("ext"));
3710        assert!(router.is_visible("specs"));
3711        assert!(router.is_visible("ext"));
3712        let writable: std::collections::HashSet<&String> = router.writable_mems().iter().collect();
3713        assert_eq!(writable.len(), 1);
3714        assert!(writable.contains(&"specs".to_string()));
3715    }
3716
3717    #[test]
3718    fn mem_router_origin_is_explicit_toml_for_workspace_mounts() {
3719        // Every mount built via `from_mounts` lands as
3720        // `MemOrigin::ExplicitToml` — the file-adapter origin.
3721        // `RuntimeCreated` is reserved for `memstead_mem_create`
3722        // runtime registrations once that handler migrates onto
3723        // the unified engine.
3724        let tmp = TempDir::new().unwrap();
3725        let mem_dir = tmp.path().join("specs");
3726        std::fs::create_dir_all(&mem_dir).unwrap();
3727        let writer = FilesystemMemWriter::new(mem_dir.clone());
3728
3729        let engine = Engine::from_mounts(vec![(
3730            folder_mount("specs", mem_dir),
3731            Box::new(writer) as Box<dyn MemBackend>,
3732        )])
3733        .unwrap();
3734
3735        let origin = engine
3736            .mem_router()
3737            .origin_for_mem("specs")
3738            .expect("known mem");
3739        assert_eq!(origin.kind(), "explicit");
3740    }
3741
3742    #[test]
3743    fn mem_router_dir_for_writable_folder_mount_matches_storage_path() {
3744        // Folder-backed writable mounts surface the storage path
3745        // via `dir_for_mem`. Handlers consuming the router for
3746        // per-mem path resolution rely on this.
3747        let tmp = TempDir::new().unwrap();
3748        let mem_dir = tmp.path().join("specs");
3749        std::fs::create_dir_all(&mem_dir).unwrap();
3750        let writer = FilesystemMemWriter::new(mem_dir.clone());
3751
3752        let engine = Engine::from_mounts(vec![(
3753            folder_mount("specs", mem_dir.clone()),
3754            Box::new(writer) as Box<dyn MemBackend>,
3755        )])
3756        .unwrap();
3757
3758        assert_eq!(
3759            engine.mem_router().dir_for_mem("specs"),
3760            Some(mem_dir.as_path()),
3761        );
3762        assert_eq!(engine.mem_router().dir_for_mem("unknown"), None);
3763    }
3764
3765    #[test]
3766    fn mem_router_archive_path_for_read_only_archive_mount() {
3767        // Read-only archive mounts register via `add_read_only` so
3768        // `archive_path_for_mem` resolves the archive's on-disk
3769        // location.
3770        let tmp = TempDir::new().unwrap();
3771        let mem_dir = tmp.path().join("specs");
3772        std::fs::create_dir_all(&mem_dir).unwrap();
3773        let writer = FilesystemMemWriter::new(mem_dir.clone());
3774        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3775
3776        let engine = Engine::from_mounts(vec![
3777            (
3778                folder_mount("specs", mem_dir),
3779                Box::new(writer) as Box<dyn MemBackend>,
3780            ),
3781            (
3782                archive_mount("ext", archive_path.clone()),
3783                Box::new(ArchiveBackend::new(archive_path.clone())),
3784            ),
3785        ])
3786        .unwrap();
3787
3788        let router = engine.mem_router();
3789        assert_eq!(
3790            router.archive_path_for_mem("ext"),
3791            Some(archive_path.as_path()),
3792        );
3793        // Writable folder mount has no archive path.
3794        assert_eq!(router.archive_path_for_mem("specs"), None);
3795    }
3796
3797    #[test]
3798    fn read_mem_config_via_backend_trait_folder_reads_bytes() {
3799        // Direct trait call against FilesystemMemWriter. Verifies
3800        // the backend-side primitive returns the raw bytes the
3801        // engine then parses.
3802        let tmp = TempDir::new().unwrap();
3803        let mem_dir = tmp.path().to_path_buf();
3804        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3805        let body = br#"{
3806            "format": 1,
3807            "schema": "default@1.0.0",
3808            "writeGuidance": { "tone": "neutral" }
3809        }"#;
3810        std::fs::write(mem_dir.join(".memstead").join("config.json"), body).unwrap();
3811
3812        let writer = FilesystemMemWriter::new(mem_dir);
3813        let result = MemBackend::read_mem_config(&writer).unwrap();
3814        let bytes = result.expect("config bytes must surface");
3815        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3816        assert_eq!(parsed["schema"], "default@1.0.0");
3817    }
3818
3819    #[test]
3820    fn read_mem_config_via_backend_trait_folder_missing_returns_none() {
3821        let tmp = TempDir::new().unwrap();
3822        let mem_dir = tmp.path().to_path_buf();
3823        let writer = FilesystemMemWriter::new(mem_dir);
3824        let result = MemBackend::read_mem_config(&writer).unwrap();
3825        assert!(result.is_none());
3826    }
3827
3828    #[test]
3829    fn read_mem_config_via_backend_trait_archive_reads_bytes() {
3830        // Build an archive containing .memstead/config.json and verify
3831        // the ArchiveBackend impl returns its bytes.
3832        let tmp = TempDir::new().unwrap();
3833        let archive_path = tmp.path().join("seed.mem");
3834        let body = br#"{
3835            "format": 1,
3836            "schema": "default@1.0.0",
3837            "writeGuidance": { "tone": "archive" }
3838        }"#;
3839        {
3840            let file = std::fs::File::create(&archive_path).unwrap();
3841            let mut writer = zip::ZipWriter::new(file);
3842            writer
3843                .start_file(
3844                    ".memstead/config.json",
3845                    zip::write::SimpleFileOptions::default(),
3846                )
3847                .unwrap();
3848            use std::io::Write;
3849            writer.write_all(body).unwrap();
3850            writer.finish().unwrap();
3851        }
3852
3853        let backend = ArchiveBackend::new(archive_path);
3854        let result = MemBackend::read_mem_config(&backend).unwrap();
3855        let bytes = result.expect("config bytes must surface");
3856        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3857        assert_eq!(parsed["writeGuidance"]["tone"], "archive");
3858    }
3859
3860    #[test]
3861    fn mem_config_for_returns_none_when_no_config_file_present() {
3862        // Folder backend without a `.memstead/config.json` file. The
3863        // accessor must lenient — return None, not error.
3864        let tmp = TempDir::new().unwrap();
3865        let mem_dir = tmp.path().to_path_buf();
3866        let writer = FilesystemMemWriter::new(mem_dir.clone());
3867        let engine = Engine::from_mounts(vec![(
3868            folder_mount("specs", mem_dir),
3869            Box::new(writer) as Box<dyn MemBackend>,
3870        )])
3871        .unwrap();
3872        assert!(engine.mem_config_for("specs").is_none());
3873    }
3874
3875    #[test]
3876    fn mem_config_for_returns_some_when_config_file_present() {
3877        // Drop a valid `.memstead/config.json` into the mem dir,
3878        // build the engine, and assert the accessor surfaces a
3879        // MemConfig with the right shape (write_guidance entries
3880        // round-trip).
3881        let tmp = TempDir::new().unwrap();
3882        let mem_dir = tmp.path().to_path_buf();
3883        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3884        let config_body = r#"{
3885            "format": 1,
3886            "schema": "default@1.0.0",
3887            "writeGuidance": {
3888                "tone": "neutral",
3889                "voice": "active"
3890            }
3891        }"#;
3892        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
3893
3894        let writer = FilesystemMemWriter::new(mem_dir.clone());
3895        let engine = Engine::from_mounts(vec![(
3896            folder_mount("specs", mem_dir),
3897            Box::new(writer) as Box<dyn MemBackend>,
3898        )])
3899        .unwrap();
3900
3901        let cfg = engine
3902            .mem_config_for("specs")
3903            .expect("mem_config should load");
3904        assert_eq!(cfg.write_guidance.len(), 2);
3905        assert_eq!(
3906            cfg.write_guidance.get("tone").and_then(|v| v.as_str()),
3907            Some("neutral"),
3908        );
3909        assert_eq!(
3910            cfg.write_guidance.get("voice").and_then(|v| v.as_str()),
3911            Some("active"),
3912        );
3913    }
3914
3915    #[test]
3916    fn mem_config_for_unknown_mem_returns_none() {
3917        // Lenient accessor — unknown names get None, not Err.
3918        let tmp = TempDir::new().unwrap();
3919        let mem_dir = tmp.path().to_path_buf();
3920        let writer = FilesystemMemWriter::new(mem_dir.clone());
3921        let engine = Engine::from_mounts(vec![(
3922            folder_mount("specs", mem_dir),
3923            Box::new(writer) as Box<dyn MemBackend>,
3924        )])
3925        .unwrap();
3926        assert!(engine.mem_config_for("missing").is_none());
3927    }
3928
3929    #[test]
3930    fn mem_config_for_archive_mount_returns_none() {
3931        // Archive backends carry mem_config = None in V1 (the
3932        // read-from-storage path is deferred to a follow-up).
3933        let tmp = TempDir::new().unwrap();
3934        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
3935        let engine = Engine::from_mounts(vec![(
3936            archive_mount("ext", archive_path.clone()),
3937            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
3938        )])
3939        .unwrap();
3940        assert!(engine.mem_config_for("ext").is_none());
3941    }
3942
3943    #[test]
3944    fn mem_configs_named_iterates_only_mounts_with_config() {
3945        // Two folder mounts; one has a config file, one doesn't.
3946        // The iterator yields exactly the configured one — verifies
3947        // the filter_map shape and that the name comes from the
3948        // mount record (authoritative), not the config body.
3949        let tmp = TempDir::new().unwrap();
3950        let with_config = tmp.path().join("specs");
3951        let without_config = tmp.path().join("memos");
3952        std::fs::create_dir_all(with_config.join(".memstead")).unwrap();
3953        std::fs::create_dir_all(&without_config).unwrap();
3954        let config_body = r#"{
3955            "format": 1,
3956            "schema": "default@1.0.0",
3957            "writeGuidance": { "tone": "neutral" }
3958        }"#;
3959        std::fs::write(
3960            with_config.join(".memstead").join("config.json"),
3961            config_body,
3962        )
3963        .unwrap();
3964
3965        let engine = Engine::from_mounts(vec![
3966            (
3967                folder_mount("specs", with_config.clone()),
3968                Box::new(FilesystemMemWriter::new(with_config)) as Box<dyn MemBackend>,
3969            ),
3970            (
3971                folder_mount("memos", without_config.clone()),
3972                Box::new(FilesystemMemWriter::new(without_config)) as Box<dyn MemBackend>,
3973            ),
3974        ])
3975        .unwrap();
3976
3977        let yielded: Vec<(&str, usize)> = engine
3978            .mem_configs_named()
3979            .map(|(name, cfg)| (name, cfg.write_guidance.len()))
3980            .collect();
3981        assert_eq!(yielded, vec![("specs", 1)]);
3982    }
3983
3984    #[test]
3985    fn schema_for_returns_some_for_known_mem_and_none_for_unknown() {
3986        // Every mount registers a schema (resolved from its pin at
3987        // boot). Lookup by mem name surfaces the same Arc that
3988        // mutations resolve internally; unknown names return None.
3989        let tmp = TempDir::new().unwrap();
3990        let mem_dir = tmp.path().to_path_buf();
3991        let writer = FilesystemMemWriter::new(mem_dir.clone());
3992        let engine = Engine::from_mounts(vec![(
3993            folder_mount("specs", mem_dir),
3994            Box::new(writer) as Box<dyn MemBackend>,
3995        )])
3996        .unwrap();
3997        assert!(engine.schema_for("specs").is_some());
3998        assert!(engine.schema_for("missing").is_none());
3999    }
4000
4001    #[test]
4002    fn gitdir_for_unknown_mem_returns_unknown_mem() {
4003        let tmp = TempDir::new().unwrap();
4004        let mem_dir = tmp.path().to_path_buf();
4005        let writer = FilesystemMemWriter::new(mem_dir.clone());
4006        let engine = Engine::from_mounts(vec![(
4007            folder_mount("specs", mem_dir),
4008            Box::new(writer) as Box<dyn MemBackend>,
4009        )])
4010        .unwrap();
4011        let err = engine.gitdir_for("missing").unwrap_err();
4012        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
4013    }
4014
4015    #[test]
4016    fn gitdir_for_folder_mount_returns_no_gitdir_error() {
4017        // Folder mounts do not have a gitdir — full's contract surfaces
4018        // a mem-level error, not UnknownMem. Mirror that here.
4019        let tmp = TempDir::new().unwrap();
4020        let mem_dir = tmp.path().to_path_buf();
4021        let writer = FilesystemMemWriter::new(mem_dir.clone());
4022        let engine = Engine::from_mounts(vec![(
4023            folder_mount("specs", mem_dir),
4024            Box::new(writer) as Box<dyn MemBackend>,
4025        )])
4026        .unwrap();
4027        let err = engine.gitdir_for("specs").unwrap_err();
4028        match err {
4029            EngineError::Mem(msg) => assert!(msg.contains("no resolved gitdir")),
4030            other => panic!("expected EngineError::Mem, got {other:?}"),
4031        }
4032    }
4033
4034    #[test]
4035    fn worktree_for_folder_mount_returns_storage_path() {
4036        let tmp = TempDir::new().unwrap();
4037        let mem_dir = tmp.path().to_path_buf();
4038        let writer = FilesystemMemWriter::new(mem_dir.clone());
4039        let engine = Engine::from_mounts(vec![(
4040            folder_mount("specs", mem_dir.clone()),
4041            Box::new(writer) as Box<dyn MemBackend>,
4042        )])
4043        .unwrap();
4044        let worktree = engine.worktree_for("specs").unwrap();
4045        assert_eq!(worktree, mem_dir);
4046    }
4047
4048    #[test]
4049    fn worktree_for_unknown_mem_returns_unknown_mem() {
4050        let tmp = TempDir::new().unwrap();
4051        let mem_dir = tmp.path().to_path_buf();
4052        let writer = FilesystemMemWriter::new(mem_dir.clone());
4053        let engine = Engine::from_mounts(vec![(
4054            folder_mount("specs", mem_dir),
4055            Box::new(writer) as Box<dyn MemBackend>,
4056        )])
4057        .unwrap();
4058        let err = engine.worktree_for("missing").unwrap_err();
4059        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
4060    }
4061
4062    #[test]
4063    fn worktree_for_archive_mount_returns_archive_backed_error() {
4064        let tmp = TempDir::new().unwrap();
4065        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
4066        let engine = Engine::from_mounts(vec![(
4067            archive_mount("ext", archive_path.clone()),
4068            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
4069        )])
4070        .unwrap();
4071        let err = engine.worktree_for("ext").unwrap_err();
4072        match err {
4073            EngineError::Mem(msg) => assert!(msg.contains("archive-backed")),
4074            other => panic!("expected EngineError::Mem, got {other:?}"),
4075        }
4076    }
4077
4078    #[test]
4079    fn mem_head_sha_for_folder_mount_is_none() {
4080        // Folder backend doesn't track a head; current_head() returns
4081        // Ok(None) at construction; mem_head_sha returns Ok(None).
4082        let tmp = TempDir::new().unwrap();
4083        let mem_dir = tmp.path().to_path_buf();
4084        let writer = FilesystemMemWriter::new(mem_dir.clone());
4085        let engine = Engine::from_mounts(vec![(
4086            folder_mount("specs", mem_dir),
4087            Box::new(writer) as Box<dyn MemBackend>,
4088        )])
4089        .unwrap();
4090        let head = engine.mem_head_sha("specs").unwrap();
4091        assert_eq!(head, None);
4092    }
4093
4094    #[test]
4095    fn mem_head_sha_unknown_mem_returns_unknown_mem() {
4096        let tmp = TempDir::new().unwrap();
4097        let mem_dir = tmp.path().to_path_buf();
4098        let writer = FilesystemMemWriter::new(mem_dir.clone());
4099        let engine = Engine::from_mounts(vec![(
4100            folder_mount("specs", mem_dir),
4101            Box::new(writer) as Box<dyn MemBackend>,
4102        )])
4103        .unwrap();
4104        let err = engine.mem_head_sha("missing").unwrap_err();
4105        assert!(matches!(err, EngineError::UnknownMem(v) if v == "missing"));
4106    }
4107
4108    #[test]
4109    fn capability_surfaces_per_mount() {
4110        let tmp = TempDir::new().unwrap();
4111        let mem_dir = tmp.path().to_path_buf();
4112        let writer = FilesystemMemWriter::new(mem_dir.clone());
4113        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
4114
4115        let engine = Engine::from_mounts(vec![
4116            (
4117                folder_mount("writable", mem_dir),
4118                Box::new(writer) as Box<dyn MemBackend>,
4119            ),
4120            (
4121                archive_mount("read-only", archive_path.clone()),
4122                Box::new(ArchiveBackend::new(archive_path)),
4123            ),
4124        ])
4125        .unwrap();
4126
4127        assert_eq!(
4128            engine.capability("writable").unwrap(),
4129            MountCapability::Write
4130        );
4131        assert_eq!(
4132            engine.capability("read-only").unwrap(),
4133            MountCapability::ReadOnly
4134        );
4135        assert!(matches!(
4136            engine.capability("missing"),
4137            Err(EngineError::UnknownMem(_))
4138        ));
4139    }
4140
4141    #[test]
4142    fn read_provenance_routes_through_backend() {
4143        let tmp = TempDir::new().unwrap();
4144        let mem_dir = tmp.path().to_path_buf();
4145        let writer = FilesystemMemWriter::new(mem_dir.clone());
4146
4147        // Append a provenance record via the backend trait directly,
4148        // then read it back through the engine.
4149        let backend_handle: &dyn MemBackend = &writer;
4150        backend_handle
4151            .append_provenance(&Provenance::new(
4152                std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
4153                crate::ProvenanceKind::Create,
4154                Some("v:e".into()),
4155                crate::vcs::Actor::Cli,
4156                None,
4157                Some("first".into()),
4158            ))
4159            .unwrap();
4160
4161        let engine = Engine::from_mounts(vec![(
4162            folder_mount("specs", mem_dir),
4163            Box::new(writer) as Box<dyn MemBackend>,
4164        )])
4165        .unwrap();
4166
4167        let records = engine.read_provenance("specs", None).unwrap();
4168        assert_eq!(records.len(), 1);
4169        assert_eq!(records[0].kind, crate::ProvenanceKind::Create);
4170        assert_eq!(records[0].entity.as_deref(), Some("v:e"));
4171        assert_eq!(records[0].note.as_deref(), Some("first"));
4172    }
4173
4174    #[test]
4175    fn archive_mount_returns_sealed_indirectly_through_backend_layer() {
4176        // The engine doesn't yet expose mutation methods, but an
4177        // archive backend held on a Mount with ReadOnly capability is
4178        // still a `&dyn MemBackend` whose write methods return
4179        // Sealed. This test locks the trait routing — when the engine
4180        // gains write methods in a later session, capability gating +
4181        // backend Sealed errors must agree.
4182        let tmp = TempDir::new().unwrap();
4183        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
4184        let backend = ArchiveBackend::new(archive_path);
4185        match MemBackend::write_entity(&backend, Path::new("x.md"), b"x") {
4186            Err(BackendError::Sealed) => {}
4187            other => panic!("expected Sealed, got {other:?}"),
4188        }
4189    }
4190
4191    // ---- Read-side delegates ----------------------------------------
4192    //
4193    // These tests pin the surface that the MCP migration consumes
4194    // (stats, health, context, communities, search, list, orphans,
4195    // stubs, most_connected, missing_required_outgoing). They run
4196    // against a folder-mount engine with a small fixture of created
4197    // entities and one relate edge — enough to exercise both the
4198    // graph-query path and the cache-invalidation hooks.
4199
4200    /// Generation-keyed memos (flywheel W8/01). Four claims: repeated
4201    /// reads serve the memo; a REFUSED engine batch leaves the memo
4202    /// untouched (the refusal path calls no hook — pinned so it stays
4203    /// cheap); a memo computed from an INTERIM (mid-batch) state never
4204    /// survives a rollback as fresh — the store-carried generation is
4205    /// what lets the invalidation hook adjudicate that correctly; and
4206    /// a real mutation invalidates, with the recomputation identical
4207    /// to a from-scratch detection (the criterion-3 identity oracle
4208    /// for the mechanism).
4209    #[test]
4210    fn generation_keyed_memos_survive_rollback_and_track_mutations() {
4211        use indexmap::IndexMap;
4212
4213        let tmp = TempDir::new().unwrap();
4214        let mut engine = build_demo_engine(&tmp);
4215
4216        // 1. Repeated reads: cell filled once, generation stable.
4217        let _ = engine.communities();
4218        let memo_gen_before = engine.community_memo.get().expect("memo filled").0;
4219        let _ = engine.communities();
4220        assert_eq!(
4221            engine.community_memo.get().expect("still filled").0,
4222            memo_gen_before,
4223            "repeated reads must serve the memo"
4224        );
4225
4226        // 2. A REFUSED engine batch rolls the store back and leaves
4227        // the memo untouched — refusal stays recompute-free.
4228        let bare = |id: crate::EntityId| crate::engine::UpdateEntityArgs {
4229            anchors: Vec::new(),
4230            anchors_unset: Vec::new(),
4231            id,
4232            expected_hash: None,
4233            sections: IndexMap::new(),
4234            append_sections: IndexMap::new(),
4235            patch_sections: IndexMap::new(),
4236            metadata: IndexMap::new(),
4237            metadata_unset: Vec::new(),
4238            declare_relations: Vec::new(),
4239            dry_run: false,
4240            relations_unset: Vec::new(),
4241        };
4242        let mut real = bare(crate::EntityId::new("specs", "source-one"));
4243        real.append_sections
4244            .insert("identity".to_string(), "appended line".to_string());
4245        let missing = bare(crate::EntityId::new("specs", "does-not-exist"));
4246        let (actor, client) = cli_actor();
4247        let result = engine
4248            .batch_update(
4249                vec![(real, None), (missing, None)],
4250                actor,
4251                Some(&client),
4252                false,
4253            )
4254            .expect("refused batch returns a report-all envelope");
4255        assert!(
4256            !result.applied,
4257            "the missing target refuses the whole batch"
4258        );
4259        assert_eq!(
4260            engine.community_memo.get().map(|m| m.0),
4261            Some(memo_gen_before),
4262            "a refused batch must leave the pre-batch memo standing"
4263        );
4264        assert_eq!(
4265            engine.store().generation(),
4266            memo_gen_before.store_generation,
4267            "rollback restored the store to the memo's generation"
4268        );
4269
4270        // 3. The dangerous direction: a memo computed from an INTERIM
4271        // state (simulated batch staging) must not survive the
4272        // rollback as fresh. The store-carried generation is what the
4273        // invalidation hook adjudicates with.
4274        let snapshot = engine.store.clone();
4275        let interim_id = crate::EntityId::new("specs", "interim-only");
4276        let mut interim = engine
4277            .store
4278            .get(&crate::EntityId::new("specs", "source-one"))
4279            .expect("demo entity present")
4280            .clone();
4281        interim.id = interim_id.clone();
4282        interim.title = "Interim Only".to_string();
4283        engine.store.upsert(interim_id, interim);
4284        engine.invalidate_communities();
4285        let _ = engine.communities();
4286        let interim_gen = engine.community_memo.get().expect("interim memo").0;
4287        assert_ne!(interim_gen, memo_gen_before);
4288        engine.store = snapshot; // rollback, generation restored with it
4289        engine.invalidate_communities();
4290        engine.invalidate_search_indexes();
4291        if let Some((g, _)) = engine.community_memo.get() {
4292            assert_ne!(
4293                *g, interim_gen,
4294                "a rolled-back interim state must never be served as fresh"
4295            );
4296        }
4297        assert!(
4298            !engine
4299                .communities()
4300                .entity_cluster_map
4301                .keys()
4302                .any(|id| id.contains("interim-only")),
4303            "the partition served after rollback reflects the restored store, not the interim one"
4304        );
4305
4306        // 4. A real mutation invalidates; the recomputation equals a
4307        // from-scratch detection and sees the new entity.
4308        let (actor, client) = cli_actor();
4309        engine
4310            .create_entity(
4311                empty_create_args("specs", "Fourth Entity"),
4312                actor,
4313                Some(&client),
4314                None,
4315            )
4316            .unwrap();
4317        assert!(
4318            engine
4319                .communities()
4320                .entity_cluster_map
4321                .keys()
4322                .any(|id| id.contains("fourth-entity")),
4323            "recomputed partition sees the new entity"
4324        );
4325        let fresh = {
4326            let schema = engine
4327                .schemas
4328                .iter()
4329                .min_by(|a, b| a.0.cmp(b.0))
4330                .map(|(_, s)| s.clone())
4331                .expect("demo engine has a schema");
4332            let schema_for_weights = schema.clone();
4333            crate::graph::community::detect_communities(
4334                engine.store(),
4335                schema.manifest.community.resolution,
4336                schema.manifest.community.seed,
4337                move |rel_type| {
4338                    schema_for_weights
4339                        .manifest
4340                        .relationships
4341                        .definitions
4342                        .iter()
4343                        .find(|d| d.name == rel_type)
4344                        .map(|d| d.default_weight as f64)
4345                        .unwrap_or(1.0)
4346                },
4347            )
4348        };
4349        assert_eq!(
4350            engine.communities().entity_cluster_map,
4351            fresh.entity_cluster_map,
4352            "memo must equal a from-scratch detection over the current store"
4353        );
4354    }
4355
4356    fn build_demo_engine(tmp: &TempDir) -> Engine {
4357        let mem_dir = tmp.path().to_path_buf();
4358        let writer = FilesystemMemWriter::new(mem_dir.clone());
4359        let mut engine = Engine::from_mounts(vec![(
4360            folder_mount("specs", mem_dir),
4361            Box::new(writer) as Box<dyn MemBackend>,
4362        )])
4363        .unwrap();
4364        let (actor, client) = cli_actor();
4365        let source = engine
4366            .create_entity(
4367                empty_create_args("specs", "Source One"),
4368                actor,
4369                Some(&client),
4370                None,
4371            )
4372            .unwrap();
4373        let target = engine
4374            .create_entity(
4375                empty_create_args("specs", "Target Two"),
4376                actor,
4377                Some(&client),
4378                None,
4379            )
4380            .unwrap();
4381        engine
4382            .create_entity(
4383                empty_create_args("specs", "Lonely Three"),
4384                actor,
4385                Some(&client),
4386                None,
4387            )
4388            .unwrap();
4389        engine
4390            .relate_entity(
4391                RelateEntityArgs {
4392                    source: source.id.clone(),
4393                    expected_hash: Some(source.content_hash.clone()),
4394                    rel_type: "USES".to_string(),
4395                    target: target.id.clone(),
4396                    remove: false,
4397                    description: None,
4398                    dry_run: false,
4399                },
4400                actor,
4401                Some(&client),
4402                None,
4403            )
4404            .unwrap();
4405        engine
4406    }
4407
4408    #[test]
4409    fn status_reports_per_engine_counts() {
4410        let tmp = TempDir::new().unwrap();
4411        let engine = build_demo_engine(&tmp);
4412        let stats = engine.status();
4413        assert_eq!(stats.entity_count, 3);
4414        assert_eq!(stats.edge_count, 1);
4415        assert_eq!(stats.mem_count, 1);
4416        assert_eq!(stats.types_in_use, vec!["spec".to_string()]);
4417        assert_eq!(stats.edge_types.get("USES"), Some(&1));
4418    }
4419
4420    #[test]
4421    fn orphans_lists_unconnected_real_entities() {
4422        let tmp = TempDir::new().unwrap();
4423        let engine = build_demo_engine(&tmp);
4424        let orphans = engine.orphans();
4425        assert_eq!(orphans.len(), 1);
4426        assert_eq!(orphans[0].as_ref(), "specs--lonely-three");
4427    }
4428
4429    /// #49: the orphan/community headlines can be attributed per pinned
4430    /// schema. Single-mem here, so one bucket — but it proves the
4431    /// attribution keys by `schema_of(mem)` and that the per-schema
4432    /// counts sum to the raw total (which a health surface keeps verbatim).
4433    #[test]
4434    fn schema_breakdowns_attribute_to_mem_pin() {
4435        let tmp = TempDir::new().unwrap();
4436        let engine = build_demo_engine(&tmp);
4437
4438        let orphans = engine.orphans();
4439        let orphans_by_schema = engine.orphans_by_schema(&orphans);
4440        assert_eq!(
4441            orphans_by_schema.values().sum::<usize>(),
4442            orphans.len(),
4443            "per-schema orphan counts must sum to the raw total"
4444        );
4445        assert_eq!(orphans_by_schema.len(), 1, "one mem ⇒ one schema bucket");
4446        let (schema, count) = orphans_by_schema.iter().next().unwrap();
4447        assert!(!schema.is_empty(), "specs mem is pinned: {schema:?}");
4448        assert_eq!(*count, 1);
4449
4450        // communities_by_schema buckets the demo mem's clusters under the
4451        // same pin; with one schema, its values sum to the global count.
4452        let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
4453        let communities_by_schema = engine.communities_by_schema(&mems);
4454        assert_eq!(communities_by_schema.len(), 1);
4455        assert_eq!(
4456            communities_by_schema.values().sum::<usize>(),
4457            engine.communities().count,
4458        );
4459    }
4460
4461    #[test]
4462    fn stubs_lists_unresolved_link_targets() {
4463        let tmp = TempDir::new().unwrap();
4464        let mem_dir = tmp.path().to_path_buf();
4465        let writer = FilesystemMemWriter::new(mem_dir.clone());
4466        let mut engine = Engine::from_mounts(vec![(
4467            folder_mount("specs", mem_dir),
4468            Box::new(writer) as Box<dyn MemBackend>,
4469        )])
4470        .unwrap();
4471        let (actor, client) = cli_actor();
4472        let source = engine
4473            .create_entity(
4474                empty_create_args("specs", "Holder"),
4475                actor,
4476                Some(&client),
4477                None,
4478            )
4479            .unwrap();
4480        // Relate to a non-existent target — relate_entity creates a
4481        // stub for the target so the edge can land.
4482        engine
4483            .relate_entity(
4484                RelateEntityArgs {
4485                    source: source.id.clone(),
4486                    expected_hash: Some(source.content_hash.clone()),
4487                    rel_type: "USES".to_string(),
4488                    target: EntityId::new("specs", "ghost"),
4489                    remove: false,
4490                    description: None,
4491                    dry_run: false,
4492                },
4493                actor,
4494                Some(&client),
4495                None,
4496            )
4497            .unwrap();
4498        let stubs = engine.stubs();
4499        assert!(
4500            stubs.iter().any(|(id, _)| id.as_ref() == "specs--ghost"),
4501            "expected ghost stub: {stubs:?}"
4502        );
4503    }
4504
4505    #[test]
4506    fn most_connected_orders_by_degree() {
4507        let tmp = TempDir::new().unwrap();
4508        let engine = build_demo_engine(&tmp);
4509        let top = engine.most_connected(5);
4510        assert_eq!(top.len(), 3);
4511        // Source and Target each have one edge; Lonely has zero.
4512        let zero_degree: Vec<_> = top
4513            .iter()
4514            .filter(|c| c.total == 0)
4515            .map(|c| c.id.as_ref().to_string())
4516            .collect();
4517        assert_eq!(zero_degree, vec!["specs--lonely-three".to_string()]);
4518    }
4519
4520    #[test]
4521    fn health_returns_per_engine_summary() {
4522        let tmp = TempDir::new().unwrap();
4523        let engine = build_demo_engine(&tmp);
4524        let health = engine.health();
4525        // `memstead_create` refuses on missing required sections, so
4526        // entities built through `empty_create_args` carry the
4527        // helper-seeded `identity` + `purpose` bodies and no longer
4528        // surface as missing-fields. Health remains the read-side
4529        // tolerance surface for legacy on-disk drift — covered by
4530        // the loader-tolerance tests that hand-craft pre-strict
4531        // markdown files.
4532        assert!(
4533            health
4534                .missing_fields
4535                .iter()
4536                .all(|r| r.id.as_ref() != "specs--source-one"),
4537            "post-strict-create fixture must not surface as missing-fields; got {:?}",
4538            health.missing_fields,
4539        );
4540    }
4541
4542    #[test]
4543    fn context_carries_neighbors_and_community() {
4544        let tmp = TempDir::new().unwrap();
4545        let engine = build_demo_engine(&tmp);
4546        let source_id = EntityId::new("specs", "source-one");
4547        let ctx = engine.context(&source_id).unwrap();
4548        assert_eq!(ctx.entity_id, source_id);
4549        assert_eq!(ctx.neighbors.len(), 1);
4550        assert_eq!(ctx.neighbors[0].relationship, "USES");
4551        assert!(matches!(ctx.neighbors[0].direction, Direction::Outgoing));
4552    }
4553
4554    #[test]
4555    fn communities_caches_louvain_until_invalidated() {
4556        let tmp = TempDir::new().unwrap();
4557        let mut engine = build_demo_engine(&tmp);
4558        // Population reflects the current store at first call.
4559        let entities_before = engine.communities().entity_cluster_map.len();
4560        // Cache hit — repeat call returns same data.
4561        assert_eq!(
4562            engine.communities().entity_cluster_map.len(),
4563            entities_before
4564        );
4565        // Mutation invalidates the cache; next call re-runs against
4566        // the post-mutation store and includes the new entity.
4567        let (actor, client) = cli_actor();
4568        engine
4569            .create_entity(
4570                empty_create_args("specs", "Disturber"),
4571                actor,
4572                Some(&client),
4573                None,
4574            )
4575            .unwrap();
4576        let entities_after = engine.communities().entity_cluster_map.len();
4577        assert_eq!(
4578            entities_after,
4579            entities_before + 1,
4580            "create_entity should have invalidated community cache and added the new entity"
4581        );
4582    }
4583
4584    #[test]
4585    fn list_filters_by_metadata_only() {
4586        let tmp = TempDir::new().unwrap();
4587        let engine = build_demo_engine(&tmp);
4588        let scope = SearchScope {
4589            entity_type: Some("spec".to_string()),
4590            ..Default::default()
4591        };
4592        let result = engine.list(&scope);
4593        // Three real spec entities created; stubs / non-spec types absent.
4594        assert_eq!(result.hits.len(), 3);
4595    }
4596
4597    #[test]
4598    fn list_applies_schema_declared_filter_on_non_default_schema_mem() {
4599        // A mem pinned to `planning` (non-default schema). The
4600        // `decision` type declares `status` with `filterable: equality`.
4601        // Pre-fix, filter dispatch consulted only the built-in default
4602        // schema via `type_by_name`, missed `status`, silently bypassed
4603        // the filter, and emitted the misleading "unknown filter key"
4604        // warning. Post-fix, the filter is honored and no warning fires.
4605        let tmp = TempDir::new().unwrap();
4606        let mem_dir = tmp.path().to_path_buf();
4607        let writer = FilesystemMemWriter::new(mem_dir.clone());
4608        let mount = Mount {
4609            mem: "planning".to_string(),
4610            schema: Some(memstead_schema::SchemaRef::new(
4611                "planning",
4612                semver::Version::new(0, 1, 0),
4613            )),
4614            storage: MountStorage::Folder { path: mem_dir },
4615            capability: MountCapability::Write,
4616            lifecycle: MountLifecycle::Eager,
4617            cross_linkable: true,
4618            migration_target: None,
4619        };
4620        let mut engine =
4621            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
4622        let (actor, client) = cli_actor();
4623
4624        // Two decisions with different status values; required fields
4625        // (decision/context/consequences sections, decided_on, deciders)
4626        // get placeholder defaults — the test only cares about the
4627        // status field's filterability.
4628        for (title, status) in &[("Skip Postgres", "accepted"), ("Use SQLite", "proposed")] {
4629            let mut metadata = indexmap::IndexMap::new();
4630            metadata.insert("status".to_string(), status.to_string());
4631            metadata.insert("deciders".to_string(), "alice".to_string());
4632            metadata.insert("decided_on".to_string(), "2026-05-19".to_string());
4633            let args = crate::engine::CreateEntityArgs {
4634                anchors: Vec::new(),
4635                mem: "planning".to_string(),
4636                title: title.to_string(),
4637                entity_type: "decision".to_string(),
4638                sections: indexmap::IndexMap::from_iter([
4639                    ("decision".to_string(), "We chose this.".to_string()),
4640                    ("context".to_string(), "Single-user dev.".to_string()),
4641                    ("consequences".to_string(), "Lose multi-writer.".to_string()),
4642                ]),
4643                metadata,
4644                relations: Vec::new(),
4645                dry_run: false,
4646            };
4647            engine
4648                .create_entity(args, actor, Some(&client), None)
4649                .unwrap();
4650        }
4651
4652        // Filter on the schema-declared filterable field.
4653        let scope = SearchScope {
4654            entity_type: Some("decision".to_string()),
4655            filters: std::collections::HashMap::from([(
4656                "status".to_string(),
4657                "accepted".to_string(),
4658            )]),
4659            ..Default::default()
4660        };
4661        let result = engine.list(&scope);
4662        assert_eq!(
4663            result.hits.len(),
4664            1,
4665            "filter on schema-declared field must select only matching entities"
4666        );
4667        assert_eq!(result.hits[0].title, "Skip Postgres");
4668        assert!(
4669            result.warnings.is_empty(),
4670            "no warning should fire when the filter is declared by the mem's pinned schema: {:?}",
4671            result.warnings
4672        );
4673    }
4674
4675    #[test]
4676    fn search_returns_results_against_built_index() {
4677        let tmp = TempDir::new().unwrap();
4678        let engine = build_demo_engine(&tmp);
4679        let scope = SearchScope {
4680            query: Some(crate::ops::Query {
4681                any: vec!["source".to_string()],
4682                ..Default::default()
4683            }),
4684            ..Default::default()
4685        };
4686        let result = engine.search(&scope).expect("native search returns Ok");
4687        assert!(result.total >= 1, "expected ≥1 hit for source: {result:?}");
4688        assert!(
4689            result
4690                .hits
4691                .iter()
4692                .any(|h| h.id.as_ref() == "specs--source-one"),
4693            "expected source-one in hits: {result:?}"
4694        );
4695    }
4696
4697    // ---- Engine::from_workspace_root (lean boot path) --------------
4698}