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}