Skip to main content

contextgraph_trace/
event.rs

1//! The trace event vocabulary (`docs/sketches/host-trace.md` §"The shape").
2//!
3//! One [`TraceEvent`] per journal line. The vocabulary is deliberately
4//! minimal — each event exists because an oracle in [`crate::oracle`] consumes
5//! it, and nothing else is recorded. Frames are named by
6//! [`FrameId`] and verify observations carry the wire [`Verdict`], so the
7//! journal reuses the protocol's own identity spine instead of inventing a
8//! parallel one. **No frame body ever travels in the journal** — identities
9//! and costs only, the same economy `context/verify` runs on.
10
11use contextgraph_types::{FrameId, Representation, Verdict};
12use serde::{Deserialize, Serialize};
13
14/// The trace-format identifier a recorder SHOULD stamp into
15/// [`EventBody::SessionStart::trace_format`], so an oracle suite can refuse a
16/// journal written to a vocabulary it does not understand.
17pub const TRACE_FORMAT: &str = "contextgraph-trace/0.1-sketch";
18
19/// One journal line: a dense sequence number, a timestamp in the protocol's
20/// RFC 3339 UTC profile (`SPEC.md` §F4), the session it belongs to, the open
21/// turn (when inside one), and the event body flattened alongside.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct TraceEvent {
24    /// Dense and strictly increasing from 1, continuing across a
25    /// crash-resume. Density is what makes "this journal is complete"
26    /// checkable at all.
27    pub seq: u64,
28    /// RFC 3339 UTC timestamp, same profile as frame temporal fields.
29    pub at: String,
30    /// The session this recording belongs to. One journal records one
31    /// session, including its resumes.
32    pub session: String,
33    /// The open turn's number. Present on `turn_start`/`turn_end` (naming the
34    /// turn they bound) and on every event inside a turn; absent between
35    /// turns.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub turn: Option<u64>,
38    #[serde(flatten)]
39    pub body: EventBody,
40}
41
42/// How a session ended — when it ended at all. A journal whose last event is
43/// not `session_end` records a crash, and that absence is load-bearing: the
44/// oracles treat dangling work before a [`EventBody::Resume`] as expected and
45/// the same work *replayed after* one as the defect.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum SessionOutcome {
49    /// The harness finished its task and tore down deliberately.
50    Completed,
51    /// The harness stopped deliberately without finishing (user interrupt,
52    /// budget exhaustion). Unresolved tool calls are permitted here.
53    Aborted,
54}
55
56/// The outcome of executing (or declining) one model-requested tool call.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ToolStatus {
60    Ok,
61    Error,
62    /// The harness declined to execute the call (permission gate, policy). A
63    /// rejected call needs no [`EventBody::ToolCall`] — declining is a
64    /// resolution, not an execution.
65    Rejected,
66}
67
68/// One frame at its point of use: the identity that names its exact bytes,
69/// what rendering it was given, what the harness declared it cost, and the
70/// label a human would see it cited under.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct RenderedFrame {
73    pub frame: FrameId,
74    /// How the frame was carried into the prompt. Absent ⇒ `full`, matching
75    /// the frame wire shape.
76    #[serde(default, skip_serializing_if = "Representation::is_full")]
77    pub representation: Representation,
78    /// The inline token cost the harness accounted for this frame at
79    /// assembly. A `reference` frame inlines nothing, so it MUST be 0.
80    pub token_cost: u32,
81    /// The citation label at the point of use — §F3's "never a bare uuid",
82    /// held where it actually matters: the prompt.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub citation_label: Option<String>,
85}
86
87/// The event bodies. Serialized internally tagged on `event` and flattened
88/// into the [`TraceEvent`] envelope, so a journal line reads
89/// `{"seq":5,…,"event":"tool_call","call_id":"call_1",…}`.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(tag = "event", rename_all = "snake_case")]
92pub enum EventBody {
93    /// The recording opens: which agent, under which harness, is being traced.
94    SessionStart {
95        /// The agent under trace (adapter-declared, e.g. `example-agent`).
96        agent: String,
97        /// The harness identity and version (e.g. `stella/0.9`).
98        harness: String,
99        /// The model in use, when the adapter knows it.
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        model: Option<String>,
102        /// The trace vocabulary this journal is written to — see
103        /// [`TRACE_FORMAT`].
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        trace_format: Option<String>,
106    },
107    /// The session tore down deliberately. A journal without one records a
108    /// crash.
109    SessionEnd { outcome: SessionOutcome },
110    /// The harness came back after a crash. `last_seq_seen` is the highest
111    /// `seq` the resumed harness actually recovered — the journal knows what
112    /// it recorded, so the delta between the two is *quantified* work loss.
113    /// A resume implicitly closes any open turn and orphans any unresolved
114    /// tool calls; resumed work starts a new turn.
115    Resume { last_seq_seen: u64 },
116    /// A turn opens. The envelope `turn` names it.
117    TurnStart,
118    /// The open turn closes. The envelope `turn` must match.
119    TurnEnd,
120    /// The harness composed a prompt and sent it. Recording assembly *as* the
121    /// model request is deliberate: the journal claims what was sent is what
122    /// was assembled, and every context guarantee is checked at this moment —
123    /// the point of use.
124    PromptAssembled {
125        /// The context budget the harness announced for this prompt, in
126        /// budget tokens (`SPEC.md` §7).
127        budget_tokens: u32,
128        /// The harness's own total of the rendered frame costs — checked
129        /// against the per-frame declarations, so the arithmetic can't drift
130        /// from the itemization.
131        declared_total_tokens: u64,
132        /// Digest of the composed context section (algorithm
133        /// recorder-declared, e.g. `sha256:<hex>`). Optional; when present,
134        /// an unchanged frame set MUST reproduce it byte-identically
135        /// (`docs/context-reuse.md` §1 prefix stability, finally checkable).
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        composition_digest: Option<String>,
138        /// The frames rendered into this prompt, in composition order.
139        frames: Vec<RenderedFrame>,
140    },
141    /// The model answered, requesting zero or more tool calls by id. The ids
142    /// are the loop's contract: each must be resolved exactly once before the
143    /// next `prompt_assembled`.
144    ModelResponse {
145        #[serde(default, skip_serializing_if = "Vec::is_empty")]
146        tool_calls: Vec<String>,
147    },
148    /// The harness began executing a model-requested call. Executing a call
149    /// the model never requested is the phantom-execution defect.
150    ToolCall { call_id: String, tool: String },
151    /// A requested call was resolved — executed to completion, errored, or
152    /// declined ([`ToolStatus::Rejected`]).
153    ToolResult { call_id: String, status: ToolStatus },
154    /// The host observed a `context/verify` answer for a frame it holds
155    /// (`docs/context-reuse.md` §4). Rendering the same identity after a
156    /// `stale`/`gone` verdict is the citing-dead-evidence defect.
157    VerifyObserved { frame: FrameId, verdict: Verdict },
158    /// The harness performed an externally visible action (file write,
159    /// network call, command). `effect_id` names an *intended-once* effect: a
160    /// deliberate re-execution is a new id, so the same id twice is the
161    /// crash-replay bug by construction.
162    SideEffect {
163        effect_id: String,
164        /// e.g. `file_write`, `network`, `command`.
165        kind: String,
166        /// The tool call this effect was performed under, when there is one.
167        #[serde(default, skip_serializing_if = "Option::is_none")]
168        call_id: Option<String>,
169    },
170}
171
172impl EventBody {
173    /// The event's wire name, for evidence strings and log lines.
174    pub fn kind(&self) -> &'static str {
175        match self {
176            EventBody::SessionStart { .. } => "session_start",
177            EventBody::SessionEnd { .. } => "session_end",
178            EventBody::Resume { .. } => "resume",
179            EventBody::TurnStart => "turn_start",
180            EventBody::TurnEnd => "turn_end",
181            EventBody::PromptAssembled { .. } => "prompt_assembled",
182            EventBody::ModelResponse { .. } => "model_response",
183            EventBody::ToolCall { .. } => "tool_call",
184            EventBody::ToolResult { .. } => "tool_result",
185            EventBody::VerifyObserved { .. } => "verify_observed",
186            EventBody::SideEffect { .. } => "side_effect",
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn a_journal_line_roundtrips_with_the_body_flattened() {
197        let event = TraceEvent {
198            seq: 5,
199            at: "2026-07-23T09:00:10Z".into(),
200            session: "sess_1".into(),
201            turn: Some(1),
202            body: EventBody::ToolCall {
203                call_id: "call_1".into(),
204                tool: "write_file".into(),
205            },
206        };
207        let json = serde_json::to_string(&event).unwrap();
208        // Flattened: the body's fields sit beside the envelope's, tagged by
209        // `event` — one flat object per line, greppable by field name.
210        assert!(json.contains("\"event\":\"tool_call\""));
211        assert!(json.contains("\"call_id\":\"call_1\""));
212        assert!(!json.contains("\"body\""));
213        let back: TraceEvent = serde_json::from_str(&json).unwrap();
214        assert_eq!(back, event);
215    }
216
217    #[test]
218    fn absent_turn_and_optional_fields_are_omitted_not_null() {
219        let event = TraceEvent {
220            seq: 1,
221            at: "2026-07-23T09:00:00Z".into(),
222            session: "sess_1".into(),
223            turn: None,
224            body: EventBody::SessionStart {
225                agent: "example-agent".into(),
226                harness: "stella/0.9".into(),
227                model: None,
228                trace_format: Some(TRACE_FORMAT.into()),
229            },
230        };
231        let json = serde_json::to_string(&event).unwrap();
232        assert!(!json.contains("\"turn\""));
233        assert!(!json.contains("\"model\""));
234        assert!(json.contains(TRACE_FORMAT));
235    }
236
237    #[test]
238    fn a_rendered_full_frame_omits_representation_like_the_frame_wire_shape() {
239        let rendered = RenderedFrame {
240            frame: FrameId::new("docs", "frm_1", Some("sha256:9f2c".into())),
241            representation: Representation::Full,
242            token_cost: 120,
243            citation_label: Some("workspace.ts L120-160".into()),
244        };
245        let json = serde_json::to_string(&rendered).unwrap();
246        assert!(!json.contains("representation"));
247        let back: RenderedFrame = serde_json::from_str(&json).unwrap();
248        assert_eq!(back, rendered);
249    }
250
251    #[test]
252    fn verify_observed_carries_the_wire_verdict_shape() {
253        let event = TraceEvent {
254            seq: 9,
255            at: "2026-07-23T09:00:14Z".into(),
256            session: "sess_1".into(),
257            turn: None,
258            body: EventBody::VerifyObserved {
259                frame: FrameId::new("docs", "frm_1", Some("sha256:9f2c".into())),
260                verdict: Verdict::Stale {
261                    replacement_digest: None,
262                },
263            },
264        };
265        let json = serde_json::to_string(&event).unwrap();
266        // The verdict serializes exactly as `context/verify` answers do —
267        // `{"status":"stale"}` — so an adapter can copy it straight through.
268        assert!(json.contains("\"verdict\":{\"status\":\"stale\"}"));
269        let back: TraceEvent = serde_json::from_str(&json).unwrap();
270        assert_eq!(back, event);
271    }
272}