salvor_replay/event.rs
1//! The event vocabulary: the envelope every event is wrapped in, the payload
2//! enum that names what happened, and the small supporting types a few
3//! payloads carry.
4//!
5//! Everything here is pure data. No constructor reads the clock, draws
6//! randomness, or performs IO: the recorded timestamp and any identity are
7//! passed in by the caller. That purity is load-bearing, because these types
8//! live in the IO-free `salvor-replay` crate that the runtime and the v0.3
9//! browser inspector both fold events with.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15
16use crate::effect::Effect;
17use crate::id::{RunId, SequenceNumber};
18
19/// The schema version stamped onto every serialized event.
20///
21/// Present from the first event ever written, so an old log is always
22/// self-describing and a future reader can branch on it. Start at 1.
23///
24/// # Why adding event variants does not bump this
25///
26/// `schema_version` exists so a reader knows how to interpret events that are
27/// already on disk. Adding a variant to [`Event`] changes nothing about how
28/// any previously written event is encoded or understood: a log written
29/// before the addition contains none of the new kinds, and every event in it
30/// parses to the identical value under the new build. The
31/// deterministic-context events ([`Event::NowObserved`] and
32/// [`Event::RandomObserved`]) were added this way and the version stayed 1.
33///
34/// A bump is reserved for changes that alter the meaning or shape of events a
35/// version-1 writer may have already produced: renaming a field, changing the
36/// envelope, or re-encoding a payload. An older binary cannot read a log that
37/// contains the newer variants, but that direction is not part of the
38/// contract: the store is embedded, so the reader always upgrades together
39/// with the binary that owns the log.
40///
41/// # Why an additive optional field does not bump this either
42///
43/// The optional `request_body` on [`Event::ModelCallRequested`] follows the
44/// same rule. It carries `#[serde(default, skip_serializing_if =
45/// "Option::is_none")]`, so with recording off (the default) the field is
46/// omitted from the wire form entirely and the event serializes byte for byte
47/// as it did before the field existed. An old log, written before the field,
48/// deserializes with the field defaulted to `None`. An older reader that meets
49/// a log where the field *is* present ignores the unknown `request_body` key.
50/// So no version-1 event changes shape or meaning, and the version stays 1.
51///
52/// The optional `labels` on [`Event::RunStarted`] is the identical contract:
53/// `#[serde(default, skip_serializing_if = "Option::is_none")]`, absent by
54/// default, so an unlabeled run's `RunStarted` serializes byte for byte as it
55/// did before the field existed, and the version stays 1 for the same reason.
56///
57/// # Why the graph events do not bump this
58///
59/// The graph-run events ([`Event::GraphRunStarted`] and the node/branch/map/fold
60/// markers) are new variants, added the same read-compatible way the
61/// deterministic-context events were: a log written before them contains none
62/// of the new kinds, so every event in it parses to the identical value under
63/// the new build. The fold markers ([`Event::FoldIterationStarted`],
64/// [`Event::FoldIterationJoined`], and [`Event::FoldConverged`]) were added
65/// last, the same way and for the same reason, and the version stayed 1. [`Event::GraphRunStarted`]'s own `labels` and `forked_from`
66/// are additive-optional under the same
67/// `#[serde(default, skip_serializing_if = "Option::is_none")]` contract, so a
68/// graph run that is neither labeled nor forked omits both keys entirely.
69/// [`Event::GraphRunStarted`] is a separate variant rather than a field on
70/// [`Event::RunStarted`] because `RunStarted`'s `agent_def_hash` is required and
71/// names one agent, while a graph run has many agent hashes and none at its
72/// head; folding the two into one variant would have forced `agent_def_hash`
73/// optional and changed the existing `RunStarted` bytes, which this discipline
74/// forbids.
75pub const SCHEMA_VERSION: u32 = 1;
76
77/// One record in a run's append-only log.
78///
79/// The envelope carries run identity, the event's position in the log, the
80/// schema version, when it was recorded, and the payload that says what
81/// happened. Serializing an envelope always includes `schema_version`, so the
82/// wire form is self-describing.
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84pub struct EventEnvelope {
85 /// The run this event belongs to.
86 pub run_id: RunId,
87 /// This event's monotonic position in the run's log.
88 pub seq: SequenceNumber,
89 /// The event schema version. Always [`SCHEMA_VERSION`] for events this
90 /// build writes; older values may appear when reading an old log.
91 pub schema_version: u32,
92 /// When the event was recorded, as an RFC 3339 timestamp on the wire.
93 ///
94 /// Passed in by the caller. Nothing in this crate reads the clock.
95 #[serde(with = "time::serde::rfc3339")]
96 pub recorded_at: OffsetDateTime,
97 /// What happened.
98 pub event: Event,
99}
100
101impl EventEnvelope {
102 /// Wraps a payload with its run identity, log position, and recorded
103 /// timestamp, stamping the current [`SCHEMA_VERSION`].
104 ///
105 /// The timestamp is a parameter on purpose: this constructor never reads
106 /// the clock, so it stays usable from the pure replay path.
107 #[must_use]
108 pub const fn new(
109 run_id: RunId,
110 seq: SequenceNumber,
111 recorded_at: OffsetDateTime,
112 event: Event,
113 ) -> Self {
114 Self {
115 run_id,
116 seq,
117 schema_version: SCHEMA_VERSION,
118 recorded_at,
119 event,
120 }
121 }
122}
123
124/// Everything that can happen in a run.
125///
126/// Adjacently tagged: each event serializes as `{"kind": "...", "payload":
127/// {...}}`. The tag (`kind`) and the content (`payload`) live in separate
128/// keys, which is the deliberate choice for a durable format. It never
129/// collides with a payload field (a payload could legitimately contain a
130/// field named `kind`), and it does not constrain payloads to be JSON objects
131/// the way internal tagging would. The wire shape is a durability contract, so
132/// it is spelled out rather than left to a default.
133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
134#[serde(tag = "kind", content = "payload")]
135pub enum Event {
136 /// A run began. Records the hash of the agent definition it ran under and
137 /// the input it started with.
138 RunStarted {
139 /// Content hash of the agent definition (model, prompt, tools,
140 /// budget) this run executed.
141 agent_def_hash: String,
142 /// The input the run started with.
143 input: serde_json::Value,
144 /// Optional operator-supplied correlation tags for the run (for
145 /// example a build id or environment), set once at creation and
146 /// never rewritten. `BTreeMap` so the wire form serializes with keys
147 /// in sorted order regardless of insertion order, matching the
148 /// deterministic-serialization discipline the rest of this crate
149 /// holds to (see `salvor_runtime::hash::canonical_json`, which this
150 /// field is deliberately never fed into: labels are a tag, not part
151 /// of the run's identity, and never enter `agent_def_hash` or
152 /// `request_hash`).
153 ///
154 /// Absent by default. `#[serde(default, skip_serializing_if =
155 /// "Option::is_none")]` follows the identical additive contract
156 /// `request_body` on [`Event::ModelCallRequested`] set: with no
157 /// labels supplied at creation, this field is omitted from the wire
158 /// form entirely, so an unlabeled run's `RunStarted` serializes byte
159 /// for byte as it did before this field existed. Sanity bounds (at
160 /// most 16 labels, keys under 64 bytes, values under 256 bytes) are
161 /// enforced where a run is created, never here: a log already on
162 /// disk is trusted and replayed as recorded, whatever it holds.
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 labels: Option<BTreeMap<String, String>>,
165 },
166 /// A model call was requested. Records the correlating sequence number and
167 /// the hash of the request, so a later completion can be matched to it.
168 ModelCallRequested {
169 /// Correlates this request with its [`Event::ModelCallCompleted`].
170 seq: SequenceNumber,
171 /// Content hash of the request sent to the model. This is the sole
172 /// replay-correlation key for the call; it is computed the same way
173 /// whether or not `request_body` is recorded, and the body never feeds
174 /// into it.
175 request_hash: String,
176 /// The full model request body, verbatim, recorded only when prompt
177 /// recording is opted into (per-agent `record_prompts` or the
178 /// `SALVOR_RECORD_PROMPTS` default). It exists so the inspector can
179 /// show the exact prompt sent.
180 ///
181 /// Off by default, and for a reason: the body can hold user data and
182 /// secrets. When recording is off the field is `None` and, thanks to
183 /// `skip_serializing_if`, is omitted from the wire form, so the event
184 /// serializes byte for byte as it did before this field existed. The
185 /// body is purely informational: replay correlates on `request_hash`
186 /// alone and ignores whatever is (or is not) recorded here, so a log
187 /// captured with bodies replays identically to one captured without.
188 // A future redaction pass, if one is ever built, would belong here at
189 // the recording edge, transforming the value before it is stored. No
190 // such transform exists today; recording is all-or-nothing.
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 request_body: Option<serde_json::Value>,
193 },
194 /// A model call completed. This is the captured nondeterministic boundary:
195 /// once recorded, replay reads the response from here and never calls the
196 /// model again.
197 ModelCallCompleted {
198 /// Correlates this completion with its [`Event::ModelCallRequested`].
199 seq: SequenceNumber,
200 /// The model response, stored inline.
201 ///
202 /// Inline is the deliberate choice: the full response
203 /// lives in the log. Large multimodal outputs may later force a
204 /// content-addressed blob store, at which point this field becomes the
205 /// seam, holding a blob reference instead of the response itself.
206 response: serde_json::Value,
207 /// Token usage reported for the call.
208 usage: TokenUsage,
209 },
210 /// A tool call was requested. Records intent before execution, which is
211 /// what lets an unrecorded-but-attempted [`Effect::Write`] be detected on
212 /// resume.
213 ToolCallRequested {
214 /// Correlates this request with its [`Event::ToolCallCompleted`].
215 seq: SequenceNumber,
216 /// The tool's name.
217 tool: String,
218 /// The typed input passed to the tool.
219 input: serde_json::Value,
220 /// The tool's declared side-effect class, which governs retry and
221 /// resume behavior for a call that did not complete.
222 effect: Effect,
223 /// The idempotency key for this attempt, when the tool has one.
224 ///
225 /// A [`Effect::Read`] call needs none; an [`Effect::Idempotent`] retry
226 /// reuses this exact key so the provider collapses duplicates.
227 idempotency_key: Option<String>,
228 },
229 /// A tool call completed. Once recorded, replay reads the output from here
230 /// and never calls the tool again, whatever its effect class.
231 ToolCallCompleted {
232 /// Correlates this completion with its [`Event::ToolCallRequested`].
233 seq: SequenceNumber,
234 /// The tool's output.
235 output: serde_json::Value,
236 },
237 /// The value `ctx.now()` returned, captured once while the run executed
238 /// live. On replay the recorded value is returned again, bit for bit; the
239 /// clock is never consulted a second time. This is how orchestration code
240 /// gets to observe time without breaking the determinism constraint.
241 ///
242 /// # Wire compatibility
243 ///
244 /// This variant (together with [`Event::RandomObserved`]) was added after
245 /// the original ten. Adding variants is a read-compatible change, so
246 /// [`SCHEMA_VERSION`] stayed at 1; the constant's docs carry the full
247 /// argument.
248 NowObserved {
249 /// The observed time. RFC 3339 on the wire with nanosecond
250 /// precision, so the value replays exactly as recorded.
251 #[serde(with = "time::serde::rfc3339")]
252 now: OffsetDateTime,
253 },
254 /// The value `ctx.random()` returned, captured once while the run
255 /// executed live. On replay the recorded value is returned again, bit for
256 /// bit; the random source is never consulted a second time.
257 RandomObserved {
258 /// Sixty-four raw bits from the runtime's random source.
259 ///
260 /// A `u64` is the deliberate representation: JSON integers carry the
261 /// full 64-bit range exactly, so replay returns the identical bits.
262 /// Richer values (a float in a range, a choice from a list) must be
263 /// derived from these bits deterministically by the caller, never
264 /// drawn fresh.
265 value: u64,
266 },
267 /// The run parked durably, awaiting input (for example, human approval).
268 /// Records why and the schema the resume input must satisfy.
269 Suspended {
270 /// Why the run suspended.
271 reason: String,
272 /// JSON Schema the [`Event::Resumed`] input is validated against.
273 input_schema: serde_json::Value,
274 },
275 /// A suspended run resumed with the given input.
276 Resumed {
277 /// The input supplied on resume.
278 input: serde_json::Value,
279 },
280 /// A declared budget was exceeded. The run suspends rather than dies, so a
281 /// human can raise the limit and resume.
282 BudgetExceeded {
283 /// The budget dimension and the limit that was crossed.
284 budget: Budget,
285 /// The observed value, interpreted in the units of `budget.kind`. An
286 /// `f64` for the same reason [`Budget::limit`] is: exact for integral
287 /// token and step counts up to 2^53, and inherently fractional for
288 /// cost and wall time.
289 observed: f64,
290 },
291 /// The run finished successfully with this output.
292 RunCompleted {
293 /// The run's final output.
294 output: serde_json::Value,
295 },
296 /// The run terminated with an error.
297 RunFailed {
298 /// A description of the failure.
299 error: String,
300 },
301 /// The run was abandoned by an operator: deliberately retired without ever
302 /// finishing or failing. A terminal event, appended by hand through the
303 /// server's abandon endpoint, never emitted by orchestration.
304 ///
305 /// # Abandonment is not failure
306 ///
307 /// This is a separate terminal from [`Event::RunFailed`] on purpose. A
308 /// failure says the run tried to continue and could not; an abandonment
309 /// says a human decided it should stop mattering (a husk that is dead
310 /// forever, or a run whose noise is no longer worth carrying in the
311 /// inbox). The two read differently everywhere downstream — the fold gives
312 /// abandonment its own status, and the surfaces treat it as a muted
313 /// resting state, never the failure ink. Keeping [`Event::RunFailed`]
314 /// untouched is the point: its recorded meaning must not shift.
315 ///
316 /// # Wire compatibility
317 ///
318 /// Added the same read-compatible way the deterministic-context and graph
319 /// events were: a new variant, so a log written before it contains none of
320 /// the kind and every event in it parses to the identical value under the
321 /// new build. [`SCHEMA_VERSION`] stays 1; the constant's docs carry the
322 /// full argument. Both fields are additive-optional under the identical
323 /// `#[serde(default, skip_serializing_if = "Option::is_none")]` contract
324 /// the other optional payloads hold to, so a bare abandonment (no reason,
325 /// no dangling write) serializes with an empty payload object:
326 /// `{"kind":"RunAbandoned","payload":{}}`.
327 RunAbandoned {
328 /// The operator's optional note for why the run was abandoned.
329 /// Absent by default: an abandonment with no reason omits the key.
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 reason: Option<String>,
332 /// Set only when the abandoned run was parked at a dangling write
333 /// (status `NeedsReconciliation`): the outstanding write intent's
334 /// position and tool, recorded as evidence.
335 ///
336 /// The abandonment never claims the write question was answered.
337 /// Abandoning a needs-reconciliation run is allowed precisely because
338 /// this field carries the honesty forward: the write may or may not
339 /// have taken effect, and the record says so by naming the intent that
340 /// was left unsettled rather than pretending a completion. Absent for
341 /// any run abandoned from a state with no dangling write, under the
342 /// same additive-optional contract as `reason`.
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 unresolved_write: Option<UnresolvedWrite>,
345 },
346 /// A graph run began. The head of a run that executes a graph document
347 /// rather than a single agent loop.
348 ///
349 /// A separate variant from [`Event::RunStarted`], not a field on it:
350 /// `RunStarted` requires exactly one `agent_def_hash`, but a graph run has
351 /// many agent hashes (one per agent node) and none at its head. It records
352 /// the hash of the frozen graph document and the run's input; the node,
353 /// branch, and map markers below then narrate the walk. Downstream of this
354 /// crate a `graph_hash` is `sha256:<64 lowercase hex>` over the canonical
355 /// graph document, but this crate treats it as an opaque string and never
356 /// depends on `salvor-graph` to interpret it.
357 GraphRunStarted {
358 /// Content hash of the frozen graph document this run executes. Opaque
359 /// here; the runtime forms and checks it against `salvor-graph`.
360 graph_hash: String,
361 /// The input the graph run started with.
362 input: serde_json::Value,
363 /// Optional operator-supplied correlation tags, carried for parity
364 /// with [`Event::RunStarted::labels`]: grouping (a build id, an
365 /// environment) matters for graph runs exactly as it does for agent
366 /// runs. Same additive-optional contract as that field — absent by
367 /// default via `#[serde(default, skip_serializing_if =
368 /// "Option::is_none")]`, so an unlabeled graph run omits the key
369 /// entirely. A tag, never part of identity: never fed into any hash.
370 /// Sanity bounds are enforced where a run is created, never here; a log
371 /// on disk is replayed as recorded.
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 labels: Option<BTreeMap<String, String>>,
374 /// Set only when this run is a fork of an earlier run: the recorded
375 /// link back to its origin (see [`ForkOrigin`]). A fork is a new run
376 /// whose log opens with the origin's prefix rewritten under the new
377 /// id, and this field at seq 0 is the durable fact that it descends
378 /// from that origin. Absent by default under the same additive-optional
379 /// contract, so an ordinary (non-forked) graph run omits the key.
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 forked_from: Option<ForkOrigin>,
382 },
383 /// Execution entered a graph node. Marks the node as the run's current
384 /// position; a later [`Event::NodeExited`] closes it.
385 NodeEntered {
386 /// The id of the node entered, unique within the graph document.
387 node: String,
388 },
389 /// Execution left a graph node, having produced its output.
390 NodeExited {
391 /// The id of the node exited.
392 node: String,
393 },
394 /// A graph node was skipped: reached on the walk but deliberately not run
395 /// (for example, a branch case that did not fire routes past its node). A
396 /// skipped node WAS reached — it is recorded precisely so a projection can
397 /// tell "skipped" apart from "never reached", which is the absence of any
398 /// event naming the node.
399 NodeSkipped {
400 /// The id of the node skipped.
401 node: String,
402 /// Why it was skipped, recorded for the audit trail.
403 reason: String,
404 },
405 /// A branch node routed: the named case fired. This is the sole recorded
406 /// authority for which way a branch went. The executed path is read from
407 /// these events, never inferred from which sibling nodes were skipped.
408 BranchTaken {
409 /// The id of the branch node that routed.
410 node: String,
411 /// The name of the case that fired, matching a branch-case name in the
412 /// graph document (realized by the like-named edge). Opaque here.
413 case: String,
414 },
415 /// A map node fanned out: the resolved list of items to map over was
416 /// determined and recorded. Recording the items here (rather than
417 /// re-resolving on replay) is what makes the fan-out deterministic — the
418 /// per-iteration child ids are derived from this recorded data.
419 MapFannedOut {
420 /// The id of the map node.
421 node: String,
422 /// The resolved list of items, one sub-run per element. Recorded
423 /// verbatim so replay reproduces the same fan-out.
424 items: serde_json::Value,
425 },
426 /// One iteration of a map fan-out started, as a child run. The child's id
427 /// is derived deterministically from recorded data (the parent run, the
428 /// node, and the index), so replay reconstructs the identical id without
429 /// drawing a fresh one.
430 MapIterationStarted {
431 /// The id of the map node this iteration belongs to.
432 node: String,
433 /// The zero-based position of this iteration in the fanned-out list.
434 index: u64,
435 /// The derived id of the child run executing this iteration.
436 child_run: String,
437 },
438 /// One iteration of a map fan-out joined back: its child run's result was
439 /// folded into the map node's output. Joins are recorded in index order,
440 /// never completion order, so the concurrency of the fan-out never
441 /// influences the parent log's byte sequence.
442 MapIterationJoined {
443 /// The id of the map node this iteration belongs to.
444 node: String,
445 /// The zero-based position of the iteration that joined.
446 index: u64,
447 },
448 /// A fold node began one bounded iteration: a single revision pass of the
449 /// accumulate-and-refine loop the node models. A fold's passes run
450 /// sequentially in the one log (never as child runs, unlike a map's
451 /// iterations), so `index` is both the pass position and its recorded
452 /// order.
453 FoldIterationStarted {
454 /// The id of the fold node this iteration belongs to.
455 node: String,
456 /// The zero-based position of this pass in the fold loop.
457 index: u64,
458 },
459 /// A fold iteration joined back: its pass result was folded into the fold
460 /// node's accumulated value. Recorded in index order, exactly like
461 /// [`Event::MapIterationJoined`]; because a fold's passes are sequential,
462 /// index order already is completion order.
463 FoldIterationJoined {
464 /// The id of the fold node this iteration belongs to.
465 node: String,
466 /// The zero-based position of the iteration that joined.
467 index: u64,
468 },
469 /// A fold node settled: its loop stopped and its `join` rule selected the
470 /// winning iteration. This is the sole recorded authority for WHICH pass
471 /// the fold's output came from — the argmax of a `best_by` join is read
472 /// from `winner_index`, never inferred from the iteration order — exactly
473 /// as [`Event::BranchTaken`] is the sole authority for a branch's route.
474 /// `reason` records WHY the loop ended (its stop predicate fired, the
475 /// iteration bound was reached, or a pass failed to improve), an opaque
476 /// audit string like [`Event::NodeSkipped`]'s.
477 FoldConverged {
478 /// The id of the fold node that settled.
479 node: String,
480 /// The zero-based index of the iteration whose value the `join` rule
481 /// selected as the fold's output.
482 winner_index: u64,
483 /// Why the loop ended, recorded for the audit trail.
484 reason: String,
485 },
486}
487
488/// The recorded link from a forked run back to the run it forked from.
489///
490/// A fork is a new run, never a mutation of the origin: its log opens with the
491/// origin's prefix (every event with `seq` below the fork boundary) rewritten
492/// under the fork's own run id, and [`ForkOrigin`] rides on the fork's
493/// [`Event::GraphRunStarted`] at seq 0 as the durable fact of that descent. The
494/// origin is immutable and never points forward at its forks; "forks of this
495/// run" is a derived server-side index over this field, not something the
496/// origin records.
497///
498/// Carries no floats, so it derives [`Eq`] (unlike [`Budget`]); every field is
499/// plain recorded data.
500#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
501pub struct ForkOrigin {
502 /// The run this fork descends from.
503 pub run_id: RunId,
504 /// The boundary the prefix was taken through: the fork's log carries the
505 /// origin's events with `seq` below the [`Event::NodeEntered`] that the
506 /// fork restarts from. A genuine log position, so it rides as a
507 /// [`SequenceNumber`].
508 pub through_seq: SequenceNumber,
509 /// The id of the node the fork restarts execution from.
510 pub from_node: String,
511 /// The origin's graph hash, which the fork must reuse unchanged. A fork may
512 /// not edit the graph: a different document could route the shared prefix
513 /// differently, turning a clean refusal into an arbitrary mid-replay
514 /// divergence. Opaque here, exactly like [`Event::GraphRunStarted::graph_hash`].
515 pub graph_hash: String,
516 /// The origin log positions of the [`Effect::Write`] intents the operator
517 /// acknowledged when forking past them. Raw `u64` positions, not
518 /// [`SequenceNumber`]s: this is an operator acknowledgement list recorded
519 /// as evidence, not a set of correlation keys this crate dereferences. An
520 /// empty vector means the fork boundary sat before any write intent, so
521 /// nothing needed acknowledging.
522 pub acknowledged_writes: Vec<u64>,
523}
524
525/// The outstanding write an abandonment left unsettled.
526///
527/// Rides on [`Event::RunAbandoned::unresolved_write`] when a run parked at a
528/// dangling [`Effect::Write`] intent is abandoned. It names the intent's log
529/// position and the tool it called, mirroring the evidence the reconciliation
530/// refusal (`409 needs_reconciliation`) surfaces, so the abandonment record
531/// points at exactly the write whose effect stays unknown.
532///
533/// Deliberately minimal: `seq` and `tool` are the evidence, not the whole
534/// recorded intent. The full intent (input, effect, idempotency key) is still
535/// in the log at `seq` for anyone who wants it; this struct is the pointer to
536/// it, not a copy. Carries no floats, so it derives [`Eq`].
537#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
538pub struct UnresolvedWrite {
539 /// The log position of the write intent that was left unresolved. A genuine
540 /// log position, so it rides as a [`SequenceNumber`], exactly as the
541 /// correlation seqs on the call events do.
542 pub seq: SequenceNumber,
543 /// The name of the tool the unresolved write called.
544 pub tool: String,
545}
546
547/// Token counts reported for a model call.
548///
549/// Minimal on purpose (input and output counts). Budget enforcement
550/// owns anything richer.
551#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
552pub struct TokenUsage {
553 /// Tokens in the request (prompt).
554 pub input_tokens: u32,
555 /// Tokens in the response (completion).
556 pub output_tokens: u32,
557}
558
559/// Which declared budget dimension a run can bump against.
560///
561/// Serializes in snake_case (`"tokens"`, `"cost_usd"`, `"wall_time"`,
562/// `"steps"`).
563#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
564#[serde(rename_all = "snake_case")]
565pub enum BudgetKind {
566 /// Total tokens across the run.
567 Tokens,
568 /// Total cost in US dollars.
569 CostUsd,
570 /// Wall-clock time.
571 WallTime,
572 /// Number of steps taken.
573 Steps,
574}
575
576/// A budget limit: which dimension, and the ceiling set for it.
577///
578/// Minimal on purpose. Budget enforcement owns the richer
579/// model; this type exists only so [`Event::BudgetExceeded`] can name what was
580/// crossed. `limit` is interpreted in the units implied by `kind`.
581///
582/// # Why every dimension is an `f64`
583///
584/// [`BudgetKind::CostUsd`] and [`BudgetKind::WallTime`] are inherently
585/// fractional, so they need a float. [`BudgetKind::Tokens`] and
586/// [`BudgetKind::Steps`] are integral in nature, yet they ride the wire as
587/// `f64` too, and that is a deliberate, kept decision rather than an
588/// oversight.
589///
590/// The concern with a float for a counter is silent rounding. It does not
591/// arise here: an IEEE 754 double represents every integer exactly up to
592/// 2^53, which is 9_007_199_254_740_992 (about 9.0e15). A run's token and step
593/// counts do not approach that bound. A billion-token run is 1e9, six orders
594/// of magnitude below the point where consecutive integers stop being exactly
595/// representable, and a step is one model turn. So a limit or an observed
596/// value that is integral in these dimensions round-trips through the wire and
597/// through every comparison exactly. The one caller-facing rule is that a
598/// declared token or step limit stay under 2^53, which no realistic budget
599/// violates.
600///
601/// The alternative, splitting the type so integral dimensions carried a `u64`
602/// and fractional ones an `f64`, was weighed and declined: it would fracture
603/// one uniform budget value into two, complicate the single crossing-check
604/// comparison that treats every dimension the same way, and change the wire
605/// format (with the snapshot and stored-log churn that implies) for no
606/// practical gain, since f64 already holds these integers exactly. The uniform
607/// `f64` is the honest representation given the bound above.
608#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
609pub struct Budget {
610 /// The dimension this limit applies to.
611 pub kind: BudgetKind,
612 /// The ceiling, in the units implied by `kind`. An `f64` for every
613 /// dimension; integral for `Tokens`/`Steps` and exact there up to 2^53
614 /// (see the type docs).
615 pub limit: f64,
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use time::macros::datetime;
622 use uuid::Uuid;
623
624 fn run_id() -> RunId {
625 RunId::from_uuid(Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap())
626 }
627
628 fn envelope(event: Event) -> EventEnvelope {
629 EventEnvelope::new(
630 run_id(),
631 SequenceNumber::new(3),
632 datetime!(2026-07-09 12:00:00 UTC),
633 event,
634 )
635 }
636
637 /// Serializing then deserializing an envelope yields an equal value.
638 fn assert_round_trips(event: Event) {
639 let original = envelope(event);
640 let json = serde_json::to_string(&original).expect("serialize");
641 let restored: EventEnvelope = serde_json::from_str(&json).expect("deserialize");
642 assert_eq!(original, restored, "round trip changed the value: {json}");
643 }
644
645 #[test]
646 fn every_variant_round_trips_through_the_envelope() {
647 assert_round_trips(Event::RunStarted {
648 agent_def_hash: "sha256:abc".into(),
649 input: serde_json::json!({"topic": "otters"}),
650 labels: None,
651 });
652 assert_round_trips(Event::RunStarted {
653 agent_def_hash: "sha256:abc".into(),
654 input: serde_json::json!({"topic": "otters"}),
655 labels: Some(BTreeMap::from([
656 ("build".to_owned(), "42".to_owned()),
657 ("env".to_owned(), "prod".to_owned()),
658 ])),
659 });
660 assert_round_trips(Event::ModelCallRequested {
661 seq: SequenceNumber::new(1),
662 request_hash: "sha256:req".into(),
663 request_body: None,
664 });
665 assert_round_trips(Event::ModelCallRequested {
666 seq: SequenceNumber::new(1),
667 request_hash: "sha256:req".into(),
668 request_body: Some(serde_json::json!({"model": "test", "messages": []})),
669 });
670 assert_round_trips(Event::ModelCallCompleted {
671 seq: SequenceNumber::new(1),
672 response: serde_json::json!({"text": "hello"}),
673 usage: TokenUsage {
674 input_tokens: 12,
675 output_tokens: 7,
676 },
677 });
678 assert_round_trips(Event::ToolCallRequested {
679 seq: SequenceNumber::new(2),
680 tool: "create_ticket".into(),
681 input: serde_json::json!({"title": "bug"}),
682 effect: Effect::Write,
683 idempotency_key: Some("key-123".into()),
684 });
685 assert_round_trips(Event::ToolCallCompleted {
686 seq: SequenceNumber::new(2),
687 output: serde_json::json!({"id": "TICKET-1"}),
688 });
689 assert_round_trips(Event::NowObserved {
690 now: datetime!(2026-07-09 12:00:00.123456789 UTC),
691 });
692 assert_round_trips(Event::RandomObserved { value: u64::MAX });
693 assert_round_trips(Event::Suspended {
694 reason: "awaiting approval".into(),
695 input_schema: serde_json::json!({"type": "object"}),
696 });
697 assert_round_trips(Event::Resumed {
698 input: serde_json::json!({"approved": true}),
699 });
700 assert_round_trips(Event::BudgetExceeded {
701 budget: Budget {
702 kind: BudgetKind::CostUsd,
703 limit: 2.0,
704 },
705 observed: 2.5,
706 });
707 assert_round_trips(Event::RunCompleted {
708 output: serde_json::json!({"summary": "done"}),
709 });
710 assert_round_trips(Event::RunFailed {
711 error: "provider timeout".into(),
712 });
713 assert_round_trips(Event::RunAbandoned {
714 reason: None,
715 unresolved_write: None,
716 });
717 assert_round_trips(Event::RunAbandoned {
718 reason: Some("husk is dead forever".into()),
719 unresolved_write: Some(UnresolvedWrite {
720 seq: SequenceNumber::new(5),
721 tool: "create_ticket".into(),
722 }),
723 });
724 assert_round_trips(Event::GraphRunStarted {
725 graph_hash: "sha256:graph".into(),
726 input: serde_json::json!({"topic": "otters"}),
727 labels: None,
728 forked_from: None,
729 });
730 assert_round_trips(Event::GraphRunStarted {
731 graph_hash: "sha256:graph".into(),
732 input: serde_json::json!({"topic": "otters"}),
733 labels: Some(BTreeMap::from([("env".to_owned(), "prod".to_owned())])),
734 forked_from: Some(ForkOrigin {
735 run_id: run_id(),
736 through_seq: SequenceNumber::new(7),
737 from_node: "review".into(),
738 graph_hash: "sha256:graph".into(),
739 acknowledged_writes: vec![3, 5],
740 }),
741 });
742 assert_round_trips(Event::NodeEntered {
743 node: "research".into(),
744 });
745 assert_round_trips(Event::NodeExited {
746 node: "research".into(),
747 });
748 assert_round_trips(Event::NodeSkipped {
749 node: "publish".into(),
750 reason: "branch case did not fire".into(),
751 });
752 assert_round_trips(Event::BranchTaken {
753 node: "gate".into(),
754 case: "approved".into(),
755 });
756 assert_round_trips(Event::MapFannedOut {
757 node: "fanout".into(),
758 items: serde_json::json!([1, 2, 3]),
759 });
760 assert_round_trips(Event::MapIterationStarted {
761 node: "fanout".into(),
762 index: 0,
763 child_run: "sha256:child".into(),
764 });
765 assert_round_trips(Event::MapIterationJoined {
766 node: "fanout".into(),
767 index: 0,
768 });
769 assert_round_trips(Event::FoldIterationStarted {
770 node: "refine".into(),
771 index: 0,
772 });
773 assert_round_trips(Event::FoldIterationJoined {
774 node: "refine".into(),
775 index: 1,
776 });
777 assert_round_trips(Event::FoldConverged {
778 node: "refine".into(),
779 winner_index: 1,
780 reason: "score >= threshold".into(),
781 });
782 }
783
784 /// Pins the exact serialized form of a representative envelope. A change to
785 /// field names, tag shape, or the presence of `schema_version` fails here
786 /// loudly, because the wire form is a durability contract.
787 #[test]
788 fn envelope_serializes_to_pinned_json() {
789 let envelope = envelope(Event::ModelCallCompleted {
790 seq: SequenceNumber::new(2),
791 response: serde_json::json!({"text": "hi"}),
792 usage: TokenUsage {
793 input_tokens: 12,
794 output_tokens: 7,
795 },
796 });
797 let json = serde_json::to_string(&envelope).expect("serialize");
798 assert_eq!(
799 json,
800 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"ModelCallCompleted","payload":{"seq":2,"response":{"text":"hi"},"usage":{"input_tokens":12,"output_tokens":7}}}}"#
801 );
802 }
803
804 /// With prompt recording off, `ModelCallRequested` serializes with no
805 /// `request_body` key at all: byte for byte what it produced before the
806 /// field existed. This is the additive-optional contract the
807 /// [`SCHEMA_VERSION`] docs promise, checked directly.
808 #[test]
809 fn model_call_requested_without_body_omits_the_key() {
810 let env = envelope(Event::ModelCallRequested {
811 seq: SequenceNumber::new(2),
812 request_hash: "sha256:req".into(),
813 request_body: None,
814 });
815 let json = serde_json::to_string(&env).expect("serialize");
816 assert_eq!(
817 json,
818 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"ModelCallRequested","payload":{"seq":2,"request_hash":"sha256:req"}}}"#
819 );
820 assert!(
821 !json.contains("request_body"),
822 "recording-off must not emit the key: {json}"
823 );
824 }
825
826 /// With recording on, the body rides alongside the hash under its own key.
827 #[test]
828 fn model_call_requested_with_body_carries_it() {
829 let env = envelope(Event::ModelCallRequested {
830 seq: SequenceNumber::new(2),
831 request_hash: "sha256:req".into(),
832 request_body: Some(serde_json::json!({"model": "m"})),
833 });
834 let json = serde_json::to_string(&env).expect("serialize");
835 assert!(json.contains(r#""request_body":{"model":"m"}"#), "{json}");
836 }
837
838 /// Pins `RunStarted` with no labels: byte for byte the shape `RunStarted`
839 /// had before `labels` existed, no `labels` key at all. This is the
840 /// unchanged-wire-shape half of the additive contract [`SCHEMA_VERSION`]
841 /// documents, checked directly against a fixed string the way
842 /// [`envelope_serializes_to_pinned_json`] pins `ModelCallCompleted`.
843 #[test]
844 fn run_started_without_labels_serializes_to_pinned_json() {
845 let env = envelope(Event::RunStarted {
846 agent_def_hash: "sha256:abc".into(),
847 input: serde_json::json!({"topic": "otters"}),
848 labels: None,
849 });
850 let json = serde_json::to_string(&env).expect("serialize");
851 assert_eq!(
852 json,
853 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"RunStarted","payload":{"agent_def_hash":"sha256:abc","input":{"topic":"otters"}}}}"#
854 );
855 assert!(
856 !json.contains("labels"),
857 "an unlabeled run must not emit the key: {json}"
858 );
859 }
860
861 /// Pins `RunStarted` with labels present: the `labels` key rides after
862 /// `input`, and the map serializes with its keys already in sorted order
863 /// (a `BTreeMap`'s own iteration order), independent of insertion order.
864 #[test]
865 fn run_started_with_labels_serializes_to_pinned_json() {
866 let env = envelope(Event::RunStarted {
867 agent_def_hash: "sha256:abc".into(),
868 input: serde_json::json!({"topic": "otters"}),
869 labels: Some(BTreeMap::from([
870 ("env".to_owned(), "prod".to_owned()),
871 ("build".to_owned(), "42".to_owned()),
872 ])),
873 });
874 let json = serde_json::to_string(&env).expect("serialize");
875 assert_eq!(
876 json,
877 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"RunStarted","payload":{"agent_def_hash":"sha256:abc","input":{"topic":"otters"},"labels":{"build":"42","env":"prod"}}}}"#
878 );
879 }
880
881 /// Pins a bare `RunAbandoned`: both optional fields absent, so the payload
882 /// is an empty object and neither key appears. This is the additive-optional
883 /// contract the [`SCHEMA_VERSION`] docs promise for the new terminal,
884 /// checked directly.
885 #[test]
886 fn run_abandoned_bare_serializes_to_pinned_json() {
887 let env = envelope(Event::RunAbandoned {
888 reason: None,
889 unresolved_write: None,
890 });
891 let json = serde_json::to_string(&env).expect("serialize");
892 assert_eq!(
893 json,
894 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"RunAbandoned","payload":{}}}"#
895 );
896 assert!(
897 !json.contains("reason") && !json.contains("unresolved_write"),
898 "a bare abandonment must omit both keys: {json}"
899 );
900 }
901
902 /// Pins a `RunAbandoned` carrying both a reason and an unresolved write:
903 /// the reason rides first, then the `unresolved_write` object with its
904 /// `seq` and `tool`. This is the honesty record an abandoned
905 /// needs-reconciliation run leaves behind.
906 #[test]
907 fn run_abandoned_with_unresolved_write_serializes_to_pinned_json() {
908 let env = envelope(Event::RunAbandoned {
909 reason: Some("husk is dead forever".into()),
910 unresolved_write: Some(UnresolvedWrite {
911 seq: SequenceNumber::new(5),
912 tool: "create_ticket".into(),
913 }),
914 });
915 let json = serde_json::to_string(&env).expect("serialize");
916 assert_eq!(
917 json,
918 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"RunAbandoned","payload":{"reason":"husk is dead forever","unresolved_write":{"seq":5,"tool":"create_ticket"}}}}"#
919 );
920 }
921
922 /// Pins the exact serialized form of the two deterministic-context
923 /// events. These variants were added after the original ten, which is a
924 /// read-compatible change (see [`SCHEMA_VERSION`]); this test extends the
925 /// pinned-snapshot coverage to them deliberately, choosing values that
926 /// stress the representation: a timestamp with all nine fractional
927 /// digits, and the largest `u64`.
928 #[test]
929 fn context_events_serialize_to_pinned_json() {
930 let now_env = envelope(Event::NowObserved {
931 now: datetime!(2026-07-09 12:00:00.123456789 UTC),
932 });
933 let json = serde_json::to_string(&now_env).expect("serialize");
934 assert_eq!(
935 json,
936 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"NowObserved","payload":{"now":"2026-07-09T12:00:00.123456789Z"}}}"#
937 );
938
939 let random_env = envelope(Event::RandomObserved { value: u64::MAX });
940 let json = serde_json::to_string(&random_env).expect("serialize");
941 assert_eq!(
942 json,
943 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"RandomObserved","payload":{"value":18446744073709551615}}}"#
944 );
945 }
946
947 /// Pins `GraphRunStarted` with neither labels nor a fork origin: both
948 /// optional keys are absent, so the payload is just `graph_hash` and
949 /// `input`. This is the additive-optional contract the [`SCHEMA_VERSION`]
950 /// docs promise for the new head variant, checked directly.
951 #[test]
952 fn graph_run_started_bare_serializes_to_pinned_json() {
953 let env = envelope(Event::GraphRunStarted {
954 graph_hash: "sha256:graph".into(),
955 input: serde_json::json!({"topic": "otters"}),
956 labels: None,
957 forked_from: None,
958 });
959 let json = serde_json::to_string(&env).expect("serialize");
960 assert_eq!(
961 json,
962 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"GraphRunStarted","payload":{"graph_hash":"sha256:graph","input":{"topic":"otters"}}}}"#
963 );
964 assert!(
965 !json.contains("labels") && !json.contains("forked_from"),
966 "an unlabeled, unforked graph run must omit both keys: {json}"
967 );
968 }
969
970 /// Pins `GraphRunStarted` carrying both labels and a fork origin: the
971 /// labels ride sorted after `input`, and `forked_from` carries the full
972 /// [`ForkOrigin`] shape (run id, seq, node, graph hash, acknowledged
973 /// writes in order).
974 #[test]
975 fn graph_run_started_forked_serializes_to_pinned_json() {
976 let env = envelope(Event::GraphRunStarted {
977 graph_hash: "sha256:graph".into(),
978 input: serde_json::json!({"topic": "otters"}),
979 labels: Some(BTreeMap::from([
980 ("env".to_owned(), "prod".to_owned()),
981 ("build".to_owned(), "42".to_owned()),
982 ])),
983 forked_from: Some(ForkOrigin {
984 run_id: run_id(),
985 through_seq: SequenceNumber::new(7),
986 from_node: "review".into(),
987 graph_hash: "sha256:graph".into(),
988 acknowledged_writes: vec![3, 5],
989 }),
990 });
991 let json = serde_json::to_string(&env).expect("serialize");
992 assert_eq!(
993 json,
994 r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":{"kind":"GraphRunStarted","payload":{"graph_hash":"sha256:graph","input":{"topic":"otters"},"labels":{"build":"42","env":"prod"},"forked_from":{"run_id":"00000000-0000-4000-8000-000000000001","through_seq":7,"from_node":"review","graph_hash":"sha256:graph","acknowledged_writes":[3,5]}}}}"#
995 );
996 }
997
998 /// Pins the exact serialized form of each graph node/branch/map marker.
999 /// These variants were added additively (see [`SCHEMA_VERSION`]); the pins
1000 /// lock their wire shape as the durability contract it is.
1001 #[test]
1002 fn graph_markers_serialize_to_pinned_json() {
1003 let prefix = r#"{"run_id":"00000000-0000-4000-8000-000000000001","seq":3,"schema_version":1,"recorded_at":"2026-07-09T12:00:00Z","event":"#;
1004
1005 let cases: Vec<(Event, &str)> = vec![
1006 (
1007 Event::NodeEntered {
1008 node: "research".into(),
1009 },
1010 r#"{"kind":"NodeEntered","payload":{"node":"research"}}"#,
1011 ),
1012 (
1013 Event::NodeExited {
1014 node: "research".into(),
1015 },
1016 r#"{"kind":"NodeExited","payload":{"node":"research"}}"#,
1017 ),
1018 (
1019 Event::NodeSkipped {
1020 node: "publish".into(),
1021 reason: "branch case did not fire".into(),
1022 },
1023 r#"{"kind":"NodeSkipped","payload":{"node":"publish","reason":"branch case did not fire"}}"#,
1024 ),
1025 (
1026 Event::BranchTaken {
1027 node: "gate".into(),
1028 case: "approved".into(),
1029 },
1030 r#"{"kind":"BranchTaken","payload":{"node":"gate","case":"approved"}}"#,
1031 ),
1032 (
1033 Event::MapFannedOut {
1034 node: "fanout".into(),
1035 items: serde_json::json!([1, 2, 3]),
1036 },
1037 r#"{"kind":"MapFannedOut","payload":{"node":"fanout","items":[1,2,3]}}"#,
1038 ),
1039 (
1040 Event::MapIterationStarted {
1041 node: "fanout".into(),
1042 index: 0,
1043 child_run: "sha256:child".into(),
1044 },
1045 r#"{"kind":"MapIterationStarted","payload":{"node":"fanout","index":0,"child_run":"sha256:child"}}"#,
1046 ),
1047 (
1048 Event::MapIterationJoined {
1049 node: "fanout".into(),
1050 index: 0,
1051 },
1052 r#"{"kind":"MapIterationJoined","payload":{"node":"fanout","index":0}}"#,
1053 ),
1054 (
1055 Event::FoldIterationStarted {
1056 node: "refine".into(),
1057 index: 0,
1058 },
1059 r#"{"kind":"FoldIterationStarted","payload":{"node":"refine","index":0}}"#,
1060 ),
1061 (
1062 Event::FoldIterationJoined {
1063 node: "refine".into(),
1064 index: 1,
1065 },
1066 r#"{"kind":"FoldIterationJoined","payload":{"node":"refine","index":1}}"#,
1067 ),
1068 (
1069 Event::FoldConverged {
1070 node: "refine".into(),
1071 winner_index: 1,
1072 reason: "score >= threshold".into(),
1073 },
1074 r#"{"kind":"FoldConverged","payload":{"node":"refine","winner_index":1,"reason":"score >= threshold"}}"#,
1075 ),
1076 ];
1077
1078 for (event, expected_event_json) in cases {
1079 let json = serde_json::to_string(&envelope(event)).expect("serialize");
1080 assert_eq!(json, format!("{prefix}{expected_event_json}}}"));
1081 }
1082 }
1083
1084 /// Effect serializes lowercase.
1085 #[test]
1086 fn effect_serializes_lowercase() {
1087 assert_eq!(serde_json::to_string(&Effect::Read).unwrap(), r#""read""#);
1088 assert_eq!(
1089 serde_json::to_string(&Effect::Idempotent).unwrap(),
1090 r#""idempotent""#
1091 );
1092 assert_eq!(serde_json::to_string(&Effect::Write).unwrap(), r#""write""#);
1093 }
1094
1095 /// Sequence numbers order by their underlying position.
1096 #[test]
1097 fn sequence_numbers_order_by_position() {
1098 assert!(SequenceNumber::new(1) < SequenceNumber::new(2));
1099 assert!(SequenceNumber::new(10) > SequenceNumber::new(2));
1100 assert_eq!(SequenceNumber::new(5), SequenceNumber::new(5));
1101 assert_eq!(SequenceNumber::new(4).next(), SequenceNumber::new(5));
1102
1103 let mut seqs = [
1104 SequenceNumber::new(3),
1105 SequenceNumber::new(1),
1106 SequenceNumber::new(2),
1107 ];
1108 seqs.sort();
1109 assert_eq!(
1110 seqs,
1111 [
1112 SequenceNumber::new(1),
1113 SequenceNumber::new(2),
1114 SequenceNumber::new(3),
1115 ]
1116 );
1117 }
1118}