Skip to main content

cortex_ledger/
trace.rs

1//! Trace assembly: open, attach event, close. Emits dense, monotonic ordinals.
2//!
3//! A `TraceAssembler` is the in-memory bookkeeping for one open trace. It
4//! tracks the trace id, the open status, and the next ordinal to assign.
5//! Attach returns the ordinal an event should be persisted with (in
6//! `trace_events.ordinal`, BUILD_SPEC ยง10).
7//!
8//! ## Ordinal invariants
9//!
10//! - The first attached event gets ordinal `0`.
11//! - Ordinals are **dense** (no gaps) and **strictly monotonic** within a
12//!   trace.
13//! - Closing a trace freezes its ordinals; further attaches return
14//!   `Err(TraceError::Closed)`.
15//!
16//! These match the `UNIQUE (trace_id, ordinal)` constraint in
17//! `trace_events`.
18//!
19//! ## What this module does NOT do
20//!
21//! - Persist anything. The caller composes a `TraceAssembler` with a
22//!   `JsonlLog` (and later, a `cortex-store` repository) to actually
23//!   write rows.
24//! - Compute hashes. Hash chaining is `crate::hash`'s job; the assembler
25//!   is purely about ordinal bookkeeping and trace lifecycle.
26
27use chrono::{DateTime, Utc};
28use cortex_core::{EventId, Trace, TraceId, TraceStatus, SCHEMA_VERSION};
29use thiserror::Error;
30
31/// Errors raised by [`TraceAssembler`].
32#[derive(Debug, Error, Clone, PartialEq, Eq)]
33pub enum TraceError {
34    /// Attempted to attach an event to a closed (sealed) trace.
35    #[error("trace {trace_id} is closed; cannot attach event {event_id}")]
36    Closed {
37        /// Trace identifier.
38        trace_id: TraceId,
39        /// Event identifier the caller tried to attach.
40        event_id: EventId,
41    },
42    /// Attempted to close a trace that was already closed.
43    #[error("trace {trace_id} is already closed")]
44    AlreadyClosed {
45        /// Trace identifier.
46        trace_id: TraceId,
47    },
48}
49
50/// In-memory assembler for one trace.
51///
52/// Construct via [`TraceAssembler::open`]. Attach events via
53/// [`TraceAssembler::attach`]. Seal via [`TraceAssembler::close`].
54#[derive(Debug, Clone)]
55pub struct TraceAssembler {
56    id: TraceId,
57    schema_version: u16,
58    opened_at: DateTime<Utc>,
59    closed_at: Option<DateTime<Utc>>,
60    trace_type: String,
61    status: TraceStatus,
62    /// Ordered event ids in the trace. Position is the ordinal.
63    event_ids: Vec<EventId>,
64}
65
66impl TraceAssembler {
67    /// Open a new trace with the given id, type tag, and open timestamp.
68    #[must_use]
69    pub fn open(id: TraceId, trace_type: impl Into<String>, opened_at: DateTime<Utc>) -> Self {
70        Self {
71            id,
72            schema_version: SCHEMA_VERSION,
73            opened_at,
74            closed_at: None,
75            trace_type: trace_type.into(),
76            status: TraceStatus::Open,
77            event_ids: Vec::new(),
78        }
79    }
80
81    /// Trace identifier.
82    #[must_use]
83    pub fn id(&self) -> TraceId {
84        self.id
85    }
86
87    /// Current status.
88    #[must_use]
89    pub fn status(&self) -> TraceStatus {
90        self.status
91    }
92
93    /// Number of events attached so far.
94    #[must_use]
95    pub fn len(&self) -> usize {
96        self.event_ids.len()
97    }
98
99    /// Whether this trace has zero events attached.
100    #[must_use]
101    pub fn is_empty(&self) -> bool {
102        self.event_ids.is_empty()
103    }
104
105    /// Attach an event to this trace and return its ordinal.
106    ///
107    /// Ordinals start at `0` and are strictly monotonic. Returns
108    /// [`TraceError::Closed`] if the trace is sealed.
109    pub fn attach(&mut self, event_id: EventId) -> Result<u64, TraceError> {
110        if !matches!(self.status, TraceStatus::Open) {
111            return Err(TraceError::Closed {
112                trace_id: self.id,
113                event_id,
114            });
115        }
116        let ordinal = self.event_ids.len() as u64;
117        self.event_ids.push(event_id);
118        Ok(ordinal)
119    }
120
121    /// Close (seal) the trace.
122    ///
123    /// Returns the persistent [`Trace`] row that should be written to the
124    /// store. Closing is idempotent on repeated calls *only* in the sense
125    /// that a second call returns `AlreadyClosed`; callers must take the
126    /// `Trace` from the first successful call.
127    pub fn close(&mut self, closed_at: DateTime<Utc>) -> Result<Trace, TraceError> {
128        if !matches!(self.status, TraceStatus::Open) {
129            return Err(TraceError::AlreadyClosed { trace_id: self.id });
130        }
131        self.status = TraceStatus::Closed;
132        self.closed_at = Some(closed_at);
133        Ok(self.snapshot())
134    }
135
136    /// Snapshot the current state as a [`Trace`] row (without sealing).
137    ///
138    /// Useful for dashboards or mid-flight inspection. The returned `Trace`
139    /// will reflect whatever `status` the assembler is currently in.
140    #[must_use]
141    pub fn snapshot(&self) -> Trace {
142        Trace {
143            id: self.id,
144            schema_version: self.schema_version,
145            opened_at: self.opened_at,
146            closed_at: self.closed_at,
147            event_ids: self.event_ids.clone(),
148            trace_type: self.trace_type.clone(),
149            status: self.status,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use chrono::TimeZone;
158
159    fn now() -> DateTime<Utc> {
160        Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap()
161    }
162
163    /// T-1.B.3 acceptance: ordinals are dense and monotonic.
164    #[test]
165    fn ordinals_are_dense_and_monotonic() {
166        let tid = TraceId::new();
167        let mut a = TraceAssembler::open(tid, "agent_run", now());
168
169        let mut ordinals = Vec::new();
170        for _ in 0..16 {
171            let e = EventId::new();
172            ordinals.push(a.attach(e).expect("open trace accepts attach"));
173        }
174
175        // Dense: ordinals are exactly 0..16.
176        assert_eq!(
177            ordinals,
178            (0u64..16).collect::<Vec<_>>(),
179            "ordinals must be dense"
180        );
181        // Monotonic strict: every next > prev.
182        for w in ordinals.windows(2) {
183            assert!(w[1] > w[0], "ordinals must be strictly monotonic");
184        }
185        assert_eq!(a.len(), 16);
186        assert_eq!(a.status(), TraceStatus::Open);
187    }
188
189    #[test]
190    fn close_seals_trace_and_blocks_further_attaches() {
191        let tid = TraceId::new();
192        let mut a = TraceAssembler::open(tid, "agent_run", now());
193        let e1 = EventId::new();
194        a.attach(e1).unwrap();
195
196        let trace = a.close(now()).expect("close succeeds");
197        assert_eq!(trace.status, TraceStatus::Closed);
198        assert_eq!(trace.event_ids, vec![e1]);
199        assert!(trace.closed_at.is_some());
200
201        // Subsequent attach is rejected.
202        let e2 = EventId::new();
203        let err = a.attach(e2).unwrap_err();
204        assert!(matches!(err, TraceError::Closed { .. }));
205
206        // Subsequent close is rejected.
207        let err = a.close(now()).unwrap_err();
208        assert!(matches!(err, TraceError::AlreadyClosed { .. }));
209    }
210
211    #[test]
212    fn empty_trace_closes_cleanly() {
213        let tid = TraceId::new();
214        let mut a = TraceAssembler::open(tid, "manual_session", now());
215        assert!(a.is_empty());
216        let trace = a.close(now()).unwrap();
217        assert!(trace.event_ids.is_empty());
218        assert_eq!(trace.status, TraceStatus::Closed);
219    }
220
221    #[test]
222    fn snapshot_reflects_open_state_without_sealing() {
223        let tid = TraceId::new();
224        let mut a = TraceAssembler::open(tid, "agent_run", now());
225        a.attach(EventId::new()).unwrap();
226        let snap = a.snapshot();
227        assert_eq!(snap.status, TraceStatus::Open);
228        assert!(snap.closed_at.is_none());
229        // Status on the assembler is unchanged.
230        assert_eq!(a.status(), TraceStatus::Open);
231    }
232}