memstead_base/engine/mod.rs
1//! Unified engine.
2//!
3//! **One [`Engine`] type, three storage backends**: the engine sits
4//! above [`MemBackend`] and routes reads / writes to the backend
5//! named by each mount's mem. The MCP filesystem-mem server
6//! (`memstead_mcp::filesystem_server::FilesystemMcpServer`) and every CLI
7//! lean subcommand reach the
8//! engine through [`Engine::from_workspace_root`] (lean: folder +
9//! archive backends) or `memstead_git_branch::engine_from_workspace_root`
10//! (full: adds git-branch).
11//!
12//! ## Routing
13//!
14//! Each mount holds one mem. Lookup is by mem name: the first
15//! mount whose `mem` field equals the requested name wins. One mount
16//! per mem is enforced — duplicates are a configuration bug, not a
17//! feature, and the constructor rejects them.
18
19use std::cell::OnceCell;
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use memstead_schema::Schema;
25
26use crate::backend::{BackendError, MemBackend};
27use crate::graph::LouvainOutput;
28use crate::mem::MemRouterSnapshot;
29use crate::ops::WarningHint;
30#[cfg(not(target_arch = "wasm32"))]
31use crate::search_index::MemIndex;
32use crate::store::Store;
33use crate::workspace::{Mount, WorkspaceSettings};
34
35pub mod apply_commit;
36pub mod archive;
37pub mod boot;
38pub mod check_ops;
39pub mod conflicts;
40pub mod drift;
41pub mod due;
42pub mod error;
43pub mod events;
44pub mod export_html;
45pub mod export_llms_txt;
46#[cfg(feature = "file-watcher")]
47pub mod file_watcher;
48pub mod gates;
49pub mod history;
50pub mod lifecycle;
51pub mod mutation;
52pub mod outcomes;
53pub mod query;
54pub mod review;
55
56pub use archive::FromArchiveBytesError;
57pub use error::{
58 BootError, EngineError, INLINE_LIST_CAP, MissingWikiLink, ReferrerInfo, SchemaSourceDiagnostic,
59 format_inline_list_overflow,
60};
61#[cfg(feature = "tokio")]
62pub use events::DEFAULT_BROADCAST_CAPACITY;
63pub use events::{EventCallback, MemChangedEvent, SubscriptionHandle};
64#[cfg(feature = "file-watcher")]
65pub use file_watcher::{FileWatcherError, MemRepoWatcher, watch_mem_repo};
66pub use history::{
67 EntityHistoryReport, EntityTouch, HISTORY_PAGE_DEFAULT, HISTORY_PAGE_MAX, StoryStart,
68};
69pub use mutation::delete::DeleteReferrers;
70pub use mutation::{PATCH_OLD_NOT_FOUND_CONTENT_CAP, RELATIONSHIP_CYCLE_PATH_CAP};
71pub use outcomes::{
72 CreateEntityArgs, CreateEntityOutcome, DeleteEntityArgs, DeleteEntityOutcome, RelateAction,
73 RelateEntityArgs, RelateEntityOutcome, RenameEntityArgs, RenameEntityOutcome, SetSchemaOutcome,
74 SetSchemaResult, UpdateEntityArgs, UpdateEntityOutcome,
75};
76pub use review::{ReviewMarkStatus, SetReviewMarkOutcome};
77
78pub use boot::{SchemaResolver, load_workspace_schemas, resolve_builtin_schema_pin_pub};
79pub use lifecycle::SchemaStaging;
80
81/// One mem attachment, paired with the backend that serves it.
82/// Constructed by [`Engine::from_mounts`] and held internally.
83/// `pub(crate)` only so the crate-internal `boot::build_mem_router_from_mounts`
84/// can name it in its signature — never re-exported.
85pub(crate) struct MountedBackend {
86 mount: Mount,
87 backend: Box<dyn MemBackend>,
88 /// Last cursor returned by `backend.current_head()`. Seeded in
89 /// [`Engine::from_mounts`]; refreshed by
90 /// [`Engine::reload_if_stale`] after a successful reload.
91 /// `None` means the backend doesn't track a head (folder /
92 /// archive) — drift detection is a no-op for this mount.
93 last_known_head: Option<String>,
94 /// Per-mem `.memstead/config.json` payload — surfaces via
95 /// [`Engine::mem_config_for`] for handlers that need
96 /// `write_guidance` / `extra` (`memstead_health
97 /// { include_config: true }`'s per-mem detail block).
98 ///
99 /// Loaded at construction for folder backends (read from
100 /// `<path>/.memstead/config.json`). Git-branch + archive backends
101 /// carry `None` for now — the read-from-storage-backend path
102 /// lifts in a follow-up session.
103 mem_config: Option<memstead_schema::config::MemConfig>,
104 /// Per-mem authoring-provenance payload read from the archive's
105 /// `.memstead/provenance.json` at construction (via
106 /// [`crate::backend::MemBackend::read_archive_provenance`]). `None`
107 /// when the backend carries no provenance member (a pre-provenance
108 /// archive, or a backend that does not surface one) — surfaced as
109 /// provenance-absent via [`Engine::archive_provenance_for`]. A
110 /// malformed payload is downgraded to `None` rather than failing the
111 /// mount: the member is additive.
112 archive_provenance: Option<memstead_schema::ArchiveProvenance>,
113 /// `true` while a [`MountLifecycle::Lazy`] mount's entities have not
114 /// been loaded into the store — the mount's metadata half (config,
115 /// schema pin, provenance) is resolved at boot, the entity load is
116 /// deferred to the first operation that needs the mem
117 /// ([`Engine::ensure_mems_loaded`]). Always `false` for eager
118 /// mounts, and permanently `false` once the deferred load lands.
119 /// A deferred mem is never absent: it stays on the mount roster
120 /// with its schema resolved, and every read surface either triggers
121 /// the load or reports the state — silence is the one forbidden
122 /// rendering.
123 ///
124 /// [`MountLifecycle::Lazy`]: crate::workspace::MountLifecycle::Lazy
125 deferred: bool,
126}
127
128/// One quarantined mem: the mem-level boot failure that took it out of
129/// service, and the retained mount record `reload` uses to re-attempt
130/// the attach after a repair. The reason code/message are plan-01
131/// typed material — the message's final clause names the repair
132/// command, so the roster entry is actionable as-is.
133#[derive(Debug, Clone)]
134pub struct QuarantinedMem {
135 /// The mount that failed to attach, retained verbatim for reload.
136 pub mount: crate::workspace::Mount,
137 /// Typed code of the underlying failure (e.g. `SCHEMA_NOT_FOUND`,
138 /// `MEM_CONFIG_INCOMPLETE`, `MEM_ERROR`).
139 pub reason_code: String,
140 /// Full message of the underlying failure, repair command
141 /// included.
142 pub reason_message: String,
143}
144
145/// Unified engine. Holds a list of mounted backends and routes
146/// mem-named operations to the right one.
147///
148/// `Send` so the engine can sit behind a `Mutex` (today's pattern
149/// in the MCP server). The trait object's `Send + Sync` bound on
150/// `MemBackend` keeps the inner backends thread-safe; the engine
151/// itself is single-threaded by design (the lazy memos are
152/// `OnceCell`, which is `!Sync`).
153///
154/// `Debug` is hand-written to avoid requiring `Debug` on the
155/// `dyn MemBackend` trait object — backend impls are free to
156/// stay non-`Debug`.
157///
158/// ## Load-on-init
159///
160/// `Engine::from_mounts` walks each backend at construction time
161/// (`list_entities` + `read_entity` + parse) and populates a single
162/// shared [`Store`] with entities and edges from every mount. Each
163/// mount's schema resolves from its own pin (the backend config's
164/// schema, or the mount-record assertion as fallback) through the
165/// `SchemaResolver`, so `schemas` holds genuinely heterogeneous
166/// schemas in a multi-schema workspace. Per-file errors don't fail
167/// construction; they collect into [`Engine::load_errors`] for the
168/// operator to inspect.
169pub struct Engine {
170 mounts: Vec<MountedBackend>,
171 store: Store,
172 schemas: HashMap<String, Arc<Schema>>,
173 /// Workspace-authored schemas loaded from
174 /// `WorkspaceSettings.schemas_dir` at construction. Distinct from
175 /// `schemas` (per-mem, only schemas pinned by a mount): this
176 /// catalogue carries every workspace-loaded schema regardless of
177 /// whether a mem pins it. Surfaced via
178 /// [`Self::workspace_schemas`] for handlers that need to enumerate
179 /// schemas referenced by `mem_create_rules.schemas[]` but not
180 /// pinned by any mem — `memstead_overview` lists them in `## Schemas`
181 /// so an agent sees what could be pinned. Empty when no
182 /// `schemas_dir` was passed.
183 workspace_schemas: Vec<Arc<Schema>>,
184 /// Embedded built-in schemas loaded once at boot from
185 /// `memstead_schema::builtins::load_builtin_schemas()`. The boot path
186 /// uses this catalogue to resolve each mount's schema pin; storing
187 /// it on the engine lets read handlers (MCP's `memstead_schema`,
188 /// `memstead_overview`'s `## Schemas` rendering) surface every built-in
189 /// without re-walking the embedded directory. Schemas declared in
190 /// `workspace_schemas` shadow built-ins on `(name, version)`
191 /// collision — handlers walking both lists must check workspace
192 /// first.
193 builtin_schemas: Vec<Arc<Schema>>,
194 load_errors: Vec<(PathBuf, String)>,
195 /// Lazily-computed Louvain community detection across the
196 /// engine-wide store. Populated on first call to
197 /// [`Self::communities`]; invalidated by
198 /// [`Self::invalidate_communities`] which every mutation method
199 /// calls after a successful write. `OnceCell` is `!Sync`; the
200 /// engine is `Send` (it is moved into a `Mutex` by every consumer)
201 /// but not `Sync`.
202 /// Generation-keyed (flywheel W8/01): the memo carries the store
203 /// generation it was computed at; the invalidation hook clears it
204 /// only when the store has actually moved past that generation —
205 /// which is what makes a rolled-back batch (store snapshot
206 /// restored, generation restored with it) keep serving the memo
207 /// its state was computed from, and makes it impossible for the
208 /// rolled-back interim state to be served as fresh.
209 community_memo: OnceCell<(DerivedKey, LouvainOutput)>,
210 /// Grounded-labelling memo — one `MemLabelling` per mem whose
211 /// schema declares `relationships.labelling`, computed on first
212 /// access and invalidated exactly where the community memo is
213 /// (the reset lives inside [`Self::invalidate_communities`], so
214 /// every mutation site, drift reload, quarantine attach/detach
215 /// and apply-commit invalidate both without a second call).
216 /// Generation-keyed like `community_memo`.
217 labelling_memo: OnceCell<(
218 DerivedKey,
219 HashMap<String, crate::ops::labelling::MemLabelling>,
220 )>,
221 /// Lazily-computed per-mem search index map. Built on first call
222 /// to [`Self::search_indexes`] via [`build_all`]; invalidated by
223 /// [`Self::invalidate_search_indexes`] alongside the community
224 /// cache so every mutation triggers a fresh build on the next
225 /// search. Absent on `wasm32` targets — search lives behind the
226 /// bridge (see `EngineError::SearchUnavailable`).
227 #[cfg(not(target_arch = "wasm32"))]
228 /// Generation-keyed like `community_memo`.
229 search_indexes_memo: OnceCell<(DerivedKey, HashMap<String, MemIndex>)>,
230 /// The second half of [`DerivedKey`]: bumped whenever the
231 /// `schemas` map changes (schema switch, mount register/remove).
232 /// Both derived structures depend on schemas as well as the store
233 /// — community weights and the index field set come from the
234 /// pinned schema — so a schema change must invalidate them even
235 /// though the STORE generation did not move (the schema-switch
236 /// staleness the whole-map drop used to mask).
237 schemas_epoch: u64,
238 /// Workspace-level operator policy — mem create/delete rules,
239 /// cross-mem link permissions. Defaults to empty; populated via
240 /// [`Self::set_settings`] when [`Self::from_workspace_root`] (or
241 /// the full counterpart) reads `.memstead/workspace.toml`. Surfaced
242 /// read-only via [`Self::settings`] for MCP handlers and other
243 /// consumers.
244 settings: WorkspaceSettings,
245 /// Lazily-compiled [`crate::mem_management::CreateRuleSet`] over
246 /// `settings.mem_create_rules`. Built on first
247 /// [`Self::cross_mem_link_allowed`] call that needs synthesis;
248 /// invalidated by [`Self::set_settings`] (so a fresh policy
249 /// re-compiles on the next call). Compilation errors are logged
250 /// and the cache stays empty — synthesis is best-effort, the
251 /// resolver falls back to explicit-policy resolution. Operators
252 /// who want hard validation pre-compile via
253 /// [`crate::mem_management::CreateRuleSet::new`] before passing
254 /// settings.
255 create_rule_set_memo: OnceCell<crate::mem_management::CreateRuleSet>,
256 /// Per-mem data-trust origin declared by the embedding deployment
257 /// (e.g. a curated hosted read tier vouching for a read-only mount as
258 /// first-party). A *composition* fact set through
259 /// [`Self::declare_mem_origin`] by the process that owns the engine —
260 /// never persisted with the mem, never derived from mem content, and
261 /// deliberately not reachable over MCP, so a publisher cannot forge
262 /// first-party. Empty by default; [`Self::mem_origin_class`] falls back
263 /// to the writability inference for undeclared mems.
264 declared_origins: HashMap<String, crate::render::OriginClass>,
265 /// Workspace root path — set when the engine boots from a
266 /// workspace store ([`Self::from_workspace_root`] or the full
267 /// counterpart). `None` for tests + ad-hoc consumers that build
268 /// the engine directly from a mount list. Surfaced via
269 /// [`Self::workspace_root`] for handlers that need filesystem
270 /// context (e.g. [`Self::health`]'s outer-repo .gitignore
271 /// check).
272 workspace_root: Option<PathBuf>,
273 /// The mount roster as this engine last read or last wrote it —
274 /// the baseline [`Engine::persist_state`] diffs against so a state
275 /// write publishes THIS engine's changes without republishing its
276 /// whole cached view over whatever a sibling process has since
277 /// registered. Interior mutability because `persist_state` takes
278 /// `&self`; the engine is already `!Sync`.
279 mounts_baseline: std::cell::RefCell<Vec<crate::workspace::Mount>>,
280 /// Typed warnings surfaced during mem load — drift findings
281 /// like [`WarningHint::SuspiciousNestedPrefix`] and
282 /// [`WarningHint::DuplicateSectionHeading`] that the loader
283 /// pipeline collects per entity. Empty for the V1 unified
284 /// engine; the field is in place so handlers and the health
285 /// surface can include them when the loader pipeline grows the
286 /// warning generators.
287 load_warnings: Vec<WarningHint>,
288 /// Mems that failed their mem-level boot step (unresolvable or
289 /// missing schema pin, backend instantiation or read failure) and
290 /// are quarantined instead of failing the whole workspace —
291 /// degrade, never disappear. A quarantined mem serves NOTHING:
292 /// operations naming it refuse with the typed `MEM_QUARANTINED`
293 /// code carrying the underlying reason (quarantine is not
294 /// tolerance — no partial data from a broken mem). The retained
295 /// [`Mount`] record lets `reload` re-attempt the attach after a
296 /// repair, without a process restart. Surfaced on overview and
297 /// health as the quarantine roster.
298 quarantined: Vec<QuarantinedMem>,
299 /// Workspace-level boot diagnosis carried by a diagnostic-shell
300 /// engine ([`Engine::diagnostic_shell`]): the typed reason the
301 /// REAL workspace could not boot at all (e.g. an unparseable
302 /// workspace store). `None` on every ordinarily booted engine.
303 /// Surfaced on overview and health so a session over a wholly
304 /// unbootable workspace can always ask WHY the graph is gone.
305 boot_diagnosis: Option<(String, String)>,
306 /// Pipeline configs (Medium / Facet / Projection / Ingest) loaded
307 /// from the workspace store at boot. Empty for engines built via
308 /// `from_mounts*` (tests, in-memory consumers) and for any workspace
309 /// that declares no pipelines; the workspace-root boot paths
310 /// (`from_workspace_root` and the full counterpart) populate it via
311 /// [`crate::pipeline_store::load_pipeline_configs`]. Read-only
312 /// runtime surface — exposed through [`Self::pipeline_configs`]; the
313 /// engine neither runs nor schedules pipelines (the ingest skill and
314 /// future consumers do).
315 pipeline_configs: crate::pipeline_store::BindingConfigs,
316 /// Runtime snapshot of writable / visible mems. Derived from
317 /// the mount list at construction: writable mounts
318 /// (`MountCapability::Write`) register via `add_writable` with
319 /// the storage's directory path (folder → `path`, git-branch →
320 /// None, archive shouldn't be writable); read-only mounts
321 /// register via `add_writable` (folder/git-branch) or
322 /// `add_read_only` (archive). Used by MCP handlers that need the
323 /// writable/visible roster + per-mem origin (`memstead_health
324 /// include_config: true`, `memstead_overview`'s mem list,
325 /// `memstead_mem_create`'s collision check).
326 ///
327 /// Wrapped in `Arc` so the COW-snapshot discipline — clone the
328 /// snapshot, mutate the clone, swap the `Arc` — keeps writers
329 /// and concurrent readers from contending on the live mount
330 /// list.
331 mem_router: Arc<MemRouterSnapshot>,
332 /// Backend factory — function pointer used by
333 /// [`crate::mem_management::create_mem`] (and future runtime
334 /// mount-add paths) to materialise a [`MemBackend`] from a
335 /// [`Mount`] declaration. Defaults to
336 /// [`crate::workspace_store::instantiate_lean_backend`] so lean
337 /// (folder + archive only) consumers work out of the box. Full
338 /// consumers swap in `memstead_git_branch::storage::instantiate_full_backend`
339 /// via [`Self::set_backend_factory`] after constructing the engine —
340 /// `engine_from_workspace_root` does this once at boot. Function
341 /// pointer (not `Box<dyn Fn>`) because the backend factory is
342 /// stateless, `Send + Sync + Copy`, and one less allocation on the
343 /// hot path matters for the multi-mem pattern this engine is
344 /// designed around.
345 backend_factory: BackendFactory,
346 /// Storage discovery for UNMOUNTED mems (flywheel W7/02) — set by
347 /// full boot, `None` in lean/embedded engines (which keep the
348 /// forward-reference mechanic unchanged for unmounted targets).
349 pub(crate) unmounted_storage_prober: Option<UnmountedStorageProber>,
350 /// Git-branch ops bundle — function pointers for the per-mount
351 /// operations whose implementations live in `memstead-git-branch`
352 /// (and therefore can't sit on the `MemBackend` trait without
353 /// inverting the crate dependency). Full boot
354 /// (`memstead_git_branch::engine_from_workspace_root`) installs the
355 /// bundle via [`Self::set_git_branch_ops`]; lean consumers leave
356 /// it `None` and `Engine::changes_since` / `Engine::export_mem`
357 /// fall through to the folder/archive-only branches.
358 git_branch_ops: Option<GitBranchOps>,
359 /// Per-mem subscriber registry for [`MemChangedEvent`]s. Held
360 /// behind `Arc<Mutex<_>>` so [`SubscriptionHandle`]s — which own
361 /// the consumer's view of the subscription lifetime — can call
362 /// back into the registry on `Drop` without a self-reference cycle
363 /// to the engine. The emit path (in `record_self_write`) snapshots
364 /// the per-mem callback list under the lock, releases the lock,
365 /// and then invokes the callbacks — so a callback that re-enters
366 /// the engine for a read does not deadlock against the registry.
367 event_subscribers: Arc<std::sync::Mutex<events::SubscriberRegistry>>,
368 /// Reload-before-operation notices accumulated by
369 /// [`Self::reload_if_stale`] when an operation triggered a mem
370 /// reload. Built at reload time — when the backend's current head
371 /// equals the head we reloaded to, *before* any mutation in the
372 /// same operation commits — so the delta describes only the
373 /// sibling's change, never the engine's own follow-on write. The
374 /// response layer drains them via
375 /// [`Self::take_mem_changed_notices`] and attaches the structured
376 /// `mem_changed` notice to the operation's response. Every entity
377 /// op that can reload drains after; an undrained accumulation would
378 /// leak into the next operation's response, so callers that reload
379 /// must take.
380 pending_mem_changed: Vec<crate::ops::MemChangedNotice>,
381 /// Timestamp source for engine-stamped mutation metadata
382 /// (`created_date` on create, `last_modified` on update/relate/
383 /// rename — every field the schema marks `init_timestamp` /
384 /// `auto_timestamp`). Defaults to the system clock; tests that
385 /// assert over canonical entity bytes pin it via
386 /// [`Self::set_mutation_clock`] so two engines stamp identical
387 /// values. A testability affordance, not a behaviour switch:
388 /// nothing in production swaps the default, and the stamped
389 /// format (second-granularity RFC 3339, see
390 /// `mutation::iso_from_system_time`) is unchanged.
391 mutation_clock: MutationClock,
392 /// The caller-declared role for mutations in this session
393 /// (agent-trust plan 13). Set by the surface before each mutation
394 /// (per-call parameter wins over the surface's session default);
395 /// `Unspecified` records as absence. Session state on the engine
396 /// — the `mutation_clock` precedent — so the role travels into
397 /// every commit context and provenance record without widening
398 /// every mutation signature.
399 current_role: crate::vcs::Role,
400 /// The caller-declared identity for mutations and checks in this
401 /// session (agent-trust plan 15). Same session-state pattern as
402 /// `current_role`: set by the surface before each operation
403 /// (per-call parameter wins over the surface's session default);
404 /// `None` records as absence. An opaque caller-chosen string —
405 /// the engine neither generates, interprets, nor enriches it.
406 current_identity: Option<String>,
407}
408
409/// Clock the engine reads when stamping mutation timestamps. `Arc`'d
410/// closure rather than a trait so a test can pin a constant with one
411/// line: `engine.set_mutation_clock(Arc::new(|| some_time))`.
412pub type MutationClock = Arc<dyn Fn() -> std::time::SystemTime + Send + Sync>;
413
414/// Backend factory function pointer. Both flavours' existing
415/// `instantiate_*_backend` functions match this signature, so the
416/// type alias is what bridges the dependency direction (memstead-base
417/// can't depend on memstead-git-branch) without an extra trait.
418/// Stateless, `Send + Sync + Copy`.
419pub type BackendFactory =
420 fn(&Mount) -> Result<Box<dyn MemBackend>, crate::workspace_store::InstantiateError>;
421
422/// Discovered storage for a mem that has NO mount record — the
423/// unmounted half of flywheel W7/02's write-time cross-mem target
424/// verification. The workspace layer owns the discovery convention
425/// (the mem-repo's branch registry, which memstead-base cannot see
426/// without inverting the crate dependency) and hands back a transient
427/// backend to ask plus the mem's schema pin when its config declares
428/// one, so the cross-schema edge routing can keep its authority
429/// without a mount.
430/// The validity key for derived-structure memos (flywheel W8/01):
431/// the store generation (bumped by every store mutation, carried by
432/// `Store::clone` so batch rollback restores it) plus the schemas
433/// epoch (bumped by every change to the engine's schema map). A memo
434/// is current exactly while both halves still match.
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub struct DerivedKey {
437 pub store_generation: u64,
438 pub schemas_epoch: u64,
439}
440
441pub struct UnmountedMemStorage {
442 /// Transient backend over the discovered storage. Used for the
443 /// cheap [`MemBackend::entity_exists`] probe and the one-blob
444 /// type read — never registered, never loaded.
445 pub backend: Box<dyn MemBackend>,
446 /// The mem's pinned schema, when its stored config declares one.
447 pub schema: Option<memstead_schema::SchemaRef>,
448}
449
450/// Discovery hook: mem name → its storage, when the workspace layer
451/// can find any (`None` = no discoverable storage; the
452/// forward-reference mechanic governs, exactly as before). Boxed
453/// closure rather than a function pointer because discovery needs the
454/// workspace root and gitdir captured at boot.
455pub type UnmountedStorageProber = Box<dyn Fn(&str) -> Option<UnmountedMemStorage> + Send + Sync>;
456
457/// `Engine::changes_since` dispatch for git-branch mounts.
458///
459/// Signature matches `memstead_git_branch::ops::changes::changes_since` after
460/// adapting the `Store` parameter away (the engine performs enrichment
461/// downstream) and the `head_ref` parameter (`refs/heads/<branch>` is
462/// constructed inside the impl from `branch`).
463pub type GitBranchChangesSinceFn = fn(
464 gitdir: &Path,
465 branch: &str,
466 mem: &str,
467 since: &str,
468 rename_similarity: f32,
469) -> Result<crate::ops::BackendChanges, BackendError>;
470
471/// `Engine::export_mem` dispatch for git-branch mounts.
472///
473/// Signature mirrors `memstead_git_branch::ops::export::export_mem_from_branch`.
474pub type GitBranchExportFn = fn(
475 gitdir: &Path,
476 branch: &str,
477 mem: &str,
478 config: &memstead_schema::MemConfig,
479 output_path: &Path,
480 workspace_root: Option<&Path>,
481 workspace_schemas_dir: Option<&Path>,
482 // Engine-sourced authoring-provenance payload bytes (from the mount's
483 // `read_provenance` log) to embed at `.memstead/provenance.json`.
484 // `None` when the mem carried no noted mutations.
485 provenance_bytes: Option<&[u8]>,
486 // Engine-sourced anchors sidecar bytes (from the mount's
487 // `read_anchors_sidecar`) to embed at `.memstead/anchors.json`. `None`
488 // when the mem carried no anchors. The engine reads the branch tip; the
489 // hook only embeds, keeping git tree-walking out of the fn-pointer.
490 anchors_bytes: Option<&[u8]>,
491) -> Result<crate::ops::MemExportResult, BackendError>;
492
493/// `Engine::export_mem_to_bytes` dispatch for git-branch mounts.
494///
495/// Signature mirrors `memstead_git_branch::ops::export::export_mem_from_branch_to_bytes`.
496/// Symmetric to `GitBranchExportFn`: same inputs minus the output path,
497/// returns archive bytes plus metadata instead of writing to disk.
498pub type GitBranchExportToBytesFn = fn(
499 gitdir: &Path,
500 branch: &str,
501 mem: &str,
502 config: &memstead_schema::MemConfig,
503 workspace_root: Option<&Path>,
504 workspace_schemas_dir: Option<&Path>,
505 // Pre-built authoring-provenance payload bytes the engine sourced from
506 // the mount's `read_provenance` log, to embed at
507 // `.memstead/provenance.json`. `None` when the mem carried no noted
508 // mutations. The engine sources it (it holds the backend); the hook
509 // only embeds, keeping git history-walking out of the fn-pointer.
510 provenance_bytes: Option<&[u8]>,
511 // Engine-sourced anchors sidecar bytes (from the mount's
512 // `read_anchors_sidecar`) to embed at `.memstead/anchors.json`. `None`
513 // when the mem carried no anchors. Symmetric with `provenance_bytes`:
514 // the engine reads the branch tip, the hook only embeds.
515 anchors_bytes: Option<&[u8]>,
516) -> Result<crate::ops::MemExportBytes, BackendError>;
517
518/// `Engine::diff` dispatch for git-branch mounts. Walks the two refs
519/// inside the workspace's mem-repo gitdir, produces a per-entity
520/// [`crate::ops::Diff`]. Refs are arbitrary `gix::rev_parse_single`
521/// inputs — branch names, commit SHAs, tag names. Resolves each
522/// independently so cross-branch (cross-mem) diffs work uniformly.
523/// `branch` is the mount's declared branch; a bare `HEAD` token in
524/// either ref re-anchors onto it.
525pub type GitBranchDiffFn = fn(
526 gitdir: &Path,
527 branch: &str,
528 mem: &str,
529 ref_a: &str,
530 ref_b: &str,
531 config: &crate::ops::DiffConfig,
532) -> Result<crate::ops::Diff, BackendError>;
533
534/// `Engine::fetch` dispatch for git-branch mounts.
535pub type GitBranchFetchFn = fn(
536 gitdir: &Path,
537 remote: &str,
538 refspecs: &[String],
539) -> Result<crate::ops::FetchOutcome, BackendError>;
540
541/// Read every `.md` blob at `ref_name` in `gitdir`, returning
542/// `(relative_path, utf8_content)` pairs. Skips `.memstead/` engine-internal
543/// entries and non-blob nodes. Used by the pre-merge schema-validation
544/// pass `Engine::pull` and `Engine::push` run before they advance the
545/// branch pointer / push to the remote.
546pub type GitBranchReadTreeFn =
547 fn(gitdir: &Path, ref_name: &str) -> Result<Vec<(String, String)>, BackendError>;
548
549/// `Engine::pull` dispatch for git-branch mounts. `branch` is the
550/// mount's declared branch — the single source of truth for both the
551/// local ref and the remote-tracking ref; `mem` labels the outcome
552/// only.
553pub type GitBranchPullFn = fn(
554 gitdir: &Path,
555 remote: &str,
556 branch: &str,
557 mem: &str,
558) -> Result<crate::ops::PullOutcome, BackendError>;
559
560/// `Engine::push` dispatch for git-branch mounts. `branch` is the
561/// mount's declared branch (see [`GitBranchPullFn`]); `mem` labels
562/// the outcome only.
563pub type GitBranchPushFn = fn(
564 gitdir: &Path,
565 remote: &str,
566 branch: &str,
567 mem: &str,
568 force: bool,
569) -> Result<crate::ops::PushOutcome, BackendError>;
570
571/// `Engine::remote_add` dispatch — configures a named remote on the
572/// mem-repo gitdir (upsert: add, or set-url when it already exists).
573pub type GitBranchRemoteAddFn =
574 fn(gitdir: &Path, name: &str, url: &str) -> Result<crate::ops::RemoteAddOutcome, BackendError>;
575
576/// `Engine::branch_reset` dispatch for git-branch mounts. Returns the
577/// outcome on success; surfaces `BackendError::Other` carrying an
578/// in-band marker (`UNKNOWN_REF:<raw>` or
579/// `PUSHED_COMMITS_PROTECTED:<sha,sha,...>`) the engine layer
580/// un-marshals into typed `EngineError`s.
581pub type GitBranchBranchResetFn = fn(
582 gitdir: &Path,
583 branch: &str,
584 target_sha: &str,
585 expected_head: Option<&str>,
586) -> Result<crate::ops::BranchResetOutcome, BackendError>;
587
588/// Residue-prune dispatch for git-branch mounts.
589/// The `create_mem` orchestrator calls this when
590/// `RecoveryAction::ForceOverwrite` is selected against pre-existing
591/// storage residue. Drops `refs/heads/<branch_full_path>` and the
592/// `__MEMSTEAD:mems/<branch_full_path>/config.json` blob in one
593/// ref-edit transaction (the same call the
594/// `MemBackend::delete_artifacts` impl wraps for delete-files
595/// flows). Surfaces as a function pointer so `memstead-engine` can
596/// drive a prune against an unmounted gitdir without depending on
597/// `memstead-git-branch`.
598pub type GitBranchPruneResidueFn =
599 fn(gitdir: &Path, branch_full_path: &str) -> Result<(), BackendError>;
600
601/// `rename_mem` dispatch for the git-branch backend: move the mem's
602/// content branch `refs/heads/<old>` to `refs/heads/<new>` at the same
603/// tip (history preserved) and relocate the `__MEMSTEAD:mems/<old>/`
604/// config blob to `mems/<new>/`, all in one ref-edit transaction.
605/// Refuses (no mutation) when the source branch is missing or the
606/// target branch already exists.
607pub type GitBranchRenameMemStorageFn =
608 fn(gitdir: &Path, old_leaf: &str, new_leaf: &str) -> Result<(), BackendError>;
609
610/// `Engine::install_schema` dispatch for the git-branch backend: write a
611/// schema package (`(relative-path, bytes)` pairs) onto the workspace's
612/// unified `__MEMSTEAD:schemas/<name>@<version>/` ref and return the
613/// resulting commit sha. Mirrors
614/// `memstead_git_branch::storage_memstead::write_schema_to_memstead_ref`.
615pub type GitBranchWriteSchemaFn = fn(
616 gitdir: &Path,
617 name: &str,
618 version: &str,
619 files: &[(String, Vec<u8>)],
620) -> Result<String, BackendError>;
621
622/// Read one file from a sealed schema package on the workspace's
623/// `__MEMSTEAD:schemas/<name>@<version>/` ref. `Ok(None)` when the
624/// ref, package, or file is absent — absence is a normal state (the
625/// install-provenance stamp only exists for path-sourced installs).
626/// Read-only; the authoring-drift health axis is the consumer.
627pub type GitBranchReadSchemaFileFn = fn(
628 gitdir: &Path,
629 name: &str,
630 version: &str,
631 rel: &str,
632) -> Result<Option<Vec<u8>>, BackendError>;
633
634/// Re-read every schema sealed on the workspace's
635/// `__MEMSTEAD:schemas/` ref (empty when the ref or subtree is
636/// absent). Read-only; `Engine::full_refresh` is the consumer — the
637/// warm-server path that makes an out-of-band `memstead schema
638/// install` resolvable without a process restart.
639pub type GitBranchReadRefSchemasFn =
640 fn(workspace_root: &Path) -> Result<Vec<Arc<memstead_schema::Schema>>, BackendError>;
641
642/// Bundle of git-branch-specific op dispatchers. Installed on the
643/// engine at full boot. Each field is one ops-method that previously
644/// lived on the `MemBackend` trait; moving them off the trait keeps
645/// the bytes-level primitive surface clean.
646#[derive(Clone, Copy)]
647pub struct GitBranchOps {
648 pub changes_since: GitBranchChangesSinceFn,
649 pub diff: GitBranchDiffFn,
650 pub branch_reset: GitBranchBranchResetFn,
651 pub fetch: GitBranchFetchFn,
652 pub pull: GitBranchPullFn,
653 pub push: GitBranchPushFn,
654 pub remote_add: GitBranchRemoteAddFn,
655 pub read_tree: GitBranchReadTreeFn,
656 pub export: GitBranchExportFn,
657 pub export_to_bytes: GitBranchExportToBytesFn,
658 pub prune_residue: GitBranchPruneResidueFn,
659 pub rename_mem_storage: GitBranchRenameMemStorageFn,
660 pub write_schema: GitBranchWriteSchemaFn,
661 pub read_schema_file: GitBranchReadSchemaFileFn,
662 pub read_ref_schemas: GitBranchReadRefSchemasFn,
663}
664
665impl std::fmt::Debug for Engine {
666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667 f.debug_struct("Engine")
668 .field(
669 "mems",
670 &self
671 .mounts
672 .iter()
673 .map(|m| m.mount.mem.as_str())
674 .collect::<Vec<_>>(),
675 )
676 .finish()
677 }
678}
679
680#[cfg(test)]
681mod in_memory_mem;
682
683#[cfg(test)]
684pub(super) mod test_helpers {
685 use std::io::Write as _;
686 use std::path::{Path, PathBuf};
687
688 use memstead_schema::SchemaRef;
689
690 use crate::backend::MemBackend;
691 use crate::storage::FilesystemMemWriter;
692 use crate::vcs::{Actor, ClientId};
693 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
694
695 use super::{CreateEntityArgs, CreateEntityOutcome, Engine, RelateEntityArgs};
696
697 use indexmap::IndexMap;
698 use tempfile::TempDir;
699
700 pub(crate) fn pin(name: &str) -> SchemaRef {
701 let version = match name {
702 "default" => semver::Version::new(1, 0, 0),
703 _ => semver::Version::new(0, 1, 0),
704 };
705 SchemaRef::new(name, version)
706 }
707
708 pub(crate) fn folder_mount(mem: &str, path: PathBuf) -> Mount {
709 Mount {
710 mem: mem.to_string(),
711 schema: Some(pin("default")),
712 storage: MountStorage::Folder { path },
713 capability: MountCapability::Write,
714 lifecycle: MountLifecycle::Eager,
715 cross_linkable: true,
716 migration_target: None,
717 }
718 }
719
720 pub(crate) fn in_memory_mount(mem: &str) -> Mount {
721 Mount {
722 mem: mem.to_string(),
723 schema: Some(pin("default")),
724 storage: MountStorage::InMemory,
725 capability: MountCapability::Write,
726 lifecycle: MountLifecycle::Eager,
727 cross_linkable: true,
728 migration_target: None,
729 }
730 }
731
732 pub(crate) fn archive_mount(mem: &str, path: PathBuf) -> Mount {
733 Mount {
734 mem: mem.to_string(),
735 schema: Some(pin("default")),
736 storage: MountStorage::Archive { path },
737 capability: MountCapability::ReadOnly,
738 // Eager, matching every production archive-mount site: these
739 // tests pin archive READ semantics over a loaded store. The
740 // lifecycle slot became real (flywheel W7/01) — a `Lazy`
741 // value here would defer the load these tests read through.
742 // The lazy behaviour has its own tests in `boot.rs`.
743 lifecycle: MountLifecycle::Eager,
744 cross_linkable: false,
745 migration_target: None,
746 }
747 }
748
749 /// Build a sealed archive at `tmp/<name>.mem` from
750 /// `(relative_path, bytes)` pairs and return the path.
751 pub(crate) fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
752 let path = tmp.join(format!("{name}.mem"));
753 let file = std::fs::File::create(&path).unwrap();
754 let mut writer = zip::ZipWriter::new(file);
755 let opts = zip::write::SimpleFileOptions::default();
756 for (rel, bytes) in entries {
757 writer.start_file(*rel, opts).unwrap();
758 writer.write_all(bytes).unwrap();
759 }
760 writer.finish().unwrap();
761 path
762 }
763
764 /// Write a schema manifest + minimal type bodies under
765 /// `<root>/<name>/`. Each type gets a body with a single
766 /// `body` section and `_default` hierarchy/no-self-loop lists — enough
767 /// to load and parse markdown that uses that type. Used by tests
768 /// that need a custom schema with shape or vocabulary constraints.
769 pub(crate) fn write_schema_files_with_default_type(
770 root: &Path,
771 name: &str,
772 manifest: &str,
773 types: &[&str],
774 ) {
775 const TYPE_BODY: &str = r#"description: t
776when_to_use: Here
777sections:
778 - key: body
779 heading: Body
780 required: true
781 search_weight: 10.0
782 catch_all: true
783 write_rules: []
784metadata_fields: []
785title_weight: 100.0
786text_fields:
787 - body
788hierarchy_relationship: _default
789no_self_loop_relationships: []
790updatable_fields:
791 - title
792 - body
793health_required_fields:
794 - body
795staleness_threshold_days: 90
796write_rules: []
797"#;
798 let dir = root.join(name);
799 std::fs::create_dir_all(dir.join("types")).unwrap();
800 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
801 for type_name in types {
802 let body = format!("name: {type_name}\n{TYPE_BODY}");
803 std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
804 }
805 }
806
807 pub(crate) fn empty_create_args(mem: &str, title: &str) -> CreateEntityArgs {
808 // The
809 // create path refuses on missing required sections. The
810 // default `spec` type requires `identity` + `purpose`. Seed
811 // both with a single space so the test fixture remains a
812 // valid creation request — every test that uses this helper
813 // as a fixture builder continues to work, and tests that
814 // specifically exercise the refusal supply an explicit
815 // empty-sections payload (see the dedicated refusal tests).
816 let mut sections = IndexMap::new();
817 sections.insert("identity".to_string(), "fixture identity body".to_string());
818 sections.insert("purpose".to_string(), "fixture purpose body".to_string());
819 CreateEntityArgs {
820 anchors: Vec::new(),
821 mem: mem.to_string(),
822 title: title.to_string(),
823 entity_type: "spec".to_string(),
824 sections,
825 metadata: IndexMap::new(),
826 relations: Vec::new(),
827 dry_run: false,
828 }
829 }
830
831 pub(crate) fn cli_actor() -> (Actor, ClientId) {
832 (
833 Actor::Cli,
834 ClientId {
835 name: "claude-code".to_string(),
836 version: "2.1.0".to_string(),
837 },
838 )
839 }
840
841 pub(crate) fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
842 let mem_dir = tmp.path().to_path_buf();
843 let writer = FilesystemMemWriter::new(mem_dir.clone());
844 let mut engine = Engine::from_mounts(vec![(
845 folder_mount("specs", mem_dir),
846 Box::new(writer) as Box<dyn MemBackend>,
847 )])
848 .unwrap();
849 let (actor, client) = cli_actor();
850 let outcome = engine
851 .create_entity(
852 empty_create_args("specs", title),
853 actor,
854 Some(&client),
855 None,
856 )
857 .unwrap();
858 (engine, outcome)
859 }
860 pub(crate) fn build_demo_engine(tmp: &TempDir) -> Engine {
861 let mem_dir = tmp.path().to_path_buf();
862 let writer = FilesystemMemWriter::new(mem_dir.clone());
863 let mut engine = Engine::from_mounts(vec![(
864 folder_mount("specs", mem_dir),
865 Box::new(writer) as Box<dyn MemBackend>,
866 )])
867 .unwrap();
868 let (actor, client) = cli_actor();
869 let source = engine
870 .create_entity(
871 empty_create_args("specs", "Source One"),
872 actor,
873 Some(&client),
874 None,
875 )
876 .unwrap();
877 let target = engine
878 .create_entity(
879 empty_create_args("specs", "Target Two"),
880 actor,
881 Some(&client),
882 None,
883 )
884 .unwrap();
885 engine
886 .create_entity(
887 empty_create_args("specs", "Lonely Three"),
888 actor,
889 Some(&client),
890 None,
891 )
892 .unwrap();
893 engine
894 .relate_entity(
895 RelateEntityArgs {
896 source: source.id.clone(),
897 expected_hash: Some(source.content_hash.clone()),
898 rel_type: "USES".to_string(),
899 target: target.id.clone(),
900 remove: false,
901 description: None,
902 dry_run: false,
903 },
904 actor,
905 Some(&client),
906 None,
907 )
908 .unwrap();
909 engine
910 }
911}