Skip to main content

memstead_base/
workspace_store.rs

1//! Persistence adapter — reads / writes the [`Workspace`] from disk
2//! (or other backing stores) so [`crate::Engine::from_mounts`] can
3//! consume an in-memory mount list without owning the disk format.
4//!
5//! ## Two-layer file adapter (default)
6//!
7//! [`FileWorkspaceStore`] is the default adapter. It splits the
8//! workspace's persisted state across two files under
9//! `<workspace>/.memstead/`:
10//!
11//! - `workspace.toml` — operator-edited. Carries the persistence-
12//!   adapter declaration plus (in later sessions) cross-mem
13//!   permissions, workspace-level policy, plugin hooks. The engine
14//!   never writes to this file.
15//! - `state/mounts.json` — engine-managed. Carries the mount list
16//!   (per-mount mem name, schema pin, capability, lifecycle,
17//!   cross-linkable flag, and the backend-specific storage reference
18//!   — folder path, gitdir+branch pair, or archive path). The
19//!   operator does not edit this file during normal operation.
20//!
21//! The split mirrors the natural authorship: the operator edits rules
22//! that change rarely; the engine writes mount-list entries that
23//! change often (planning mems, ingest scratch mems, …). Sharing
24//! one file would force two authors with different update frequencies
25//! through the same merge surface.
26//!
27//! ## Adapter trait
28//!
29//! [`WorkspaceStoreAdapter`] is the seam. Future adapters (SQLite,
30//! remote, in-memory test fixture) implement it without changing the
31//! engine API.
32//! The adapter is selected at startup via the persistence-adapter
33//! declaration in `workspace.toml` — the file adapter is the only
34//! built-in V1.
35//!
36//! ## Backend instantiation
37//!
38//! The adapter produces a [`Workspace`] (mount list + operator
39//! policy). Turning each [`Mount`]'s [`MountStorage`] into a
40//! `Box<dyn MemBackend>` is a separate concern — handled by
41//! [`instantiate_lean_backend`] for folder + archive variants. The
42//! git-branch backend lives in the `memstead-git-branch` crate behind the
43//! `mem-repo` Cargo feature; consumers in the lean flavour cannot
44//! materialise a `MountStorage::GitBranch` mount and surface
45//! [`InstantiateError::GitBranchRequiresMemRepoFeature`].
46
47use std::path::{Path, PathBuf};
48
49use serde::{Deserialize, Serialize};
50
51use crate::backend::MemBackend;
52use crate::storage::{ArchiveBackend, FilesystemMemWriter, InMemoryBackend};
53use crate::workspace::{
54    McpSection, Mount, MountCapability, MountLifecycle, MountStorage, MutationsSection, Workspace,
55    WorkspaceSettings,
56};
57
58/// The engine-managed workspace store directory under the workspace
59/// root — `<workspace_root>/.memstead/` holds `workspace.toml` and
60/// `state/mounts.json`, the roster of everything this workspace
61/// mounts. (It also carries an empty `memstead-io/` directory the mem
62/// initialiser seeds; nothing reads it since the tier-3 archive
63/// resolver was removed on 2026-08-27, and retiring the directory
64/// itself is a separate change to what `init` creates.) Distinct from
65/// the per-mem meta directory
66/// ([`memstead_schema::MEM_META_DIR`], re-exported as
67/// `crate::mem::MEM_META_DIR`) and from the literal `".memstead/..."`
68/// member paths inside sealed archives, which are a separate on-disk
69/// format and never use this constant.
70pub const WORKSPACE_STORE_DIR: &str = ".memstead";
71
72/// Errors surfaced by [`WorkspaceStoreAdapter::load`] and
73/// [`WorkspaceStoreAdapter::save_state`]. Backend-specific failures
74/// surface as [`StoreError::Other`] with a string message; structured
75/// per-adapter errors can extend the enum later.
76#[derive(Debug, thiserror::Error)]
77pub enum StoreError {
78    /// Workspace root has no `.memstead/` directory or no recognised
79    /// adapter file inside it. Distinct from `Io` so callers can
80    /// distinguish "needs `memstead init`" from "permissions broke".
81    #[error("workspace store not found at {path} — run `memstead mem-repo init` first")]
82    NotInitialised { path: PathBuf },
83    /// IO failure reading or writing one of the adapter files.
84    #[error(
85        "workspace store io error at {path}: {source} — no memstead command repairs this; \
86         check filesystem permissions and disk state"
87    )]
88    Io {
89        path: PathBuf,
90        #[source]
91        source: std::io::Error,
92    },
93    /// TOML or JSON parse / serialise failure. The wrapped string is
94    /// the underlying serde error; the file is named so operators
95    /// know where to look.
96    #[error(
97        "workspace store parse error at {path}: {message} — no memstead command repairs this; \
98         fix the named file by hand or restore it from version control"
99    )]
100    Parse { path: PathBuf, message: String },
101    /// Format version mismatch — adapter understands a different
102    /// schema version than the file declares.
103    #[error(
104        "workspace store format mismatch at {path}: expected {expected}, found {found} — \
105         no memstead command repairs this; use an engine version whose format matches the file"
106    )]
107    FormatMismatch {
108        path: PathBuf,
109        expected: String,
110        found: String,
111    },
112    /// Pre-rename workspace layout. The unit-noun cut renamed every
113    /// on-disk shape with no dual-read; refusing keeps an old
114    /// workspace from booting empty, half-mounted, or silently
115    /// rewritten. The message names the one-shot migration steps.
116    #[error(
117        "pre-rename workspace layout at {path} (found format {found}): migrate the workspace \
118         state in place — rewrite mounts.json to memstead-mounts-3 (record field `mem`, storage \
119         paths under mem-repo/), workspace.toml to memstead-git-branch-2 (tables `mem_management`, \
120         `cross_mem_links`), rename the gitdir container to mem-repo/, and move the metadata \
121         branch tree to mems/ — then retry"
122    )]
123    LegacyLayout { path: PathBuf, found: String },
124    /// A `projections/` directory holds a pre-v2 config — either a
125    /// version-less (gen-2 four-primitive) projection or a v1 binding of the
126    /// retired three-file store. The loader serves only v2 (one record per
127    /// pipeline); prior generations are migrated once (`memstead projection
128    /// migrate`), never silently served. The message names the one-shot
129    /// migration command; [`StoreError::code`] maps it to the
130    /// `PROJECTION_STORE_LEGACY` token on every surface.
131    #[error(
132        "legacy (pre-v2) projection config at {path}: this workspace predates the single-record \
133         binding format v2 — run `memstead projection migrate` to convert it in place once"
134    )]
135    LegacyProjectionStore { path: PathBuf },
136    /// A binding file declares a `version` the loader does not understand
137    /// (only v2 = `2` is supported; v1 and version-less files surface
138    /// [`Self::LegacyProjectionStore`] instead). Refused, never reinterpreted.
139    #[error(
140        "unsupported binding format version {version} at {path}: this engine understands v2 (version 2)"
141    )]
142    UnknownBindingVersion { path: PathBuf, version: i64 },
143    /// Catch-all for adapter-specific failures. Carries an
144    /// agent-readable message; structured variants extend the enum.
145    #[error("workspace store error: {0}")]
146    Other(String),
147}
148
149impl StoreError {
150    /// Stable, surface-independent error code token, following the
151    /// [`crate::EngineError::code`] convention (UPPER_SNAKE). Boot
152    /// failures route through [`crate::engine::BootError::code`],
153    /// which delegates here — a store-layer failure carries the same
154    /// typed code on CLI stderr, `--json` envelopes, and the MCP
155    /// server's boot diagnostics.
156    pub fn code(&self) -> &'static str {
157        match self {
158            // Same token the CLI's setup layer uses for "no workspace
159            // marker found" — one condition, one code, regardless of
160            // whether the walk or the store load detected it.
161            StoreError::NotInitialised { .. } => "WORKSPACE_NOT_INITIALISED",
162            StoreError::Io { .. } => "WORKSPACE_STORE_IO",
163            StoreError::Parse { .. } => "WORKSPACE_STORE_PARSE",
164            StoreError::FormatMismatch { .. } => "WORKSPACE_STORE_FORMAT_MISMATCH",
165            StoreError::LegacyLayout { .. } => "LEGACY_WORKSPACE_LAYOUT",
166            StoreError::LegacyProjectionStore { .. } => "PROJECTION_STORE_LEGACY",
167            StoreError::UnknownBindingVersion { .. } => "UNKNOWN_BINDING_VERSION",
168            StoreError::Other(_) => "WORKSPACE_STORE_ERROR",
169        }
170    }
171}
172
173/// Adapter trait — the seam between the engine and the persisted
174/// workspace state. Implementations decide *where* the mount list
175/// lives (two files under `.memstead/`, a SQLite database, a remote
176/// service, an in-memory test fixture); the engine consumes the
177/// produced [`Workspace`] uniformly.
178pub trait WorkspaceStoreAdapter: Send + Sync {
179    /// Load the workspace from `workspace_root`. Implementations
180    /// resolve the adapter-specific files relative to this root
181    /// (e.g. the file adapter reads
182    /// `<workspace_root>/.memstead/workspace.toml` +
183    /// `<workspace_root>/.memstead/state/mounts.json`).
184    fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError>;
185
186    /// Persist the engine-managed slice of state (today: the mount
187    /// list). Operator-edited fields stay untouched — adapters that
188    /// share one file with operator content must not overwrite it
189    /// here. The two-layer file adapter writes only
190    /// `state/mounts.json`.
191    ///
192    /// Last-writer-wins. Prefer [`Self::save_state_cas`] whenever the
193    /// caller can name what it read: a long-lived process that dumps
194    /// its cached roster over this call drops every mount a sibling
195    /// process registered since the dump was taken.
196    fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError>;
197
198    /// Raw bytes of the engine-managed state file, or `None` when it
199    /// does not exist yet. The compare token for
200    /// [`Self::save_state_cas`].
201    fn read_state_bytes(&self, workspace_root: &Path) -> Result<Option<Vec<u8>>, StoreError>;
202
203    /// Parse state bytes previously returned by
204    /// [`Self::read_state_bytes`] into the mount roster they carry.
205    fn parse_state_bytes(
206        &self,
207        workspace_root: &Path,
208        bytes: &[u8],
209    ) -> Result<Vec<Mount>, StoreError>;
210
211    /// Compare-and-set counterpart of [`Self::save_state`]: write only
212    /// when the on-disk state still equals `expected`, and report a
213    /// mismatch as `Ok(false)` rather than an error so the caller can
214    /// re-read, re-merge and retry. The check and the write are one
215    /// step under the adapter's own lock — a caller that reads, then
216    /// compares, then writes leaves exactly the window this exists to
217    /// close.
218    fn save_state_cas(
219        &self,
220        workspace_root: &Path,
221        workspace: &Workspace,
222        expected: Option<&[u8]>,
223    ) -> Result<bool, StoreError>;
224}
225
226/// Two-layer file adapter — the default. Reads
227/// `.memstead/workspace.toml` (operator) +
228/// `.memstead/state/mounts.json` (engine). Constructed without
229/// arguments; everything is keyed off the workspace root passed
230/// per-call.
231#[derive(Debug, Default, Clone, Copy)]
232pub struct FileWorkspaceStore;
233
234impl FileWorkspaceStore {
235    /// Construct the adapter. Stateless; safe to share across
236    /// engines, callers, and tests.
237    pub fn new() -> Self {
238        Self
239    }
240
241    /// Path of the operator-edited file.
242    pub fn workspace_toml_path(workspace_root: &Path) -> PathBuf {
243        workspace_root
244            .join(WORKSPACE_STORE_DIR)
245            .join("workspace.toml")
246    }
247
248    /// Path of the engine-managed state file.
249    pub fn mounts_json_path(workspace_root: &Path) -> PathBuf {
250        workspace_root
251            .join(WORKSPACE_STORE_DIR)
252            .join("state")
253            .join("mounts.json")
254    }
255}
256
257const WORKSPACE_TOML_FORMAT: &str = "memstead-git-branch-2";
258/// Pre-rename `workspace.toml` format. V1 carried the old unit-noun
259/// policy tables. Recognised only to refuse with
260/// [`StoreError::LegacyLayout`] — no dual-read.
261const WORKSPACE_TOML_FORMAT_LEGACY: &str = "memstead-git-branch-1";
262/// Current `mounts.json` format. V3 is the unit-noun cut: mount
263/// records carry a `"mem"` field and mem-repo path values. Like V2 it
264/// stores `gitdir` / `path` values relative to `workspace_root` when
265/// they live inside the workspace, so checked-in state survives a
266/// clone into a different home dir. Absolute paths are still accepted
267/// on write (and round-trip untouched) when the mount target sits
268/// outside `workspace_root` — e.g., an archive on a shared cache. The
269/// reader resolves relative values against `workspace_root` at load
270/// time.
271const MOUNTS_JSON_FORMAT_V3: &str = "memstead-mounts-3";
272/// Pre-rename `mounts.json` formats (V1: absolute paths; V2: relative
273/// paths; both with the old unit-noun record field). Recognised only
274/// to refuse with [`StoreError::LegacyLayout`] — there is no
275/// dual-read; a one-shot migration rewrites state in place.
276const MOUNTS_JSON_FORMAT_LEGACY: [&str; 2] = ["memstead-mounts-1", "memstead-mounts-2"];
277
278/// Format-only probe for `mounts.json`, parsed before the full
279/// document so format refusals (legacy layout, unknown version)
280/// surface as typed errors rather than record-level parse failures.
281#[derive(Deserialize)]
282struct MountsFormatProbe {
283    format: String,
284}
285
286/// Shared `workspace.toml` format gate: current passes, the
287/// pre-rename V1 refuses as [`StoreError::LegacyLayout`], anything
288/// else as [`StoreError::FormatMismatch`].
289fn check_workspace_toml_format(format: &str, toml_path: &Path) -> Result<(), StoreError> {
290    if format == WORKSPACE_TOML_FORMAT {
291        return Ok(());
292    }
293    if format == WORKSPACE_TOML_FORMAT_LEGACY {
294        return Err(StoreError::LegacyLayout {
295            path: toml_path.to_path_buf(),
296            found: format.to_string(),
297        });
298    }
299    Err(StoreError::FormatMismatch {
300        path: toml_path.to_path_buf(),
301        expected: WORKSPACE_TOML_FORMAT.to_string(),
302        found: format.to_string(),
303    })
304}
305
306/// Resolve a path read from `mounts.json` against the workspace
307/// root. Absolute paths are returned untouched (they sit outside
308/// `workspace_root` by design — typically a shared archive cache);
309/// relative paths are joined against `workspace_root` so the
310/// in-memory `Mount` always carries an absolute path.
311fn absolutize_mount_path(value: PathBuf, workspace_root: &Path) -> PathBuf {
312    if value.is_absolute() {
313        value
314    } else {
315        workspace_root.join(value)
316    }
317}
318
319/// Normalise an absolute mount path for `mounts.json` serialisation.
320/// When the path sits inside `workspace_root`, strip the prefix so
321/// the on-disk form is portable. When it sits outside — an archive
322/// in a global cache, a mem on a separate filesystem — keep the
323/// absolute form; `strip_prefix` failure is the explicit fallback.
324fn relativize_mount_path(value: &Path, workspace_root: &Path) -> PathBuf {
325    match value.strip_prefix(workspace_root) {
326        Ok(rel) => rel.to_path_buf(),
327        Err(_) => value.to_path_buf(),
328    }
329}
330
331/// True when `dir` is a workspace root: it carries
332/// `.memstead/workspace.toml`. The shared recognition primitive for
333/// every workspace walk-up (MCP boot, CLI setup, per-command walkers)
334/// — keep them all on this helper so workspaces resolve uniformly.
335pub fn is_workspace_root(dir: &Path) -> bool {
336    FileWorkspaceStore::workspace_toml_path(dir).is_file()
337}
338
339/// True when the workspace root carries `mem-repo/.git/` — the
340/// multi-mem, git-backed shape. False for the single-mem, history-free
341/// filesystem-mem shape. The `.memstead/workspace.toml` marker is
342/// shape-neutral, so this directory probe is what distinguishes the two.
343///
344/// The shared shape primitive: the CLI's `WorkspaceShape` resolution,
345/// the mem-repo-only refusals, and the MCP boot line all route through
346/// here so no surface can name a shape the others disagree with.
347pub fn is_mem_repo_shaped(workspace_root: &Path) -> bool {
348    workspace_root.join("mem-repo").join(".git").is_dir()
349}
350
351/// The one spelling of a workspace's shape, for any surface that names
352/// it to a human or an agent: `"mem-repo"` or `"filesystem-mem"`. Both
353/// spellings match the vocabulary the refusals use
354/// (`UNSUPPORTED_WORKSPACE_SHAPE`) and the commands that create each
355/// shape (`memstead mem-repo init` / `memstead quickstart`).
356pub fn workspace_shape_label(workspace_root: &Path) -> &'static str {
357    if is_mem_repo_shaped(workspace_root) {
358        "mem-repo"
359    } else {
360        "filesystem-mem"
361    }
362}
363
364impl WorkspaceStoreAdapter for FileWorkspaceStore {
365    fn load(&self, workspace_root: &Path) -> Result<Workspace, StoreError> {
366        let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
367        if !memstead_dir.is_dir() {
368            return Err(StoreError::NotInitialised {
369                path: workspace_root.to_path_buf(),
370            });
371        }
372
373        // workspace.toml is required (carries the adapter declaration).
374        let toml_path = Self::workspace_toml_path(workspace_root);
375        let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
376            if e.kind() == std::io::ErrorKind::NotFound {
377                StoreError::NotInitialised {
378                    path: workspace_root.to_path_buf(),
379                }
380            } else {
381                StoreError::Io {
382                    path: toml_path.clone(),
383                    source: e,
384                }
385            }
386        })?;
387        let toml_doc: WorkspaceTomlDoc =
388            toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
389                path: toml_path.clone(),
390                message: e.to_string(),
391            })?;
392        check_workspace_toml_format(&toml_doc.format, &toml_path)?;
393
394        // state/mounts.json is optional — a fresh workspace has the
395        // adapter declaration but no mounts yet. Treat the missing
396        // file as "zero mounts".
397        let mounts_path = Self::mounts_json_path(workspace_root);
398        let mounts: Vec<Mount> = match std::fs::read_to_string(&mounts_path) {
399            Ok(text) => parse_mounts_text(&text, &mounts_path, workspace_root)?,
400            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
401            Err(e) => {
402                return Err(StoreError::Io {
403                    path: mounts_path,
404                    source: e,
405                });
406            }
407        };
408
409        warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
410        let settings = build_settings(
411            toml_doc.mem_management,
412            toml_doc.cross_mem_links,
413            toml_doc.mcp,
414            toml_doc.mutations,
415            toml_doc.plugin,
416        )?;
417        Ok(Workspace { mounts, settings })
418    }
419
420    fn save_state(&self, workspace_root: &Path, workspace: &Workspace) -> Result<(), StoreError> {
421        let mounts_path = Self::mounts_json_path(workspace_root);
422        ensure_state_dir(&mounts_path)?;
423        let text = render_mounts_text(workspace, workspace_root, &mounts_path)?;
424        std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
425            path: mounts_path,
426            source: e,
427        })?;
428        Ok(())
429    }
430
431    fn read_state_bytes(&self, workspace_root: &Path) -> Result<Option<Vec<u8>>, StoreError> {
432        let mounts_path = Self::mounts_json_path(workspace_root);
433        match std::fs::read(&mounts_path) {
434            Ok(b) => Ok(Some(b)),
435            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
436            Err(e) => Err(StoreError::Io {
437                path: mounts_path,
438                source: e,
439            }),
440        }
441    }
442
443    fn parse_state_bytes(
444        &self,
445        workspace_root: &Path,
446        bytes: &[u8],
447    ) -> Result<Vec<Mount>, StoreError> {
448        let mounts_path = Self::mounts_json_path(workspace_root);
449        let text = std::str::from_utf8(bytes).map_err(|e| StoreError::Parse {
450            path: mounts_path.clone(),
451            message: e.to_string(),
452        })?;
453        parse_mounts_text(text, &mounts_path, workspace_root)
454    }
455
456    fn save_state_cas(
457        &self,
458        workspace_root: &Path,
459        workspace: &Workspace,
460        expected: Option<&[u8]>,
461    ) -> Result<bool, StoreError> {
462        let mounts_path = Self::mounts_json_path(workspace_root);
463        ensure_state_dir(&mounts_path)?;
464        let text = render_mounts_text(workspace, workspace_root, &mounts_path)?;
465
466        // Same lockfile shape the folder backend's config compare-and-set
467        // uses: hold an exclusive marker, compare, write, release on every
468        // exit path. 50 x 10ms, then the holder is presumed dead — a state
469        // write is milliseconds of work, so half a second of contention is
470        // not a busy writer.
471        let lock_path = mounts_path.with_extension("json.lock");
472        let mut held = None;
473        for attempt in 0..50 {
474            match std::fs::OpenOptions::new()
475                .write(true)
476                .create_new(true)
477                .open(&lock_path)
478            {
479                Ok(f) => {
480                    held = Some(f);
481                    break;
482                }
483                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
484                    if attempt == 49 {
485                        let _ = std::fs::remove_file(&lock_path);
486                    }
487                    std::thread::sleep(std::time::Duration::from_millis(10));
488                }
489                Err(e) => {
490                    return Err(StoreError::Io {
491                        path: lock_path,
492                        source: e,
493                    });
494                }
495            }
496        }
497        let _lock = match held {
498            Some(f) => f,
499            None => std::fs::OpenOptions::new()
500                .write(true)
501                .create(true)
502                .truncate(true)
503                .open(&lock_path)
504                .map_err(|e| StoreError::Io {
505                    path: lock_path.clone(),
506                    source: e,
507                })?,
508        };
509        let release = || {
510            let _ = std::fs::remove_file(&lock_path);
511        };
512
513        let current = match std::fs::read(&mounts_path) {
514            Ok(b) => Some(b),
515            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
516            Err(e) => {
517                release();
518                return Err(StoreError::Io {
519                    path: mounts_path,
520                    source: e,
521                });
522            }
523        };
524        if current.as_deref() != expected {
525            release();
526            return Ok(false);
527        }
528        let result = std::fs::write(&mounts_path, text).map_err(|e| StoreError::Io {
529            path: mounts_path,
530            source: e,
531        });
532        release();
533        result.map(|_| true)
534    }
535}
536
537/// Create the `state/` directory the mounts file lives in.
538fn ensure_state_dir(mounts_path: &Path) -> Result<(), StoreError> {
539    if let Some(parent) = mounts_path.parent() {
540        std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
541            path: parent.to_path_buf(),
542            source: e,
543        })?;
544    }
545    Ok(())
546}
547
548/// Render a workspace's mount roster as `mounts.json` text.
549fn render_mounts_text(
550    workspace: &Workspace,
551    workspace_root: &Path,
552    mounts_path: &Path,
553) -> Result<String, StoreError> {
554    let doc = MountsJsonDoc {
555        format: MOUNTS_JSON_FORMAT_V3.to_string(),
556        mounts: workspace
557            .mounts
558            .iter()
559            .map(|m| MountWire::from_mount(m, workspace_root))
560            .collect(),
561    };
562    serde_json::to_string_pretty(&doc).map_err(|e| StoreError::Parse {
563        path: mounts_path.to_path_buf(),
564        message: e.to_string(),
565    })
566}
567
568/// Parse `mounts.json` text into the roster it carries, enforcing the
569/// format pin. Probes the format field before the full parse: a
570/// pre-rename file fails record deserialisation (old unit-noun field
571/// name), and the typed `LegacyLayout` refusal must win over that
572/// generic parse error.
573fn parse_mounts_text(
574    text: &str,
575    mounts_path: &Path,
576    workspace_root: &Path,
577) -> Result<Vec<Mount>, StoreError> {
578    let probe: MountsFormatProbe = serde_json::from_str(text).map_err(|e| StoreError::Parse {
579        path: mounts_path.to_path_buf(),
580        message: e.to_string(),
581    })?;
582    if MOUNTS_JSON_FORMAT_LEGACY.contains(&probe.format.as_str()) {
583        return Err(StoreError::LegacyLayout {
584            path: mounts_path.to_path_buf(),
585            found: probe.format,
586        });
587    }
588    if probe.format != MOUNTS_JSON_FORMAT_V3 {
589        return Err(StoreError::FormatMismatch {
590            path: mounts_path.to_path_buf(),
591            expected: MOUNTS_JSON_FORMAT_V3.to_string(),
592            found: probe.format,
593        });
594    }
595    let doc: MountsJsonDoc = serde_json::from_str(text).map_err(|e| StoreError::Parse {
596        path: mounts_path.to_path_buf(),
597        message: e.to_string(),
598    })?;
599    Ok(doc
600        .mounts
601        .into_iter()
602        .map(|w| w.into_mount(workspace_root))
603        .collect())
604}
605
606/// On-disk shape of `workspace.toml`. Operator-edited; engine never
607/// writes. V1 carries the adapter declaration, `[mem_management]`
608/// rule lists, and `[cross_mem_links]` permission policy. Plugin
609/// hooks land additively when consumers need them.
610#[derive(Debug, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612struct WorkspaceTomlDoc {
613    /// Schema version of the TOML file. Must equal
614    /// `memstead-git-branch-1` for V1; mismatch surfaces
615    /// [`StoreError::FormatMismatch`].
616    format: String,
617    /// Persistence-adapter declaration. Carries the adapter `name`
618    /// (default: `"file-two-layer"`); future adapters add their own
619    /// nested config blocks.
620    #[serde(default)]
621    persistence_adapter: PersistenceAdapterDecl,
622    /// `[mem_management]` rule lists. Both arrays default to empty
623    /// — an empty list means "no agent-driven mem create / delete
624    /// allowed" (mirrors full). Operators add `[[mem_management.create]]`
625    /// / `[[mem_management.delete]]` entries to opt in.
626    #[serde(default)]
627    mem_management: MemManagementWire,
628    /// `[cross_mem_links]` raw shape — `<mem> = "*"` (wildcard)
629    /// or `<mem> = ["target", ...]` (allowlist) per key. Parsed
630    /// post-decode via `memstead_schema::workspace_config::CrossLinkValue::parse_toml`
631    /// because the wildcard-or-list shape doesn't fit serde's
632    /// untagged-enum pattern. Empty when the section is absent;
633    /// interpreted as default-deny.
634    #[serde(default)]
635    cross_mem_links: toml::Table,
636    /// **Retired key.** The folder-backend authored-schema location is
637    /// fixed at `<workspace>/.memstead/schemas/`; this key is no longer
638    /// honoured. Kept here only so an older workspace.toml that still
639    /// carries it parses cleanly — `warn_if_legacy_schemas_dir` emits a
640    /// one-line warning and the value is dropped (never threaded into
641    /// `WorkspaceSettings`).
642    #[serde(default)]
643    schemas_dir: Option<std::path::PathBuf>,
644    /// `[mcp]` section — MCP-binary tuning. Absent → defaults
645    /// (`token_budget` falls back to the binary's compile-time
646    /// default, `disabled_tools` is empty).
647    #[serde(default)]
648    mcp: McpSection,
649    /// `[mutations]` section — engine-wide mutation policy. Absent →
650    /// `require_notes = None` (interpreted as `false`).
651    #[serde(default)]
652    mutations: MutationsSection,
653    /// `[plugin.*]` namespace — opaque pass-through map keyed by
654    /// plugin identifier. Values are raw TOML tables; the engine
655    /// never inspects them.
656    #[serde(default)]
657    plugin: std::collections::HashMap<String, toml::Table>,
658}
659
660/// Wire shape for the `[mem_management]` section. Both arrays
661/// default to empty so the section may be omitted from
662/// `workspace.toml` entirely.
663#[derive(Debug, Default, Serialize, Deserialize)]
664struct MemManagementWire {
665    #[serde(default)]
666    create: Vec<CreateRuleWire>,
667    #[serde(default)]
668    delete: Vec<DeleteRuleWire>,
669}
670
671/// Wire shape for one `[[mem_management.create]]` entry. Mirrors
672/// [`crate::workspace::CreateRuleSetting`] with serde defaults so
673/// `schemas` may be omitted (treated as the empty allowlist —
674/// effectively a deny rule, surfaced for parity with full).
675///
676/// `default_cross_links` is decoded as a raw `toml::Value` and lifted
677/// to `CrossLinkValue` post-decode via the same parser as the
678/// top-level `[cross_mem_links]` section, sharing the wildcard-vs-
679/// list-vs-mixed-rejection semantics.
680#[derive(Debug, Serialize, Deserialize)]
681struct CreateRuleWire {
682    pattern: String,
683    #[serde(default)]
684    schemas: Vec<String>,
685    #[serde(default)]
686    default_cross_links: Option<toml::Value>,
687}
688
689/// Wire shape for one `[[mem_management.delete]]` entry. Mirrors
690/// [`crate::workspace::DeleteRuleSetting`].
691#[derive(Debug, Serialize, Deserialize)]
692struct DeleteRuleWire {
693    pattern: String,
694}
695
696/// Parse the operator-edited `.memstead/workspace.toml` at `workspace_root`
697/// into a fresh `WorkspaceSettings`. Exposed so MCP- and CLI-driven
698/// policy-mutation tools (the `workspace_config_edit::{grant,revoke}_*`
699/// family) can refresh the engine's in-memory settings after writing
700/// to disk — closing the stale-cache footgun where the next call
701/// into the engine after a successful policy mutation still saw the
702/// pre-mutation policy.
703///
704/// Reads only `workspace.toml` — the engine-managed `mounts.json` is
705/// untouched. The function pays one file-read; the alternative
706/// (threading projections through the policy-mutation functions
707/// without an engine handle) was rejected for the coupling cost.
708///
709/// Errors mirror [`FileWorkspaceStore::load`]'s subset that touches
710/// `workspace.toml` only: `NotInitialised` (no `.memstead/` dir),
711/// `Io` / `Parse` (file read or TOML parse failure),
712/// `FormatMismatch` (unsupported `format` field).
713pub fn parse_workspace_settings(
714    workspace_root: &Path,
715) -> Result<crate::workspace::WorkspaceSettings, StoreError> {
716    let memstead_dir = workspace_root.join(WORKSPACE_STORE_DIR);
717    if !memstead_dir.is_dir() {
718        return Err(StoreError::NotInitialised {
719            path: workspace_root.to_path_buf(),
720        });
721    }
722    let toml_path = FileWorkspaceStore::workspace_toml_path(workspace_root);
723    let toml_text = std::fs::read_to_string(&toml_path).map_err(|e| {
724        if e.kind() == std::io::ErrorKind::NotFound {
725            StoreError::NotInitialised {
726                path: workspace_root.to_path_buf(),
727            }
728        } else {
729            StoreError::Io {
730                path: toml_path.clone(),
731                source: e,
732            }
733        }
734    })?;
735    let toml_doc: WorkspaceTomlDoc = toml::from_str(&toml_text).map_err(|e| StoreError::Parse {
736        path: toml_path.clone(),
737        message: e.to_string(),
738    })?;
739    check_workspace_toml_format(&toml_doc.format, &toml_path)?;
740    warn_if_legacy_schemas_dir(toml_doc.schemas_dir.as_deref());
741    build_settings(
742        toml_doc.mem_management,
743        toml_doc.cross_mem_links,
744        toml_doc.mcp,
745        toml_doc.mutations,
746        toml_doc.plugin,
747    )
748}
749
750/// Build a `WorkspaceSettings` from the raw wire shapes. Folds in
751/// the `[mem_management]` rules and the post-decoded
752/// `[cross_mem_links]` map; surfaces a typed parse error if any
753/// cross-link value violates the wildcard / list / non-empty
754/// invariants.
755fn build_settings(
756    vm: MemManagementWire,
757    cross_mem_links_raw: toml::Table,
758    mcp: McpSection,
759    mutations: MutationsSection,
760    plugin: std::collections::HashMap<String, toml::Table>,
761) -> Result<WorkspaceSettings, StoreError> {
762    let mut create_rules = Vec::with_capacity(vm.create.len());
763    for r in vm.create {
764        let default_cross_links = match r.default_cross_links {
765            None => None,
766            Some(value) => {
767                let location = format!(
768                    "[[mem_management.create]] pattern={}.default_cross_links",
769                    r.pattern
770                );
771                Some(parse_cross_link_value(&location, &value)?)
772            }
773        };
774        create_rules.push(crate::workspace::CreateRuleSetting {
775            pattern: r.pattern,
776            schemas: r.schemas,
777            default_cross_links,
778        });
779    }
780
781    let mut cross_mem_links = std::collections::BTreeMap::new();
782    for (mem, value) in &cross_mem_links_raw {
783        let location = format!("[cross_mem_links].{mem}");
784        let parsed = parse_cross_link_value(&location, value)?;
785        cross_mem_links.insert(mem.clone(), parsed);
786    }
787
788    Ok(WorkspaceSettings {
789        mem_create_rules: create_rules,
790        mem_delete_rules: vm
791            .delete
792            .into_iter()
793            .map(|r| crate::workspace::DeleteRuleSetting { pattern: r.pattern })
794            .collect(),
795        cross_mem_links,
796        mcp,
797        mutations,
798        plugin,
799    })
800}
801
802/// The folder-backend authored-schema location is fixed at
803/// `<workspace>/.memstead/schemas/` — the `schemas_dir` workspace.toml
804/// key is retired (no configurability without demonstrated need). A
805/// workspace.toml that still carries the key gets a one-line warning
806/// naming the fixed location; the key is otherwise ignored, never
807/// honoured. Called from both `workspace.toml` parse entry points.
808fn warn_if_legacy_schemas_dir(schemas_dir: Option<&std::path::Path>) {
809    if let Some(dir) = schemas_dir {
810        tracing::warn!(
811            "`schemas_dir` (= {:?}) in workspace.toml is retired and ignored — \
812             authored schemas are read from the fixed `<workspace>/.memstead/schemas/`. \
813             Remove the key to silence this warning.",
814            dir
815        );
816    }
817}
818
819/// Parse one cross-link value via `memstead_schema::workspace_config::CrossLinkValue::parse_toml`,
820/// lifting the schema-crate's `ConfigError` into a `StoreError::Parse` with
821/// the operator-facing TOML location prefix.
822fn parse_cross_link_value(
823    location: &str,
824    value: &toml::Value,
825) -> Result<memstead_schema::workspace_config::CrossLinkValue, StoreError> {
826    memstead_schema::workspace_config::CrossLinkValue::parse_toml(location, value).map_err(|e| {
827        StoreError::Parse {
828            path: std::path::PathBuf::from("workspace.toml"),
829            message: e.to_string(),
830        }
831    })
832}
833
834/// Persistence-adapter section of `workspace.toml`. Future adapters
835/// nest their config under this section.
836#[derive(Debug, Serialize, Deserialize)]
837struct PersistenceAdapterDecl {
838    name: String,
839}
840
841impl Default for PersistenceAdapterDecl {
842    fn default() -> Self {
843        Self {
844            name: "file-two-layer".to_string(),
845        }
846    }
847}
848
849/// On-disk shape of `state/mounts.json`. Engine-managed; operator
850/// does not edit during normal operation (lifecycle tools rewrite it
851/// after every mount-state mutation).
852#[derive(Debug, Serialize, Deserialize)]
853struct MountsJsonDoc {
854    format: String,
855    mounts: Vec<MountWire>,
856}
857
858/// Wire shape for one mount. Mirrors [`Mount`] with serializable
859/// fields. Schema pin serialises as a plain string (`"default"` or
860/// `"default@1.0.0"`); storage uses an internally-tagged enum
861/// (`type: "folder" | "git-branch" | "archive"`).
862#[derive(Debug, Serialize, Deserialize)]
863struct MountWire {
864    mem: String,
865    /// Optional schema-pin *expectation assertion* (`<name>@<version>`).
866    /// The authoritative pin is the mem's own `MemConfig.schema`;
867    /// this is a workspace-local cross-check. `default` on read keeps
868    /// older `mounts.json` files (which always carried the key) loading
869    /// as `Some`; skip-on-`None` omits the key for assertion-less mounts.
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    schema: Option<String>,
872    /// In-flight migration target (`<name>@<version>`), absent for
873    /// settled mems. `default` on read keeps pre-dual-pin
874    /// `mounts.json` files loading unchanged; skip-on-`None` keeps
875    /// settled mems' entries byte-identical to before.
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    migration_target: Option<String>,
878    storage: MountStorageWire,
879    capability: CapabilityWire,
880    lifecycle: LifecycleWire,
881    cross_linkable: bool,
882}
883
884#[derive(Debug, Serialize, Deserialize)]
885#[serde(tag = "type", rename_all = "kebab-case")]
886enum MountStorageWire {
887    Folder {
888        path: PathBuf,
889    },
890    GitBranch {
891        gitdir: PathBuf,
892        branch: String,
893    },
894    Archive {
895        path: PathBuf,
896    },
897    /// In-memory backend. Carries no fields — it serialises as the
898    /// bare tag `{ "type": "in-memory" }`. Unambiguous against the
899    /// other three variants (each of which carries a `path` or
900    /// `gitdir`/`branch`), so a round-trip never confuses it for one
901    /// of them. Present for wire completeness; ephemeral session
902    /// mems are normally constructed via `Engine::from_mounts`
903    /// rather than persisted to `mounts.json`.
904    InMemory,
905}
906
907#[derive(Debug, Serialize, Deserialize)]
908#[serde(rename_all = "kebab-case")]
909enum CapabilityWire {
910    ReadOnly,
911    Write,
912}
913
914#[derive(Debug, Serialize, Deserialize)]
915#[serde(rename_all = "kebab-case")]
916enum LifecycleWire {
917    Eager,
918    Lazy,
919}
920
921impl MountWire {
922    fn from_mount(m: &Mount, workspace_root: &Path) -> Self {
923        Self {
924            mem: m.mem.clone(),
925            schema: m.schema.as_ref().map(|s| s.to_string()),
926            migration_target: m.migration_target.as_ref().map(|t| t.to_string()),
927            storage: match &m.storage {
928                MountStorage::Folder { path } => MountStorageWire::Folder {
929                    path: relativize_mount_path(path, workspace_root),
930                },
931                MountStorage::GitBranch { gitdir, branch } => MountStorageWire::GitBranch {
932                    gitdir: relativize_mount_path(gitdir, workspace_root),
933                    branch: branch.clone(),
934                },
935                MountStorage::Archive { path } => MountStorageWire::Archive {
936                    path: relativize_mount_path(path, workspace_root),
937                },
938                MountStorage::InMemory => MountStorageWire::InMemory,
939            },
940            capability: match m.capability {
941                MountCapability::ReadOnly => CapabilityWire::ReadOnly,
942                MountCapability::Write => CapabilityWire::Write,
943            },
944            lifecycle: match m.lifecycle {
945                MountLifecycle::Eager => LifecycleWire::Eager,
946                MountLifecycle::Lazy => LifecycleWire::Lazy,
947            },
948            cross_linkable: m.cross_linkable,
949        }
950    }
951
952    fn into_mount(self, workspace_root: &Path) -> Mount {
953        Mount {
954            mem: self.mem,
955            schema: self.schema.map(|s| {
956                s.parse()
957                    .expect("schema pin on disk must be `<name>@<version>`")
958            }),
959            migration_target: self.migration_target.map(|t| {
960                t.parse()
961                    .expect("migration_target on disk must be `<name>@<version>`")
962            }),
963            storage: match self.storage {
964                MountStorageWire::Folder { path } => MountStorage::Folder {
965                    path: absolutize_mount_path(path, workspace_root),
966                },
967                MountStorageWire::GitBranch { gitdir, branch } => MountStorage::GitBranch {
968                    gitdir: absolutize_mount_path(gitdir, workspace_root),
969                    branch,
970                },
971                MountStorageWire::Archive { path } => MountStorage::Archive {
972                    path: absolutize_mount_path(path, workspace_root),
973                },
974                MountStorageWire::InMemory => MountStorage::InMemory,
975            },
976            capability: match self.capability {
977                CapabilityWire::ReadOnly => MountCapability::ReadOnly,
978                CapabilityWire::Write => MountCapability::Write,
979            },
980            lifecycle: match self.lifecycle {
981                LifecycleWire::Eager => MountLifecycle::Eager,
982                LifecycleWire::Lazy => MountLifecycle::Lazy,
983            },
984            cross_linkable: self.cross_linkable,
985        }
986    }
987}
988
989/// Errors surfaced by [`instantiate_lean_backend`].
990#[derive(Debug, thiserror::Error)]
991pub enum InstantiateError {
992    /// Mount declares a `MountStorage::GitBranch` storage variant but
993    /// `memstead-base` alone cannot construct a git-branch backend —
994    /// the implementation lives in the `memstead-git-branch` crate.
995    ///
996    /// The remedy text names the two real, published entry points and
997    /// nothing else: an earlier revision pointed at a `mem-repo` Cargo
998    /// feature that does not exist on the published crates and named
999    /// `instantiate_full_backend` without saying how to wire it —
1000    /// a cold-start run burned its longest wall on exactly that
1001    /// (protocol 0-8-0, F6). Keep this message in lockstep with the
1002    /// actual `memstead-git-branch` public API.
1003    #[error(
1004        "mem {mem}: git-branch storage needs the memstead-git-branch crate \
1005         (`cargo add memstead-git-branch`). Simplest: open the workspace with \
1006         `memstead_git_branch::workspace_store::engine_from_workspace_root(root)` \
1007         instead of the memstead-base constructor. Alternative, if you build the \
1008         Engine yourself: `engine.set_backend_factory(\
1009         memstead_git_branch::storage::instantiate_full_backend)` before mounting"
1010    )]
1011    GitBranchRequiresMemRepoFeature { mem: String },
1012}
1013
1014impl InstantiateError {
1015    /// Stable, surface-independent error code token (UPPER_SNAKE, per
1016    /// the [`crate::EngineError::code`] convention). Reuses the CLI's
1017    /// existing `UNSUPPORTED_WORKSPACE_SHAPE` token: both fire when a
1018    /// lean binary meets a git-branch-shaped workspace, and the
1019    /// agent's next step is identical.
1020    pub fn code(&self) -> &'static str {
1021        match self {
1022            InstantiateError::GitBranchRequiresMemRepoFeature { .. } => {
1023                "UNSUPPORTED_WORKSPACE_SHAPE"
1024            }
1025        }
1026    }
1027}
1028
1029/// Materialise a [`MemBackend`] for `mount` using the lean-flavour
1030/// backends (folder + archive). Returns an error for the git-branch
1031/// variant — full consumers handle that with a feature-gated
1032/// counterpart in `memstead-git-branch`.
1033///
1034/// Lives in `memstead-base` because both folder and archive backends are
1035/// always-on; the function shape (one mount in, one boxed backend
1036/// out) stays uniform for both flavours so the engine's
1037/// `from_mounts` glue is identical between lean and full.
1038pub fn instantiate_lean_backend(mount: &Mount) -> Result<Box<dyn MemBackend>, InstantiateError> {
1039    match &mount.storage {
1040        MountStorage::Folder { path } => Ok(Box::new(FilesystemMemWriter::new(path.clone()))),
1041        MountStorage::Archive { path } => Ok(Box::new(ArchiveBackend::new(path.clone()))),
1042        MountStorage::InMemory => Ok(Box::new(InMemoryBackend::new())),
1043        MountStorage::GitBranch { .. } => Err(InstantiateError::GitBranchRequiresMemRepoFeature {
1044            mem: mount.mem.clone(),
1045        }),
1046    }
1047}
1048
1049/// On-disk layout the workspace root carries today.
1050///
1051/// Drives [`Engine::from_workspace_root`](crate::Engine::from_workspace_root):
1052/// a workspace either carries the two-layer file adapter shape
1053/// (`Layout::New`) or it does not (`Layout::Empty`). Pre-rebuild
1054/// layouts are no longer recognised — operators run
1055/// `memstead mem-repo init` to bootstrap a fresh workspace.
1056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1057pub enum Layout {
1058    /// No `.memstead/workspace.toml` present — operator should run
1059    /// `memstead mem-repo init` to bootstrap.
1060    Empty,
1061    /// `.memstead/workspace.toml` present — workspace runs on the
1062    /// two-layer file adapter.
1063    New,
1064}
1065
1066/// Detect the on-disk layout at `workspace_root`. The returned
1067/// [`Layout`] discriminator is total: every workspace falls into
1068/// exactly one variant.
1069pub fn detect_layout(workspace_root: &Path) -> Layout {
1070    if is_workspace_root(workspace_root) {
1071        Layout::New
1072    } else {
1073        Layout::Empty
1074    }
1075}
1076
1077/// Synthesize a one-mount [`Workspace`] from a bare *standalone* folder
1078/// mem — a directory carrying `.memstead/config.json` but **no**
1079/// `.memstead/workspace.toml`. The mem root *is* the workspace root
1080/// (the collapsed single-mem form), so the lone mount is a folder
1081/// backend pointed at `workspace_root` itself.
1082///
1083/// Returns `None` when the directory is not a standalone mem — no
1084/// readable, schema-pinned `config.json` — so boot callers fall through
1085/// to [`crate::BootError::NotInitialised`] exactly as before. This is what
1086/// collapses the old separate standalone-mem boot path into the unified
1087/// roster+detail experience: a lone mem opens as a workspace with one
1088/// mount, no `workspace.toml` required.
1089///
1090/// The synthesized workspace carries default (empty) settings — a
1091/// standalone mem has no `[mem_management]` / `[cross_mem_links]`
1092/// policy — and the mount is writable, not cross-linkable (there is no
1093/// sibling to link to). The schema pin and name come from the mem's own
1094/// `config.json`; an engine-written config omits `name`, so the directory
1095/// basename is the fallback identity.
1096pub fn standalone_workspace(workspace_root: &Path) -> Option<Workspace> {
1097    let config = memstead_schema::config::load_and_validate(workspace_root).ok()?;
1098    let schema = config.schema.clone()?;
1099    let name = config.name.clone().unwrap_or_else(|| {
1100        workspace_root
1101            .file_name()
1102            .map(|n| n.to_string_lossy().to_string())
1103            .unwrap_or_else(|| "mem".to_string())
1104    });
1105    let mount = Mount {
1106        mem: name,
1107        schema: Some(schema),
1108        storage: MountStorage::Folder {
1109            path: workspace_root.to_path_buf(),
1110        },
1111        capability: MountCapability::Write,
1112        lifecycle: MountLifecycle::Eager,
1113        cross_linkable: false,
1114        migration_target: None,
1115    };
1116    Some(Workspace {
1117        mounts: vec![mount],
1118        settings: WorkspaceSettings::default(),
1119    })
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125    use memstead_schema::SchemaRef;
1126    use std::io::Write as _;
1127    use tempfile::TempDir;
1128
1129    fn pin(s: &str) -> SchemaRef {
1130        s.parse().unwrap()
1131    }
1132
1133    fn folder_mount(mem: &str, path: PathBuf) -> Mount {
1134        Mount {
1135            mem: mem.to_string(),
1136            schema: Some(pin("default@1.0.0")),
1137            storage: MountStorage::Folder { path },
1138            capability: MountCapability::Write,
1139            lifecycle: MountLifecycle::Eager,
1140            cross_linkable: true,
1141            migration_target: None,
1142        }
1143    }
1144
1145    fn write_workspace_toml(workspace_root: &Path, body: &str) {
1146        let path = FileWorkspaceStore::workspace_toml_path(workspace_root);
1147        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1148        std::fs::write(path, body).unwrap();
1149    }
1150
1151    #[test]
1152    fn load_returns_not_initialised_when_memstead_dir_absent() {
1153        let tmp = TempDir::new().unwrap();
1154        let store = FileWorkspaceStore::new();
1155        let err = store.load(tmp.path()).unwrap_err();
1156        assert!(matches!(err, StoreError::NotInitialised { .. }));
1157    }
1158
1159    /// The [`parse_workspace_settings`] helper reads only
1160    /// `.memstead/workspace.toml` and returns a fresh `WorkspaceSettings`.
1161    /// MCP-driven policy mutations call this after writing to disk
1162    /// to refresh the engine's in-memory cache without re-loading
1163    /// the engine-managed `mounts.json`.
1164    #[test]
1165    fn parse_workspace_settings_reflects_cross_mem_links_edit() {
1166        let tmp = TempDir::new().unwrap();
1167        write_workspace_toml(
1168            tmp.path(),
1169            r#"
1170format = "memstead-git-branch-2"
1171
1172[persistence_adapter]
1173name = "file-two-layer"
1174
1175[cross_mem_links]
1176team-a = ["team-b"]
1177"#,
1178        );
1179        let settings = super::parse_workspace_settings(tmp.path()).unwrap();
1180        assert!(
1181            settings.cross_mem_links.contains_key("team-a"),
1182            "initial parse must surface the team-a grant; got {:?}",
1183            settings.cross_mem_links
1184        );
1185
1186        // Mutate the file (simulating `workspace_config_edit::revoke_cross_link`).
1187        write_workspace_toml(
1188            tmp.path(),
1189            r#"
1190format = "memstead-git-branch-2"
1191
1192[persistence_adapter]
1193name = "file-two-layer"
1194
1195[cross_mem_links]
1196"#,
1197        );
1198        let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1199        assert!(
1200            refreshed.cross_mem_links.is_empty(),
1201            "refreshed parse must drop the team-a grant; got {:?}",
1202            refreshed.cross_mem_links
1203        );
1204    }
1205
1206    /// `parse_workspace_settings` surfaces
1207    /// `[[mem_management.create]]` / `[[mem_management.delete]]`
1208    /// rules so the engine's allowlist gate sees the post-mutation
1209    /// state immediately.
1210    #[test]
1211    fn parse_workspace_settings_reflects_allowlist_edit() {
1212        let tmp = TempDir::new().unwrap();
1213        write_workspace_toml(
1214            tmp.path(),
1215            r#"
1216format = "memstead-git-branch-2"
1217
1218[persistence_adapter]
1219name = "file-two-layer"
1220"#,
1221        );
1222        let initial = super::parse_workspace_settings(tmp.path()).unwrap();
1223        assert!(initial.mem_create_rules.is_empty());
1224
1225        // Mutate (simulating `workspace_config_edit::add_create_rule`).
1226        write_workspace_toml(
1227            tmp.path(),
1228            r#"
1229format = "memstead-git-branch-2"
1230
1231[persistence_adapter]
1232name = "file-two-layer"
1233
1234[[mem_management.create]]
1235pattern = "test-*"
1236schemas = ["default@1.0.0"]
1237"#,
1238        );
1239        let refreshed = super::parse_workspace_settings(tmp.path()).unwrap();
1240        assert_eq!(refreshed.mem_create_rules.len(), 1);
1241        assert_eq!(refreshed.mem_create_rules[0].pattern, "test-*");
1242    }
1243
1244    #[test]
1245    fn load_returns_not_initialised_when_workspace_toml_missing() {
1246        let tmp = TempDir::new().unwrap();
1247        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1248        let store = FileWorkspaceStore::new();
1249        let err = store.load(tmp.path()).unwrap_err();
1250        assert!(matches!(err, StoreError::NotInitialised { .. }));
1251    }
1252
1253    #[test]
1254    fn load_with_no_mounts_yields_empty_mount_list() {
1255        let tmp = TempDir::new().unwrap();
1256        write_workspace_toml(
1257            tmp.path(),
1258            r#"
1259format = "memstead-git-branch-2"
1260
1261[persistence_adapter]
1262name = "file-two-layer"
1263"#,
1264        );
1265        let store = FileWorkspaceStore::new();
1266        let workspace = store.load(tmp.path()).unwrap();
1267        assert!(workspace.mounts.is_empty());
1268    }
1269
1270    #[test]
1271    fn load_with_no_mem_management_yields_empty_settings() {
1272        // Workspace.toml without a `[mem_management]` section
1273        // produces the default empty settings — mirrors full's
1274        // behaviour where missing rules mean "no agent-driven
1275        // mem create / delete allowed".
1276        let tmp = TempDir::new().unwrap();
1277        write_workspace_toml(
1278            tmp.path(),
1279            r#"
1280format = "memstead-git-branch-2"
1281
1282[persistence_adapter]
1283name = "file-two-layer"
1284"#,
1285        );
1286        let store = FileWorkspaceStore::new();
1287        let workspace = store.load(tmp.path()).unwrap();
1288        assert!(workspace.settings.mem_create_rules.is_empty());
1289        assert!(workspace.settings.mem_delete_rules.is_empty());
1290        assert!(workspace.settings.cross_mem_links.is_empty());
1291    }
1292
1293    #[test]
1294    fn load_picks_up_cross_mem_links_wildcard_and_list() {
1295        // [cross_mem_links] is parsed via CrossLinkValue::parse_toml
1296        // to handle the wildcard ("*") vs allowlist ([...]) shape that
1297        // serde untagged-enum decode can't express. Both shapes round
1298        // through to WorkspaceSettings.cross_mem_links.
1299        use memstead_schema::workspace_config::CrossLinkValue;
1300        let tmp = TempDir::new().unwrap();
1301        write_workspace_toml(
1302            tmp.path(),
1303            r#"
1304format = "memstead-git-branch-2"
1305
1306[persistence_adapter]
1307name = "file-two-layer"
1308
1309[cross_mem_links]
1310specs = "*"
1311engine = ["specs", "macos"]
1312locked = []
1313"#,
1314        );
1315        let store = FileWorkspaceStore::new();
1316        let workspace = store.load(tmp.path()).unwrap();
1317        let cvl = &workspace.settings.cross_mem_links;
1318        assert_eq!(cvl.len(), 3);
1319        assert_eq!(cvl.get("specs"), Some(&CrossLinkValue::Wildcard));
1320        assert_eq!(
1321            cvl.get("engine"),
1322            Some(&CrossLinkValue::List(vec![
1323                "specs".to_string(),
1324                "macos".to_string()
1325            ]))
1326        );
1327        assert_eq!(cvl.get("locked"), Some(&CrossLinkValue::List(vec![])));
1328    }
1329
1330    #[test]
1331    fn load_rejects_cross_mem_links_mixed_wildcard_and_names() {
1332        // The shared parser rejects `["*", "specs"]` — wildcard must
1333        // be the sole entry. The schema-crate's typed error lifts via
1334        // StoreError::Parse so the operator-facing error names the
1335        // exact key.
1336        let tmp = TempDir::new().unwrap();
1337        write_workspace_toml(
1338            tmp.path(),
1339            r#"
1340format = "memstead-git-branch-2"
1341
1342[persistence_adapter]
1343name = "file-two-layer"
1344
1345[cross_mem_links]
1346specs = ["*", "engine"]
1347"#,
1348        );
1349        let store = FileWorkspaceStore::new();
1350        let err = store.load(tmp.path()).unwrap_err();
1351        match err {
1352            StoreError::Parse { message, .. } => {
1353                assert!(message.contains("[cross_mem_links].specs"));
1354                assert!(message.contains("wildcard"));
1355            }
1356            other => panic!("expected StoreError::Parse, got {other:?}"),
1357        }
1358    }
1359
1360    #[test]
1361    fn load_picks_up_default_cross_links_on_create_rule() {
1362        // CreateRule.default_cross_links uses the same CrossLinkValue
1363        // parser. A rule with `default_cross_links = "*"` lifts to
1364        // CreateRuleSetting.default_cross_links = Some(Wildcard).
1365        use memstead_schema::workspace_config::CrossLinkValue;
1366        let tmp = TempDir::new().unwrap();
1367        write_workspace_toml(
1368            tmp.path(),
1369            r#"
1370format = "memstead-git-branch-2"
1371
1372[persistence_adapter]
1373name = "file-two-layer"
1374
1375[[mem_management.create]]
1376pattern = "exec-*"
1377schemas = ["default"]
1378default_cross_links = "*"
1379"#,
1380        );
1381        let store = FileWorkspaceStore::new();
1382        let workspace = store.load(tmp.path()).unwrap();
1383        let rule = &workspace.settings.mem_create_rules[0];
1384        assert_eq!(rule.pattern, "exec-*");
1385        assert_eq!(rule.default_cross_links, Some(CrossLinkValue::Wildcard));
1386    }
1387
1388    #[test]
1389    fn load_picks_up_mem_management_create_and_delete_rules() {
1390        // Operator-edited `[mem_management]` section flows through
1391        // FileWorkspaceStore::load into Workspace.settings; the
1392        // engine layer then propagates via Engine::set_settings.
1393        let tmp = TempDir::new().unwrap();
1394        write_workspace_toml(
1395            tmp.path(),
1396            r#"
1397format = "memstead-git-branch-2"
1398
1399[persistence_adapter]
1400name = "file-two-layer"
1401
1402[[mem_management.create]]
1403pattern = "exec-*"
1404schemas = ["default@1.0.0", "*"]
1405
1406[[mem_management.create]]
1407pattern = "scratch-*"
1408schemas = ["default"]
1409
1410[[mem_management.delete]]
1411pattern = "exec-*"
1412"#,
1413        );
1414        let store = FileWorkspaceStore::new();
1415        let workspace = store.load(tmp.path()).unwrap();
1416        assert_eq!(workspace.settings.mem_create_rules.len(), 2);
1417        assert_eq!(workspace.settings.mem_create_rules[0].pattern, "exec-*");
1418        assert_eq!(
1419            workspace.settings.mem_create_rules[0].schemas,
1420            vec!["default@1.0.0".to_string(), "*".to_string()]
1421        );
1422        assert_eq!(workspace.settings.mem_create_rules[1].pattern, "scratch-*");
1423        assert_eq!(workspace.settings.mem_delete_rules.len(), 1);
1424        assert_eq!(workspace.settings.mem_delete_rules[0].pattern, "exec-*");
1425    }
1426
1427    /// Dual-pin state survives the store round-trip: a mount carrying
1428    /// `migration_target` writes it to `mounts.json` and reads it
1429    /// back; settled mounts' entries stay byte-compatible (the key is
1430    /// skipped when `None`).
1431    #[test]
1432    fn save_state_round_trips_migration_target() {
1433        let tmp = TempDir::new().unwrap();
1434        write_workspace_toml(
1435            tmp.path(),
1436            "\nformat = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1437        );
1438        let store = FileWorkspaceStore::new();
1439        let mut migrating = folder_mount("specs", PathBuf::from("/work/mem"));
1440        migrating.migration_target = Some(pin("mig-b@0.1.0"));
1441        let settled = folder_mount("other", PathBuf::from("/work/other"));
1442        let original = Workspace {
1443            mounts: vec![migrating, settled],
1444            settings: WorkspaceSettings::default(),
1445        };
1446        store.save_state(tmp.path(), &original).unwrap();
1447        let raw =
1448            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1449        assert!(
1450            raw.contains("mig-b@0.1.0"),
1451            "migration_target must persist: {raw}"
1452        );
1453        assert_eq!(
1454            raw.matches("migration_target").count(),
1455            1,
1456            "settled mounts must omit the key entirely: {raw}"
1457        );
1458        let loaded = store.load(tmp.path()).unwrap();
1459        assert_eq!(loaded.mounts[0].migration_target, Some(pin("mig-b@0.1.0")));
1460        assert_eq!(loaded.mounts[1].migration_target, None);
1461    }
1462
1463    #[test]
1464    fn save_state_then_load_round_trips_mount_list() {
1465        let tmp = TempDir::new().unwrap();
1466        write_workspace_toml(
1467            tmp.path(),
1468            r#"
1469format = "memstead-git-branch-2"
1470
1471[persistence_adapter]
1472name = "file-two-layer"
1473"#,
1474        );
1475        let store = FileWorkspaceStore::new();
1476        let original = Workspace {
1477            mounts: vec![
1478                folder_mount("specs", PathBuf::from("/work/mem")),
1479                Mount {
1480                    mem: "engine".to_string(),
1481                    schema: Some(pin("default@1.0.0")),
1482                    storage: MountStorage::GitBranch {
1483                        gitdir: PathBuf::from("/work/mem-repo/.git"),
1484                        branch: "engine".to_string(),
1485                    },
1486                    capability: MountCapability::Write,
1487                    lifecycle: MountLifecycle::Eager,
1488                    cross_linkable: true,
1489                    migration_target: None,
1490                },
1491                Mount {
1492                    mem: "external".to_string(),
1493                    schema: Some(pin("default@1.0.0")),
1494                    storage: MountStorage::Archive {
1495                        path: PathBuf::from("/deps/external.mem"),
1496                    },
1497                    capability: MountCapability::ReadOnly,
1498                    lifecycle: MountLifecycle::Lazy,
1499                    cross_linkable: false,
1500                    migration_target: None,
1501                },
1502            ],
1503            settings: WorkspaceSettings::default(),
1504        };
1505        store.save_state(tmp.path(), &original).unwrap();
1506
1507        // The mounts.json file lives where we expect.
1508        assert!(FileWorkspaceStore::mounts_json_path(tmp.path()).is_file());
1509
1510        let reloaded = store.load(tmp.path()).unwrap();
1511        assert_eq!(reloaded.mounts.len(), original.mounts.len());
1512        for (a, b) in reloaded.mounts.iter().zip(original.mounts.iter()) {
1513            assert_eq!(a.mem, b.mem);
1514            assert_eq!(a.schema, b.schema);
1515            assert_eq!(a.capability, b.capability);
1516            assert_eq!(a.lifecycle, b.lifecycle);
1517            assert_eq!(a.cross_linkable, b.cross_linkable);
1518            assert_eq!(a.storage, b.storage);
1519        }
1520    }
1521
1522    #[test]
1523    fn save_state_round_trips_unset_schema_assertion() {
1524        // `Mount.schema = None` (no expectation assertion) must survive a
1525        // mounts.json round-trip and omit the `schema` key on the wire —
1526        // the authoritative pin lives in the mem's backend config, not
1527        // in the mount record.
1528        let tmp = TempDir::new().unwrap();
1529        write_workspace_toml(
1530            tmp.path(),
1531            r#"
1532format = "memstead-git-branch-2"
1533
1534[persistence_adapter]
1535name = "file-two-layer"
1536"#,
1537        );
1538        let store = FileWorkspaceStore::new();
1539        let original = Workspace {
1540            mounts: vec![Mount {
1541                mem: "foreign".to_string(),
1542                schema: None,
1543                storage: MountStorage::Folder {
1544                    path: tmp.path().join("foreign"),
1545                },
1546                capability: MountCapability::ReadOnly,
1547                lifecycle: MountLifecycle::Eager,
1548                cross_linkable: false,
1549                migration_target: None,
1550            }],
1551            settings: WorkspaceSettings::default(),
1552        };
1553        store.save_state(tmp.path(), &original).unwrap();
1554
1555        // The wire form omits the `schema` key entirely (skip-on-None).
1556        let raw =
1557            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1558        assert!(
1559            !raw.contains("\"schema\""),
1560            "unset schema assertion must omit the key on the wire; got:\n{raw}"
1561        );
1562
1563        // And it reloads as `None`.
1564        let reloaded = store.load(tmp.path()).unwrap();
1565        assert_eq!(reloaded.mounts.len(), 1);
1566        assert_eq!(reloaded.mounts[0].schema, None);
1567    }
1568
1569    #[test]
1570    fn save_state_does_not_touch_workspace_toml() {
1571        let tmp = TempDir::new().unwrap();
1572        let original_body = r#"
1573format = "memstead-git-branch-2"
1574
1575[persistence_adapter]
1576name = "file-two-layer"
1577"#;
1578        write_workspace_toml(tmp.path(), original_body);
1579        let store = FileWorkspaceStore::new();
1580        let workspace = Workspace::default();
1581        store.save_state(tmp.path(), &workspace).unwrap();
1582        // Operator's TOML untouched.
1583        let toml_after =
1584            std::fs::read_to_string(FileWorkspaceStore::workspace_toml_path(tmp.path())).unwrap();
1585        assert_eq!(toml_after, original_body);
1586    }
1587
1588    #[test]
1589    fn save_state_writes_paths_relative_to_workspace_root() {
1590        let tmp = TempDir::new().unwrap();
1591        write_workspace_toml(
1592            tmp.path(),
1593            r#"
1594format = "memstead-git-branch-2"
1595
1596[persistence_adapter]
1597name = "file-two-layer"
1598"#,
1599        );
1600        let store = FileWorkspaceStore::new();
1601        let workspace = Workspace {
1602            mounts: vec![
1603                Mount {
1604                    mem: "engine".to_string(),
1605                    schema: Some(pin("default@1.0.0")),
1606                    storage: MountStorage::GitBranch {
1607                        gitdir: tmp.path().join("mem-repo").join(".git"),
1608                        branch: "engine".to_string(),
1609                    },
1610                    capability: MountCapability::Write,
1611                    lifecycle: MountLifecycle::Eager,
1612                    cross_linkable: true,
1613                    migration_target: None,
1614                },
1615                Mount {
1616                    mem: "external".to_string(),
1617                    schema: Some(pin("default@1.0.0")),
1618                    storage: MountStorage::Archive {
1619                        path: PathBuf::from("/global/cache/external.mem"),
1620                    },
1621                    capability: MountCapability::ReadOnly,
1622                    lifecycle: MountLifecycle::Lazy,
1623                    cross_linkable: false,
1624                    migration_target: None,
1625                },
1626            ],
1627            settings: WorkspaceSettings::default(),
1628        };
1629        store.save_state(tmp.path(), &workspace).unwrap();
1630
1631        let on_disk =
1632            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1633        // Format bumped to V2.
1634        assert!(on_disk.contains("\"memstead-mounts-3\""));
1635        // In-workspace path stored relative — no absolute prefix bake-in.
1636        assert!(
1637            on_disk.contains("\"mem-repo/.git\""),
1638            "expected relative gitdir, got: {on_disk}"
1639        );
1640        assert!(
1641            !on_disk.contains(tmp.path().to_str().unwrap()),
1642            "in-workspace path should not include the absolute tmp prefix: {on_disk}"
1643        );
1644        // Out-of-workspace path kept absolute (fallback for shared caches / external archives).
1645        assert!(on_disk.contains("\"/global/cache/external.mem\""));
1646
1647        // Re-load reconstructs absolute paths.
1648        let reloaded = store.load(tmp.path()).unwrap();
1649        match &reloaded.mounts[0].storage {
1650            MountStorage::GitBranch { gitdir, .. } => {
1651                assert_eq!(gitdir, &tmp.path().join("mem-repo").join(".git"));
1652            }
1653            other => panic!("expected GitBranch storage, got {other:?}"),
1654        }
1655        match &reloaded.mounts[1].storage {
1656            MountStorage::Archive { path } => {
1657                assert_eq!(path, &PathBuf::from("/global/cache/external.mem"));
1658            }
1659            other => panic!("expected Archive storage, got {other:?}"),
1660        }
1661    }
1662
1663    #[test]
1664    fn load_absolute_inside_root_path_then_save_rewrites_relative() {
1665        let tmp = TempDir::new().unwrap();
1666        write_workspace_toml(
1667            tmp.path(),
1668            r#"
1669format = "memstead-git-branch-2"
1670
1671[persistence_adapter]
1672name = "file-two-layer"
1673"#,
1674        );
1675        // Hand-write a mounts.json carrying an absolute gitdir that
1676        // lives inside this workspace_root — simulates the "committed
1677        // by another operator's home dir" failure mode that motivated
1678        // relative serialisation.
1679        let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1680        std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1681        let abs_gitdir = tmp.path().join("mem-repo").join(".git");
1682        let mounts_body = format!(
1683            r#"{{
1684  "format": "memstead-mounts-3",
1685  "mounts": [
1686    {{
1687      "mem": "engine",
1688      "schema": "default@1.0.0",
1689      "storage": {{
1690        "type": "git-branch",
1691        "gitdir": "{}",
1692        "branch": "engine"
1693      }},
1694      "capability": "write",
1695      "lifecycle": "eager",
1696      "cross_linkable": true
1697    }}
1698  ]
1699}}"#,
1700            abs_gitdir.to_str().unwrap()
1701        );
1702        std::fs::write(&mounts_path, &mounts_body).unwrap();
1703
1704        let store = FileWorkspaceStore::new();
1705        // The reader accepts the file; the absolute path round-trips
1706        // untouched (it's already absolute).
1707        let workspace = store.load(tmp.path()).unwrap();
1708        match &workspace.mounts[0].storage {
1709            MountStorage::GitBranch { gitdir, .. } => assert_eq!(gitdir, &abs_gitdir),
1710            other => panic!("expected GitBranch storage, got {other:?}"),
1711        }
1712
1713        // Saving the same workspace rewrites the file with a relative
1714        // path — self-healing, no explicit command needed.
1715        store.save_state(tmp.path(), &workspace).unwrap();
1716        let on_disk = std::fs::read_to_string(&mounts_path).unwrap();
1717        assert!(on_disk.contains("\"memstead-mounts-3\""));
1718        assert!(on_disk.contains("\"mem-repo/.git\""));
1719        assert!(!on_disk.contains(tmp.path().to_str().unwrap()));
1720    }
1721
1722    /// Item 03 round-trip: writing a `MountStorage::GitBranch` mount
1723    /// whose `branch` already carries the canonical `refs/heads/<leaf>`
1724    /// form serialises that exact string into `mounts.json` (no
1725    /// rewrite, no truncation), and reading the file back produces a
1726    /// `Mount` whose in-memory `branch` equals the input verbatim. The
1727    /// write path is the one source of truth for the on-disk shape — a
1728    /// regression that re-introduces short-form writes would surface
1729    /// here as a mismatch against `refs/heads/demo/engine`.
1730    #[test]
1731    fn save_state_preserves_refs_heads_branch_form() {
1732        let tmp = TempDir::new().unwrap();
1733        write_workspace_toml(
1734            tmp.path(),
1735            r#"
1736format = "memstead-git-branch-2"
1737
1738[persistence_adapter]
1739name = "file-two-layer"
1740"#,
1741        );
1742        let store = FileWorkspaceStore::new();
1743        let original = Workspace {
1744            mounts: vec![Mount {
1745                mem: "engine".to_string(),
1746                schema: Some(pin("default@1.0.0")),
1747                storage: MountStorage::GitBranch {
1748                    gitdir: tmp.path().join("mem-repo").join(".git"),
1749                    branch: "refs/heads/demo/engine".to_string(),
1750                },
1751                capability: MountCapability::Write,
1752                lifecycle: MountLifecycle::Eager,
1753                cross_linkable: true,
1754                migration_target: None,
1755            }],
1756            settings: WorkspaceSettings::default(),
1757        };
1758        store.save_state(tmp.path(), &original).unwrap();
1759
1760        let on_disk =
1761            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
1762        assert!(
1763            on_disk.contains("\"branch\": \"refs/heads/demo/engine\""),
1764            "expected fully-qualified ref on disk, got: {on_disk}"
1765        );
1766
1767        let reloaded = store.load(tmp.path()).unwrap();
1768        match &reloaded.mounts[0].storage {
1769            MountStorage::GitBranch { branch, .. } => {
1770                assert_eq!(branch, "refs/heads/demo/engine");
1771            }
1772            other => panic!("expected GitBranch storage, got {other:?}"),
1773        }
1774    }
1775
1776    /// Item 03 reader tolerance: a hand-written legacy `mounts.json`
1777    /// whose `branch` field carries the short-form leaf (no
1778    /// `refs/heads/` prefix) loads without error, and the in-memory
1779    /// `Mount` carries the input string intact. The reader does not
1780    /// silently normalise — backend factories are responsible for
1781    /// fully-qualifying short forms at instantiation time. This pin
1782    /// guards against an over-eager normaliser landing on the read
1783    /// path and masking out the legacy shape that older committed
1784    /// `mounts.json` files used to carry.
1785    #[test]
1786    fn load_preserves_short_form_branch_without_rewrite() {
1787        let tmp = TempDir::new().unwrap();
1788        write_workspace_toml(
1789            tmp.path(),
1790            r#"
1791format = "memstead-git-branch-2"
1792
1793[persistence_adapter]
1794name = "file-two-layer"
1795"#,
1796        );
1797        let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1798        std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1799        std::fs::write(
1800            &mounts_path,
1801            r#"{
1802  "format": "memstead-mounts-3",
1803  "mounts": [
1804    {
1805      "mem": "engine",
1806      "schema": "default@1.0.0",
1807      "storage": {
1808        "type": "git-branch",
1809        "gitdir": "mem-repo/.git",
1810        "branch": "demo/engine"
1811      },
1812      "capability": "write",
1813      "lifecycle": "eager",
1814      "cross_linkable": true
1815    }
1816  ]
1817}"#,
1818        )
1819        .unwrap();
1820
1821        let store = FileWorkspaceStore::new();
1822        let workspace = store.load(tmp.path()).unwrap();
1823        match &workspace.mounts[0].storage {
1824            MountStorage::GitBranch { branch, .. } => {
1825                assert_eq!(
1826                    branch, "demo/engine",
1827                    "reader must not silently rewrite short-form branch"
1828                );
1829            }
1830            other => panic!("expected GitBranch storage, got {other:?}"),
1831        }
1832    }
1833
1834    #[test]
1835    fn load_rejects_format_version_mismatch_on_toml() {
1836        let tmp = TempDir::new().unwrap();
1837        write_workspace_toml(
1838            tmp.path(),
1839            r#"
1840format = "memstead-git-branch-99"
1841
1842[persistence_adapter]
1843name = "file-two-layer"
1844"#,
1845        );
1846        let store = FileWorkspaceStore::new();
1847        let err = store.load(tmp.path()).unwrap_err();
1848        match err {
1849            StoreError::FormatMismatch {
1850                expected, found, ..
1851            } => {
1852                assert_eq!(expected, "memstead-git-branch-2");
1853                assert_eq!(found, "memstead-git-branch-99");
1854            }
1855            other => panic!("expected FormatMismatch, got {other:?}"),
1856        }
1857    }
1858
1859    #[test]
1860    fn load_rejects_format_version_mismatch_on_mounts_json() {
1861        let tmp = TempDir::new().unwrap();
1862        write_workspace_toml(
1863            tmp.path(),
1864            r#"
1865format = "memstead-git-branch-2"
1866
1867[persistence_adapter]
1868name = "file-two-layer"
1869"#,
1870        );
1871        let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1872        std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1873        std::fs::write(
1874            &mounts_path,
1875            r#"{ "format": "memstead-mounts-99", "mounts": [] }"#,
1876        )
1877        .unwrap();
1878        let store = FileWorkspaceStore::new();
1879        let err = store.load(tmp.path()).unwrap_err();
1880        assert!(matches!(err, StoreError::FormatMismatch { .. }));
1881    }
1882
1883    /// A pre-rename workspace.toml (format V1) refuses with the typed
1884    /// LegacyLayout error — it must not boot empty or half-parsed.
1885    #[test]
1886    fn load_refuses_pre_rename_toml_as_legacy_layout() {
1887        let tmp = TempDir::new().unwrap();
1888        write_workspace_toml(
1889            tmp.path(),
1890            r#"
1891format = "memstead-git-branch-1"
1892
1893[persistence_adapter]
1894name = "file-two-layer"
1895"#,
1896        );
1897        let store = FileWorkspaceStore::new();
1898        let err = store.load(tmp.path()).unwrap_err();
1899        match err {
1900            StoreError::LegacyLayout { found, .. } => {
1901                assert_eq!(found, "memstead-git-branch-1");
1902            }
1903            other => panic!("expected LegacyLayout, got {other:?}"),
1904        }
1905    }
1906
1907    /// Pre-rename mounts.json formats refuse with LegacyLayout even
1908    /// though their records no longer deserialise (old unit-noun
1909    /// field name) — the format probe must win over the record-level
1910    /// parse error so the agent sees the migration hint, not a serde
1911    /// message. The fixture's record deliberately lacks the `mem`
1912    /// field to prove the full parse is never reached.
1913    #[test]
1914    fn load_refuses_pre_rename_mounts_json_as_legacy_layout() {
1915        for legacy in ["memstead-mounts-1", "memstead-mounts-2"] {
1916            let tmp = TempDir::new().unwrap();
1917            write_workspace_toml(
1918                tmp.path(),
1919                r#"
1920format = "memstead-git-branch-2"
1921
1922[persistence_adapter]
1923name = "file-two-layer"
1924"#,
1925            );
1926            let mounts_path = FileWorkspaceStore::mounts_json_path(tmp.path());
1927            std::fs::create_dir_all(mounts_path.parent().unwrap()).unwrap();
1928            std::fs::write(
1929                &mounts_path,
1930                format!(
1931                    r#"{{ "format": "{legacy}", "mounts": [{{ "unit": "notes", "storage": {{ "type": "folder", "path": "notes" }}, "capability": "write", "lifecycle": "eager", "cross_linkable": true }}] }}"#
1932                ),
1933            )
1934            .unwrap();
1935            let store = FileWorkspaceStore::new();
1936            let err = store.load(tmp.path()).unwrap_err();
1937            match err {
1938                StoreError::LegacyLayout { found, .. } => assert_eq!(found, legacy),
1939                other => panic!("expected LegacyLayout for {legacy}, got {other:?}"),
1940            }
1941        }
1942    }
1943
1944    #[test]
1945    fn load_rejects_invalid_toml() {
1946        let tmp = TempDir::new().unwrap();
1947        write_workspace_toml(tmp.path(), "this is not = valid = toml");
1948        let store = FileWorkspaceStore::new();
1949        let err = store.load(tmp.path()).unwrap_err();
1950        assert!(matches!(err, StoreError::Parse { .. }));
1951    }
1952
1953    #[test]
1954    fn load_rejects_unknown_top_level_key() {
1955        // The workspace config's contract (and its shipped example's
1956        // claim) is that typos never pass silently — at the top level,
1957        // not just inside [mcp]/[mutations].
1958        let tmp = TempDir::new().unwrap();
1959        write_workspace_toml(
1960            tmp.path(),
1961            "format = \"memstead-git-branch-2\"\nnonexistent_key = true\n",
1962        );
1963        let store = FileWorkspaceStore::new();
1964        let err = store.load(tmp.path()).unwrap_err();
1965        match err {
1966            StoreError::Parse { message, .. } => {
1967                assert!(
1968                    message.contains("nonexistent_key"),
1969                    "refusal must name the unknown key: {message}"
1970                );
1971            }
1972            other => panic!("expected Parse error, got {other:?}"),
1973        }
1974    }
1975
1976    #[test]
1977    fn instantiate_lean_backend_handles_folder_archive_and_in_memory() {
1978        let tmp = TempDir::new().unwrap();
1979        let folder = folder_mount("local", tmp.path().to_path_buf());
1980        let archive_path = tmp.path().join("ext.mem");
1981        // Make a minimal valid zip so the archive backend can open it.
1982        let f = std::fs::File::create(&archive_path).unwrap();
1983        let mut w = zip::ZipWriter::new(f);
1984        w.start_file("a.md", zip::write::SimpleFileOptions::default())
1985            .unwrap();
1986        w.write_all(b"# a").unwrap();
1987        w.finish().unwrap();
1988        let archive = Mount {
1989            mem: "external".to_string(),
1990            schema: Some(pin("default@1.0.0")),
1991            storage: MountStorage::Archive { path: archive_path },
1992            capability: MountCapability::ReadOnly,
1993            lifecycle: MountLifecycle::Lazy,
1994            cross_linkable: false,
1995            migration_target: None,
1996        };
1997        let in_memory = Mount {
1998            mem: "session".to_string(),
1999            schema: Some(pin("default@1.0.0")),
2000            storage: MountStorage::InMemory,
2001            capability: MountCapability::Write,
2002            lifecycle: MountLifecycle::Eager,
2003            cross_linkable: true,
2004            migration_target: None,
2005        };
2006
2007        let _: Box<dyn MemBackend> = instantiate_lean_backend(&folder).unwrap();
2008        let _: Box<dyn MemBackend> = instantiate_lean_backend(&archive).unwrap();
2009        // The in-memory variant is a lean backend — no feature gate,
2010        // no path, materialises directly.
2011        let _: Box<dyn MemBackend> = instantiate_lean_backend(&in_memory).unwrap();
2012    }
2013
2014    /// AC3 (plan 01): the in-memory storage variant round-trips through
2015    /// `mounts.json` and its wire shape is unambiguous — it serialises
2016    /// as the bare `{"type":"in-memory"}` tag and never parses as one
2017    /// of the path-carrying variants, nor they as it.
2018    #[test]
2019    fn save_state_round_trips_in_memory_variant_unambiguously() {
2020        let tmp = TempDir::new().unwrap();
2021        write_workspace_toml(
2022            tmp.path(),
2023            r#"
2024format = "memstead-git-branch-2"
2025
2026[persistence_adapter]
2027name = "file-two-layer"
2028"#,
2029        );
2030        let store = FileWorkspaceStore::new();
2031        let original = Workspace {
2032            mounts: vec![
2033                folder_mount("local", PathBuf::from("/work/mem")),
2034                Mount {
2035                    mem: "session".to_string(),
2036                    schema: Some(pin("default@1.0.0")),
2037                    storage: MountStorage::InMemory,
2038                    capability: MountCapability::Write,
2039                    lifecycle: MountLifecycle::Eager,
2040                    cross_linkable: true,
2041                    migration_target: None,
2042                },
2043            ],
2044            settings: WorkspaceSettings::default(),
2045        };
2046        store.save_state(tmp.path(), &original).unwrap();
2047
2048        // On the wire it is the bare tag — no `path`, no `gitdir`.
2049        let raw =
2050            std::fs::read_to_string(FileWorkspaceStore::mounts_json_path(tmp.path())).unwrap();
2051        assert!(raw.contains("\"type\": \"in-memory\""), "got: {raw}");
2052
2053        let reloaded = store.load(tmp.path()).unwrap();
2054        assert_eq!(reloaded.mounts.len(), 2);
2055        // The in-memory mount round-trips back to exactly InMemory —
2056        // not silently reinterpreted as a folder/archive/git variant.
2057        let session = reloaded
2058            .mounts
2059            .iter()
2060            .find(|m| m.mem == "session")
2061            .expect("session mount survives reload");
2062        assert_eq!(session.storage, MountStorage::InMemory);
2063        // And the sibling folder mount is untouched — the two wire
2064        // shapes do not bleed into each other.
2065        let local = reloaded.mounts.iter().find(|m| m.mem == "local").unwrap();
2066        assert!(matches!(local.storage, MountStorage::Folder { .. }));
2067    }
2068
2069    #[test]
2070    fn instantiate_lean_backend_rejects_git_branch_with_typed_error() {
2071        let mount = Mount {
2072            mem: "engine".to_string(),
2073            schema: Some(pin("default@1.0.0")),
2074            storage: MountStorage::GitBranch {
2075                gitdir: PathBuf::from("/some/path/.git"),
2076                branch: "engine".to_string(),
2077            },
2078            capability: MountCapability::Write,
2079            lifecycle: MountLifecycle::Eager,
2080            cross_linkable: true,
2081            migration_target: None,
2082        };
2083        // `unwrap_err()` requires Box<dyn MemBackend> to be Debug;
2084        // matching on the Result keeps the test gix-free of that
2085        // bound while still asserting the typed error.
2086        match instantiate_lean_backend(&mount) {
2087            Err(InstantiateError::GitBranchRequiresMemRepoFeature { mem }) => {
2088                assert_eq!(mem, "engine");
2089            }
2090            Ok(_) => panic!("expected GitBranchRequiresMemRepoFeature, got Ok"),
2091        }
2092    }
2093
2094    #[test]
2095    fn detect_layout_returns_empty_for_unrecognised_workspace() {
2096        let tmp = TempDir::new().unwrap();
2097        assert_eq!(detect_layout(tmp.path()), Layout::Empty);
2098    }
2099    #[test]
2100    fn detect_layout_returns_new_when_workspace_toml_present() {
2101        let tmp = TempDir::new().unwrap();
2102        write_workspace_toml(
2103            tmp.path(),
2104            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2105        );
2106        assert_eq!(detect_layout(tmp.path()), Layout::New);
2107    }
2108}