Skip to main content

aura_core/effects/
guard.rs

1//! Pure guard evaluation with effect commands
2//!
3//! This module implements ADR-014's pure guard evaluation model where:
4//! - Guards are pure functions that return effect commands as data
5//! - Effect interpreters execute the commands asynchronously
6//! - No blocking operations or sync/async bridges
7//!
8//! This enables algebraic effects, WASM compatibility, and deterministic simulation
9//! while maintaining clean separation between business logic and I/O.
10//!
11//! # Effect Classification
12//!
13//! - **Category**: Application Effect
14//! - **Implementation**: `aura-protocol::guards` (Layer 4)
15//! - **Usage**: Guard chain evaluation, effect command generation per ADR-014
16//!
17//! This is an application effect implementing Aura's guard chain model (CapGuard →
18//! FlowGuard → JournalCoupler → LeakageTracker). The pure guard evaluation and
19//! effect interpreter pattern is core to Aura's architecture. Handlers implement
20//! the guard chain pipeline in `aura-protocol`.
21
22use super::NetworkAddress;
23use crate::{
24    domain::journal::{Cap, Fact},
25    time::TimeStamp,
26    types::flow::FlowCost,
27    types::identifiers::{AuthorityId, ContextId},
28    AuraResult as Result, Receipt,
29};
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use std::collections::HashMap;
33
34/// Immutable snapshot of state for pure guard evaluation
35///
36/// This contains all data that guards are allowed to inspect during evaluation.
37/// It's prepared asynchronously before guard evaluation and remains immutable
38/// during the synchronous guard chain execution.
39#[derive(Debug, Clone)]
40pub struct GuardSnapshot {
41    /// Current timestamp
42    pub now: TimeStamp,
43    /// Derived capability set for the current context
44    pub caps: Cap,
45    /// Current flow budget headroom (context, authority) -> remaining budget
46    pub budgets: FlowBudgetView,
47    /// Key-value metadata for guard decisions
48    pub metadata: MetadataView,
49    /// Pre-allocated randomness for deterministic nonce generation
50    pub rng_seed: [u8; 32],
51}
52
53/// Read-only view of flow budgets
54#[derive(Debug, Clone, Default)]
55pub struct FlowBudgetView {
56    budgets: HashMap<(ContextId, AuthorityId), FlowCost>,
57}
58
59impl FlowBudgetView {
60    /// Create a new flow budget view
61    pub fn new(budgets: HashMap<(ContextId, AuthorityId), FlowCost>) -> Self {
62        Self { budgets }
63    }
64
65    /// Get remaining budget for a context/authority
66    pub fn get(&self, context: &ContextId, authority: &AuthorityId) -> Option<FlowCost> {
67        self.budgets.get(&(*context, *authority)).copied()
68    }
69
70    /// Check if authority has at least the specified budget in a context
71    pub fn has_budget(
72        &self,
73        context: &ContextId,
74        authority: &AuthorityId,
75        amount: FlowCost,
76    ) -> bool {
77        self.get(context, authority)
78            .is_some_and(|budget| budget >= amount)
79    }
80}
81
82/// Read-only view of metadata
83#[derive(Debug, Clone, Default)]
84pub struct MetadataView {
85    metadata: HashMap<String, String>,
86}
87
88impl MetadataView {
89    /// Create a new metadata view
90    pub fn new(metadata: HashMap<String, String>) -> Self {
91        Self { metadata }
92    }
93
94    /// Get metadata value by key
95    pub fn get(&self, key: &str) -> Option<&str> {
96        self.metadata.get(key).map(|s| s.as_str())
97    }
98
99    /// Check if metadata key exists
100    pub fn contains(&self, key: &str) -> bool {
101        self.metadata.contains_key(key)
102    }
103}
104
105/// Decision from guard evaluation
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub enum Decision {
108    /// Request is authorized
109    Authorized,
110    /// Request is denied with reason
111    Denied(String),
112}
113
114impl Decision {
115    /// Check if decision is authorized
116    pub fn is_authorized(&self) -> bool {
117        matches!(self, Self::Authorized)
118    }
119
120    /// Get denial reason if denied
121    pub fn denial_reason(&self) -> Option<&str> {
122        match self {
123            Self::Denied(reason) => Some(reason),
124            Self::Authorized => None,
125        }
126    }
127}
128
129/// Outcome of pure guard evaluation
130#[derive(Debug, Clone)]
131pub struct GuardOutcome {
132    /// Authorization decision
133    pub decision: Decision,
134    /// Effect commands to execute if authorized
135    pub effects: Vec<EffectCommand>,
136}
137
138impl GuardOutcome {
139    /// Create an authorized outcome with effects
140    pub fn authorized(effects: Vec<EffectCommand>) -> Self {
141        Self {
142            decision: Decision::Authorized,
143            effects,
144        }
145    }
146
147    /// Create a denied outcome with reason
148    pub fn denied(reason: impl Into<String>) -> Self {
149        Self {
150            decision: Decision::Denied(reason.into()),
151            effects: Vec::new(),
152        }
153    }
154
155    /// Check if outcome is authorized
156    pub fn is_authorized(&self) -> bool {
157        self.decision.is_authorized()
158    }
159}
160
161/// Minimal, domain-agnostic effect commands
162///
163/// These are the primitive algebraic operations that guards can request.
164/// They represent what should happen, not how it happens.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub enum EffectCommand {
167    /// Charge flow budget for spam/DoS protection
168    ChargeBudget {
169        /// Context being charged
170        context: ContextId,
171        /// Authority to charge
172        authority: AuthorityId,
173        /// Peer being communicated with (if applicable)
174        peer: AuthorityId,
175        /// Amount to charge
176        amount: FlowCost,
177    },
178    /// Append entry to the journal
179    AppendJournal {
180        /// Journal entry to append
181        entry: JournalEntry,
182    },
183    /// Record metadata leakage for privacy analysis
184    RecordLeakage {
185        /// Number of metadata bits revealed
186        bits: u32,
187    },
188    /// Store metadata key-value pair
189    StoreMetadata {
190        /// Storage key
191        key: String,
192        /// Storage value
193        value: String,
194    },
195    /// Send envelope to network address
196    SendEnvelope {
197        /// Target address
198        to: NetworkAddress,
199        /// Optional explicit peer ID (avoids address-derived IDs)
200        peer_id: Option<uuid::Uuid>,
201        /// Envelope payload
202        envelope: Vec<u8>,
203    },
204    /// Generate cryptographic nonce
205    GenerateNonce {
206        /// Number of random bytes needed
207        bytes: u32,
208    },
209}
210
211/// Journal entry for effect commands
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct JournalEntry {
214    /// Fact to record in journal
215    pub fact: Fact,
216    /// Authority making the entry
217    pub authority: AuthorityId,
218    /// Timestamp of entry
219    pub timestamp: TimeStamp,
220}
221
222/// Result of effect execution
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub enum EffectResult {
225    /// Command executed successfully
226    Success,
227    /// Command failed with error
228    Failure(String),
229    /// Budget charge produced a receipt
230    Receipt(Receipt),
231    /// Generated nonce bytes
232    Nonce(Vec<u8>),
233    /// Remaining flow budget after charge
234    RemainingBudget(u32),
235}
236
237/// Asynchronous effect interpreter trait
238///
239/// Implementations execute effect commands according to their environment:
240/// - Production: Real I/O operations
241/// - Simulation: Deterministic event recording
242/// - Testing: Mock responses
243#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
244#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
245pub trait EffectInterpreter: Send + Sync {
246    /// Execute an effect command asynchronously
247    async fn execute(&self, cmd: EffectCommand) -> Result<EffectResult>;
248
249    /// Get interpreter type for debugging
250    fn interpreter_type(&self) -> &'static str;
251}
252
253/// Simulation events for deterministic replay
254///
255/// These events capture all observable side effects from effect execution,
256/// enabling replay, analysis, and property checking in simulation.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub enum SimulationEvent {
259    /// Flow budget was charged
260    BudgetCharged {
261        /// Event timestamp
262        time: TimeStamp,
263        /// Authority that was charged
264        authority: AuthorityId,
265        /// Amount charged
266        amount: u32,
267        /// Remaining budget after charge
268        remaining: u32,
269    },
270    /// Journal entry was appended
271    JournalAppended {
272        /// Event timestamp
273        time: TimeStamp,
274        /// Entry that was appended
275        entry: JournalEntry,
276    },
277    /// Metadata leakage was recorded
278    LeakageRecorded {
279        /// Event timestamp
280        time: TimeStamp,
281        /// Bits of metadata leaked
282        bits: u32,
283    },
284    /// Metadata was stored
285    MetadataStored {
286        /// Event timestamp
287        time: TimeStamp,
288        /// Storage key
289        key: String,
290        /// Storage value
291        value: String,
292    },
293    /// Network envelope was queued
294    EnvelopeQueued {
295        /// Event timestamp
296        time: TimeStamp,
297        /// Source address
298        from: NetworkAddress,
299        /// Target address
300        to: NetworkAddress,
301        /// Envelope content
302        envelope: Vec<u8>,
303    },
304    /// Nonce was generated
305    NonceGenerated {
306        /// Event timestamp
307        time: TimeStamp,
308        /// Generated bytes
309        nonce: Vec<u8>,
310    },
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_flow_budget_view() {
319        let mut budgets = HashMap::new();
320        let authority = AuthorityId::new_from_entropy([31u8; 32]);
321        let context = ContextId::new_from_entropy([32u8; 32]);
322        budgets.insert((context, authority), FlowCost::new(1000));
323
324        let view = FlowBudgetView::new(budgets);
325
326        assert_eq!(view.get(&context, &authority), Some(FlowCost::new(1000)));
327        assert!(view.has_budget(&context, &authority, FlowCost::new(500)));
328        assert!(!view.has_budget(&context, &authority, FlowCost::new(2000)));
329
330        let unknown = AuthorityId::new_from_entropy([33u8; 32]);
331        assert_eq!(view.get(&context, &unknown), None);
332    }
333
334    #[test]
335    fn test_metadata_view() {
336        let mut metadata = HashMap::new();
337        metadata.insert("key1".to_string(), "value1".to_string());
338
339        let view = MetadataView::new(metadata);
340
341        assert_eq!(view.get("key1"), Some("value1"));
342        assert_eq!(view.get("key2"), None);
343        assert!(view.contains("key1"));
344        assert!(!view.contains("key2"));
345    }
346
347    #[test]
348    fn test_decision() {
349        let auth = Decision::Authorized;
350        assert!(auth.is_authorized());
351        assert_eq!(auth.denial_reason(), None);
352
353        let denied = Decision::Denied("Insufficient budget".to_string());
354        assert!(!denied.is_authorized());
355        assert_eq!(denied.denial_reason(), Some("Insufficient budget"));
356    }
357
358    #[test]
359    fn test_guard_outcome() {
360        let effects = vec![EffectCommand::ChargeBudget {
361            context: ContextId::new_from_entropy([34u8; 32]),
362            authority: AuthorityId::new_from_entropy([35u8; 32]),
363            peer: AuthorityId::new_from_entropy([36u8; 32]),
364            amount: FlowCost::new(100),
365        }];
366
367        let authorized = GuardOutcome::authorized(effects);
368        assert!(authorized.is_authorized());
369        assert_eq!(authorized.effects.len(), 1);
370
371        let denied = GuardOutcome::denied("Not allowed");
372        assert!(!denied.is_authorized());
373        assert!(denied.effects.is_empty());
374    }
375
376    #[test]
377    fn test_effect_command_serialization() {
378        let cmd = EffectCommand::ChargeBudget {
379            context: ContextId::new_from_entropy([37u8; 32]),
380            authority: AuthorityId::new_from_entropy([38u8; 32]),
381            peer: AuthorityId::new_from_entropy([39u8; 32]),
382            amount: FlowCost::new(100),
383        };
384
385        let serialized = crate::util::serialization::to_vec(&cmd).unwrap();
386        let deserialized: EffectCommand =
387            crate::util::serialization::from_slice(&serialized).unwrap();
388
389        match deserialized {
390            EffectCommand::ChargeBudget { amount, .. } => {
391                assert_eq!(amount, FlowCost::new(100));
392            }
393            _ => panic!("Wrong command type"),
394        }
395    }
396}