Skip to main content

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