Skip to main content

wm_memory/
mandala.rs

1//! Mandala Compartments — isolated LMDB environments per security tier.
2//!
3//! Each compartment (Research, Sandbox, Production, Secure) has its own
4//! LMDB environment, Tantivy index, and association store, providing
5//! complete storage isolation between tiers.
6//!
7//! Governance configuration (rate limits, Dharma gate strictness, resource
8//! rules) is handled at a higher level (e.g., wm-mcp) to avoid circular
9//! dependencies. This module focuses purely on storage isolation.
10//!
11//! - **Research**: Small map size (256MB), for experimentation
12//! - **Sandbox**: Small map size (256MB), for testing
13//! - **Production**: 1GB map, for live data
14//! - **Secure**: 4GB map, read-only by default, for hardened data
15
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use crate::{AssociationStore, MemoryStore, SearchEngine};
20use serde::{Deserialize, Serialize};
21use wm_core::Result;
22
23/// Security tier for a Mandala compartment.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MandalaLevel {
27    /// Experimental — 256MB map, for experimentation.
28    Research,
29    /// Testing — 256MB map, isolated from production.
30    Sandbox,
31    /// Live — 1GB map, for production workloads.
32    Production,
33    /// Hardened — 4GB map, read-only by default.
34    Secure,
35}
36
37impl MandalaLevel {
38    /// All levels in order of increasing strictness.
39    #[must_use]
40    pub const fn all() -> &'static [Self] {
41        &[
42            Self::Research,
43            Self::Sandbox,
44            Self::Production,
45            Self::Secure,
46        ]
47    }
48
49    /// Human-readable name.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Research => "research",
54            Self::Sandbox => "sandbox",
55            Self::Production => "production",
56            Self::Secure => "secure",
57        }
58    }
59
60    /// Directory name for this compartment.
61    #[must_use]
62    pub const fn dir_name(self) -> &'static str {
63        self.as_str()
64    }
65
66    /// LMDB map size in bytes for this compartment.
67    ///
68    /// On Windows NTFS materializes the LMDB map file at full size on open
69    /// (no sparse ftruncate like Unix), so reserving 1–4GB per compartment
70    /// allocates real disk immediately. Windows sizes are therefore smaller;
71    /// Unix sizes are unchanged. Callers needing larger Windows maps should
72    /// open with an explicit size.
73    #[must_use]
74    pub const fn map_size(self) -> usize {
75        #[cfg(windows)]
76        {
77            match self {
78                Self::Research | Self::Sandbox => 64 * 1024 * 1024, // 64 MB
79                Self::Production => 128 * 1024 * 1024,              // 128 MB
80                Self::Secure => 256 * 1024 * 1024,                  // 256 MB
81            }
82        }
83        #[cfg(not(windows))]
84        {
85            match self {
86                Self::Research | Self::Sandbox => 256 * 1024 * 1024, // 256 MB
87                Self::Production => 1024 * 1024 * 1024,              // 1 GB
88                Self::Secure => 4 * 1024 * 1024 * 1024,              // 4 GB
89            }
90        }
91    }
92
93    /// Whether this compartment is read-only by default.
94    #[must_use]
95    pub const fn read_only_default(self) -> bool {
96        matches!(self, Self::Secure)
97    }
98
99    /// Parse from string (case-insensitive).
100    #[must_use]
101    pub fn parse(s: &str) -> Option<Self> {
102        match s.to_ascii_lowercase().as_str() {
103            "research" => Some(Self::Research),
104            "sandbox" => Some(Self::Sandbox),
105            "production" => Some(Self::Production),
106            "secure" => Some(Self::Secure),
107            _ => None,
108        }
109    }
110}
111
112impl std::fmt::Display for MandalaLevel {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// Configuration for a single Mandala compartment.
119#[derive(Debug, Clone)]
120pub struct CompartmentConfig {
121    /// Security level.
122    pub level: MandalaLevel,
123    /// Base path for this compartment's LMDB/Tantivy files.
124    pub path: PathBuf,
125    /// Override map size (None = use level default).
126    pub map_size: Option<usize>,
127    /// Whether this compartment is read-only.
128    pub read_only: bool,
129}
130
131impl CompartmentConfig {
132    /// Create a config for the given level at the base path.
133    #[must_use]
134    pub fn new(level: MandalaLevel, base_path: &Path) -> Self {
135        let path = base_path.join(level.dir_name());
136        Self {
137            level,
138            path,
139            map_size: None,
140            read_only: level.read_only_default(),
141        }
142    }
143
144    /// Effective map size.
145    #[must_use]
146    pub fn effective_map_size(&self) -> usize {
147        self.map_size.unwrap_or_else(|| self.level.map_size())
148    }
149
150    /// Override map size.
151    #[must_use]
152    pub const fn with_map_size(mut self, size: usize) -> Self {
153        self.map_size = Some(size);
154        self
155    }
156
157    /// Set read-only flag.
158    #[must_use]
159    pub const fn read_only(mut self, ro: bool) -> Self {
160        self.read_only = ro;
161        self
162    }
163}
164
165/// An opened Mandala compartment with isolated stores.
166///
167/// Each compartment has completely separate LMDB, Tantivy, and association
168/// storage. Data written to one compartment is invisible to others.
169pub struct Compartment {
170    /// Configuration for this compartment.
171    pub config: CompartmentConfig,
172    /// Isolated LMDB memory store.
173    pub store: Arc<MemoryStore>,
174    /// Isolated Tantivy search engine.
175    pub search: Arc<SearchEngine>,
176    /// Isolated association store.
177    pub associations: Arc<AssociationStore>,
178}
179
180impl Compartment {
181    /// Open a compartment from its config.
182    pub fn open(config: CompartmentConfig) -> Result<Self> {
183        std::fs::create_dir_all(&config.path)
184            .map_err(|e| wm_core::CoreError::Memory(format!("create compartment dir: {e}")))?;
185
186        let store = Arc::new(MemoryStore::open(
187            &config.path,
188            config.effective_map_size(),
189        )?);
190
191        let search_path = config.path.join("tantivy");
192        std::fs::create_dir_all(&search_path)
193            .map_err(|e| wm_core::CoreError::Memory(format!("create tantivy dir: {e}")))?;
194        let search = Arc::new(SearchEngine::open(&search_path)?);
195
196        let associations = Arc::new(AssociationStore::open(store.env())?);
197
198        Ok(Self {
199            config,
200            store,
201            search,
202            associations,
203        })
204    }
205
206    /// Security level of this compartment.
207    #[must_use]
208    pub const fn level(&self) -> MandalaLevel {
209        self.config.level
210    }
211
212    /// Whether this compartment is read-only.
213    #[must_use]
214    pub const fn is_read_only(&self) -> bool {
215        self.config.read_only
216    }
217}
218
219/// Manager for all Mandala compartments.
220///
221/// Opens and manages isolated LMDB environments for each security tier.
222/// Each compartment has completely separate storage, search, and associations.
223pub struct MandalaManager {
224    /// Base path for all compartments.
225    base_path: PathBuf,
226    /// Opened compartments.
227    compartments: ahash::AHashMap<MandalaLevel, Compartment>,
228}
229
230impl MandalaManager {
231    /// Create a new manager at the given base path.
232    /// Does not open any compartments — call `open_compartment` for each.
233    #[must_use]
234    pub fn new(base_path: &Path) -> Self {
235        Self {
236            base_path: base_path.to_path_buf(),
237            compartments: ahash::AHashMap::new(),
238        }
239    }
240
241    /// Open a single compartment at the given level.
242    pub fn open_compartment(&mut self, level: MandalaLevel) -> Result<&Compartment> {
243        let config = CompartmentConfig::new(level, &self.base_path);
244        let compartment = Compartment::open(config)?;
245        self.compartments.insert(level, compartment);
246        Ok(self.compartments.get(&level).unwrap())
247    }
248
249    /// Open all four compartments.
250    pub fn open_all(&mut self) -> Result<()> {
251        for level in MandalaLevel::all() {
252            self.open_compartment(*level)?;
253        }
254        Ok(())
255    }
256
257    /// Get a compartment by level.
258    #[must_use]
259    pub fn get(&self, level: MandalaLevel) -> Option<&Compartment> {
260        self.compartments.get(&level)
261    }
262
263    /// Get a compartment by level (mutable access).
264    #[must_use]
265    pub fn get_mut(&mut self, level: MandalaLevel) -> Option<&mut Compartment> {
266        self.compartments.get_mut(&level)
267    }
268
269    /// List all opened compartment levels.
270    #[must_use]
271    pub fn opened_levels(&self) -> Vec<MandalaLevel> {
272        self.compartments.keys().copied().collect()
273    }
274
275    /// Number of opened compartments.
276    #[must_use]
277    pub fn len(&self) -> usize {
278        self.compartments.len()
279    }
280
281    /// Whether any compartments are opened.
282    #[must_use]
283    pub fn is_empty(&self) -> bool {
284        self.compartments.is_empty()
285    }
286
287    /// Base path for all compartments.
288    #[must_use]
289    pub fn base_path(&self) -> &Path {
290        &self.base_path
291    }
292
293    /// Close a compartment (drops all stores and releases LMDB file handles).
294    pub fn close(&mut self, level: MandalaLevel) -> bool {
295        self.compartments.remove(&level).is_some()
296    }
297
298    /// Close all compartments.
299    pub fn close_all(&mut self) {
300        self.compartments.clear();
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn mandala_level_ordering() {
310        let levels = MandalaLevel::all();
311        assert_eq!(levels.len(), 4);
312        assert_eq!(levels[0], MandalaLevel::Research);
313        assert_eq!(levels[3], MandalaLevel::Secure);
314    }
315
316    #[test]
317    fn mandala_level_str_roundtrip() {
318        for level in MandalaLevel::all() {
319            let s = level.as_str();
320            let back = MandalaLevel::parse(s).unwrap();
321            assert_eq!(*level, back);
322        }
323    }
324
325    #[test]
326    fn mandala_level_parse_case_insensitive() {
327        assert_eq!(
328            MandalaLevel::parse("RESEARCH"),
329            Some(MandalaLevel::Research)
330        );
331        assert_eq!(
332            MandalaLevel::parse("Production"),
333            Some(MandalaLevel::Production)
334        );
335        assert_eq!(MandalaLevel::parse("unknown"), None);
336    }
337
338    #[test]
339    fn mandala_level_map_size_increases_with_strictness() {
340        let sizes: Vec<_> = MandalaLevel::all().iter().map(|l| l.map_size()).collect();
341        assert!(sizes[0] < sizes[2]); // Research < Production
342        assert!(sizes[2] < sizes[3]); // Production < Secure
343    }
344
345    #[test]
346    fn secure_is_read_only_by_default() {
347        assert!(!MandalaLevel::Research.read_only_default());
348        assert!(!MandalaLevel::Sandbox.read_only_default());
349        assert!(!MandalaLevel::Production.read_only_default());
350        assert!(MandalaLevel::Secure.read_only_default());
351    }
352
353    #[test]
354    fn compartment_config_defaults() {
355        let tmp = tempfile::tempdir().unwrap();
356        let config = CompartmentConfig::new(MandalaLevel::Production, tmp.path());
357        assert_eq!(config.level, MandalaLevel::Production);
358        assert!(config.path.ends_with("production"));
359        assert_eq!(
360            config.effective_map_size(),
361            MandalaLevel::Production.map_size()
362        );
363        assert!(!config.read_only);
364    }
365
366    #[test]
367    fn compartment_config_overrides() {
368        let tmp = tempfile::tempdir().unwrap();
369        let config = CompartmentConfig::new(MandalaLevel::Sandbox, tmp.path())
370            .with_map_size(512 * 1024 * 1024)
371            .read_only(true);
372        assert_eq!(config.effective_map_size(), 512 * 1024 * 1024);
373        assert!(config.read_only);
374    }
375
376    #[test]
377    fn compartment_open_creates_stores() {
378        let tmp = tempfile::tempdir().unwrap();
379        let config = CompartmentConfig::new(MandalaLevel::Research, tmp.path());
380        let compartment = Compartment::open(config).unwrap();
381
382        // Stores should be initialized
383        assert_eq!(compartment.level(), MandalaLevel::Research);
384        assert!(!compartment.is_read_only());
385
386        // LMDB should have the 14 galaxies
387        use wm_core::Galaxy;
388        for galaxy in Galaxy::all() {
389            let _ = compartment.store.galaxy_db(galaxy).unwrap();
390        }
391    }
392
393    #[test]
394    fn compartment_open_secure_is_strict() {
395        let tmp = tempfile::tempdir().unwrap();
396        let config = CompartmentConfig::new(MandalaLevel::Secure, tmp.path());
397        let compartment = Compartment::open(config).unwrap();
398
399        assert_eq!(compartment.level(), MandalaLevel::Secure);
400        assert!(compartment.is_read_only());
401    }
402
403    #[test]
404    fn mandala_manager_open_single() {
405        let tmp = tempfile::tempdir().unwrap();
406        let mut manager = MandalaManager::new(tmp.path());
407        manager.open_compartment(MandalaLevel::Sandbox).unwrap();
408
409        assert_eq!(manager.len(), 1);
410        assert!(manager.get(MandalaLevel::Sandbox).is_some());
411        assert!(manager.get(MandalaLevel::Research).is_none());
412    }
413
414    #[test]
415    fn mandala_manager_open_all() {
416        let tmp = tempfile::tempdir().unwrap();
417        let mut manager = MandalaManager::new(tmp.path());
418        manager.open_all().unwrap();
419
420        assert_eq!(manager.len(), 4);
421        for level in MandalaLevel::all() {
422            assert!(manager.get(*level).is_some(), "missing compartment {level}");
423        }
424    }
425
426    #[test]
427    fn mandala_manager_close() {
428        let tmp = tempfile::tempdir().unwrap();
429        let mut manager = MandalaManager::new(tmp.path());
430        manager.open_all().unwrap();
431        assert_eq!(manager.len(), 4);
432
433        manager.close(MandalaLevel::Research);
434        assert_eq!(manager.len(), 3);
435        assert!(manager.get(MandalaLevel::Research).is_none());
436
437        manager.close_all();
438        assert_eq!(manager.len(), 0);
439        assert!(manager.is_empty());
440    }
441
442    #[test]
443    fn mandala_manager_opened_levels() {
444        let tmp = tempfile::tempdir().unwrap();
445        let mut manager = MandalaManager::new(tmp.path());
446        manager.open_compartment(MandalaLevel::Production).unwrap();
447        manager.open_compartment(MandalaLevel::Secure).unwrap();
448
449        let levels = manager.opened_levels();
450        assert_eq!(levels.len(), 2);
451        assert!(levels.contains(&MandalaLevel::Production));
452        assert!(levels.contains(&MandalaLevel::Secure));
453    }
454
455    #[test]
456    fn compartments_are_isolated() {
457        let tmp = tempfile::tempdir().unwrap();
458        let mut manager = MandalaManager::new(tmp.path());
459        manager.open_compartment(MandalaLevel::Research).unwrap();
460        manager.open_compartment(MandalaLevel::Production).unwrap();
461
462        let research = manager.get(MandalaLevel::Research).unwrap();
463        let production = manager.get(MandalaLevel::Production).unwrap();
464
465        // Write to research
466        let mem = crate::Memory::new(wm_core::Galaxy::Codex, "research data".into());
467        research.store.put(wm_core::Galaxy::Codex, &mem).unwrap();
468
469        // Should not be visible in production
470        let result = production
471            .store
472            .get(wm_core::Galaxy::Codex, mem.metadata.id)
473            .unwrap();
474        assert!(result.is_none(), "data leaked between compartments!");
475
476        // But should be visible in research
477        let result = research
478            .store
479            .get(wm_core::Galaxy::Codex, mem.metadata.id)
480            .unwrap();
481        assert!(result.is_some());
482    }
483
484    #[test]
485    fn compartment_paths_are_separate() {
486        let tmp = tempfile::tempdir().unwrap();
487        let mut manager = MandalaManager::new(tmp.path());
488        manager.open_all().unwrap();
489
490        for level in MandalaLevel::all() {
491            let compartment = manager.get(*level).unwrap();
492            let path = &compartment.config.path;
493            assert!(path.ends_with(level.dir_name()));
494            assert!(path.exists(), "compartment path should exist");
495        }
496    }
497
498    #[test]
499    fn mandala_level_display() {
500        assert_eq!(format!("{}", MandalaLevel::Research), "research");
501        assert_eq!(format!("{}", MandalaLevel::Secure), "secure");
502    }
503
504    #[test]
505    fn mandala_level_serde() {
506        let json = serde_json::to_string(&MandalaLevel::Production).unwrap();
507        assert_eq!(json, "\"production\"");
508        let back: MandalaLevel = serde_json::from_str(&json).unwrap();
509        assert_eq!(back, MandalaLevel::Production);
510    }
511}