Skip to main content

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}