crtx-core 0.1.1

Core IDs, errors, and schema constants for Cortex.
Documentation
//! Immutable observed-fact events.
//!
//! Mirrors BUILD_SPEC §9.1. The struct is the on-disk and on-wire shape:
//! `serde` field order matches the spec, and rename-style attributes are
//! stable identifiers that **may not be silently changed** without a
//! [`crate::SCHEMA_VERSION`] bump and an ADR (see [`crate::version`]).
//!
//! ## Stable wire strings
//!
//! [`EventType`] serializes as `cortex.event.<snake_case>.v<N>` strings
//! (e.g. `cortex.event.user_message.v1`). These strings are **part of the
//! public contract**: renaming a Rust variant must NOT change the wire
//! string. The snapshot test in this module pins every variant's wire
//! string and fails if a rename leaks through.
//!
//! Doctrine reference:
//! [`.doctrine/principles/event-contracts.md`](../../../.doctrine/principles/event-contracts.md) §3.
//!
//! ## What this version (v1) does NOT include
//!
//! - `Attestation` (from ADR 0014) — deliberately deferred to schema v2.
//! - `payload_hash` is a `String` here; the actual BLAKE3 framing lives in
//!   `cortex-ledger` (lane 1.B). `cortex-core` only owns the typed shape.

use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::ids::{EventId, TraceId};

/// Where an event originated.
///
/// Carries internal data on a per-variant basis. Serialized as an internally
/// tagged enum (`{"type": "...", ...}`) so adding fields to a variant is a
/// non-breaking change at the JSON level.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EventSource {
    /// A human user.
    User,
    /// An ephemeral child agent invocation.
    ChildAgent {
        /// Model identifier (e.g. `claude-3.5-sonnet`, `llama3.1:8b`).
        model: String,
    },
    /// A tool invocation result.
    Tool {
        /// Tool name (free-form; matches the runtime registry).
        name: String,
    },
    /// The Cortex runtime itself (system events, lifecycle markers).
    Runtime,
    /// An externally-observed outcome (CI result, deployment, user rating).
    ExternalOutcome,
    /// An explicit operator correction.
    ManualCorrection,
}

/// What kind of event was observed.
///
/// Each variant serializes to a stable, versioned wire string of the form
/// `cortex.event.<snake_case>.v<N>` (see module docs). Changing a wire
/// string requires a [`crate::SCHEMA_VERSION`] bump.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum EventType {
    /// User authored a message.
    #[serde(rename = "cortex.event.user_message.v1")]
    UserMessage,
    /// An agent emitted a response.
    #[serde(rename = "cortex.event.agent_response.v1")]
    AgentResponse,
    /// An agent invoked a tool.
    #[serde(rename = "cortex.event.tool_call.v1")]
    ToolCall,
    /// A tool returned a result (or error).
    #[serde(rename = "cortex.event.tool_result.v1")]
    ToolResult,
    /// A code diff was produced or applied.
    #[serde(rename = "cortex.event.code_diff.v1")]
    CodeDiff,
    /// A test run produced a result.
    #[serde(rename = "cortex.event.test_result.v1")]
    TestResult,
    /// A typed decision was recorded (e.g. a policy gate firing).
    #[serde(rename = "cortex.event.decision.v1")]
    Decision,
    /// An explicit correction event (immutability-preserving fixup).
    #[serde(rename = "cortex.event.correction.v1")]
    Correction,
    /// An externally observed outcome (validation signal).
    #[serde(rename = "cortex.event.outcome.v1")]
    Outcome,
    /// A system note from the Cortex runtime.
    #[serde(rename = "cortex.event.system_note.v1")]
    SystemNote,
}

impl EventType {
    /// Canonical wire string for this variant.
    ///
    /// Cheaper and more obvious than `serde_json::to_value(&v)` when you just
    /// want the identifier (e.g. for log lines or table rows).
    #[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",
        }
    }
}

/// An immutable observed fact in the Cortex ledger.
///
/// Field order is the BUILD_SPEC §9.1 wire order; do not reorder without a
/// schema bump.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Event {
    /// Stable identifier (also the dedup key for re-ingest).
    pub id: EventId,
    /// Schema version this row was written under.
    pub schema_version: u16,
    /// When the event was observed in the world.
    pub observed_at: DateTime<Utc>,
    /// When Cortex recorded the event (typically `>= observed_at`).
    pub recorded_at: DateTime<Utc>,
    /// Where the event came from.
    pub source: EventSource,
    /// What kind of event this is.
    pub event_type: EventType,
    /// Trace this event belongs to, if any.
    pub trace_id: Option<TraceId>,
    /// Session identifier (free-form; not modeled as a typed ID at this layer).
    pub session_id: Option<String>,
    /// Free-form domain tags (`agents`, `security`, …).
    pub domain_tags: Vec<String>,
    /// Structured payload. Schema is event-type-specific and not validated
    /// at this layer; downstream consumers may run typed validation.
    pub payload: serde_json::Value,
    /// Hex-encoded BLAKE3 hash of the canonical payload encoding.
    /// Computed by `cortex-ledger`; opaque here.
    pub payload_hash: String,
    /// Hex-encoded `event_hash` of the previous event in the chain (if any).
    pub prev_event_hash: Option<String>,
    /// Hex-encoded `event_hash` of this event.
    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);

        // Wire shape sanity: top-level keys are present in spec order. We don't
        // assert exact ordering (serde_json::Map is BTreeMap-ish with arbitrary
        // ordering depending on features), but we do assert presence.
        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}`");
        }

        // event_type serializes as the wire string.
        assert_eq!(
            obj["event_type"],
            serde_json::json!("cortex.event.agent_response.v1")
        );

        // EventSource serializes as an internally-tagged object.
        assert_eq!(obj["source"]["type"], serde_json::json!("child_agent"));
        assert_eq!(
            obj["source"]["model"],
            serde_json::json!("claude-3.5-sonnet")
        );
    }

    /// Snapshot of the wire string for every `EventType` variant.
    ///
    /// The snapshot is hand-maintained (no third-party snapshot crate to keep
    /// dependencies tight) and intentionally exhaustive: matching on `et` with
    /// no wildcard makes a new variant a compile error here, forcing the
    /// author to add an entry. Renaming an existing variant changes the
    /// `match` arm but not the wire string — which is the whole point of the
    /// `#[serde(rename = "...")]` contract.
    ///
    /// **If this test fails:** either you renamed a wire string (BAD — bump
    /// `SCHEMA_VERSION` and write an ADR) or you added a new variant (GOOD —
    /// add it to the snapshot in `cortex.event.<snake_case>.v<N>` form).
    #[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"),
        ];

        // Format invariant: every wire string is `cortex.event.<snake>.v<N>`.
        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>`"
            );
            // Round-trip the wire string back to the variant.
            let back: EventType = serde_json::from_value(json).unwrap();
            assert_eq!(back, *et);
        }

        // Exhaustiveness: this `match` has no wildcard, so a new variant
        // breaks the build until it's added to the snapshot above.
        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 => (),
            };
        }
    }

    /// Tiny dependency-free shape checker for the wire-string format. We do
    /// not pull in `regex` for this single assertion.
    fn regex_like(_pat: &'static str) -> impl Fn(&str) -> bool {
        // Format we accept: `cortex.event.<snake>.v<N>` where:
        //   - prefix is literally `cortex.event.`
        //   - middle is `[a-z][a-z0-9_]*`
        //   - suffix is `.v` + one-or-more ASCII digits
        |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 == '_')
        }
    }
}