Skip to main content

memstead_base/engine/
query.rs

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