Skip to main content

assay_core/mcp/
lifecycle.rs

1//! Mandate lifecycle event builders (CloudEvents).
2//!
3//! Builds `assay.mandate.used.v1` and `assay.mandate.revoked.v1` events
4//! per SPEC-Mandate-v1.0.4.
5//!
6//! Key design:
7//! - CloudEvents.id = use_id (deterministic) for idempotent retries
8//! - source = configured event_source (validated at startup)
9//! - time = consumed_at from receipt
10
11use crate::runtime::AuthzReceipt;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::io::Write;
16
17/// CloudEvents type for mandate consumption.
18pub const EVENT_TYPE_USED: &str = "assay.mandate.used.v1";
19/// CloudEvents type for mandate revocation.
20pub const EVENT_TYPE_REVOKED: &str = "assay.mandate.revoked.v1";
21
22/// A mandate lifecycle event (CloudEvents compliant).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LifecycleEvent {
25    pub specversion: &'static str,
26    /// CloudEvents.id = use_id for idempotent deduplication
27    pub id: String,
28    #[serde(rename = "type")]
29    pub event_type: String,
30    pub source: String,
31    pub time: String,
32    pub datacontenttype: &'static str,
33    pub data: Value,
34}
35
36/// Build a `assay.mandate.used.v1` CloudEvent from an AuthzReceipt.
37///
38/// Key properties:
39/// - `id` = `receipt.use_id` (deterministic, idempotent)
40/// - `time` = `receipt.consumed_at`
41/// - `data.use_id` = `receipt.use_id`
42pub fn mandate_used_event(source: &str, receipt: &AuthzReceipt) -> LifecycleEvent {
43    LifecycleEvent {
44        specversion: "1.0",
45        id: receipt.use_id.clone(), // Idempotent: same use_id = same event
46        event_type: EVENT_TYPE_USED.to_string(),
47        source: source.to_string(),
48        time: receipt.consumed_at.to_rfc3339(),
49        datacontenttype: "application/json",
50        data: serde_json::json!({
51            "mandate_id": receipt.mandate_id,
52            "use_id": receipt.use_id,
53            "tool_call_id": receipt.tool_call_id,
54            "consumed_at": receipt.consumed_at.to_rfc3339(),
55            "use_count": receipt.use_count,
56        }),
57    }
58}
59
60/// Build a `assay.mandate.revoked.v1` CloudEvent.
61pub fn mandate_revoked_event(
62    source: &str,
63    mandate_id: &str,
64    revoked_at: DateTime<Utc>,
65    reason: Option<&str>,
66    revoked_by: Option<&str>,
67    event_id: Option<&str>,
68) -> LifecycleEvent {
69    // Use provided event_id or generate deterministic one
70    let id = event_id
71        .map(String::from)
72        .unwrap_or_else(|| format!("revoke:{}", mandate_id));
73
74    LifecycleEvent {
75        specversion: "1.0",
76        id,
77        event_type: EVENT_TYPE_REVOKED.to_string(),
78        source: source.to_string(),
79        time: revoked_at.to_rfc3339(),
80        datacontenttype: "application/json",
81        data: serde_json::json!({
82            "mandate_id": mandate_id,
83            "revoked_at": revoked_at.to_rfc3339(),
84            "reason": reason,
85            "revoked_by": revoked_by,
86        }),
87    }
88}
89
90/// Trait for emitting lifecycle events.
91pub trait LifecycleEmitter: Send + Sync {
92    /// Emit a lifecycle event.
93    fn emit(&self, event: &LifecycleEvent);
94}
95
96/// File-based lifecycle emitter (NDJSON to audit log).
97pub struct FileLifecycleEmitter {
98    file: std::sync::Mutex<std::fs::File>,
99}
100
101impl FileLifecycleEmitter {
102    /// Create a new file emitter.
103    pub fn new(path: &std::path::Path) -> std::io::Result<Self> {
104        let file = std::fs::OpenOptions::new()
105            .create(true)
106            .append(true)
107            .open(path)?;
108        Ok(Self {
109            file: std::sync::Mutex::new(file),
110        })
111    }
112}
113
114impl LifecycleEmitter for FileLifecycleEmitter {
115    fn emit(&self, event: &LifecycleEvent) {
116        if let Ok(json) = serde_json::to_string(event) {
117            if let Ok(mut f) = self.file.lock() {
118                let _ = writeln!(f, "{}", json);
119            }
120        }
121    }
122}
123
124/// Null emitter for testing or when audit logging is disabled.
125pub struct NullLifecycleEmitter;
126
127impl LifecycleEmitter for NullLifecycleEmitter {
128    fn emit(&self, _event: &LifecycleEvent) {}
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use chrono::Utc;
135
136    #[test]
137    fn test_used_event_id_is_use_id() {
138        let receipt = AuthzReceipt {
139            mandate_id: "sha256:mandate123".to_string(),
140            use_id: "sha256:deterministic_use_id".to_string(),
141            use_count: 1,
142            consumed_at: Utc::now(),
143            tool_call_id: "tc_001".to_string(),
144            was_new: true,
145        };
146
147        let event = mandate_used_event("assay://myorg/myapp", &receipt);
148
149        // Critical: CloudEvents.id == use_id for idempotency
150        assert_eq!(event.id, receipt.use_id);
151        assert_eq!(event.event_type, EVENT_TYPE_USED);
152        assert_eq!(event.source, "assay://myorg/myapp");
153    }
154
155    #[test]
156    fn test_used_event_contains_required_fields() {
157        let receipt = AuthzReceipt {
158            mandate_id: "sha256:m".to_string(),
159            use_id: "sha256:u".to_string(),
160            use_count: 3,
161            consumed_at: Utc::now(),
162            tool_call_id: "tc".to_string(),
163            was_new: true,
164        };
165
166        let event = mandate_used_event("assay://test", &receipt);
167
168        // Check data fields
169        assert_eq!(event.data["mandate_id"], "sha256:m");
170        assert_eq!(event.data["use_id"], "sha256:u");
171        assert_eq!(event.data["tool_call_id"], "tc");
172        assert_eq!(event.data["use_count"], 3);
173    }
174
175    #[test]
176    fn test_revoked_event_structure() {
177        let event = mandate_revoked_event(
178            "assay://myorg/myapp",
179            "sha256:mandate456",
180            Utc::now(),
181            Some("User requested"),
182            Some("admin@example.com"),
183            Some("evt_revoke_001"),
184        );
185
186        assert_eq!(event.id, "evt_revoke_001");
187        assert_eq!(event.event_type, EVENT_TYPE_REVOKED);
188        assert_eq!(event.data["mandate_id"], "sha256:mandate456");
189        assert_eq!(event.data["reason"], "User requested");
190    }
191
192    #[test]
193    fn test_used_event_serialization() {
194        let receipt = AuthzReceipt {
195            mandate_id: "sha256:m".to_string(),
196            use_id: "sha256:u".to_string(),
197            use_count: 1,
198            consumed_at: Utc::now(),
199            tool_call_id: "tc".to_string(),
200            was_new: true,
201        };
202
203        let event = mandate_used_event("assay://test", &receipt);
204        let json = serde_json::to_string(&event).unwrap();
205
206        assert!(json.contains("assay.mandate.used.v1"));
207        assert!(json.contains("sha256:u"));
208        assert!(json.contains("specversion"));
209    }
210}