Skip to main content

memstead_base/
workspace.rs

1//! Workspace concept — first-class in `memstead-base` after the
2//! workspace-store rebuild.
3//!
4//! A [`Workspace`] is the operator-curated collection of mounts;
5//! each [`Mount`] attaches one mem to the workspace via a storage
6//! backend (folder / git-branch / archive). One mount = one mem:
7//! five mems living on five branches in one git-repo materialise as
8//! five mounts; the engine pools the gitdir handle internally for the
9//! shared backend rather than collapsing the conceptual mount.
10//!
11//! This module ships the data shapes only. The persistence adapter
12//! that materialises a `Workspace` from `.memstead/workspace.toml` +
13//! `.memstead/state/mounts.json` lands separately as the file-adapter
14//! sessions move forward; tests and the macOS app's in-memory builder
15//! construct `Workspace` directly without going through any adapter.
16//!
17//! Distinct from [`crate::mem::MemRouterSnapshot`], which is the
18//! engine's *runtime* snapshot of writable / visible mems. The
19//! engine derives a `MemRouterSnapshot` from a `Workspace` at boot;
20//! the two coexist while the rebuild is in flight.
21
22use std::collections::{BTreeMap, HashMap};
23use std::path::PathBuf;
24
25use memstead_schema::SchemaRef;
26use memstead_schema::workspace_config::CrossLinkValue;
27use serde::{Deserialize, Serialize};
28
29/// A single mem attachment in a [`Workspace`]. One mount = one
30/// mem. The schema pin is on the mount because per-mem schema
31/// resolution is fixed in code (local-storage → built-in → registry,
32/// with the storage layer owning where "local" lives — see the
33/// glossary's *Schema* entry); the mount carries which schema this
34/// mem expects, the backend resolves where the YAMLs come from.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Mount {
37    /// Operator-facing mem name within this workspace.
38    pub mem: String,
39    /// Optional *expectation assertion* about this mem's schema pin
40    /// (exact `<name>@<version>`). The authoritative pin is the mem's
41    /// own `MemConfig.schema` on its storage backend; boot/load
42    /// resolve from there. This field is a workspace-local cross-check —
43    /// useful for foreign or read-only mounts. `None` means "assert
44    /// nothing, trust the backend config". When `Some` and mismatching
45    /// the config pin, loading surfaces a `SchemaPinMismatch` finding
46    /// naming both values; neither is silently preferred. Resolution
47    /// falls back to this value only when the backend config carries no
48    /// pin.
49    pub schema: Option<SchemaRef>,
50    /// Backend-specific reference to the mem's content.
51    pub storage: MountStorage,
52    /// Read-only or writable attachment.
53    pub capability: MountCapability,
54    /// Eager (open the backend at engine start) or lazy (defer to
55    /// first read). V1 runtime treats every mount as `Eager`; the
56    /// `Lazy` slot is reserved for archive backends that should not
57    /// unzip at boot.
58    pub lifecycle: MountLifecycle,
59    /// Whether other mounts in the same workspace may form
60    /// cross-mem edges into this mount. Workspace-level cross-mem
61    /// permission policy can override.
62    pub cross_linkable: bool,
63    /// In-flight schema migration target. `Some(target)` puts the
64    /// mem in dual-pin state: writes validate against `target`
65    /// (the engine's effective validation schema), reads stay
66    /// permissive, and `schema` remains the settled pin until every
67    /// entity is integral against the target — then the atomic
68    /// switch sets `schema = target` and clears this field in one
69    /// workspace-store write. Persisted so a long migration is
70    /// resumable across engine restarts.
71    pub migration_target: Option<SchemaRef>,
72}
73
74impl Mount {
75    /// Hierarchical organisational path for this mount, or `None` for
76    /// flat layout / non-hierarchical storage. Mirrors the
77    /// `MemCreateParams.path` create-side input — at delete time the
78    /// lifecycle candidate composes as `<mem_path>/<name>` (or
79    /// `<name>` alone when `None`) to match the create-side rule.
80    ///
81    /// Derivation: `MountStorage::GitBranch` carries the path in its
82    /// `branch` field. Tolerates both fully-qualified
83    /// `refs/heads/<mem_path>/<mem>` (the shape `create_mem`
84    /// produces) and bare `<mem_path>/<mem>` (the shape
85    /// `mounts.json` operator-edited entries carry) — full's
86    /// `instantiate_full_backend` already normalises both forms. Strip
87    /// the optional `refs/heads/` prefix and the trailing `<mem>`
88    /// leaf. `Folder` / `Archive` carry no hierarchical path on the
89    /// storage variant — runtime callers that know the create-time
90    /// `path` plumb it directly into the router via
91    /// `Engine::register_writable_mem`.
92    pub fn mem_path(&self) -> Option<String> {
93        match &self.storage {
94            MountStorage::GitBranch { branch, .. } => {
95                let leaf = branch
96                    .strip_prefix("refs/heads/")
97                    .unwrap_or(branch.as_str());
98                let after_leaf = leaf.strip_suffix(&self.mem)?;
99                let trimmed = after_leaf.trim_end_matches('/');
100                if trimmed.is_empty() {
101                    None
102                } else {
103                    Some(trimmed.to_string())
104                }
105            }
106            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
107                None
108            }
109        }
110    }
111}
112
113/// Storage reference for a [`Mount`]. One variant per
114/// [`crate::backend::MemBackend`] implementation. New backends add
115/// a variant; the file-adapter learns to round-trip it.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum MountStorage {
118    /// Folder backend — mem lives as a directory tree on disk.
119    /// The mem root may be the workspace root itself (collapsed
120    /// single-mem form: `.memstead/config.json` at root, no `mems/`
121    /// subfolder) or a sibling mem subfolder.
122    Folder {
123        /// Absolute path to the mem root directory.
124        path: PathBuf,
125    },
126    /// Git-branch backend — mem lives as a branch in a mem-repo
127    /// gitdir. Multi-repo workspaces are supported by varying
128    /// `gitdir` across mounts (see the *Storage backend* glossary
129    /// entry's *per-mount git-repo* block for the trade-offs).
130    GitBranch {
131        /// Absolute path to the gitdir
132        /// (typically `<workspace>/mem-repo/.git`).
133        gitdir: PathBuf,
134        /// Branch name within the gitdir holding the mem content.
135        branch: String,
136    },
137    /// Archive backend — mem lives inside a sealed `.mem` zip archive.
138    /// Always read-only; mounts of this storage carry
139    /// [`MountCapability::ReadOnly`].
140    Archive {
141        /// Absolute path to the sealed archive file.
142        path: PathBuf,
143    },
144    /// In-memory backend — mem lives entirely in RAM, with no
145    /// filesystem path and no git. Created empty, dropped with the
146    /// engine, leaving no on-disk residue. Serves ephemeral
147    /// per-session playground mems. Carries no fields: there is
148    /// nothing to locate on disk, and the backend holds all state
149    /// itself (see [`crate::storage::InMemoryBackend`]).
150    InMemory,
151}
152
153impl MountStorage {
154    /// Stable kebab-case backend identifier surfaced in error envelopes
155    /// (e.g. `MARKDOWN_EXPORT_UNSUPPORTED_BACKEND`'s `active_backend`
156    /// detail) and in the on-disk `mounts.json` serialisation. The
157    /// kebab-case form matches the `MountStorageWire` `#[serde(tag,
158    /// rename_all = "kebab-case")]` tag.
159    pub fn backend_id(&self) -> &'static str {
160        match self {
161            MountStorage::Folder { .. } => "folder",
162            MountStorage::GitBranch { .. } => "git-branch",
163            MountStorage::Archive { .. } => "archive",
164            MountStorage::InMemory => "in-memory",
165        }
166    }
167
168    /// Whether writes (and, for read-only backends, the loaded content)
169    /// survive process restart / session-TTL eviction. `Folder`,
170    /// `GitBranch`, and `Archive` all live on disk and persist; only
171    /// `InMemory` is volatile — its state is dropped with the engine,
172    /// so a `commit_sha` it returns denotes nothing durable. This is the
173    /// fact the durability marker projects: derived from the storage
174    /// *kind*, not from `current_head()` (which is `None` for both
175    /// `Folder` and `InMemory` and so cannot tell them apart).
176    pub fn is_durable(&self) -> bool {
177        match self {
178            MountStorage::Folder { .. }
179            | MountStorage::GitBranch { .. }
180            | MountStorage::Archive { .. } => true,
181            MountStorage::InMemory => false,
182        }
183    }
184}
185
186/// What the workspace may do with a mount.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum MountCapability {
189    /// Mutations rejected — the engine surfaces a typed read-only
190    /// error before reaching the backend.
191    ReadOnly,
192    /// Full read + write.
193    Write,
194}
195
196/// When the mount's backend initialises.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum MountLifecycle {
199    /// Open the backend at engine start.
200    Eager,
201    /// Defer initialisation until first read. V1 runtime ignores
202    /// this and treats every mount as `Eager`; the slot is reserved
203    /// for archive-backed mounts that should not unzip at boot.
204    Lazy,
205}
206
207/// Operator-curated workspace — the in-memory shape the engine
208/// receives.
209///
210/// The two-layer file adapter produces a `Workspace` by reading
211/// `.memstead/workspace.toml` (operator-edited rules) and
212/// `.memstead/state/mounts.json` (engine-managed mount list). Tests and
213/// the macOS app's in-memory builder construct `Workspace` directly.
214///
215/// V1 carries the mount list and operator policy. Plugin hooks and
216/// pipeline-config handles attach as additive fields — no breaking
217/// changes expected.
218#[derive(Debug, Clone, Default)]
219pub struct Workspace {
220    pub mounts: Vec<Mount>,
221    /// Workspace-level operator policy (mem create/delete rules,
222    /// cross-mem link permissions). Defaults to empty for tests
223    /// and the macOS app's in-memory builder; the file adapter
224    /// populates from `.memstead/workspace.toml`'s `[mem_management]`
225    /// and `[cross_mem_links]` sections. The unified engine reads
226    /// this via [`crate::Engine::settings`] after
227    /// [`crate::Engine::from_workspace_root`] threads it through
228    /// [`crate::Engine::set_settings`].
229    pub settings: WorkspaceSettings,
230}
231
232impl Workspace {
233    /// Empty workspace — zero mounts, default settings. Useful for
234    /// tests; production workspaces always carry at least one mount
235    /// (the engine rejects an empty `Workspace` at boot).
236    pub fn empty() -> Self {
237        Self {
238            mounts: Vec::new(),
239            settings: WorkspaceSettings::default(),
240        }
241    }
242}
243
244/// Workspace-level operator policy carried alongside the mount list.
245///
246/// Data carriers only — the matcher compilation lives in
247/// `crate::mem_management::CreateRuleSet`. The engine carries the
248/// raw settings so MCP handlers can surface them under `memstead_health
249/// { include_config: true }` and `memstead_overview`'s
250/// lifecycle-namespaces section.
251///
252/// `Default::default()` is a totally-empty policy: zero create rules,
253/// zero delete rules, no cross-mem link policy. The unified engine
254/// uses this as the bootstrap value at construction time; consumers
255/// that load a real policy call [`crate::Engine::set_settings`].
256#[derive(Debug, Clone, Default)]
257pub struct WorkspaceSettings {
258    /// Raw `[[mem_management.create]]` rules in declaration order.
259    /// Each entry carries a gitignore-style `pattern` matched against
260    /// the candidate mem path, an `schemas[]` allowlist, and an
261    /// optional `default_cross_links` synthesised cross-link
262    /// permission. Empty list means "no agent-driven mem creation
263    /// allowed" — `memstead_mem_create` rejects every candidate.
264    pub mem_create_rules: Vec<CreateRuleSetting>,
265    /// Raw `[[mem_management.delete]]` rules. Same first-match
266    /// semantics as [`Self::mem_create_rules`], minus the schema
267    /// dimension. Empty list means "no agent-driven mem deletion
268    /// allowed".
269    pub mem_delete_rules: Vec<DeleteRuleSetting>,
270    /// `[cross_mem_links]` policy — workspace-level cross-mem
271    /// edge permissions keyed by source mem. Empty map means
272    /// default-deny: every cross-mem edge fails until at least one
273    /// matching entry exists or a create-rule synthesised one.
274    pub cross_mem_links: BTreeMap<String, CrossLinkValue>,
275    /// `[mcp]` section — MCP-binary tuning knobs that operators set
276    /// per-workspace. The MCP binary reads this off
277    /// `Engine::settings()` at boot to size the response chunker
278    /// (`token_budget`) and filter the advertised tool surface
279    /// (`disabled_tools`). Defaulted when the section is absent.
280    pub mcp: McpSection,
281    /// `[mutations]` section — engine-wide mutation policy. The
282    /// `require_notes` field surfaces a `WarningHint::NoteMissing` on
283    /// mutation calls that omit a `note`. Default-zeroed when absent.
284    pub mutations: MutationsSection,
285    /// `[plugin.*]` namespace — opaque pass-through map keyed by
286    /// plugin identifier (`claude_code`, `macos`, …). Values are raw
287    /// TOML tables the engine never inspects; named plugins read
288    /// their own sub-table via `memstead_health { include_config: true }`.
289    pub plugin: HashMap<String, toml::Table>,
290}
291
292/// `[mcp]` section — settings the MCP binary reads at boot. Carried
293/// on `WorkspaceSettings` so the MCP server sources its tuning from
294/// `Engine::settings()` instead of a parallel TOML parse.
295#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
296#[serde(deny_unknown_fields)]
297pub struct McpSection {
298    /// Per-response chunking budget in tokens. `None` → caller falls
299    /// back to the compile-time `DEFAULT_TOKEN_BUDGET`.
300    pub token_budget: Option<usize>,
301    /// Blocklist of tool names. Entries matching a compiled-in tool
302    /// are hidden from `tools/list` and rejected with `TOOL_DISABLED`
303    /// on direct invocation. Unknown entries log a warning and drop
304    /// from the effective set. Empty / absent → every compiled-in
305    /// tool is advertised.
306    pub disabled_tools: Option<Vec<String>>,
307}
308
309/// `[mutations]` section — engine-wide mutation policy. Carried on
310/// `WorkspaceSettings` so plugins can read the configured posture via
311/// `memstead_health { include_config: true }` without a round-trip.
312#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
313#[serde(deny_unknown_fields)]
314pub struct MutationsSection {
315    /// When `true`, a mutation call without a `note` field emits a
316    /// `WarningHint { code: "note_missing" }`. The mutation still
317    /// succeeds — provenance is best-effort.
318    pub require_notes: Option<bool>,
319}
320
321/// One `[[mem_management.create]]` rule. Carries a glob `pattern`
322/// matched against the candidate mem path, the `schemas` allowlist
323/// (each entry an exact `name@x.y.z` pin or the literal `"*"` for
324/// any-schema), and an optional `default_cross_links` value applied
325/// to every mem the rule matches.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct CreateRuleSetting {
328    pub pattern: String,
329    pub schemas: Vec<String>,
330    pub default_cross_links: Option<CrossLinkValue>,
331}
332
333/// One `[[mem_management.delete]]` rule. Carries only a `pattern`;
334/// delete has no schema dimension.
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct DeleteRuleSetting {
337    pub pattern: String,
338}
339
340/// The literal `"*"` schema-allowlist entry that admits any pinned
341/// schema. Consumed by the create-rule allowlist parser.
342pub const SCHEMA_WILDCARD: &str = "*";
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn pin(name: &str) -> SchemaRef {
349        SchemaRef::new(name, semver::Version::new(1, 0, 0))
350    }
351
352    #[test]
353    fn empty_workspace_has_no_mounts() {
354        let ws = Workspace::empty();
355        assert!(ws.mounts.is_empty());
356    }
357
358    #[test]
359    fn durability_follows_storage_kind() {
360        // On-disk backends persist; only in-memory is volatile. This is
361        // the fact the durability marker projects across overview / health
362        // / mutation responses.
363        let folder = MountStorage::Folder {
364            path: PathBuf::from("/work/mem"),
365        };
366        let git = MountStorage::GitBranch {
367            gitdir: PathBuf::from("/work/mem-repo/.git"),
368            branch: "specs".into(),
369        };
370        let archive = MountStorage::Archive {
371            path: PathBuf::from("/work/curated.mem"),
372        };
373        let in_memory = MountStorage::InMemory;
374
375        assert!(folder.is_durable());
376        assert!(git.is_durable());
377        assert!(archive.is_durable());
378        assert!(!in_memory.is_durable());
379
380        // The backend_id kebab string the marker rides alongside.
381        assert_eq!(folder.backend_id(), "folder");
382        assert_eq!(git.backend_id(), "git-branch");
383        assert_eq!(archive.backend_id(), "archive");
384        assert_eq!(in_memory.backend_id(), "in-memory");
385    }
386
387    #[test]
388    fn mount_can_describe_folder_storage() {
389        let m = Mount {
390            mem: "specs".into(),
391            schema: Some(pin("default")),
392            storage: MountStorage::Folder {
393                path: PathBuf::from("/work/mem"),
394            },
395            capability: MountCapability::Write,
396            lifecycle: MountLifecycle::Eager,
397            cross_linkable: true,
398            migration_target: None,
399        };
400        assert_eq!(m.mem, "specs");
401        assert!(matches!(m.storage, MountStorage::Folder { .. }));
402    }
403
404    #[test]
405    fn mount_can_describe_git_branch_storage() {
406        let m = Mount {
407            mem: "engine".into(),
408            schema: Some(pin("default")),
409            storage: MountStorage::GitBranch {
410                gitdir: PathBuf::from("/work/mem-repo/.git"),
411                branch: "engine".into(),
412            },
413            capability: MountCapability::Write,
414            lifecycle: MountLifecycle::Eager,
415            cross_linkable: true,
416            migration_target: None,
417        };
418        assert!(matches!(m.storage, MountStorage::GitBranch { .. }));
419    }
420
421    /// `Mount::mem_path()` derives the hierarchical path component
422    /// the delete-side lifecycle composer needs. Tolerates both bare
423    /// `<path>/<mem>` (operator-edited mounts.json) and
424    /// fully-qualified `refs/heads/<path>/<mem>` (runtime-created
425    /// mems). Folder / Archive variants always return `None`.
426    #[test]
427    fn mem_path_extracts_hierarchical_prefix_from_git_branch() {
428        // Bare hierarchical (mounts.json shape).
429        let m = Mount {
430            mem: "engine".into(),
431            schema: Some(pin("default")),
432            storage: MountStorage::GitBranch {
433                gitdir: PathBuf::from("/work/mem-repo/.git"),
434                branch: "memstead/engine".into(),
435            },
436            capability: MountCapability::Write,
437            lifecycle: MountLifecycle::Eager,
438            cross_linkable: true,
439            migration_target: None,
440        };
441        assert_eq!(m.mem_path(), Some("memstead".to_string()));
442
443        // Fully-qualified hierarchical (create_mem shape).
444        let m = Mount {
445            mem: "plan-foo".into(),
446            schema: Some(pin("default")),
447            storage: MountStorage::GitBranch {
448                gitdir: PathBuf::from("/work/mem-repo/.git"),
449                branch: "refs/heads/planning/plan-foo".into(),
450            },
451            capability: MountCapability::Write,
452            lifecycle: MountLifecycle::Eager,
453            cross_linkable: true,
454            migration_target: None,
455        };
456        assert_eq!(m.mem_path(), Some("planning".to_string()));
457
458        // Multi-segment hierarchical prefix.
459        let m = Mount {
460            mem: "leaf".into(),
461            schema: Some(pin("default")),
462            storage: MountStorage::GitBranch {
463                gitdir: PathBuf::from("/work/mem-repo/.git"),
464                branch: "refs/heads/a/b/c/leaf".into(),
465            },
466            capability: MountCapability::Write,
467            lifecycle: MountLifecycle::Eager,
468            cross_linkable: true,
469            migration_target: None,
470        };
471        assert_eq!(m.mem_path(), Some("a/b/c".to_string()));
472
473        // Flat layout (bare leaf, no prefix).
474        let m = Mount {
475            mem: "engine".into(),
476            schema: Some(pin("default")),
477            storage: MountStorage::GitBranch {
478                gitdir: PathBuf::from("/work/mem-repo/.git"),
479                branch: "engine".into(),
480            },
481            capability: MountCapability::Write,
482            lifecycle: MountLifecycle::Eager,
483            cross_linkable: true,
484            migration_target: None,
485        };
486        assert_eq!(m.mem_path(), None);
487
488        // Flat layout (fully-qualified, no prefix beyond refs/heads/).
489        let m = Mount {
490            mem: "engine".into(),
491            schema: Some(pin("default")),
492            storage: MountStorage::GitBranch {
493                gitdir: PathBuf::from("/work/mem-repo/.git"),
494                branch: "refs/heads/engine".into(),
495            },
496            capability: MountCapability::Write,
497            lifecycle: MountLifecycle::Eager,
498            cross_linkable: true,
499            migration_target: None,
500        };
501        assert_eq!(m.mem_path(), None);
502
503        // Folder backend has no hierarchical concept.
504        let m = Mount {
505            mem: "engine".into(),
506            schema: Some(pin("default")),
507            storage: MountStorage::Folder {
508                path: PathBuf::from("/work/mem"),
509            },
510            capability: MountCapability::Write,
511            lifecycle: MountLifecycle::Eager,
512            cross_linkable: true,
513            migration_target: None,
514        };
515        assert_eq!(m.mem_path(), None);
516    }
517
518    #[test]
519    fn mount_can_describe_archive_storage() {
520        let m = Mount {
521            mem: "external".into(),
522            schema: Some(pin("default")),
523            storage: MountStorage::Archive {
524                path: PathBuf::from("/deps/external.mem"),
525            },
526            capability: MountCapability::ReadOnly,
527            lifecycle: MountLifecycle::Lazy,
528            cross_linkable: false,
529            migration_target: None,
530        };
531        assert!(matches!(m.storage, MountStorage::Archive { .. }));
532        assert_eq!(m.capability, MountCapability::ReadOnly);
533    }
534
535    #[test]
536    fn workspace_with_heterogeneous_mounts() {
537        let ws = Workspace {
538            mounts: vec![
539                Mount {
540                    mem: "engine".into(),
541                    schema: Some(pin("default")),
542                    storage: MountStorage::GitBranch {
543                        gitdir: PathBuf::from("/work/mem-repo/.git"),
544                        branch: "engine".into(),
545                    },
546                    capability: MountCapability::Write,
547                    lifecycle: MountLifecycle::Eager,
548                    cross_linkable: true,
549                    migration_target: None,
550                },
551                Mount {
552                    mem: "macos".into(),
553                    schema: Some(pin("default")),
554                    storage: MountStorage::GitBranch {
555                        gitdir: PathBuf::from("/work/mem-repo/.git"),
556                        branch: "macos".into(),
557                    },
558                    capability: MountCapability::Write,
559                    lifecycle: MountLifecycle::Eager,
560                    cross_linkable: true,
561                    migration_target: None,
562                },
563                Mount {
564                    mem: "external".into(),
565                    schema: Some(pin("default")),
566                    storage: MountStorage::Archive {
567                        path: PathBuf::from("/deps/external.mem"),
568                    },
569                    capability: MountCapability::ReadOnly,
570                    lifecycle: MountLifecycle::Lazy,
571                    cross_linkable: false,
572                    migration_target: None,
573                },
574            ],
575            settings: WorkspaceSettings::default(),
576        };
577        assert_eq!(ws.mounts.len(), 3);
578        // Two mounts share a gitdir — the engine will pool the handle
579        // internally; the conceptual mount stays per-mem.
580        let shared_gitdir_mounts = ws
581            .mounts
582            .iter()
583            .filter(|m| matches!(&m.storage, MountStorage::GitBranch { gitdir, .. } if gitdir == std::path::Path::new("/work/mem-repo/.git")))
584            .count();
585        assert_eq!(shared_gitdir_mounts, 2);
586    }
587}