Skip to main content

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}