Skip to main content

memstead_base/engine/
query.rs

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