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