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