use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ids::{EventId, TraceId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EventSource {
User,
ChildAgent {
model: String,
},
Tool {
name: String,
},
Runtime,
ExternalOutcome,
ManualCorrection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum EventType {
#[serde(rename = "cortex.event.user_message.v1")]
UserMessage,
#[serde(rename = "cortex.event.agent_response.v1")]
AgentResponse,
#[serde(rename = "cortex.event.tool_call.v1")]
ToolCall,
#[serde(rename = "cortex.event.tool_result.v1")]
ToolResult,
#[serde(rename = "cortex.event.code_diff.v1")]
CodeDiff,
#[serde(rename = "cortex.event.test_result.v1")]
TestResult,
#[serde(rename = "cortex.event.decision.v1")]
Decision,
#[serde(rename = "cortex.event.correction.v1")]
Correction,
#[serde(rename = "cortex.event.outcome.v1")]
Outcome,
#[serde(rename = "cortex.event.system_note.v1")]
SystemNote,
}
impl EventType {
#[must_use]
pub const fn wire_str(&self) -> &'static str {
match self {
Self::UserMessage => "cortex.event.user_message.v1",
Self::AgentResponse => "cortex.event.agent_response.v1",
Self::ToolCall => "cortex.event.tool_call.v1",
Self::ToolResult => "cortex.event.tool_result.v1",
Self::CodeDiff => "cortex.event.code_diff.v1",
Self::TestResult => "cortex.event.test_result.v1",
Self::Decision => "cortex.event.decision.v1",
Self::Correction => "cortex.event.correction.v1",
Self::Outcome => "cortex.event.outcome.v1",
Self::SystemNote => "cortex.event.system_note.v1",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Event {
pub id: EventId,
pub schema_version: u16,
pub observed_at: DateTime<Utc>,
pub recorded_at: DateTime<Utc>,
pub source: EventSource,
pub event_type: EventType,
pub trace_id: Option<TraceId>,
pub session_id: Option<String>,
pub domain_tags: Vec<String>,
pub payload: serde_json::Value,
pub payload_hash: String,
pub prev_event_hash: Option<String>,
pub event_hash: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SCHEMA_VERSION;
use chrono::TimeZone;
fn fixture_event() -> Event {
Event {
id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap(),
schema_version: SCHEMA_VERSION,
observed_at: Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
recorded_at: Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 1).unwrap(),
source: EventSource::ChildAgent {
model: "claude-3.5-sonnet".into(),
},
event_type: EventType::AgentResponse,
trace_id: Some("trc_01ARZ3NDEKTSV4RRFFQ69G5FAW".parse().unwrap()),
session_id: Some("session-001".into()),
domain_tags: vec!["agents".into(), "demo".into()],
payload: serde_json::json!({"text": "hello world"}),
payload_hash: "deadbeef".into(),
prev_event_hash: None,
event_hash: "feedface".into(),
}
}
#[test]
fn event_serde_round_trip() {
let e = fixture_event();
let j = serde_json::to_value(&e).expect("serialize");
let back: Event = serde_json::from_value(j.clone()).expect("deserialize");
assert_eq!(e, back);
let obj = j.as_object().expect("event serializes as a JSON object");
for k in [
"id",
"schema_version",
"observed_at",
"recorded_at",
"source",
"event_type",
"trace_id",
"session_id",
"domain_tags",
"payload",
"payload_hash",
"prev_event_hash",
"event_hash",
] {
assert!(obj.contains_key(k), "event JSON missing field `{k}`");
}
assert_eq!(
obj["event_type"],
serde_json::json!("cortex.event.agent_response.v1")
);
assert_eq!(obj["source"]["type"], serde_json::json!("child_agent"));
assert_eq!(
obj["source"]["model"],
serde_json::json!("claude-3.5-sonnet")
);
}
#[test]
fn event_type_wire_strings_snapshot() {
let pairs: &[(EventType, &str)] = &[
(EventType::UserMessage, "cortex.event.user_message.v1"),
(EventType::AgentResponse, "cortex.event.agent_response.v1"),
(EventType::ToolCall, "cortex.event.tool_call.v1"),
(EventType::ToolResult, "cortex.event.tool_result.v1"),
(EventType::CodeDiff, "cortex.event.code_diff.v1"),
(EventType::TestResult, "cortex.event.test_result.v1"),
(EventType::Decision, "cortex.event.decision.v1"),
(EventType::Correction, "cortex.event.correction.v1"),
(EventType::Outcome, "cortex.event.outcome.v1"),
(EventType::SystemNote, "cortex.event.system_note.v1"),
];
let pat = regex_like(r"^cortex\.event\.[a-z][a-z0-9_]*\.v[0-9]+$");
for (et, wire) in pairs {
assert_eq!(et.wire_str(), *wire, "wire_str() vs snapshot for {et:?}");
let json = serde_json::to_value(et).unwrap();
assert_eq!(
json,
serde_json::Value::String((*wire).to_string()),
"serde wire string for {et:?}"
);
assert!(
pat(wire),
"wire string `{wire}` does not match `cortex.event.<snake>.v<N>`"
);
let back: EventType = serde_json::from_value(json).unwrap();
assert_eq!(back, *et);
}
for (et, _) in pairs {
let _: () = match et {
EventType::UserMessage
| EventType::AgentResponse
| EventType::ToolCall
| EventType::ToolResult
| EventType::CodeDiff
| EventType::TestResult
| EventType::Decision
| EventType::Correction
| EventType::Outcome
| EventType::SystemNote => (),
};
}
}
fn regex_like(_pat: &'static str) -> impl Fn(&str) -> bool {
|s: &str| -> bool {
let Some(rest) = s.strip_prefix("cortex.event.") else {
return false;
};
let Some((middle, version_tail)) = rest.rsplit_once(".v") else {
return false;
};
if version_tail.is_empty() || !version_tail.chars().all(|c| c.is_ascii_digit()) {
return false;
}
let mut chars = middle.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
}
}