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