Skip to main content

openlatch_client/core/envelope/
mod.rs

1//! Envelope module — core data types for event wrapping, agent identification, and verdict responses.
2//!
3//! This is a leaf module with zero internal dependencies. Every event flowing through the daemon
4//! gets wrapped in an [`EventEnvelope`]. The types here are consumed by daemon handlers, the
5//! privacy filter, and the logging module.
6
7use serde::{Deserialize, Serialize};
8
9/// Closed enumeration of supported agent platforms.
10///
11/// This enum is non-negotiable per the envelope format spec. Unknown agent types must be rejected
12/// at deserialization. Adding a new agent requires updating this enum and bumping the minor version.
13///
14/// Serializes to kebab-case (e.g. `ClaudeCode` → `"claude-code"`).
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub enum AgentType {
18    ClaudeCode,
19    Cursor,
20    Windsurf,
21    GithubCopilot,
22    CodexCli,
23    GeminiCli,
24    Cline,
25    // SECURITY: Closed enum — unknown agent types are rejected at deserialization (OL-1001).
26    // Manual rename needed: kebab-case would produce "open-claw" but the spec requires "openclaw".
27    #[serde(rename = "openclaw")]
28    OpenClaw,
29}
30
31/// Hook event type — identifies which agent lifecycle event triggered this envelope.
32///
33/// Serializes to snake_case (e.g. `PreToolUse` → `"pre_tool_use"`).
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
35#[serde(rename_all = "snake_case")]
36pub enum HookEventType {
37    PreToolUse,
38    UserPromptSubmit,
39    Stop,
40}
41
42/// Verdict returned to the agent hook.
43///
44/// In M1, only `Allow` and `Approve` are used. `Deny` is defined for schema completeness
45/// but is never returned by M1 handlers.
46///
47/// Serializes to lowercase (e.g. `Allow` → `"allow"`).
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "lowercase")]
50pub enum Verdict {
51    Allow,
52    Approve,
53    /// Defined for schema completeness — not used in M1.
54    Deny,
55}
56
57/// Complete event envelope wrapping a raw agent hook event with cross-cutting metadata.
58///
59/// All fields correspond to EVNT-02 requirements. Optional fields are omitted from JSON
60/// when `None` to keep payloads compact.
61///
62/// # Examples
63///
64/// ```rust
65/// use openlatch_client::envelope::{EventEnvelope, AgentType, HookEventType, Verdict,
66///     new_event_id, current_timestamp, os_string, arch_string};
67///
68/// let envelope = EventEnvelope {
69///     schema_version: "1.0".to_string(),
70///     id: new_event_id(),
71///     timestamp: current_timestamp(),
72///     event_type: HookEventType::PreToolUse,
73///     session_id: "session-abc".to_string(),
74///     tool_name: Some("bash".to_string()),
75///     tool_input: None,
76///     user_prompt: None,
77///     reason: None,
78///     verdict: Verdict::Allow,
79///     latency_ms: 3,
80///     agent_platform: AgentType::ClaudeCode,
81///     agent_version: None,
82///     os: os_string().to_string(),
83///     arch: arch_string().to_string(),
84///     client_version: env!("CARGO_PKG_VERSION").to_string(),
85/// };
86/// ```
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88#[serde(rename_all = "snake_case")]
89pub struct EventEnvelope {
90    pub schema_version: String,
91    pub id: String,
92    pub timestamp: String,
93    pub event_type: HookEventType,
94    pub session_id: String,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub tool_name: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub tool_input: Option<serde_json::Value>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub user_prompt: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub reason: Option<String>,
103    pub verdict: Verdict,
104    pub latency_ms: u64,
105    pub agent_platform: AgentType,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub agent_version: Option<String>,
108    pub os: String,
109    pub arch: String,
110    pub client_version: String,
111}
112
113/// Verdict response returned to the agent hook after processing.
114///
115/// Mirrors the cloud response schema. Optional fields are omitted when `None`.
116/// Use the [`VerdictResponse::allow`] and [`VerdictResponse::approve`] constructors for M1.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(rename_all = "snake_case")]
119pub struct VerdictResponse {
120    pub schema_version: String,
121    pub verdict: Verdict,
122    pub event_id: String,
123    pub latency_ms: f64,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub reason: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub severity: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub threat_category: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub rule_id: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub details_url: Option<String>,
134}
135
136impl VerdictResponse {
137    /// Constructs an `allow` verdict response for PreToolUse and UserPromptSubmit events.
138    pub fn allow(event_id: String, latency_ms: f64) -> Self {
139        Self {
140            schema_version: "1.0".to_string(),
141            verdict: Verdict::Allow,
142            event_id,
143            latency_ms,
144            reason: None,
145            severity: None,
146            threat_category: None,
147            rule_id: None,
148            details_url: None,
149        }
150    }
151
152    /// Constructs an `approve` verdict response for Stop events.
153    pub fn approve(event_id: String, latency_ms: f64) -> Self {
154        Self {
155            schema_version: "1.0".to_string(),
156            verdict: Verdict::Approve,
157            event_id,
158            latency_ms,
159            reason: None,
160            severity: None,
161            threat_category: None,
162            rule_id: None,
163            details_url: None,
164        }
165    }
166}
167
168/// Generates a new UUIDv7 event ID.
169///
170/// UUIDv7 IDs encode a millisecond-precision Unix timestamp in the most significant bits,
171/// making them monotonically ordered when compared lexicographically. This satisfies EVNT-01.
172pub fn new_event_id() -> String {
173    format!("evt_{}", uuid::Uuid::now_v7())
174}
175
176/// Returns the current UTC timestamp as an RFC 3339 string with Z suffix.
177///
178/// Example output: `"2026-04-07T12:00:00Z"`
179///
180/// # PERFORMANCE: Pure in-memory — no I/O, no allocation beyond the returned String.
181pub fn current_timestamp() -> String {
182    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
183}
184
185/// Returns the current OS name as reported by the Rust standard library.
186///
187/// Examples: `"linux"`, `"macos"`, `"windows"`
188pub fn os_string() -> &'static str {
189    std::env::consts::OS
190}
191
192/// Returns the current CPU architecture as reported by the Rust standard library.
193///
194/// Examples: `"x86_64"`, `"aarch64"`
195pub fn arch_string() -> &'static str {
196    std::env::consts::ARCH
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn make_test_envelope() -> EventEnvelope {
204        EventEnvelope {
205            schema_version: "1.0".to_string(),
206            id: new_event_id(),
207            timestamp: current_timestamp(),
208            event_type: HookEventType::PreToolUse,
209            session_id: "test-session-123".to_string(),
210            tool_name: Some("bash".to_string()),
211            tool_input: Some(serde_json::json!({"command": "ls -la"})),
212            user_prompt: None,
213            reason: None,
214            verdict: Verdict::Allow,
215            latency_ms: 3,
216            agent_platform: AgentType::ClaudeCode,
217            agent_version: None,
218            os: os_string().to_string(),
219            arch: arch_string().to_string(),
220            client_version: env!("CARGO_PKG_VERSION").to_string(),
221        }
222    }
223
224    #[test]
225    fn test_event_envelope_serializes_all_required_fields() {
226        let envelope = make_test_envelope();
227        let json = serde_json::to_value(&envelope).expect("serialization must succeed");
228
229        // All required fields must be present
230        assert!(
231            json.get("schema_version").is_some(),
232            "missing schema_version"
233        );
234        assert!(json.get("id").is_some(), "missing id");
235        assert!(json.get("timestamp").is_some(), "missing timestamp");
236        assert!(json.get("event_type").is_some(), "missing event_type");
237        assert!(json.get("session_id").is_some(), "missing session_id");
238        assert!(json.get("verdict").is_some(), "missing verdict");
239        assert!(json.get("latency_ms").is_some(), "missing latency_ms");
240        assert!(
241            json.get("agent_platform").is_some(),
242            "missing agent_platform"
243        );
244        assert!(json.get("os").is_some(), "missing os");
245        assert!(json.get("arch").is_some(), "missing arch");
246        assert!(
247            json.get("client_version").is_some(),
248            "missing client_version"
249        );
250    }
251
252    #[test]
253    fn test_agent_type_claude_code_serializes_to_kebab_case() {
254        let agent_type = AgentType::ClaudeCode;
255        let json = serde_json::to_string(&agent_type).expect("serialization must succeed");
256        assert_eq!(json, "\"claude-code\"");
257    }
258
259    #[test]
260    fn test_all_8_agent_types_serialize_correctly() {
261        let cases = [
262            (AgentType::ClaudeCode, "claude-code"),
263            (AgentType::Cursor, "cursor"),
264            (AgentType::Windsurf, "windsurf"),
265            (AgentType::GithubCopilot, "github-copilot"),
266            (AgentType::CodexCli, "codex-cli"),
267            (AgentType::GeminiCli, "gemini-cli"),
268            (AgentType::Cline, "cline"),
269            (AgentType::OpenClaw, "openclaw"),
270        ];
271
272        for (agent, expected) in cases {
273            let json = serde_json::to_string(&agent).expect("serialization must succeed");
274            assert_eq!(
275                json,
276                format!("\"{}\"", expected),
277                "wrong serialization for {:?}",
278                agent
279            );
280        }
281    }
282
283    #[test]
284    fn test_event_envelope_id_has_evt_prefix_and_valid_uuid_v7() {
285        let id = new_event_id();
286        assert!(
287            id.starts_with("evt_"),
288            "ID must start with evt_ prefix: {}",
289            id
290        );
291        let uuid_part = id.strip_prefix("evt_").unwrap();
292        let parsed = uuid::Uuid::parse_str(uuid_part).expect("ID must contain a valid UUID");
293        // UUIDv7 has version nibble = 7 in the 7th byte (bits 12-15)
294        assert_eq!(parsed.get_version_num(), 7, "UUID version must be 7");
295    }
296
297    #[test]
298    fn test_consecutive_event_ids_are_monotonically_ordered() {
299        let id1 = new_event_id();
300        // Small sleep not needed — UUIDv7 uses sub-millisecond monotonic counter
301        let id2 = new_event_id();
302        assert!(
303            id1 <= id2,
304            "UUIDv7 IDs must be monotonically ordered: {} <= {}",
305            id1,
306            id2
307        );
308    }
309
310    #[test]
311    fn test_timestamp_ends_with_z_suffix() {
312        let ts = current_timestamp();
313        assert!(
314            ts.ends_with('Z'),
315            "Timestamp must end with 'Z' for UTC: {}",
316            ts
317        );
318    }
319
320    #[test]
321    fn test_verdict_response_serializes_optional_fields_omitted_when_none() {
322        let resp = VerdictResponse::allow("evt-001".to_string(), 5.0);
323        let json = serde_json::to_value(&resp).expect("serialization must succeed");
324
325        // Optional fields must NOT appear when None
326        assert!(
327            json.get("reason").is_none(),
328            "reason should be omitted when None"
329        );
330        assert!(
331            json.get("severity").is_none(),
332            "severity should be omitted when None"
333        );
334        assert!(
335            json.get("threat_category").is_none(),
336            "threat_category should be omitted when None"
337        );
338        assert!(
339            json.get("rule_id").is_none(),
340            "rule_id should be omitted when None"
341        );
342        assert!(
343            json.get("details_url").is_none(),
344            "details_url should be omitted when None"
345        );
346
347        // Required fields must still be present
348        assert_eq!(json["schema_version"], "1.0");
349        assert_eq!(json["event_id"], "evt-001");
350        assert_eq!(json["latency_ms"], 5.0);
351    }
352
353    #[test]
354    fn test_verdict_allow_serializes_to_allow() {
355        let verdict = Verdict::Allow;
356        let json = serde_json::to_string(&verdict).expect("serialization must succeed");
357        assert_eq!(json, "\"allow\"");
358    }
359
360    #[test]
361    fn test_verdict_approve_serializes_to_approve() {
362        let verdict = Verdict::Approve;
363        let json = serde_json::to_string(&verdict).expect("serialization must succeed");
364        assert_eq!(json, "\"approve\"");
365    }
366
367    #[test]
368    fn test_hook_event_type_pre_tool_use_serializes_to_snake_case() {
369        let event_type = HookEventType::PreToolUse;
370        let json = serde_json::to_string(&event_type).expect("serialization must succeed");
371        assert_eq!(json, "\"pre_tool_use\"");
372    }
373
374    #[test]
375    fn test_event_envelope_round_trips_through_serde_json() {
376        let original = make_test_envelope();
377        let serialized = serde_json::to_string(&original).expect("serialization must succeed");
378        let deserialized: EventEnvelope =
379            serde_json::from_str(&serialized).expect("deserialization must succeed");
380        assert_eq!(original, deserialized);
381    }
382
383    #[test]
384    fn test_client_version_matches_cargo_pkg_version() {
385        let envelope = make_test_envelope();
386        assert_eq!(envelope.client_version, env!("CARGO_PKG_VERSION"));
387    }
388
389    #[test]
390    fn test_user_prompt_serialized_when_present_omitted_when_none() {
391        let mut envelope = make_test_envelope();
392
393        // When None, field should be absent from JSON
394        let json = serde_json::to_value(&envelope).unwrap();
395        assert!(
396            json.get("user_prompt").is_none(),
397            "user_prompt should be omitted when None"
398        );
399
400        // When Some, field should be present
401        envelope.user_prompt = Some("tell me about Rust".to_string());
402        let json = serde_json::to_value(&envelope).unwrap();
403        assert_eq!(json["user_prompt"], "tell me about Rust");
404    }
405
406    #[test]
407    fn test_reason_serialized_when_present_omitted_when_none() {
408        let mut envelope = make_test_envelope();
409
410        // When None, field should be absent from JSON
411        let json = serde_json::to_value(&envelope).unwrap();
412        assert!(
413            json.get("reason").is_none(),
414            "reason should be omitted when None"
415        );
416
417        // When Some, field should be present
418        envelope.reason = Some("end_turn".to_string());
419        let json = serde_json::to_value(&envelope).unwrap();
420        assert_eq!(json["reason"], "end_turn");
421    }
422
423    #[test]
424    fn test_schema_version_serializes_as_string() {
425        let envelope = make_test_envelope();
426        let json = serde_json::to_value(&envelope).unwrap();
427        assert_eq!(
428            json["schema_version"], "1.0",
429            "schema_version must be string \"1.0\""
430        );
431    }
432}