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