Skip to main content

wm_core/
effects.rs

1//! Effect Row System — Inspired by Koka's Effect Types
2//!
3//! Every tool declares what resources it reads, writes, invokes, and
4//! whether it spawns external processes. This enables compile-time
5//! effect safety via Rust traits and runtime governance via Dharma.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A resource that a tool may read from or write to.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Resource {
13    /// A specific LMDB galaxy database
14    Galaxy(String),
15    /// The karma ledger
16    KarmaLedger,
17    /// The Dharma rule engine
18    DharmaRules,
19    /// The Tantivy full-text index
20    SearchIndex,
21    /// The vector embedding store
22    VectorStore,
23    /// External network (HTTP, gRPC)
24    Network,
25    /// Local filesystem outside LMDB
26    Filesystem,
27    /// System process spawning
28    Process,
29    /// LLM inference (local or remote)
30    Inference,
31    /// User session state
32    Session,
33    /// The Gan Ying event bus (persisted to a JSONL log when enabled)
34    EventBus,
35}
36
37/// A capability that a tool may invoke.
38#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum Capability {
40    /// Memory read operations
41    MemoryRead,
42    /// Memory write operations
43    MemoryWrite,
44    /// Memory deletion
45    MemoryDelete,
46    /// Full-text search
47    Search,
48    /// Vector similarity search
49    VectorSearch,
50    /// Embedding generation
51    Embed,
52    /// LLM inference
53    LlmInfer,
54    /// Tool-to-tool delegation
55    Delegate,
56    /// External process execution
57    Execute,
58    /// Network request
59    NetworkRequest,
60    /// Dream cycle execution
61    Dream,
62    /// Consciousness update
63    CittaUpdate,
64}
65
66/// Estimated resource cost for a tool call.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct CostEstimate {
69    /// Estimated CPU time in nanoseconds (0 = unknown)
70    pub cpu_ns: u64,
71    /// Estimated memory touched in bytes (0 = unknown)
72    pub memory_bytes: u64,
73    /// Estimated disk I/O in bytes (0 = unknown)
74    pub disk_bytes: u64,
75    /// Estimated network I/O in bytes (0 = unknown)
76    pub network_bytes: u64,
77    /// Whether this tool is expensive enough to skip in Alpha/Theta modes
78    pub expensive: bool,
79}
80
81/// Kernel-sandbox declaration for a tool — the Landlock A→B seam.
82///
83/// v0 (declarative/audit-facing): the serve-level Landlock ruleset
84/// (`WM_LANDLOCK=1`) confines the whole process's write-class filesystem
85/// rights to the store root; this field records nothing enforced per tool.
86/// v1 (enforced): tools declaring [`Sandbox::StoreScoped`] become eligible
87/// to run on a dedicated Landlock-restricted thread, upgrading the pathway
88/// without reworking tool definitions.
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Sandbox {
92    /// Tool runs with the process's ambient filesystem rights (default).
93    /// The serve-level ruleset still applies process-wide when enabled.
94    #[default]
95    Inherit,
96    /// Tool only touches paths beneath the store root — eligible for the
97    /// v1 per-tool restricted-thread pathway.
98    StoreScoped,
99    /// Tool launches external processes — its spawn sites must build
100    /// commands through [`crate::Context::spawn`] so the OS runner
101    /// (`mandala-sandbox`) can wrap them (B2). Declaring this is the
102    /// tool's assertion that it uses the policy; the dispatcher injects
103    /// it and loud-degrades when no runner resolves.
104    Subprocess,
105}
106
107/// The effect row of a tool — what it does to the world.
108///
109/// Inspired by Koka's effect row system, this is checked at compile time
110/// via Rust trait bounds and at runtime by the Dharma governance layer.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct EffectRow {
113    /// Resources this tool reads from
114    pub reads: Vec<Resource>,
115    /// Resources this tool writes to
116    pub writes: Vec<Resource>,
117    /// Capabilities this tool invokes
118    pub invokes: Vec<Capability>,
119    /// Whether this tool spawns external processes
120    pub spawns: bool,
121    /// Whether this tool is destructive (deletes/overwrites data).
122    /// Destructive tools require explicit confirmation via `confirm: true` in args.
123    pub destructive: bool,
124    /// Kernel-sandbox eligibility (Landlock A→B seam, declarative in v0).
125    #[serde(default)]
126    pub sandbox: Sandbox,
127    /// Estimated resource cost
128    pub cost: CostEstimate,
129}
130
131impl EffectRow {
132    /// Create an empty effect row (pure function)
133    #[must_use]
134    pub fn pure() -> Self {
135        Self::default()
136    }
137
138    /// Create a read-only effect row
139    #[must_use]
140    pub fn read_only(resources: Vec<Resource>) -> Self {
141        Self {
142            reads: resources,
143            writes: vec![],
144            invokes: vec![],
145            spawns: false,
146            destructive: false,
147            sandbox: Sandbox::Inherit,
148            cost: CostEstimate::default(),
149        }
150    }
151
152    /// Check if this effect row is compatible with a brain-wave state.
153    ///
154    /// In Alpha/Theta/Delta modes, expensive or write-heavy tools are
155    /// filtered out to conserve resources.
156    #[must_use]
157    pub fn is_available_in(&self, brain_wave: crate::BrainWave) -> bool {
158        use crate::BrainWave::{Alpha, Beta, Delta, Gamma, Theta};
159        match brain_wave {
160            Gamma => true,
161            Beta => true,
162            Alpha => !self.cost.expensive && self.writes.is_empty(),
163            Theta => !self.cost.expensive && self.writes.is_empty() && !self.spawns,
164            Delta => false, // Delta: no tools available, only wake on event
165        }
166    }
167
168    /// Check if this effect row conflicts with another (for parallel execution)
169    #[must_use]
170    pub fn conflicts_with(&self, other: &Self) -> bool {
171        // Write-write conflicts
172        for w in &self.writes {
173            if other.writes.contains(w) || other.reads.contains(w) {
174                return true;
175            }
176        }
177        for w in &other.writes {
178            if self.reads.contains(w) {
179                return true;
180            }
181        }
182        // Both spawn processes — could overload
183        if self.spawns && other.spawns {
184            return true;
185        }
186        false
187    }
188}
189
190impl fmt::Display for EffectRow {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        write!(
193            f,
194            "reads:{}, writes:{}, invokes:{}, spawns:{}",
195            self.reads.len(),
196            self.writes.len(),
197            self.invokes.len(),
198            self.spawns
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn pure_effect_has_no_side_effects() {
209        let e = EffectRow::pure();
210        assert!(e.reads.is_empty());
211        assert!(e.writes.is_empty());
212        assert!(!e.spawns);
213    }
214
215    #[test]
216    fn effect_conflict_detection() {
217        let writer = EffectRow {
218            writes: vec![Resource::Galaxy("citta".into())],
219            ..Default::default()
220        };
221        let reader = EffectRow {
222            reads: vec![Resource::Galaxy("citta".into())],
223            ..Default::default()
224        };
225        assert!(writer.conflicts_with(&reader));
226        assert!(reader.conflicts_with(&writer));
227
228        let other_reader = EffectRow {
229            reads: vec![Resource::Galaxy("codex".into())],
230            ..Default::default()
231        };
232        assert!(!reader.conflicts_with(&other_reader));
233    }
234
235    #[test]
236    fn brain_wave_filtering() {
237        use crate::BrainWave::*;
238        let expensive = EffectRow {
239            cost: CostEstimate {
240                expensive: true,
241                ..Default::default()
242            },
243            ..Default::default()
244        };
245        assert!(expensive.is_available_in(Gamma));
246        assert!(!expensive.is_available_in(Alpha));
247        assert!(!expensive.is_available_in(Delta));
248    }
249
250    #[test]
251    fn sandbox_seam_defaults_and_serializes() {
252        let row = EffectRow::pure();
253        assert_eq!(row.sandbox, Sandbox::Inherit);
254
255        let scoped = EffectRow {
256            sandbox: Sandbox::StoreScoped,
257            ..Default::default()
258        };
259        assert_eq!(scoped.sandbox, Sandbox::StoreScoped);
260
261        // Serialize round-trip, including payloads from before the field
262        // existed (serde default keeps old JSON deserializable).
263        let json = serde_json::to_string(&scoped).expect("serialize");
264        assert!(json.contains("store_scoped"));
265        let back: EffectRow = serde_json::from_str(&json).expect("deserialize");
266        assert_eq!(back.sandbox, Sandbox::StoreScoped);
267        let legacy: EffectRow = serde_json::from_str(
268            "{\"reads\":[],\"writes\":[],\"invokes\":[],\"spawns\":false,\"destructive\":false,\
269             \"cost\":{\"cpu_ns\":0,\"memory_bytes\":0,\"disk_bytes\":0,\"network_bytes\":0,\"expensive\":false}}",
270        )
271        .expect("legacy payload without sandbox field");
272        assert_eq!(legacy.sandbox, Sandbox::Inherit);
273    }
274
275    // ── Property-based tests (proptest) ─────────────────────────────
276
277    use crate::BrainWave;
278    use proptest::prelude::*;
279
280    fn arb_resource() -> impl Strategy<Value = Resource> {
281        prop_oneof![
282            Just(Resource::Galaxy("codex".into())),
283            Just(Resource::Galaxy("citta".into())),
284            Just(Resource::Filesystem),
285            Just(Resource::Network),
286            Just(Resource::Process),
287        ]
288    }
289
290    fn arb_effect_row() -> impl Strategy<Value = EffectRow> {
291        (
292            proptest::collection::vec(arb_resource(), 0..6),
293            proptest::collection::vec(arb_resource(), 0..6),
294            any::<bool>(),
295            any::<bool>(),
296        )
297            .prop_map(|(reads, writes, spawns, expensive)| EffectRow {
298                reads,
299                writes,
300                spawns,
301                cost: CostEstimate {
302                    expensive,
303                    ..Default::default()
304                },
305                ..Default::default()
306            })
307    }
308
309    proptest! {
310        /// Delta must always return false (no tools available in Delta).
311        #[test]
312        fn delta_blocks_all(effects in arb_effect_row()) {
313            prop_assert!(!effects.is_available_in(BrainWave::Delta));
314        }
315
316        /// Gamma must always return true (all tools available in Gamma).
317        #[test]
318        fn gamma_allows_all(effects in arb_effect_row()) {
319            prop_assert!(effects.is_available_in(BrainWave::Gamma));
320        }
321
322        /// Beta must always return true (all tools available in Beta).
323        #[test]
324        fn beta_allows_all(effects in arb_effect_row()) {
325            prop_assert!(effects.is_available_in(BrainWave::Beta));
326        }
327
328        /// Alpha blocks writes and expensive tools.
329        #[test]
330        fn alpha_blocks_writes_and_expensive(effects in arb_effect_row()) {
331            let result = effects.is_available_in(BrainWave::Alpha);
332            if !effects.writes.is_empty() || effects.cost.expensive {
333                prop_assert!(!result, "Alpha should block writes/expensive: {effects}");
334            } else {
335                prop_assert!(result, "Alpha should allow pure reads: {effects}");
336            }
337        }
338
339        /// Theta blocks writes, spawns, and expensive tools.
340        #[test]
341        fn theta_blocks_writes_spawns_expensive(effects in arb_effect_row()) {
342            let result = effects.is_available_in(BrainWave::Theta);
343            if !effects.writes.is_empty() || effects.cost.expensive || effects.spawns {
344                prop_assert!(!result, "Theta should block: {effects}");
345            } else {
346                prop_assert!(result, "Theta should allow pure reads: {effects}");
347            }
348        }
349
350        /// conflicts_with is symmetric: a.conflicts_with(b) == b.conflicts_with(a).
351        #[test]
352        fn conflicts_symmetric(a in arb_effect_row(), b in arb_effect_row()) {
353            let ab = a.conflicts_with(&b);
354            let ba = b.conflicts_with(&a);
355            prop_assert_eq!(ab, ba, "conflicts_with must be symmetric");
356        }
357
358        /// conflicts_with is reflexive for effect rows with writes or spawns.
359        #[test]
360        fn conflicts_self_with_writes_or_spawns(effects in arb_effect_row()) {
361            let self_conflict = effects.conflicts_with(&effects);
362            if !effects.writes.is_empty() || effects.spawns {
363                prop_assert!(self_conflict, "effect row with writes/spawns should conflict with itself");
364            } else {
365                prop_assert!(!self_conflict, "pure effect row should not conflict with itself");
366            }
367        }
368    }
369}