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