cortex_core/event.rs
1//! Immutable observed-fact events.
2//!
3//! Mirrors BUILD_SPEC §9.1. The struct is the on-disk and on-wire shape:
4//! `serde` field order matches the spec, and rename-style attributes are
5//! stable identifiers that **may not be silently changed** without a
6//! [`crate::SCHEMA_VERSION`] bump and an ADR (see [`crate::version`]).
7//!
8//! ## Stable wire strings
9//!
10//! [`EventType`] serializes as `cortex.event.<snake_case>.v<N>` strings
11//! (e.g. `cortex.event.user_message.v1`). These strings are **part of the
12//! public contract**: renaming a Rust variant must NOT change the wire
13//! string. The snapshot test in this module pins every variant's wire
14//! string and fails if a rename leaks through.
15//!
16//! Doctrine reference:
17//! [`.doctrine/principles/event-contracts.md`](../../../.doctrine/principles/event-contracts.md) §3.
18//!
19//! ## What this version (v1) does NOT include
20//!
21//! - `Attestation` (from ADR 0014) — deliberately deferred to schema v2.
22//! - `payload_hash` is a `String` here; the actual BLAKE3 framing lives in
23//! `cortex-ledger` (lane 1.B). `cortex-core` only owns the typed shape.
24
25use chrono::{DateTime, Utc};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use crate::ids::{EventId, TraceId};
30
31/// Where an event originated.
32///
33/// Carries internal data on a per-variant basis. Serialized as an internally
34/// tagged enum (`{"type": "...", ...}`) so adding fields to a variant is a
35/// non-breaking change at the JSON level.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum EventSource {
39 /// A human user.
40 User,
41 /// An ephemeral child agent invocation.
42 ChildAgent {
43 /// Model identifier (e.g. `claude-3.5-sonnet`, `llama3.1:8b`).
44 model: String,
45 },
46 /// A tool invocation result.
47 Tool {
48 /// Tool name (free-form; matches the runtime registry).
49 name: String,
50 },
51 /// The Cortex runtime itself (system events, lifecycle markers).
52 Runtime,
53 /// An externally-observed outcome (CI result, deployment, user rating).
54 ExternalOutcome,
55 /// An explicit operator correction.
56 ManualCorrection,
57}
58
59/// What kind of event was observed.
60///
61/// Each variant serializes to a stable, versioned wire string of the form
62/// `cortex.event.<snake_case>.v<N>` (see module docs). Changing a wire
63/// string requires a [`crate::SCHEMA_VERSION`] bump.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
65pub enum EventType {
66 /// User authored a message.
67 #[serde(rename = "cortex.event.user_message.v1")]
68 UserMessage,
69 /// An agent emitted a response.
70 #[serde(rename = "cortex.event.agent_response.v1")]
71 AgentResponse,
72 /// An agent invoked a tool.
73 #[serde(rename = "cortex.event.tool_call.v1")]
74 ToolCall,
75 /// A tool returned a result (or error).
76 #[serde(rename = "cortex.event.tool_result.v1")]
77 ToolResult,
78 /// A code diff was produced or applied.
79 #[serde(rename = "cortex.event.code_diff.v1")]
80 CodeDiff,
81 /// A test run produced a result.
82 #[serde(rename = "cortex.event.test_result.v1")]
83 TestResult,
84 /// A typed decision was recorded (e.g. a policy gate firing).
85 #[serde(rename = "cortex.event.decision.v1")]
86 Decision,
87 /// An explicit correction event (immutability-preserving fixup).
88 #[serde(rename = "cortex.event.correction.v1")]
89 Correction,
90 /// An externally observed outcome (validation signal).
91 #[serde(rename = "cortex.event.outcome.v1")]
92 Outcome,
93 /// A system note from the Cortex runtime.
94 #[serde(rename = "cortex.event.system_note.v1")]
95 SystemNote,
96}
97
98impl EventType {
99 /// Canonical wire string for this variant.
100 ///
101 /// Cheaper and more obvious than `serde_json::to_value(&v)` when you just
102 /// want the identifier (e.g. for log lines or table rows).
103 #[must_use]
104 pub const fn wire_str(&self) -> &'static str {
105 match self {
106 Self::UserMessage => "cortex.event.user_message.v1",
107 Self::AgentResponse => "cortex.event.agent_response.v1",
108 Self::ToolCall => "cortex.event.tool_call.v1",
109 Self::ToolResult => "cortex.event.tool_result.v1",
110 Self::CodeDiff => "cortex.event.code_diff.v1",
111 Self::TestResult => "cortex.event.test_result.v1",
112 Self::Decision => "cortex.event.decision.v1",
113 Self::Correction => "cortex.event.correction.v1",
114 Self::Outcome => "cortex.event.outcome.v1",
115 Self::SystemNote => "cortex.event.system_note.v1",
116 }
117 }
118}
119
120/// An immutable observed fact in the Cortex ledger.
121///
122/// Field order is the BUILD_SPEC §9.1 wire order; do not reorder without a
123/// schema bump.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
125pub struct Event {
126 /// Stable identifier (also the dedup key for re-ingest).
127 pub id: EventId,
128 /// Schema version this row was written under.
129 pub schema_version: u16,
130 /// When the event was observed in the world.
131 pub observed_at: DateTime<Utc>,
132 /// When Cortex recorded the event (typically `>= observed_at`).
133 pub recorded_at: DateTime<Utc>,
134 /// Where the event came from.
135 pub source: EventSource,
136 /// What kind of event this is.
137 pub event_type: EventType,
138 /// Trace this event belongs to, if any.
139 pub trace_id: Option<TraceId>,
140 /// Session identifier (free-form; not modeled as a typed ID at this layer).
141 pub session_id: Option<String>,
142 /// Free-form domain tags (`agents`, `security`, …).
143 pub domain_tags: Vec<String>,
144 /// Structured payload. Schema is event-type-specific and not validated
145 /// at this layer; downstream consumers may run typed validation.
146 pub payload: serde_json::Value,
147 /// Hex-encoded BLAKE3 hash of the canonical payload encoding.
148 /// Computed by `cortex-ledger`; opaque here.
149 pub payload_hash: String,
150 /// Hex-encoded `event_hash` of the previous event in the chain (if any).
151 pub prev_event_hash: Option<String>,
152 /// Hex-encoded `event_hash` of this event.
153 pub event_hash: String,
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::SCHEMA_VERSION;
160 use chrono::TimeZone;
161
162 fn fixture_event() -> Event {
163 Event {
164 id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap(),
165 schema_version: SCHEMA_VERSION,
166 observed_at: Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap(),
167 recorded_at: Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 1).unwrap(),
168 source: EventSource::ChildAgent {
169 model: "claude-3.5-sonnet".into(),
170 },
171 event_type: EventType::AgentResponse,
172 trace_id: Some("trc_01ARZ3NDEKTSV4RRFFQ69G5FAW".parse().unwrap()),
173 session_id: Some("session-001".into()),
174 domain_tags: vec!["agents".into(), "demo".into()],
175 payload: serde_json::json!({"text": "hello world"}),
176 payload_hash: "deadbeef".into(),
177 prev_event_hash: None,
178 event_hash: "feedface".into(),
179 }
180 }
181
182 #[test]
183 fn event_serde_round_trip() {
184 let e = fixture_event();
185 let j = serde_json::to_value(&e).expect("serialize");
186 let back: Event = serde_json::from_value(j.clone()).expect("deserialize");
187 assert_eq!(e, back);
188
189 // Wire shape sanity: top-level keys are present in spec order. We don't
190 // assert exact ordering (serde_json::Map is BTreeMap-ish with arbitrary
191 // ordering depending on features), but we do assert presence.
192 let obj = j.as_object().expect("event serializes as a JSON object");
193 for k in [
194 "id",
195 "schema_version",
196 "observed_at",
197 "recorded_at",
198 "source",
199 "event_type",
200 "trace_id",
201 "session_id",
202 "domain_tags",
203 "payload",
204 "payload_hash",
205 "prev_event_hash",
206 "event_hash",
207 ] {
208 assert!(obj.contains_key(k), "event JSON missing field `{k}`");
209 }
210
211 // event_type serializes as the wire string.
212 assert_eq!(
213 obj["event_type"],
214 serde_json::json!("cortex.event.agent_response.v1")
215 );
216
217 // EventSource serializes as an internally-tagged object.
218 assert_eq!(obj["source"]["type"], serde_json::json!("child_agent"));
219 assert_eq!(
220 obj["source"]["model"],
221 serde_json::json!("claude-3.5-sonnet")
222 );
223 }
224
225 /// Snapshot of the wire string for every `EventType` variant.
226 ///
227 /// The snapshot is hand-maintained (no third-party snapshot crate to keep
228 /// dependencies tight) and intentionally exhaustive: matching on `et` with
229 /// no wildcard makes a new variant a compile error here, forcing the
230 /// author to add an entry. Renaming an existing variant changes the
231 /// `match` arm but not the wire string — which is the whole point of the
232 /// `#[serde(rename = "...")]` contract.
233 ///
234 /// **If this test fails:** either you renamed a wire string (BAD — bump
235 /// `SCHEMA_VERSION` and write an ADR) or you added a new variant (GOOD —
236 /// add it to the snapshot in `cortex.event.<snake_case>.v<N>` form).
237 #[test]
238 fn event_type_wire_strings_snapshot() {
239 let pairs: &[(EventType, &str)] = &[
240 (EventType::UserMessage, "cortex.event.user_message.v1"),
241 (EventType::AgentResponse, "cortex.event.agent_response.v1"),
242 (EventType::ToolCall, "cortex.event.tool_call.v1"),
243 (EventType::ToolResult, "cortex.event.tool_result.v1"),
244 (EventType::CodeDiff, "cortex.event.code_diff.v1"),
245 (EventType::TestResult, "cortex.event.test_result.v1"),
246 (EventType::Decision, "cortex.event.decision.v1"),
247 (EventType::Correction, "cortex.event.correction.v1"),
248 (EventType::Outcome, "cortex.event.outcome.v1"),
249 (EventType::SystemNote, "cortex.event.system_note.v1"),
250 ];
251
252 // Format invariant: every wire string is `cortex.event.<snake>.v<N>`.
253 let pat = regex_like(r"^cortex\.event\.[a-z][a-z0-9_]*\.v[0-9]+$");
254 for (et, wire) in pairs {
255 assert_eq!(et.wire_str(), *wire, "wire_str() vs snapshot for {et:?}");
256 let json = serde_json::to_value(et).unwrap();
257 assert_eq!(
258 json,
259 serde_json::Value::String((*wire).to_string()),
260 "serde wire string for {et:?}"
261 );
262 assert!(
263 pat(wire),
264 "wire string `{wire}` does not match `cortex.event.<snake>.v<N>`"
265 );
266 // Round-trip the wire string back to the variant.
267 let back: EventType = serde_json::from_value(json).unwrap();
268 assert_eq!(back, *et);
269 }
270
271 // Exhaustiveness: this `match` has no wildcard, so a new variant
272 // breaks the build until it's added to the snapshot above.
273 for (et, _) in pairs {
274 let _: () = match et {
275 EventType::UserMessage
276 | EventType::AgentResponse
277 | EventType::ToolCall
278 | EventType::ToolResult
279 | EventType::CodeDiff
280 | EventType::TestResult
281 | EventType::Decision
282 | EventType::Correction
283 | EventType::Outcome
284 | EventType::SystemNote => (),
285 };
286 }
287 }
288
289 /// Tiny dependency-free shape checker for the wire-string format. We do
290 /// not pull in `regex` for this single assertion.
291 fn regex_like(_pat: &'static str) -> impl Fn(&str) -> bool {
292 // Format we accept: `cortex.event.<snake>.v<N>` where:
293 // - prefix is literally `cortex.event.`
294 // - middle is `[a-z][a-z0-9_]*`
295 // - suffix is `.v` + one-or-more ASCII digits
296 |s: &str| -> bool {
297 let Some(rest) = s.strip_prefix("cortex.event.") else {
298 return false;
299 };
300 let Some((middle, version_tail)) = rest.rsplit_once(".v") else {
301 return false;
302 };
303 if version_tail.is_empty() || !version_tail.chars().all(|c| c.is_ascii_digit()) {
304 return false;
305 }
306 let mut chars = middle.chars();
307 match chars.next() {
308 Some(c) if c.is_ascii_lowercase() => {}
309 _ => return false,
310 }
311 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
312 }
313 }
314}