1use crate::runtime::AuthzReceipt;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::io::Write;
16
17pub const EVENT_TYPE_USED: &str = "assay.mandate.used.v1";
19pub const EVENT_TYPE_REVOKED: &str = "assay.mandate.revoked.v1";
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LifecycleEvent {
25 pub specversion: &'static str,
26 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
36pub fn mandate_used_event(source: &str, receipt: &AuthzReceipt) -> LifecycleEvent {
43 LifecycleEvent {
44 specversion: "1.0",
45 id: receipt.use_id.clone(), 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
60pub 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 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
90pub trait LifecycleEmitter: Send + Sync {
92 fn emit(&self, event: &LifecycleEvent);
94}
95
96pub struct FileLifecycleEmitter {
98 file: std::sync::Mutex<std::fs::File>,
99}
100
101impl FileLifecycleEmitter {
102 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
124pub 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 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 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}