Skip to main content

cpm_planner/
audit.rs

1//! Minimal audit surface for the planner's lock-lifecycle events.
2//!
3//! The planner emits an [`AuditEvent`] for every lock state transition
4//! (acquired / released / expired / force-released) and drains them to an
5//! [`AuditSink`]. The default [`NullAuditSink`] drops them; an embedder can
6//! supply its own sink (file, syslog, a host's audit log) by implementing the
7//! trait.
8
9use std::sync::{Arc, Mutex};
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use uuid::Uuid;
15
16/// A single audit record. Builder-style: `AuditEvent::new(type).with_*(…)`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AuditEvent {
19    pub id: String,
20    pub timestamp: DateTime<Utc>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub workflow_id: Option<String>,
23    pub correlation_id: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub actor: Option<String>,
26    pub event_type: String,
27    pub payload: Value,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub trace_id: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub run_id: Option<String>,
32}
33
34impl AuditEvent {
35    pub fn new(event_type: impl Into<String>) -> Self {
36        Self {
37            id: format!("evt_{}", Uuid::new_v4().simple()),
38            timestamp: Utc::now(),
39            workflow_id: None,
40            correlation_id: format!("cor_{}", Uuid::new_v4().simple()),
41            actor: None,
42            event_type: event_type.into(),
43            payload: json!({}),
44            trace_id: None,
45            run_id: None,
46        }
47    }
48
49    pub fn with_workflow(mut self, workflow_id: impl Into<String>) -> Self {
50        self.workflow_id = Some(workflow_id.into());
51        self
52    }
53
54    pub fn with_correlation(mut self, correlation_id: impl Into<String>) -> Self {
55        self.correlation_id = correlation_id.into();
56        self
57    }
58
59    pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
60        self.actor = Some(actor.into());
61        self
62    }
63
64    pub fn with_payload(mut self, payload: Value) -> Self {
65        self.payload = payload;
66        self
67    }
68
69    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
70        self.trace_id = Some(trace_id.into());
71        self
72    }
73
74    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
75        self.run_id = Some(run_id.into());
76        self
77    }
78}
79
80/// A destination for [`AuditEvent`]s.
81#[async_trait::async_trait]
82pub trait AuditSink: Send + Sync {
83    async fn record(&self, event: AuditEvent) -> anyhow::Result<()>;
84
85    /// Return all recorded events, or `None` if the sink doesn't retain them.
86    async fn list_events(&self) -> Option<Vec<AuditEvent>> {
87        None
88    }
89}
90
91/// Drops every event. The default when audit isn't configured.
92pub struct NullAuditSink;
93
94#[async_trait::async_trait]
95impl AuditSink for NullAuditSink {
96    async fn record(&self, _event: AuditEvent) -> anyhow::Result<()> {
97        Ok(())
98    }
99}
100
101/// Writes one JSON line per event to stderr (stdout is the MCP transport
102/// channel, so audit narration goes to the diagnostic stream).
103pub struct StderrAuditSink;
104
105#[async_trait::async_trait]
106impl AuditSink for StderrAuditSink {
107    async fn record(&self, event: AuditEvent) -> anyhow::Result<()> {
108        eprintln!("{}", serde_json::to_string(&event)?);
109        Ok(())
110    }
111}
112
113/// Stores events in memory. Cheap; useful for tests and short-lived processes.
114#[derive(Default, Clone)]
115pub struct MemoryAuditSink {
116    events: Arc<Mutex<Vec<AuditEvent>>>,
117}
118
119impl MemoryAuditSink {
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    pub fn snapshot(&self) -> Vec<AuditEvent> {
125        self.events
126            .lock()
127            .expect("LOCK_POISONED: audit event buffer")
128            .clone()
129    }
130
131    pub fn event_types(&self) -> Vec<String> {
132        self.events
133            .lock()
134            .expect("LOCK_POISONED: audit event buffer")
135            .iter()
136            .map(|e| e.event_type.clone())
137            .collect()
138    }
139
140    pub fn clear(&self) {
141        self.events
142            .lock()
143            .expect("LOCK_POISONED: audit event buffer")
144            .clear();
145    }
146}
147
148#[async_trait::async_trait]
149impl AuditSink for MemoryAuditSink {
150    async fn record(&self, event: AuditEvent) -> anyhow::Result<()> {
151        self.events
152            .lock()
153            .expect("LOCK_POISONED: audit event buffer")
154            .push(event);
155        Ok(())
156    }
157
158    async fn list_events(&self) -> Option<Vec<AuditEvent>> {
159        Some(self.snapshot())
160    }
161}