Skip to main content

moloch_core/agent/
causality.rs

1//! Causal context types for agent accountability.
2//!
3//! The causal context links every agent event to its predecessors and ultimately
4//! to a human principal. This enables answering "why did this happen?" and
5//! "who authorized this?" for any agent action.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use crate::crypto::Hash;
11use crate::error::{Error, Result};
12use crate::event::EventId;
13
14use super::principal::PrincipalId;
15use super::session::SessionId;
16
17/// Context linking an event to its causal predecessors.
18///
19/// Every agent-initiated event MUST include a CausalContext that:
20/// - Links to the parent event that triggered this action
21/// - Links to the root event (human request) that started the chain
22/// - Identifies the session and principal
23///
24/// # Invariants
25///
26/// - INV-CAUSAL-1: If parent_event_id is Some(p), then p.sequence < self.sequence
27/// - INV-CAUSAL-2: root_event_id always points to an event with depth = 0
28/// - INV-CAUSAL-3: depth <= session.max_depth
29/// - INV-CAUSAL-4: Exactly one event per session has depth = 0
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct CausalContext {
32    /// The event that directly triggered this action.
33    /// None only for session-initiating events (depth = 0).
34    parent_event_id: Option<EventId>,
35
36    /// The originating human request that started this causal chain.
37    /// MUST always be present for agent actions.
38    root_event_id: EventId,
39
40    /// Session identifier for grouping related events.
41    session_id: SessionId,
42
43    /// The human principal ultimately responsible.
44    principal: PrincipalId,
45
46    /// Depth in the causal chain (0 = human-initiated).
47    depth: u32,
48
49    /// Monotonic sequence within session.
50    sequence: u64,
51
52    /// Optional cross-session reference for linked operations.
53    cross_session_ref: Option<CrossSessionReference>,
54}
55
56impl CausalContext {
57    /// Create a builder for constructing a CausalContext.
58    pub fn builder() -> CausalContextBuilder {
59        CausalContextBuilder::new()
60    }
61
62    /// Create a root context (depth 0) for a human-initiated event.
63    ///
64    /// This is the starting point of any causal chain.
65    pub fn root(event_id: EventId, session_id: SessionId, principal: PrincipalId) -> Self {
66        Self {
67            parent_event_id: None,
68            root_event_id: event_id,
69            session_id,
70            principal,
71            depth: 0,
72            sequence: 0,
73            cross_session_ref: None,
74        }
75    }
76
77    /// Create a child context from this context.
78    ///
79    /// # Arguments
80    /// * `parent_event_id` - The event ID of the parent (this context's event)
81    /// * `sequence` - The sequence number for the new event (must be > self.sequence)
82    ///
83    /// # Errors
84    /// Returns error if sequence is not greater than parent's sequence.
85    pub fn child(&self, parent_event_id: EventId, sequence: u64) -> Result<Self> {
86        if sequence <= self.sequence {
87            return Err(Error::invalid_input(format!(
88                "Child sequence {} must be greater than parent sequence {}",
89                sequence, self.sequence
90            )));
91        }
92
93        Ok(Self {
94            parent_event_id: Some(parent_event_id),
95            root_event_id: self.root_event_id,
96            session_id: self.session_id,
97            principal: self.principal.clone(),
98            depth: self.depth + 1,
99            sequence,
100            cross_session_ref: None,
101        })
102    }
103
104    /// Get the parent event ID.
105    pub fn parent_event_id(&self) -> Option<&EventId> {
106        self.parent_event_id.as_ref()
107    }
108
109    /// Get the root event ID.
110    pub fn root_event_id(&self) -> &EventId {
111        &self.root_event_id
112    }
113
114    /// Get the session ID.
115    pub fn session_id(&self) -> SessionId {
116        self.session_id
117    }
118
119    /// Get the principal.
120    pub fn principal(&self) -> &PrincipalId {
121        &self.principal
122    }
123
124    /// Get the depth in the causal chain.
125    pub fn depth(&self) -> u32 {
126        self.depth
127    }
128
129    /// Get the sequence number within the session.
130    pub fn sequence(&self) -> u64 {
131        self.sequence
132    }
133
134    /// Get the cross-session reference, if any.
135    pub fn cross_session_ref(&self) -> Option<&CrossSessionReference> {
136        self.cross_session_ref.as_ref()
137    }
138
139    /// Check if this is a root event (depth = 0).
140    pub fn is_root(&self) -> bool {
141        self.depth == 0
142    }
143
144    /// Validate this context against constraints.
145    ///
146    /// # Arguments
147    /// * `max_depth` - Maximum allowed depth (typically from session)
148    ///
149    /// # Errors
150    /// Returns error if validation fails.
151    pub fn validate(&self, max_depth: u32) -> Result<()> {
152        // INV-CAUSAL-3: depth <= max_depth
153        if self.depth > max_depth {
154            return Err(Error::invalid_input(format!(
155                "Causal depth {} exceeds maximum {}",
156                self.depth, max_depth
157            )));
158        }
159
160        // Depth 0 must have no parent
161        if self.depth == 0 && self.parent_event_id.is_some() {
162            return Err(Error::invalid_input(
163                "Root event (depth=0) must not have a parent",
164            ));
165        }
166
167        // Depth > 0 must have parent
168        if self.depth > 0 && self.parent_event_id.is_none() {
169            return Err(Error::invalid_input(
170                "Non-root event (depth>0) must have a parent",
171            ));
172        }
173
174        // Root event ID must equal self event ID for depth 0
175        // (This can only be fully validated with the actual event ID)
176
177        Ok(())
178    }
179
180    /// Validate this context against a parent context.
181    ///
182    /// Ensures INV-CAUSAL-1 and INV-CAUSAL-2 hold.
183    pub fn validate_against_parent(&self, parent: &CausalContext) -> Result<()> {
184        // INV-CAUSAL-1: parent.sequence < self.sequence
185        if parent.sequence >= self.sequence {
186            return Err(Error::invalid_input(format!(
187                "Parent sequence {} must be less than child sequence {}",
188                parent.sequence, self.sequence
189            )));
190        }
191
192        // Parent depth must be exactly one less
193        if parent.depth + 1 != self.depth {
194            return Err(Error::invalid_input(format!(
195                "Parent depth {} + 1 must equal child depth {}",
196                parent.depth, self.depth
197            )));
198        }
199
200        // Root event ID must match
201        if parent.root_event_id != self.root_event_id {
202            return Err(Error::invalid_input(
203                "Root event ID must match parent's root event ID",
204            ));
205        }
206
207        // Session must match (unless cross-session)
208        if self.cross_session_ref.is_none() && parent.session_id != self.session_id {
209            return Err(Error::invalid_input(
210                "Session ID must match parent's session ID (or use cross-session reference)",
211            ));
212        }
213
214        // Principal must match
215        if parent.principal != self.principal {
216            return Err(Error::invalid_input(
217                "Principal must match parent's principal",
218            ));
219        }
220
221        Ok(())
222    }
223}
224
225impl fmt::Display for CausalContext {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        write!(
228            f,
229            "CausalContext(session={}, depth={}, seq={})",
230            self.session_id, self.depth, self.sequence
231        )
232    }
233}
234
235/// Builder for constructing CausalContext.
236#[derive(Debug, Default)]
237pub struct CausalContextBuilder {
238    parent_event_id: Option<EventId>,
239    root_event_id: Option<EventId>,
240    session_id: Option<SessionId>,
241    principal: Option<PrincipalId>,
242    depth: Option<u32>,
243    sequence: Option<u64>,
244    cross_session_ref: Option<CrossSessionReference>,
245}
246
247impl CausalContextBuilder {
248    /// Create a new builder.
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    /// Set the parent event ID.
254    pub fn parent_event_id(mut self, id: EventId) -> Self {
255        self.parent_event_id = Some(id);
256        self
257    }
258
259    /// Set the root event ID.
260    pub fn root_event_id(mut self, id: EventId) -> Self {
261        self.root_event_id = Some(id);
262        self
263    }
264
265    /// Set the session ID.
266    pub fn session_id(mut self, id: SessionId) -> Self {
267        self.session_id = Some(id);
268        self
269    }
270
271    /// Set the principal.
272    pub fn principal(mut self, principal: PrincipalId) -> Self {
273        self.principal = Some(principal);
274        self
275    }
276
277    /// Set the depth.
278    pub fn depth(mut self, depth: u32) -> Self {
279        self.depth = Some(depth);
280        self
281    }
282
283    /// Set the sequence.
284    pub fn sequence(mut self, sequence: u64) -> Self {
285        self.sequence = Some(sequence);
286        self
287    }
288
289    /// Set a cross-session reference.
290    pub fn cross_session_ref(mut self, reference: CrossSessionReference) -> Self {
291        self.cross_session_ref = Some(reference);
292        self
293    }
294
295    /// Build the CausalContext.
296    ///
297    /// # Errors
298    /// Returns error if required fields are missing.
299    pub fn build(self) -> Result<CausalContext> {
300        let root_event_id = self
301            .root_event_id
302            .ok_or_else(|| Error::invalid_input("root_event_id is required"))?;
303
304        let session_id = self
305            .session_id
306            .ok_or_else(|| Error::invalid_input("session_id is required"))?;
307
308        let principal = self
309            .principal
310            .ok_or_else(|| Error::invalid_input("principal is required"))?;
311
312        let depth = self.depth.unwrap_or(0);
313        let sequence = self.sequence.unwrap_or(0);
314
315        // Validate consistency
316        if depth == 0 && self.parent_event_id.is_some() {
317            return Err(Error::invalid_input(
318                "Root context (depth=0) must not have parent_event_id",
319            ));
320        }
321
322        if depth > 0 && self.parent_event_id.is_none() {
323            return Err(Error::invalid_input(
324                "Non-root context (depth>0) requires parent_event_id",
325            ));
326        }
327
328        Ok(CausalContext {
329            parent_event_id: self.parent_event_id,
330            root_event_id,
331            session_id,
332            principal,
333            depth,
334            sequence,
335            cross_session_ref: self.cross_session_ref,
336        })
337    }
338}
339
340/// Reference to an event in a different session.
341///
342/// Used when an action in one session is causally related to
343/// an event in another session (e.g., follow-up tasks).
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct CrossSessionReference {
346    /// The session being referenced.
347    pub source_session_id: SessionId,
348
349    /// The event being referenced.
350    pub source_event_id: EventId,
351
352    /// Reason for the cross-session reference.
353    pub reason: String,
354
355    /// Hash of the referenced event for integrity.
356    pub source_event_hash: Hash,
357}
358
359impl CrossSessionReference {
360    /// Create a new cross-session reference.
361    pub fn new(
362        source_session_id: SessionId,
363        source_event_id: EventId,
364        reason: impl Into<String>,
365        source_event_hash: Hash,
366    ) -> Self {
367        Self {
368            source_session_id,
369            source_event_id,
370            reason: reason.into(),
371            source_event_hash,
372        }
373    }
374}
375
376/// Query interface for causal chains (Section 6, G-6.1).
377///
378/// Implementors provide queries against the causal chain for accountability
379/// auditing and forensics. This trait is object-safe so it can be used
380/// with `dyn CausalChainQuery`.
381pub trait CausalChainQuery {
382    /// Retrieve the full causal chain from an event back to the root.
383    ///
384    /// Returns events in order from root (depth 0) to the queried event.
385    fn trace_to_root(&self, event_id: &EventId) -> Result<Vec<CausalContext>>;
386
387    /// Find the root (human-initiated) event for a given event.
388    fn find_root(&self, event_id: &EventId) -> Result<CausalContext>;
389
390    /// List all events in a session, ordered by sequence.
391    fn events_in_session(&self, session_id: &SessionId) -> Result<Vec<CausalContext>>;
392
393    /// Find all direct children of an event.
394    fn children_of(&self, event_id: &EventId) -> Result<Vec<CausalContext>>;
395
396    /// Get the maximum depth reached in a session.
397    fn max_depth_in_session(&self, session_id: &SessionId) -> Result<u32>;
398}
399
400/// An in-memory implementation of [`CausalChainQuery`] for testing and
401/// single-node deployments.
402#[derive(Debug, Default)]
403pub struct InMemoryCausalStore {
404    /// Event ID -> CausalContext mapping.
405    contexts: std::collections::HashMap<EventId, CausalContext>,
406    /// Session ID -> ordered event IDs.
407    session_events: std::collections::HashMap<SessionId, Vec<EventId>>,
408}
409
410impl InMemoryCausalStore {
411    /// Create a new empty store.
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// Insert a causal context for an event.
417    pub fn insert(&mut self, event_id: EventId, context: CausalContext) {
418        let session_id = context.session_id();
419        self.session_events
420            .entry(session_id)
421            .or_default()
422            .push(event_id);
423        self.contexts.insert(event_id, context);
424    }
425
426    /// Get a context by event ID.
427    pub fn get(&self, event_id: &EventId) -> Option<&CausalContext> {
428        self.contexts.get(event_id)
429    }
430
431    /// Get the number of stored contexts.
432    pub fn len(&self) -> usize {
433        self.contexts.len()
434    }
435
436    /// Check if the store is empty.
437    pub fn is_empty(&self) -> bool {
438        self.contexts.is_empty()
439    }
440}
441
442impl CausalChainQuery for InMemoryCausalStore {
443    fn trace_to_root(&self, event_id: &EventId) -> Result<Vec<CausalContext>> {
444        let mut chain = Vec::new();
445        let mut current_id = *event_id;
446
447        loop {
448            let ctx = self.contexts.get(&current_id).ok_or_else(|| {
449                Error::invalid_input(format!("event {} not found in causal store", current_id))
450            })?;
451            chain.push(ctx.clone());
452
453            if ctx.is_root() {
454                break;
455            }
456
457            match ctx.parent_event_id() {
458                Some(parent) => current_id = *parent,
459                None => break,
460            }
461        }
462
463        chain.reverse();
464        Ok(chain)
465    }
466
467    fn find_root(&self, event_id: &EventId) -> Result<CausalContext> {
468        let chain = self.trace_to_root(event_id)?;
469        chain
470            .into_iter()
471            .next()
472            .ok_or_else(|| Error::invalid_input("empty causal chain"))
473    }
474
475    fn events_in_session(&self, session_id: &SessionId) -> Result<Vec<CausalContext>> {
476        let event_ids = self
477            .session_events
478            .get(session_id)
479            .cloned()
480            .unwrap_or_default();
481        let mut contexts: Vec<CausalContext> = event_ids
482            .iter()
483            .filter_map(|id| self.contexts.get(id).cloned())
484            .collect();
485        contexts.sort_by_key(|c| c.sequence());
486        Ok(contexts)
487    }
488
489    fn children_of(&self, event_id: &EventId) -> Result<Vec<CausalContext>> {
490        let children: Vec<CausalContext> = self
491            .contexts
492            .values()
493            .filter(|ctx| ctx.parent_event_id() == Some(event_id))
494            .cloned()
495            .collect();
496        Ok(children)
497    }
498
499    fn max_depth_in_session(&self, session_id: &SessionId) -> Result<u32> {
500        let events = self.events_in_session(session_id)?;
501        Ok(events.iter().map(|c| c.depth()).max().unwrap_or(0))
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::crypto::hash;
509
510    fn test_event_id() -> EventId {
511        EventId(hash(b"test-event"))
512    }
513
514    fn test_session_id() -> SessionId {
515        SessionId::random()
516    }
517
518    fn test_principal() -> PrincipalId {
519        PrincipalId::user("alice").unwrap()
520    }
521
522    // === Construction Tests ===
523
524    #[test]
525    fn causal_context_root_created_successfully() {
526        let event_id = test_event_id();
527        let session_id = test_session_id();
528        let principal = test_principal();
529
530        let ctx = CausalContext::root(event_id, session_id, principal.clone());
531
532        assert!(ctx.is_root());
533        assert_eq!(ctx.depth(), 0);
534        assert_eq!(ctx.sequence(), 0);
535        assert!(ctx.parent_event_id().is_none());
536        assert_eq!(ctx.root_event_id(), &event_id);
537        assert_eq!(ctx.principal(), &principal);
538    }
539
540    #[test]
541    fn causal_context_requires_session_id() {
542        let result = CausalContext::builder()
543            .root_event_id(test_event_id())
544            .principal(test_principal())
545            .build();
546
547        assert!(result.is_err());
548    }
549
550    #[test]
551    fn causal_context_requires_principal() {
552        let result = CausalContext::builder()
553            .root_event_id(test_event_id())
554            .session_id(test_session_id())
555            .build();
556
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn causal_context_depth_zero_has_no_parent() {
562        let ctx = CausalContext::builder()
563            .root_event_id(test_event_id())
564            .session_id(test_session_id())
565            .principal(test_principal())
566            .depth(0)
567            .build()
568            .unwrap();
569
570        assert!(ctx.parent_event_id().is_none());
571        assert!(ctx.is_root());
572    }
573
574    #[test]
575    fn causal_context_depth_zero_with_parent_rejected() {
576        let result = CausalContext::builder()
577            .root_event_id(test_event_id())
578            .session_id(test_session_id())
579            .principal(test_principal())
580            .depth(0)
581            .parent_event_id(test_event_id())
582            .build();
583
584        assert!(result.is_err());
585    }
586
587    #[test]
588    fn causal_context_depth_nonzero_requires_parent() {
589        let result = CausalContext::builder()
590            .root_event_id(test_event_id())
591            .session_id(test_session_id())
592            .principal(test_principal())
593            .depth(1)
594            .build();
595
596        assert!(result.is_err());
597    }
598
599    #[test]
600    fn causal_context_depth_nonzero_with_parent_succeeds() {
601        let ctx = CausalContext::builder()
602            .root_event_id(test_event_id())
603            .session_id(test_session_id())
604            .principal(test_principal())
605            .depth(1)
606            .sequence(1)
607            .parent_event_id(test_event_id())
608            .build()
609            .unwrap();
610
611        assert!(!ctx.is_root());
612        assert_eq!(ctx.depth(), 1);
613    }
614
615    // === Child Creation Tests ===
616
617    #[test]
618    fn child_context_created_successfully() {
619        let root = CausalContext::root(test_event_id(), test_session_id(), test_principal());
620
621        let parent_id = test_event_id();
622        let child = root.child(parent_id, 1).unwrap();
623
624        assert_eq!(child.depth(), 1);
625        assert_eq!(child.sequence(), 1);
626        assert_eq!(child.parent_event_id(), Some(&parent_id));
627        assert_eq!(child.root_event_id(), root.root_event_id());
628    }
629
630    #[test]
631    fn child_sequence_must_exceed_parent() {
632        let root = CausalContext::root(test_event_id(), test_session_id(), test_principal());
633
634        // Same sequence should fail
635        let result = root.child(test_event_id(), 0);
636        assert!(result.is_err());
637
638        // Greater sequence should succeed
639        let result = root.child(test_event_id(), 1);
640        assert!(result.is_ok());
641    }
642
643    // === Validation Tests ===
644
645    #[test]
646    fn validate_rejects_depth_exceeding_max() {
647        let ctx = CausalContext::builder()
648            .root_event_id(test_event_id())
649            .session_id(test_session_id())
650            .principal(test_principal())
651            .depth(5)
652            .sequence(5)
653            .parent_event_id(test_event_id())
654            .build()
655            .unwrap();
656
657        // Depth 5 exceeds max of 3
658        let result = ctx.validate(3);
659        assert!(result.is_err());
660
661        // Depth 5 within max of 10
662        let result = ctx.validate(10);
663        assert!(result.is_ok());
664    }
665
666    #[test]
667    fn validate_against_parent_checks_sequence() {
668        let parent = CausalContext::root(test_event_id(), test_session_id(), test_principal());
669
670        let child = parent.child(test_event_id(), 1).unwrap();
671
672        // Valid: parent.sequence < child.sequence
673        assert!(child.validate_against_parent(&parent).is_ok());
674
675        // Create invalid child with lower sequence
676        let invalid_child = CausalContext::builder()
677            .root_event_id(parent.root_event_id)
678            .session_id(parent.session_id)
679            .principal(parent.principal.clone())
680            .depth(1)
681            .sequence(0) // Same as parent!
682            .parent_event_id(test_event_id())
683            .build()
684            .unwrap();
685
686        assert!(invalid_child.validate_against_parent(&parent).is_err());
687    }
688
689    #[test]
690    fn validate_against_parent_checks_depth() {
691        let parent = CausalContext::root(test_event_id(), test_session_id(), test_principal());
692
693        // Correct child depth
694        let valid_child = parent.child(test_event_id(), 1).unwrap();
695        assert!(valid_child.validate_against_parent(&parent).is_ok());
696
697        // Wrong depth (skipped a level)
698        let invalid_child = CausalContext::builder()
699            .root_event_id(parent.root_event_id)
700            .session_id(parent.session_id)
701            .principal(parent.principal.clone())
702            .depth(2) // Should be 1
703            .sequence(1)
704            .parent_event_id(test_event_id())
705            .build()
706            .unwrap();
707
708        assert!(invalid_child.validate_against_parent(&parent).is_err());
709    }
710
711    #[test]
712    fn validate_accepts_cross_session_reference() {
713        let source_session = test_session_id();
714        let target_session = test_session_id();
715        let event_id = test_event_id();
716
717        let cross_ref = CrossSessionReference::new(
718            source_session,
719            event_id,
720            "Follow-up task",
721            hash(b"event-data"),
722        );
723
724        let ctx = CausalContext::builder()
725            .root_event_id(event_id)
726            .session_id(target_session)
727            .principal(test_principal())
728            .depth(1)
729            .sequence(1)
730            .parent_event_id(event_id)
731            .cross_session_ref(cross_ref)
732            .build()
733            .unwrap();
734
735        assert!(ctx.cross_session_ref().is_some());
736    }
737
738    // === Display Tests ===
739
740    #[test]
741    fn display_format_correct() {
742        let ctx = CausalContext::root(test_event_id(), test_session_id(), test_principal());
743
744        let display = format!("{}", ctx);
745        assert!(display.contains("CausalContext"));
746        assert!(display.contains("depth=0"));
747        assert!(display.contains("seq=0"));
748    }
749}