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