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}