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