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    /// The coordination lease ledger's acquire/renew path (fixed
36    /// `<git-common-dir>/wm-leases.json`): strict mode refuses new claims and
37    /// renewals so system stress cannot trap new work (AHIMSA Target A, 9.1.8).
38    CoordinationLease,
39    /// Owner cleanup of one coordination lease (exact owner + exact scope,
40    /// fixed ledger only): the single coordination mutation strict mode
41    /// admits (AHIMSA Target A, 9.1.8).
42    CoordinationRelease,
43}
44
45/// A capability that a tool may invoke.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub enum Capability {
48    /// Memory read operations
49    MemoryRead,
50    /// Memory write operations
51    MemoryWrite,
52    /// Memory deletion
53    MemoryDelete,
54    /// Full-text search
55    Search,
56    /// Vector similarity search
57    VectorSearch,
58    /// Embedding generation
59    Embed,
60    /// LLM inference
61    LlmInfer,
62    /// Tool-to-tool delegation
63    Delegate,
64    /// External process execution
65    Execute,
66    /// Network request
67    NetworkRequest,
68    /// Dream cycle execution
69    Dream,
70    /// Consciousness update
71    CittaUpdate,
72}
73
74/// Estimated resource cost for a tool call.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct CostEstimate {
77    /// Estimated CPU time in nanoseconds (0 = unknown)
78    pub cpu_ns: u64,
79    /// Estimated memory touched in bytes (0 = unknown)
80    pub memory_bytes: u64,
81    /// Estimated disk I/O in bytes (0 = unknown)
82    pub disk_bytes: u64,
83    /// Estimated network I/O in bytes (0 = unknown)
84    pub network_bytes: u64,
85    /// Whether this tool is expensive enough to skip in Alpha/Theta modes
86    pub expensive: bool,
87}
88
89/// Kernel-sandbox declaration for a tool — the Landlock A→B seam.
90///
91/// v0 (declarative/audit-facing): the serve-level Landlock ruleset
92/// (`WM_LANDLOCK=1`) confines the whole process's write-class filesystem
93/// rights to the store root; this field records nothing enforced per tool.
94/// v1 (enforced): tools declaring [`Sandbox::StoreScoped`] become eligible
95/// to run on a dedicated Landlock-restricted thread, upgrading the pathway
96/// without reworking tool definitions.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum Sandbox {
100    /// Tool runs with the process's ambient filesystem rights (default).
101    /// The serve-level ruleset still applies process-wide when enabled.
102    #[default]
103    Inherit,
104    /// Tool only touches paths beneath the store root — eligible for the
105    /// v1 per-tool restricted-thread pathway.
106    StoreScoped,
107    /// Tool launches external processes — its spawn sites must build
108    /// commands through [`crate::Context::spawn`] so the OS runner
109    /// (`mandala-sandbox`) can wrap them (B2). Declaring this is the
110    /// tool's assertion that it uses the policy; the dispatcher injects
111    /// it and loud-degrades when no runner resolves.
112    Subprocess,
113}
114
115/// The effect row of a tool — what it does to the world.
116///
117/// Inspired by Koka's effect row system, this is checked at compile time
118/// via Rust trait bounds and at runtime by the Dharma governance layer.
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct EffectRow {
121    /// Resources this tool reads from
122    pub reads: Vec<Resource>,
123    /// Resources this tool writes to
124    pub writes: Vec<Resource>,
125    /// Capabilities this tool invokes
126    pub invokes: Vec<Capability>,
127    /// Whether this tool spawns external processes
128    pub spawns: bool,
129    /// Whether this tool is destructive (deletes/overwrites data).
130    /// Destructive tools require explicit confirmation via `confirm: true` in args.
131    pub destructive: bool,
132    /// Kernel-sandbox eligibility (Landlock A→B seam, declarative in v0).
133    #[serde(default)]
134    pub sandbox: Sandbox,
135    /// Estimated resource cost
136    pub cost: CostEstimate,
137}
138
139impl EffectRow {
140    /// Create an empty effect row (pure function)
141    #[must_use]
142    pub fn pure() -> Self {
143        Self::default()
144    }
145
146    /// Create a read-only effect row
147    #[must_use]
148    pub fn read_only(resources: Vec<Resource>) -> Self {
149        Self {
150            reads: resources,
151            writes: vec![],
152            invokes: vec![],
153            spawns: false,
154            destructive: false,
155            sandbox: Sandbox::Inherit,
156            cost: CostEstimate::default(),
157        }
158    }
159
160    /// Check if this effect row is compatible with a brain-wave state.
161    ///
162    /// In Alpha/Theta/Delta modes, expensive or write-heavy tools are
163    /// filtered out to conserve resources.
164    #[must_use]
165    pub fn is_available_in(&self, brain_wave: crate::BrainWave) -> bool {
166        use crate::BrainWave::{Alpha, Beta, Delta, Gamma, Theta};
167        match brain_wave {
168            Gamma => true,
169            Beta => true,
170            Alpha => !self.cost.expensive && self.writes.is_empty(),
171            Theta => !self.cost.expensive && self.writes.is_empty() && !self.spawns,
172            Delta => false, // Delta: no tools available, only wake on event
173        }
174    }
175
176    /// True when this row mutates the coordination lease ledger (claim or
177    /// same-owner renewal). Strict mode refuses acquisition/renewal so system
178    /// stress cannot trap new work (AHIMSA Target A, 9.1.8).
179    #[must_use]
180    pub fn acquires_coordination_lease(&self) -> bool {
181        self.writes
182            .iter()
183            .any(|r| matches!(r, Resource::CoordinationLease))
184    }
185
186    /// True when this row is exactly the coordination owner-cleanup effect:
187    /// one `CoordinationRelease` write, no spawns, not destructive. The strict
188    /// gate admits this shape (and only this shape) so an already-held lease
189    /// can always be released under stress.
190    #[must_use]
191    pub fn is_coordination_cleanup(&self) -> bool {
192        !self.spawns
193            && !self.destructive
194            && self.writes.len() == 1
195            && matches!(self.writes[0], Resource::CoordinationRelease)
196    }
197
198    /// True when this row is exactly the no-discovery checkpoint shape: one
199    /// Sessions-galaxy write, no spawns, not destructive. The strict gate
200    /// admits this shape so a checkpoint can be stored under stress without
201    /// repository discovery, filesystem reads, or subprocesses.
202    #[must_use]
203    pub fn is_no_discovery_checkpoint(&self) -> bool {
204        !self.spawns
205            && !self.destructive
206            && self.writes.len() == 1
207            && matches!(&self.writes[0], Resource::Galaxy(g) if g == "sessions")
208    }
209
210    /// Check if this effect row conflicts with another (for parallel execution)
211    #[must_use]
212    pub fn conflicts_with(&self, other: &Self) -> bool {
213        // Write-write conflicts
214        for w in &self.writes {
215            if other.writes.contains(w) || other.reads.contains(w) {
216                return true;
217            }
218        }
219        for w in &other.writes {
220            if self.reads.contains(w) {
221                return true;
222            }
223        }
224        // Both spawn processes — could overload
225        if self.spawns && other.spawns {
226            return true;
227        }
228        false
229    }
230}
231
232impl fmt::Display for EffectRow {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        write!(
235            f,
236            "reads:{}, writes:{}, invokes:{}, spawns:{}",
237            self.reads.len(),
238            self.writes.len(),
239            self.invokes.len(),
240            self.spawns
241        )
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn pure_effect_has_no_side_effects() {
251        let e = EffectRow::pure();
252        assert!(e.reads.is_empty());
253        assert!(e.writes.is_empty());
254        assert!(!e.spawns);
255    }
256
257    #[test]
258    fn effect_conflict_detection() {
259        let writer = EffectRow {
260            writes: vec![Resource::Galaxy("citta".into())],
261            ..Default::default()
262        };
263        let reader = EffectRow {
264            reads: vec![Resource::Galaxy("citta".into())],
265            ..Default::default()
266        };
267        assert!(writer.conflicts_with(&reader));
268        assert!(reader.conflicts_with(&writer));
269
270        let other_reader = EffectRow {
271            reads: vec![Resource::Galaxy("codex".into())],
272            ..Default::default()
273        };
274        assert!(!reader.conflicts_with(&other_reader));
275    }
276
277    #[test]
278    fn brain_wave_filtering() {
279        use crate::BrainWave::*;
280        let expensive = EffectRow {
281            cost: CostEstimate {
282                expensive: true,
283                ..Default::default()
284            },
285            ..Default::default()
286        };
287        assert!(expensive.is_available_in(Gamma));
288        assert!(!expensive.is_available_in(Alpha));
289        assert!(!expensive.is_available_in(Delta));
290    }
291
292    #[test]
293    fn sandbox_seam_defaults_and_serializes() {
294        let row = EffectRow::pure();
295        assert_eq!(row.sandbox, Sandbox::Inherit);
296
297        let scoped = EffectRow {
298            sandbox: Sandbox::StoreScoped,
299            ..Default::default()
300        };
301        assert_eq!(scoped.sandbox, Sandbox::StoreScoped);
302
303        // Serialize round-trip, including payloads from before the field
304        // existed (serde default keeps old JSON deserializable).
305        let json = serde_json::to_string(&scoped).expect("serialize");
306        assert!(json.contains("store_scoped"));
307        let back: EffectRow = serde_json::from_str(&json).expect("deserialize");
308        assert_eq!(back.sandbox, Sandbox::StoreScoped);
309        let legacy: EffectRow = serde_json::from_str(
310            "{\"reads\":[],\"writes\":[],\"invokes\":[],\"spawns\":false,\"destructive\":false,\
311             \"cost\":{\"cpu_ns\":0,\"memory_bytes\":0,\"disk_bytes\":0,\"network_bytes\":0,\"expensive\":false}}",
312        )
313        .expect("legacy payload without sandbox field");
314        assert_eq!(legacy.sandbox, Sandbox::Inherit);
315    }
316
317    // ── Property-based tests (proptest) ─────────────────────────────
318
319    use crate::BrainWave;
320    use proptest::prelude::*;
321
322    fn arb_resource() -> impl Strategy<Value = Resource> {
323        prop_oneof![
324            Just(Resource::Galaxy("codex".into())),
325            Just(Resource::Galaxy("citta".into())),
326            Just(Resource::Filesystem),
327            Just(Resource::Network),
328            Just(Resource::Process),
329        ]
330    }
331
332    fn arb_effect_row() -> impl Strategy<Value = EffectRow> {
333        (
334            proptest::collection::vec(arb_resource(), 0..6),
335            proptest::collection::vec(arb_resource(), 0..6),
336            any::<bool>(),
337            any::<bool>(),
338        )
339            .prop_map(|(reads, writes, spawns, expensive)| EffectRow {
340                reads,
341                writes,
342                spawns,
343                cost: CostEstimate {
344                    expensive,
345                    ..Default::default()
346                },
347                ..Default::default()
348            })
349    }
350
351    proptest! {
352        /// Delta must always return false (no tools available in Delta).
353        #[test]
354        fn delta_blocks_all(effects in arb_effect_row()) {
355            prop_assert!(!effects.is_available_in(BrainWave::Delta));
356        }
357
358        /// Gamma must always return true (all tools available in Gamma).
359        #[test]
360        fn gamma_allows_all(effects in arb_effect_row()) {
361            prop_assert!(effects.is_available_in(BrainWave::Gamma));
362        }
363
364        /// Beta must always return true (all tools available in Beta).
365        #[test]
366        fn beta_allows_all(effects in arb_effect_row()) {
367            prop_assert!(effects.is_available_in(BrainWave::Beta));
368        }
369
370        /// Alpha blocks writes and expensive tools.
371        #[test]
372        fn alpha_blocks_writes_and_expensive(effects in arb_effect_row()) {
373            let result = effects.is_available_in(BrainWave::Alpha);
374            if !effects.writes.is_empty() || effects.cost.expensive {
375                prop_assert!(!result, "Alpha should block writes/expensive: {effects}");
376            } else {
377                prop_assert!(result, "Alpha should allow pure reads: {effects}");
378            }
379        }
380
381        /// Theta blocks writes, spawns, and expensive tools.
382        #[test]
383        fn theta_blocks_writes_spawns_expensive(effects in arb_effect_row()) {
384            let result = effects.is_available_in(BrainWave::Theta);
385            if !effects.writes.is_empty() || effects.cost.expensive || effects.spawns {
386                prop_assert!(!result, "Theta should block: {effects}");
387            } else {
388                prop_assert!(result, "Theta should allow pure reads: {effects}");
389            }
390        }
391
392        /// conflicts_with is symmetric: a.conflicts_with(b) == b.conflicts_with(a).
393        #[test]
394        fn conflicts_symmetric(a in arb_effect_row(), b in arb_effect_row()) {
395            let ab = a.conflicts_with(&b);
396            let ba = b.conflicts_with(&a);
397            prop_assert_eq!(ab, ba, "conflicts_with must be symmetric");
398        }
399
400        /// conflicts_with is reflexive for effect rows with writes or spawns.
401        #[test]
402        fn conflicts_self_with_writes_or_spawns(effects in arb_effect_row()) {
403            let self_conflict = effects.conflicts_with(&effects);
404            if !effects.writes.is_empty() || effects.spawns {
405                prop_assert!(self_conflict, "effect row with writes/spawns should conflict with itself");
406            } else {
407                prop_assert!(!self_conflict, "pure effect row should not conflict with itself");
408            }
409        }
410    }
411}