Skip to main content

memstead_base/
backend.rs

1//! `MemBackend` — uniform trait surface over folder, git-branch,
2//! and archive storage.
3//!
4//! Bytes-level: list / read / write / delete / move / commit /
5//! append-provenance / read-provenance. The one-engine architecture
6//! that the workspace-store rebuild produces sits above this trait;
7//! entity-mutation logic, validation, the in-memory store, and the
8//! search index live in one place regardless of which backend serves
9//! a given mount.
10//!
11//! Today's [`crate::storage::MemWriter`] is a write-side subset of
12//! this trait. As each backend gains its `MemBackend` impl the
13//! `MemWriter` references in that backend's call sites collapse
14//! into the unified surface; `MemWriter` stays in
15//! `crate::storage::filesystem` for now as the on-disk write helpers
16//! it embodies are reused by the folder-backend `MemBackend` impl.
17//!
18//! ## Per-backend write semantics
19//!
20//! - **Folder** — writes go to the workspace's mem subdirectory;
21//!   commit is a no-op CAS-token mint (no history).
22//! - **Git-branch** — writes buffer in memory, commit produces a real
23//!   git commit on the per-mem branch with the trailer block.
24//! - **Archive** — writes return [`BackendError::Sealed`] without
25//!   touching disk. Read methods return live content from inside the
26//!   sealed `.mem` zip.
27
28use std::path::{Path, PathBuf};
29
30use crate::provenance::Provenance;
31use crate::storage::{CommitId, MemWriterError};
32use crate::vcs::CommitContext;
33
34/// Mem-backend trait. Implementations live next to the backend's
35/// other code (folder under `crate::storage::filesystem`; git-branch
36/// in the renamed-from-`memstead-git-branch` crate; archive under the
37/// archive read-paths in `crate::entity` once that wiring lands).
38///
39/// Methods are not split into `Read` / `Write` sub-traits because
40/// the engine's mutation paths frequently need both surfaces on the
41/// same backend handle (read current bytes, validate, write new
42/// bytes). Backends that cannot write return [`BackendError::Sealed`]
43/// from the write methods — typed and stable so callers branch on
44/// the discriminant rather than parsing a message string.
45pub trait MemBackend: Send + Sync {
46    /// Mem-relative paths of every entity-bearing file the backend
47    /// holds. Order is not specified; callers that need stable
48    /// ordering sort.
49    fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError>;
50
51    /// Read raw bytes at `rel_path`. `Ok(None)` for a missing path
52    /// (idempotent reads); `Err` for IO or backend-specific failures.
53    fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError>;
54
55    /// Does storage hold an entity at `rel_path`? A pure existence
56    /// probe — the write-time cross-mem target check's primitive
57    /// (flywheel W7/02): callers verifying a reference into a mem that
58    /// is not loaded ask storage directly instead of forcing the mem's
59    /// full load. The answer observes the same pending-buffer
60    /// precedence as [`Self::read_entity`] (a staged upsert exists, a
61    /// staged delete does not).
62    ///
63    /// The default reads the bytes and drops them — correct
64    /// everywhere, cheap nowhere. Backends with a cheaper metadata
65    /// answer override it: the folder backend asks the filesystem
66    /// (`symlink_metadata`, no open), the git-branch backend stops at
67    /// the tree entry (`lookup_entry_by_path`, never the blob read —
68    /// the public listing walk reads every blob and is the wrong
69    /// primitive for this question).
70    fn entity_exists(&self, rel_path: &Path) -> Result<bool, BackendError> {
71        Ok(self.read_entity(rel_path)?.is_some())
72    }
73
74    /// Does the storage location this backend names exist at all: the
75    /// branch ref, the folder, the archive file? Distinct from "holds
76    /// no entities": a mount whose branch was never created and a mount
77    /// whose branch is empty both list zero entities, and only this
78    /// probe tells them apart. Boot asks it to raise `MOUNT_UNBACKED`
79    /// with the right reason (`missing_ref` / `missing_path` versus
80    /// `empty`); before it existed, a mount pointing at a branch that
81    /// did not exist sat in the writable roster with zero entities and
82    /// no warning. The default says `true` (in-memory and test
83    /// backends have nothing to be missing).
84    fn storage_present(&self) -> Result<bool, BackendError> {
85        Ok(true)
86    }
87
88    /// Upsert `content` at `rel_path`. Pending until [`Self::commit`].
89    fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError>;
90
91    /// Remove `rel_path`. Idempotent: no-op when the path is already
92    /// absent. Pending until [`Self::commit`].
93    fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError>;
94
95    /// Rename `from` to `to`. Pending until [`Self::commit`]. Errors
96    /// when `to` already exists.
97    fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError>;
98
99    /// Discard every pending (uncommitted) mutation, returning the
100    /// staging buffer to empty *without* producing a commit. The
101    /// transactional escape hatch for stage-then-commit callers:
102    /// the atomic `batch_update` stages each item's write into the
103    /// pending set, and when a later item fails validation it calls
104    /// this to drop the already-staged writes rather than commit a
105    /// half-applied batch. Idempotent — discarding an empty buffer
106    /// is a no-op.
107    ///
108    /// Default impl is a no-op: backends that never stage writes
109    /// (archive / any sealed backend) have no buffer to clear. The
110    /// folder and git-branch backends override to clear their
111    /// pending buffer (the git-branch backend also drops the
112    /// captured parent snapshot, symmetric with what `commit` does
113    /// on success).
114    fn discard_pending(&self) -> Result<(), BackendError> {
115        Ok(())
116    }
117
118    /// Flush pending mutations into a single commit. Returns the
119    /// resulting opaque [`CommitId`]; backends without history
120    /// return a synthetic id (UNIX-nanos + counter, hex) so callers
121    /// always get a non-empty cursor.
122    fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, BackendError>;
123
124    /// Commit pending mutations with a parent-ref pinning guard.
125    /// When `expected_parent` is `Some`, the backend MUST refuse the
126    /// commit (`Err(BackendError::ParentMismatch { ... })`) if its
127    /// current head no longer matches the supplied ref — a sibling
128    /// writer advanced the on-disk state between the snapshot the
129    /// caller pinned and now. When `expected_parent` is `None`, the
130    /// call is equivalent to [`Self::commit`].
131    ///
132    /// Used by atomic multi-file mutations (notably the planned
133    /// referrer-rewriting rename) to surface drift mid-operation
134    /// rather than between operations. Backends without history
135    /// (folder, archive) inherit the default impl: they ignore
136    /// `expected_parent` because there's no concept of a parent to
137    /// pin against — drift detection on those mounts is a no-op
138    /// today and stays a no-op here. The git-branch backend
139    /// overrides to check the per-mem branch tip and surfaces
140    /// the mismatch with a typed error the engine layer can map to
141    /// `MEM_RELOADED` / `RENAME_PARTIAL_FAILURE`.
142    ///
143    /// Default impl: ignore `expected_parent` and delegate to
144    /// [`Self::commit`]. Bisect-safe — existing callers using
145    /// `Self::commit` directly are unaffected.
146    fn commit_with_expected_parent(
147        &self,
148        message: &str,
149        ctx: &CommitContext<'_>,
150        _expected_parent: Option<&str>,
151    ) -> Result<CommitId, BackendError> {
152        self.commit(message, ctx)
153    }
154
155    /// Append a [`Provenance`] record to the backend's mutation log.
156    /// Persistence form differs per backend — JSONL line, commit
157    /// trailer, etc. — but the in-memory shape is identical.
158    fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError>;
159
160    /// Read provenance entries since `cursor` (opaque,
161    /// backend-defined: a commit SHA for git-branch, an RFC-3339
162    /// timestamp for folder, ignored for archive). `None` cursor
163    /// means "from the beginning".
164    fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError>;
165
166    /// Opaque cursor pointing at the backend's current state. The
167    /// engine compares against a per-mount cached cursor to detect
168    /// drift — a sibling writer (another `Engine` instance, an
169    /// out-of-band `git pull`, etc.) advancing the on-disk state past
170    /// what the engine last read. Backends without history (folder,
171    /// archive) inherit the default impl returning `Ok(None)`; the
172    /// engine treats `None` as "no drift signal available" and skips
173    /// drift detection for that mount. The git-branch backend
174    /// overrides to return the per-mem branch tip's commit SHA hex;
175    /// the filesystem backend overrides to return the changelog's
176    /// last-line timestamp cursor (folder mems with no changelog yet
177    /// keep `None`). Archive and in-memory backends stay on the
178    /// default.
179    ///
180    /// Returning `Err` is reserved for backend-internal failures
181    /// (refdb hiccup, archive read failure, etc.); the engine logs
182    /// the error and treats it as a transient None — drift detection
183    /// is best-effort and never blocks the read it accompanies.
184    fn current_head(&self) -> Result<Option<String>, BackendError> {
185        Ok(None)
186    }
187
188    /// Read the per-mem `.memstead/config.json` payload, if any.
189    ///
190    /// Returns the raw bytes the backend has for the mem's
191    /// config. The engine parses via
192    /// [`memstead_schema::config::parse_mem_config`] and stores the
193    /// result on the [`crate::Engine::mem_config_for`] accessor.
194    ///
195    /// Default impl returns `Ok(None)` — backends that don't
196    /// surface a config (or haven't yet implemented this primitive)
197    /// inherit and signal "no config available". The engine
198    /// treats `None` the same as a parse failure: `mem_config_for`
199    /// returns `None` for the affected mem, and consumers
200    /// (`memstead_health { include_config: true }`) emit empty
201    /// `writeGuidance` + `extra` blocks for that mem.
202    ///
203    /// Mirrors the pattern of [`Self::current_head`] —
204    /// backend-internal capability with a sensible no-op default.
205    ///
206    /// Implementations:
207    /// - Folder backend reads `<root>/.memstead/config.json`.
208    /// - Archive backend reads `.memstead/config.json` from inside the
209    ///   zip.
210    /// - Git-branch backend reads `__MEMSTEAD:mems/<mem>/config.json`.
211    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
212        Ok(None)
213    }
214
215    /// Read the optional authoring-provenance payload
216    /// (`.memstead/provenance.json`) the archive carries, if any.
217    ///
218    /// Returns the raw bytes the engine parses into a
219    /// [`memstead_schema::ArchiveProvenance`] and surfaces via
220    /// [`crate::Engine::archive_provenance_for`]. Default impl returns
221    /// `Ok(None)` — a backend with no provenance member (a pre-provenance
222    /// archive, the folder/git-branch backends until their read paths
223    /// lift) inherits and signals "provenance absent". Mirrors
224    /// [`Self::read_mem_config`].
225    fn read_archive_provenance(&self) -> Result<Option<Vec<u8>>, BackendError> {
226        Ok(None)
227    }
228
229    /// Write the per-mem `.memstead/config.json` payload. Symmetric
230    /// counterpart to [`Self::read_mem_config`].
231    ///
232    /// Backends that cannot persist a config (today: archive)
233    /// inherit the default and return [`BackendError::Sealed`]. The
234    /// engine's create / migrate paths branch on the discriminant
235    /// before calling.
236    ///
237    /// Implementations:
238    /// - Folder backend writes `<root>/.memstead/config.json` to disk.
239    /// - Git-branch backend writes
240    ///   `__MEMSTEAD:mems/<mem>/config.json` (workspace-level ref) —
241    ///   its own commit, separate from any per-mem-branch
242    ///   mutation.
243    /// - Archive backend returns [`BackendError::Sealed`] — sealed
244    ///   archives never re-write configs.
245    ///
246    /// Mirrors the symmetry pattern of
247    /// [`Self::read_entity`] / [`Self::write_entity`]: the trait
248    /// surface stays balanced so the engine doesn't branch on
249    /// backend type for write paths.
250    fn write_mem_config(&self, _bytes: &[u8]) -> Result<(), BackendError> {
251        Err(BackendError::Sealed)
252    }
253
254    /// Like [`Self::write_mem_config`] but records `note` (an optional
255    /// agent/operator-supplied provenance reason) on the resulting
256    /// commit body. The default delegates to the note-less form, so
257    /// backends without a commit (folder) simply ignore the note; the
258    /// git-branch backend overrides this to thread `note` into the
259    /// `__MEMSTEAD`-ref commit. Lets `set_mem_version` carry a `--note`
260    /// like the other commit-producing mem-lifecycle operations.
261    fn write_mem_config_with_note(
262        &self,
263        bytes: &[u8],
264        _note: Option<&str>,
265    ) -> Result<(), BackendError> {
266        self.write_mem_config(bytes)
267    }
268
269    /// Record provenance for a pipeline-config edit (mediums / facets /
270    /// projections / ingests). The canonical pipeline config is a plain
271    /// JSON file under `.memstead/` on the workspace root — it has no
272    /// commit of its own — so backends with a commit timeline mirror the
273    /// edit into their provenance record; the commit is the audit trail,
274    /// the disk file stays the read path.
275    ///
276    /// `edits`: `(config_name, Some(bytes))` upserts the mirrored blob,
277    /// `(config_name, None)` removes it (a rename passes both). `kind`
278    /// is the primitive's plural (`mediums`, `facets`, `projections`,
279    /// `ingests`); `verb` names the operation for the commit subject.
280    ///
281    /// The default is a successful no-op: folder and archive backends
282    /// have no commit timeline, so the note is accepted and dropped —
283    /// the same posture as [`Self::write_mem_config_with_note`]. The
284    /// git-branch backend overrides this to commit the mirror under
285    /// `__MEMSTEAD:pipeline/<kind>/<mem>/<name>.json` with `note` on
286    /// the commit body.
287    fn record_pipeline_edit(
288        &self,
289        _kind: &str,
290        _edits: &[(String, Option<Vec<u8>>)],
291        _note: Option<&str>,
292        _verb: &str,
293    ) -> Result<(), BackendError> {
294        Ok(())
295    }
296
297    /// Read the engine-owned anchors sidecar
298    /// ([`crate::anchor::ANCHOR_SIDECAR_PATH`]) bytes, if any.
299    ///
300    /// The sidecar lives on the mem branch under the `.memstead/`
301    /// umbrella every external reader already filters, so it never
302    /// surfaces as an entity. Returns the raw bytes the engine parses via
303    /// [`crate::anchor::AnchorSidecar::from_bytes`]; `Ok(None)` for a mem
304    /// that has never written anchors.
305    ///
306    /// Default impl returns `Ok(None)` — a backend that does not persist
307    /// anchors (a pre-anchor archive, any read-only mount) inherits and
308    /// signals "no anchors". Mirrors [`Self::read_mem_config`]. The
309    /// git-branch and in-memory backends override to read the sidecar
310    /// from their store (pending-buffer precedence, so a staged sidecar
311    /// write is visible before its commit).
312    fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
313        Ok(None)
314    }
315
316    /// Stage a write of the engine-owned anchors sidecar so it rides the
317    /// **same commit** as the entity mutation that produced it — the
318    /// atomicity guarantee anchors depend on (rename's referrer-rewrite,
319    /// delete's anchor removal, and branch_reset's rewind all move
320    /// entity + anchor state together).
321    ///
322    /// Pending until the next [`Self::commit`] — callers stage the entity
323    /// write, then the sidecar write, then commit once. Backends that
324    /// cannot persist anchors (archive / any sealed backend) inherit the
325    /// default returning [`BackendError::Sealed`]; the engine's write
326    /// path branches on mount capability before calling. The git-branch
327    /// and in-memory backends override to buffer the sidecar under
328    /// [`crate::anchor::ANCHOR_SIDECAR_PATH`] in the same pending set the
329    /// entity write used.
330    fn write_anchors_sidecar(&self, _bytes: &[u8]) -> Result<(), BackendError> {
331        Err(BackendError::Sealed)
332    }
333
334    /// Drop every backend-side artifact for this mem — the
335    /// symmetric counterpart to the writes performed by
336    /// `memstead_mem_create` (entity-seed commit on the per-mem
337    /// branch + [`Self::write_mem_config`] on `__MEMSTEAD`). Called by
338    /// `memstead_mem_delete` orchestration when `delete_files=true` and
339    /// the delete rule matched, to give the backend a chance to
340    /// prune ref-store state the engine alone has the git authority
341    /// to touch.
342    ///
343    /// Idempotent: safe to call on a backend whose artifacts already
344    /// went away (a sibling engine pruned them, the branch was
345    /// deleted manually, etc.). The default impl returns `Ok(())` —
346    /// backends whose on-disk state is fully captured by the mem
347    /// directory (folder, archive) inherit the no-op. The
348    /// orchestrator handles its `remove_dir_all` separately at the
349    /// outer layer.
350    ///
351    /// Implementations:
352    /// - Folder backend keeps the default — its disk state is the
353    ///   mem directory, which the orchestrator rmdirs.
354    /// - Archive backend keeps the default — sealed archives have
355    ///   nothing additional to prune.
356    /// - Git-branch backend deletes `refs/heads/<branch_leaf>` and
357    ///   commits a tree edit on `refs/heads/__MEMSTEAD` that removes
358    ///   `mems/<branch_leaf>/config.json`. `<branch_leaf>` is the
359    ///   mem's full hierarchical path (e.g.
360    ///   `planning/plan-q4-revamp` or the bare `<name>` for flat
361    ///   layouts).
362    fn delete_artifacts(&self) -> Result<(), BackendError> {
363        Ok(())
364    }
365}
366
367/// Errors surfaced by [`MemBackend`].
368///
369/// The `Sealed` variant is the typed read-only signal — backends
370/// that physically cannot write (archive) return it from every
371/// mutating method. Callers (the engine's mutation pipeline) branch
372/// on the discriminant before reaching the backend; a `Sealed`
373/// reaching this layer is a programming error in the upstream
374/// capability check.
375#[derive(Debug, thiserror::Error)]
376pub enum BackendError {
377    /// Re-thrown from the existing [`MemWriterError`] surface so
378    /// folder-backend implementations can lift `MemWriter`
379    /// failures without lossy conversion.
380    #[error(transparent)]
381    MemWriter(#[from] MemWriterError),
382    /// Backend physically rejects writes. Returned by the archive
383    /// backend and any future read-only backend (e.g. registry pin).
384    #[error("backend is sealed (writes rejected)")]
385    Sealed,
386    /// Filesystem IO failure outside the [`MemWriterError`] path.
387    #[error("backend io error: {0}")]
388    Io(#[from] std::io::Error),
389    /// Backend-specific failure not modelled by the variants above.
390    /// Carries an agent-readable message; structured backend errors
391    /// add their own variant.
392    #[error("backend error: {0}")]
393    Other(String),
394    /// Parent-ref pinning guard tripped on
395    /// [`MemBackend::commit_with_expected_parent`] — the backend's
396    /// current head no longer matches the caller's `expected_parent`.
397    /// A sibling writer (another `Engine` instance, an out-of-band
398    /// `git pull`, a manual git operation) advanced the on-disk state
399    /// between the snapshot the caller pinned and now. The engine
400    /// layer maps this into `MEM_RELOADED` /
401    /// `RENAME_PARTIAL_FAILURE` depending on whether other mems
402    /// already committed in the same logical operation. Today only
403    /// the git-branch backend (planned override) produces this
404    /// variant; folder and archive backends inherit the default impl
405    /// of `commit_with_expected_parent` which delegates to `commit`
406    /// without parent checking.
407    #[error(
408        "parent-ref mismatch: expected {expected}, found {actual} — sibling writer advanced the mem"
409    )]
410    ParentMismatch { expected: String, actual: String },
411}