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