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