Skip to main content

aion_core/
event.rs

1//! Workflow history events and their deterministic recording envelope.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    ActivityError, ActivityId, PackageVersion, Payload, RunId, ScheduleConfig, ScheduleId,
10    SearchAttributeValue, TimerId, WorkerAttribution, WorkflowError, WorkflowId,
11};
12
13/// Metadata recorded with every workflow history event.
14#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
15pub struct EventEnvelope {
16    /// Monotonic sequence number within the owning workflow history.
17    pub seq: u64,
18    /// Recorded UTC timestamp for this event.
19    ///
20    /// This timestamp is the determinism source for `workflow.now`; replay must use the recorded
21    /// value rather than consulting wall-clock time.
22    pub recorded_at: DateTime<Utc>,
23    /// Workflow history that owns this event.
24    pub workflow_id: WorkflowId,
25}
26
27/// The named default task queue: the single sanctioned fallback when no explicit task queue was
28/// selected (no SDK-level selection exists yet — that is NSTQ-4) and the replay-safe decode value
29/// for [`Event::ActivityScheduled`] events recorded before the `task_queue` field existed.
30///
31/// This is the canonical task-queue default for the whole workspace. `aion_store::DEFAULT_OUTBOX_ROUTE`
32/// and `aion_server::worker::registry::DEFAULT_TASK_QUEUE` both alias/re-export this constant rather
33/// than redeclaring the literal, so a history-derived task queue and an outbox-row-derived task queue
34/// cannot drift.
35pub const DEFAULT_TASK_QUEUE: &str = "default";
36
37/// serde default for [`Event::ActivityScheduled::task_queue`]: the named [`DEFAULT_TASK_QUEUE`].
38///
39/// Used by `#[serde(default = ...)]` so an old recorded history that has no `task_queue` on its
40/// `ActivityScheduled` events decodes deterministically to `"default"`.
41fn default_task_queue() -> String {
42    String::from(DEFAULT_TASK_QUEUE)
43}
44
45/// Sentinel `attempt` value for activity lifecycle events decoded from a history recorded BEFORE the
46/// `attempt` field existed on [`Event::ActivityStarted`] / [`Event::ActivityCompleted`] /
47/// [`Event::ActivityCancelled`] (NOI-0).
48///
49/// Activity attempts are **one-based** everywhere they are produced (see [`Event::ActivityFailed`]'s
50/// `attempt`, which is documented "One-based activity attempt number", and the engine's
51/// `FIRST_DELIVERY_ATTEMPT = 1`). A real attempt is therefore always `>= 1`, so `0` can never collide
52/// with a genuine attempt: it is a distinguishable "legacy / unknown attempt" marker. Old histories
53/// that predate the field decode to this sentinel via `#[serde(default = "legacy_activity_attempt")]`
54/// — deterministically, never panicking, never differing run-to-run — while the compiler still forces
55/// every LIVE construction site to supply the genuine one-based attempt (there is no blanket
56/// `Default` on the variant).
57const LEGACY_ACTIVITY_ATTEMPT: u32 = 0;
58
59/// serde default for the `attempt` field on the activity lifecycle events that gained it in NOI-0.
60///
61/// Returns [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) so a history recorded before the field existed decodes
62/// deterministically to the legacy/unknown sentinel rather than failing. See
63/// [`LEGACY_ACTIVITY_ATTEMPT`] for why `0` is a safe distinguishable value under one-based attempts.
64fn legacy_activity_attempt() -> u32 {
65    LEGACY_ACTIVITY_ATTEMPT
66}
67
68/// Search attribute name that records the task queue a workflow was STARTED on.
69///
70/// The server stamps this attribute durably in the SAME atomic append as
71/// [`Event::WorkflowStarted`] (via [`Event::SearchAttributesUpdated`]) when the
72/// start request selected a task queue — mirroring the `aion.namespace`
73/// attribute that records the owning namespace. It is therefore part of
74/// RECORDED HISTORY: recovery/replay re-derive the identical value, so an
75/// activity that falls back to its workflow's start-time queue (#144) resolves
76/// to the same queue on every replay. The attribute is absent when the start
77/// did not select a queue (the legacy / "no selection anywhere" case).
78///
79/// This is the canonical name for the whole workspace;
80/// `aion_server::TASK_QUEUE_ATTRIBUTE` re-exports it rather than redeclaring the
81/// literal, so a history-derived start-time queue and the server's recorded
82/// attribute cannot drift.
83pub const START_TIME_TASK_QUEUE_ATTRIBUTE: &str = "aion.task_queue";
84
85/// The task queue a workflow was STARTED on, projected from recorded history.
86///
87/// Reads the [`START_TIME_TASK_QUEUE_ATTRIBUTE`] search attribute folded from
88/// the run's [`Event::SearchAttributesUpdated`] events (the server records it in
89/// the same append as [`Event::WorkflowStarted`]). Returns `None` when the start
90/// recorded no task-queue selection — a legacy history, or a start that left the
91/// queue unset — so callers fall back to the named [`DEFAULT_TASK_QUEUE`].
92///
93/// Because the value is read purely from recorded history (never from live or
94/// wall-clock state), it is replay-deterministic: the same history always
95/// projects the same start-time queue.
96#[must_use]
97pub fn start_time_task_queue(events: &[Event]) -> Option<String> {
98    let attributes = crate::search_attributes_from_events(events);
99    match attributes.get(START_TIME_TASK_QUEUE_ATTRIBUTE) {
100        Some(crate::SearchAttributeValue::String(queue)) => Some(queue.clone()),
101        _ => None,
102    }
103}
104
105/// Search attribute name that carries a workflow's operator-facing display
106/// name (#211).
107///
108/// The display name is a LABEL over the workflow's UUID identity, never an
109/// address. Stated exactly, because the label rule is about ACTS and not about
110/// whether a name may appear on the wire:
111///
112/// 1. **No operation acts on a name.** Every act-verb — start, signal, query,
113///    cancel, pause, resume, reopen, rename, intervene — takes a `WorkflowId`
114///    (and optionally a `RunId`). None of them accepts a name as its target.
115/// 2. **Name-shaped FILTERING returns lists and acts on nothing.** This
116///    attribute is an ordinary search attribute, so it is filterable like any
117///    other: a visibility predicate on it yields candidate summaries. That is
118///    the first half of a picker (name in, candidates out, an operator
119///    chooses, a UUID acts), and it is deliberately supported.
120/// 3. **The composition is prohibited.** No SDK, CLI verb, MCP tool, or
121///    convenience function may compose name-filter → take-first → act. That
122///    composition re-imports addressing one layer up: the system, not the
123///    caller, would be choosing the referent. The CHOOSING STEP STAYS WITH THE
124///    CALLER, BY CONTRACT. A future "find by name" helper RETURNS CANDIDATES
125///    AND STOPS.
126///
127/// The sweep predicate, written down so the next audit checks the right thing
128/// rather than re-deriving it: *for every verb, HTTP route, RPC, MCP tool, and
129/// SDK function that CHANGES state, the target is identified by a UUID; a
130/// display name appears only as a value being SET, never as the selector, and
131/// no single call turns a name into an act.* The MCP tool catalogue enforces
132/// this mechanically (`aion_server::mcp::catalog` — see its
133/// `name_is_not_an_address_tests`); the other surfaces are Rust types rather
134/// than data and are checked by reading against that predicate.
135///
136/// It is recorded durably as an ordinary [`Event::SearchAttributesUpdated`]
137/// search attribute — stamped in the same atomic append as
138/// [`Event::WorkflowStarted`] when the start named one, and re-recorded by
139/// every rename — so history keeps every name the workflow has worn and
140/// replay/failover re-derive the current one.
141///
142/// The recorded attribute carries NO run id. A rename is addressed to a
143/// workflow AND a run, but what it records is a workflow-level attribute, and
144/// every reader folds it over the whole history (see [`display_name`]). So a
145/// continue-as-new successor inherits the name its predecessor was wearing, and
146/// describing any run of the workflow reads back the same, current name.
147///
148/// This is the canonical name for the whole workspace;
149/// `aion_server::DISPLAY_NAME_ATTRIBUTE` re-exports it rather than redeclaring
150/// the literal, so the projector below and the server's recorded attribute
151/// cannot drift.
152pub const DISPLAY_NAME_ATTRIBUTE: &str = "aion.display_name";
153
154/// The workflow's current display name, projected from recorded history.
155///
156/// Reads the [`DISPLAY_NAME_ATTRIBUTE`] search attribute folded over EVERY
157/// [`Event::SearchAttributesUpdated`] in `events`, last write wins — so a
158/// rename's later update supersedes the start-time name while history keeps
159/// both. Returns `None` when no name was ever recorded (the unnamed workflow
160/// renders as its bare UUID).
161///
162/// The fold is per WORKFLOW, not per run: it is not run-scoped the way
163/// [`crate::failure_projection`] and [`crate::current_lease_terminal`] are, and
164/// the recorded attribute carries no run id to scope it by. Hand it a whole
165/// workflow history and the newest recorded name wins whichever run recorded
166/// it — so a continue-as-new successor reads back the name its predecessor was
167/// wearing until something renames it.
168///
169/// Because the value is read purely from recorded history (never from live or
170/// wall-clock state), it is replay-deterministic: the same history always
171/// projects the same display name.
172#[must_use]
173pub fn display_name(events: &[Event]) -> Option<String> {
174    display_name_from_attributes(&crate::search_attributes_from_events(events))
175}
176
177/// The display name carried by an already-folded search-attribute map.
178///
179/// The same projection as [`display_name`], for readers that hold the folded
180/// map rather than the events — the visibility projection's
181/// `search_attributes`, which the store keeps refreshed on every append. Both
182/// entry points share this one lookup so the attribute name and the
183/// String-typed expectation cannot drift between the history reader and the
184/// visibility reader. The map is itself folded over a whole workflow history,
185/// so this reads the same per-workflow label with the same last-write-wins
186/// rule.
187#[must_use]
188pub fn display_name_from_attributes<S: std::hash::BuildHasher>(
189    attributes: &std::collections::HashMap<String, crate::SearchAttributeValue, S>,
190) -> Option<String> {
191    match attributes.get(DISPLAY_NAME_ATTRIBUTE) {
192        Some(crate::SearchAttributeValue::String(name)) => Some(name.clone()),
193        _ => None,
194    }
195}
196
197/// A recorded workflow history event.
198///
199/// User data is carried as opaque [`Payload`] values, while failures use the closed workflow and
200/// activity error types from this crate.
201#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
202#[serde(tag = "type", content = "data")]
203pub enum Event {
204    /// A workflow execution started with a type name and input payload.
205    WorkflowStarted {
206        /// Recording metadata for this event.
207        envelope: EventEnvelope,
208        /// Workflow type selected by the caller.
209        workflow_type: String,
210        /// Opaque workflow input payload.
211        input: Payload,
212        /// Concrete run identifier started by this event.
213        run_id: RunId,
214        /// Parent run that continued as this run, when this start is part of a
215        /// continue-as-new chain.
216        parent_run_id: Option<RunId>,
217        /// The parent WORKFLOW that spawned this run as a child (aion#77):
218        /// the child wears its parent, so a handoff chain is traversable
219        /// backward from any run and the console can group an arc without
220        /// scanning every parent history. `None` for operator-started roots
221        /// and for histories recorded before the field existed (the serde
222        /// default, exactly as `task_queue` and `attempt` decode legacies).
223        #[serde(default)]
224        parent_workflow_id: Option<WorkflowId>,
225        /// Package version this run was resolved against at record time.
226        ///
227        /// Recovery and replay resolve workflow code from this recorded
228        /// version; they never re-resolve a "latest" version.
229        package_version: PackageVersion,
230    },
231    /// A workflow execution completed successfully; this terminal event projects to Completed.
232    WorkflowCompleted {
233        /// Recording metadata for this event.
234        envelope: EventEnvelope,
235        /// Opaque workflow result payload.
236        result: Payload,
237    },
238    /// A workflow execution failed terminally; this terminal event projects to Failed.
239    WorkflowFailed {
240        /// Recording metadata for this event.
241        envelope: EventEnvelope,
242        /// Terminal workflow failure.
243        error: WorkflowError,
244    },
245    /// A workflow execution was cancelled; this terminal event projects to Cancelled.
246    WorkflowCancelled {
247        /// Recording metadata for this event.
248        envelope: EventEnvelope,
249        /// Human-readable cancellation reason.
250        reason: String,
251    },
252    /// A workflow execution timed out; this terminal event projects to `TimedOut`.
253    WorkflowTimedOut {
254        /// Recording metadata for this event.
255        envelope: EventEnvelope,
256        /// Descriptor identifying the timeout that elapsed.
257        ///
258        /// Intentionally stringly-typed: the closed set of timeout kinds is defined by cluster AT
259        /// (timers and signals), not by the core event model.
260        timeout: String,
261    },
262    /// A workflow execution continued as a new run; this terminal event projects to
263    /// `ContinuedAsNew`.
264    WorkflowContinuedAsNew {
265        /// Recording metadata for this event.
266        envelope: EventEnvelope,
267        /// Opaque workflow input payload carried into the new run.
268        input: Payload,
269        /// Workflow type override for the new run, when migration changes the workflow type.
270        ///
271        /// When absent, the new run uses the current workflow type.
272        workflow_type: Option<String>,
273        /// Run identifier for the current run that is being continued.
274        parent_run_id: RunId,
275    },
276    /// A failed run was reopened.
277    ///
278    /// Engine-internal — never authored by workflow or SDK code. This is the
279    /// compensating event that reconciles reopen with the status-is-a-projection
280    /// invariant: under the last-lifecycle-event-wins scan it supersedes the
281    /// run's prior terminal event and returns the run to Running, exactly as a
282    /// replacement [`Event::WorkflowStarted`] does for continue-as-new. Terminal
283    /// detection is scoped to "since the last reopen point", so a run holds
284    /// exactly one terminal event per lease.
285    WorkflowReopened {
286        /// Recording metadata for this event.
287        envelope: EventEnvelope,
288        /// Run being reopened — the run that recorded the superseded terminal
289        /// event and that the reopened execution continues.
290        run_id: RunId,
291        /// Activities to re-dispatch on replay: those that ended in a terminal
292        /// failure in this run with no later successful attempt. The history
293        /// cursor treats each as a reset point so the recorded failure is
294        /// superseded and the activity resolves to live re-dispatch.
295        reopened: Vec<ActivityId>,
296    },
297    /// A running workflow was paused by an operator.
298    ///
299    /// Engine-internal — never authored by workflow or SDK code. A NON-terminal
300    /// lifecycle marker: under the last-lifecycle-event-wins scan it projects the
301    /// run to [`crate::WorkflowStatus::Paused`], holding new activity dispatch at
302    /// the outbox while every durable record path (timer fires, signal receipts,
303    /// drained completions) keeps recording. It is invisible to the replay cursor
304    /// (it is neither a terminal nor a run-start reset), so a paused-then-resumed
305    /// history replays byte-identically to one that was never paused.
306    WorkflowPaused {
307        /// Recording metadata for this event.
308        envelope: EventEnvelope,
309        /// Run being paused — the live, non-terminal run the operator held.
310        run_id: RunId,
311        /// Optional operator-supplied pause reason.
312        reason: Option<String>,
313        /// Optional identity of the operator who issued the pause.
314        operator: Option<String>,
315    },
316    /// A paused workflow was resumed by an operator.
317    ///
318    /// Engine-internal — never authored by workflow or SDK code. Supersedes the
319    /// run's prior [`Event::WorkflowPaused`] under the last-lifecycle-event-wins
320    /// scan, returning the run to [`crate::WorkflowStatus::Running`], and — like
321    /// [`Event::WorkflowPaused`] — is invisible to the replay cursor.
322    WorkflowResumed {
323        /// Recording metadata for this event.
324        envelope: EventEnvelope,
325        /// Run being resumed.
326        run_id: RunId,
327        /// Optional identity of the operator who issued the resume.
328        operator: Option<String>,
329    },
330    /// Workflow search attributes were updated for visibility and query projection.
331    SearchAttributesUpdated {
332        /// Recording metadata for this event.
333        envelope: EventEnvelope,
334        /// Workflow whose search attributes changed.
335        workflow_id: WorkflowId,
336        /// Updated search attributes keyed by attribute name.
337        attributes: HashMap<String, SearchAttributeValue>,
338    },
339    /// An activity was scheduled by workflow code.
340    ActivityScheduled {
341        /// Recording metadata for this event.
342        envelope: EventEnvelope,
343        /// Deterministic activity identifier derived from the scheduling sequence position.
344        activity_id: ActivityId,
345        /// Activity type selected by workflow code.
346        activity_type: String,
347        /// Opaque activity input payload.
348        input: Payload,
349        /// Pool/flavour selector this activity dispatches to within the workflow's namespace
350        /// (NSTQ-3). This is the durable source-of-truth for re-targeting the **same** task queue
351        /// on reopen/recovery, mirroring how the namespace is recovered from history but recorded
352        /// **per-activity** rather than as a workflow-level search attribute.
353        ///
354        /// Replay-safety: histories recorded before this field existed have no `task_queue` on
355        /// their `ActivityScheduled` events. Decode defaults the missing value to
356        /// [`DEFAULT_TASK_QUEUE`] (`"default"`) via `#[serde(default = ...)]`, so an old history
357        /// deterministically re-derives `task_queue = "default"` — never panics, never differs
358        /// run-to-run. The encoding of the existing fields is untouched.
359        #[serde(default = "default_task_queue")]
360        task_queue: String,
361        /// OPTIONAL node affinity this activity dispatches to (NODE-3). `None` = no affinity (the
362        /// genuine current value; SDK-level node selection is NODE-4). This is the durable
363        /// source-of-truth for re-targeting the **same** node on reopen/recovery, recorded
364        /// **per-activity** alongside `task_queue`.
365        ///
366        /// Replay-safety: histories recorded before this field existed have no `node` key on their
367        /// `ActivityScheduled` events. serde's `Option` default is `None`, so `#[serde(default)]`
368        /// decodes a missing `node` deterministically to `None` — never a sentinel, never panics,
369        /// never differs run-to-run. The encoding of the existing fields is untouched.
370        #[serde(default)]
371        node: Option<String>,
372    },
373    /// The engine DISPATCHED an activity attempt to its task queue.
374    ///
375    /// **This does NOT mean a worker has taken the work.** The event is written
376    /// by the engine at dispatch time, in the SAME atomic append as its
377    /// [`Event::ActivityScheduled`] and under the same `recorded_at`, before any
378    /// worker has been selected — let alone leased the attempt. Worker selection
379    /// happens afterwards, in the server, and may wait indefinitely: a dispatch
380    /// to a task queue nobody serves records this event and then nothing, so a
381    /// run parked forever and a run a worker is actively executing have
382    /// identical history shapes and both project
383    /// [`WorkflowStatus::Running`](crate::WorkflowStatus::Running). That is not
384    /// a projection bug — history genuinely holds no terminal event — but it
385    /// means this event alone can never answer "is anyone working on it".
386    ///
387    /// Every producing seam behaves this way: the single-dispatch and in-VM
388    /// seams (`aion::runtime::nif_activity_dispatch`,
389    /// `aion::runtime::nif_activity_in_vm`), the retry delivery
390    /// (`aion::runtime::nif_activity_retry_dispatch`, which records the next
391    /// attempt's start "before it goes on the wire"), and the fan-out batch
392    /// (`aion::durability::recorder::fan_out`).
393    ///
394    /// The name is therefore wrong and the behaviour is not. A rename to
395    /// `ActivityDispatched` is PROPOSED and PENDING an owner ruling, together
396    /// with the question of whether a separate lease-time event should exist;
397    /// see `docs/design/aion-authoring/ACTIVITY-STARTED-SEMANTICS-DECISION.md`.
398    /// Deferring THIS event to lease time is recommended against there, because
399    /// it would silently change what every already-recorded `ActivityStarted`
400    /// meant.
401    ///
402    /// To ask whether an in-flight activity can still reach a worker, join its
403    /// recorded address to the live fleet — `aion_server`'s
404    /// `worker::ActivityReachability`, surfaced on `POST /workflows/describe`.
405    ActivityStarted {
406        /// Recording metadata for this event.
407        envelope: EventEnvelope,
408        /// Activity that was dispatched.
409        activity_id: ActivityId,
410        /// One-based activity attempt number this start belongs to (NOI-0).
411        ///
412        /// Matches the `attempt` on the [`Event::ActivityFailed`] / [`Event::ActivityCompleted`] /
413        /// [`Event::ActivityCancelled`] that terminates the SAME attempt, so
414        /// `(workflow, activity, attempt)` is a stable identity across the whole lifecycle — the key
415        /// the NOI dedupe/guard/session-id design is built on.
416        ///
417        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
418        /// `ActivityStarted` events. Decode defaults the missing value to
419        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
420        /// differs run-to-run. Because real attempts are one-based, `0` is a distinguishable
421        /// legacy/unknown sentinel, never a genuine attempt. The encoding of the existing fields is
422        /// untouched.
423        #[serde(default = "legacy_activity_attempt")]
424        attempt: u32,
425    },
426    /// A selected worker accepted the push of one activity attempt.
427    ///
428    /// This is the lease-time fact [`Event::ActivityStarted`] is not: the
429    /// dispatch record says the engine put the attempt on a queue; this says
430    /// WHICH worker took it off. It is recorded by the workflow's one Recorder
431    /// at the outbox handoff seam, after the selected worker accepted delivery
432    /// and before any completion, on both transports. It is an asynchronous
433    /// arrival like [`Event::SignalReceived`]: no workflow command waits on it,
434    /// replay treats it as informational, and it projects no status.
435    ///
436    /// `worker` carries durable NAMES only (identity, task queue, node,
437    /// deployment, instance, transport) and deliberately NOT the server-process
438    /// `WorkerId`: that is a registry counter minted per process, so after a
439    /// restart the same number names a different worker and in an exported
440    /// history it names nothing. An attribution that dies with the server would
441    /// be a lie in a durable log.
442    ///
443    /// Histories recorded before this event existed carry none; readers report
444    /// such attempts as unattributed rather than inventing a worker. The
445    /// `(workflow, activity, attempt)` identity is the one the matching
446    /// `ActivityStarted` anchored — a lease adds a fact about that attempt, it
447    /// never opens a new one.
448    ActivityLeased {
449        /// Recording metadata for this event.
450        envelope: EventEnvelope,
451        /// Activity whose attempt was leased.
452        activity_id: ActivityId,
453        /// One-based attempt number the lease belongs to — the same `attempt`
454        /// its [`Event::ActivityStarted`] carries.
455        attempt: u32,
456        /// The worker that accepted the attempt, by durable names.
457        worker: WorkerAttribution,
458    },
459    /// Recovery offered a previously dispatched, still-dangling activity
460    /// attempt back to the worker adoption path.
461    ///
462    /// This is deliberately an OFFER, not a claim that the worker found a live
463    /// holder: the engine cannot observe a harness process directly. It records
464    /// the truthful recovery decision — retain and redeliver the same execution
465    /// identity instead of falsely terminating it as server death. The worker's
466    /// single-flight/spawn gate then either joins the holder or starts it when no
467    /// holder survived.
468    ActivityAdoptionOffered {
469        /// Recording metadata for this event.
470        envelope: EventEnvelope,
471        /// Activity whose dangling attempt is being offered for adoption.
472        activity_id: ActivityId,
473        /// One-based attempt identity retained across the recovery redelivery.
474        attempt: u32,
475    },
476    /// An activity completed successfully.
477    ActivityCompleted {
478        /// Recording metadata for this event.
479        envelope: EventEnvelope,
480        /// Activity that produced the result.
481        activity_id: ActivityId,
482        /// Opaque activity result payload.
483        result: Payload,
484        /// One-based activity attempt number that produced this completion (NOI-0).
485        ///
486        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so a
487        /// completed activity carries one consistent `attempt` readable off both its start and its
488        /// terminal — the negative-control invariant NOI-0 gates on.
489        ///
490        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
491        /// `ActivityCompleted` events. Decode defaults the missing value to
492        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
493        /// differs run-to-run. The encoding of the existing fields is untouched.
494        #[serde(default = "legacy_activity_attempt")]
495        attempt: u32,
496    },
497    /// An activity attempt failed.
498    ///
499    /// The `attempt` field together with [`ActivityError`]'s retryable or terminal classification
500    /// lets replay distinguish a retryable interim failure from a terminal one for the same
501    /// [`ActivityId`].
502    ActivityFailed {
503        /// Recording metadata for this event.
504        envelope: EventEnvelope,
505        /// Activity whose attempt failed.
506        activity_id: ActivityId,
507        /// Classified activity failure.
508        error: ActivityError,
509        /// One-based activity attempt number that produced this failure.
510        attempt: u32,
511    },
512    /// An ADVISORY activity spent its whole attempt budget and failed for good
513    /// (RUNTIME-OPERATIONS.md R5).
514    ///
515    /// The warning the class promises: an advisory action is a side channel
516    /// (a heartbeat, a notification), so its exhaustion never faults the
517    /// calling step — but it must never be silent either. This event is that
518    /// visibility. It ACCOMPANIES the activity's honest terminal
519    /// [`Event::ActivityFailed`]; it never replaces it, because the activity
520    /// really did fail and history says so.
521    ///
522    /// Non-terminal for the workflow: it says nothing about the run's
523    /// outcome, so status projection deliberately ignores it.
524    ActivityAdvisoryExhausted {
525        /// Recording metadata for this event.
526        envelope: EventEnvelope,
527        /// Advisory activity whose attempt budget was spent.
528        activity_id: ActivityId,
529        /// Activity type of the exhausted advisory activity.
530        activity_type: String,
531        /// The last attempt's failure reason, verbatim — the same string the
532        /// accompanying terminal [`Event::ActivityFailed`] carries.
533        reason: String,
534        /// One-based number of the attempt that spent the budget.
535        attempt: u32,
536    },
537    /// A policy-refused activity execution was durably routed to a fallback queue.
538    ///
539    /// This event is nonterminal and status-invisible. Its recorded destination
540    /// is the durable replay answer; recovery reuses it rather than choosing again.
541    ActivityFallbackRouted {
542        /// Recording metadata for this event.
543        envelope: EventEnvelope,
544        /// Activity whose refusing execution caused the hop.
545        activity_id: ActivityId,
546        /// One-based execution attempt whose policy refusal caused this hop.
547        attempt: u32,
548        /// Queue the refusing execution was dispatched on.
549        from_task_queue: String,
550        /// Queue the next execution is dispatched on.
551        to_task_queue: String,
552        /// Zero-based position consumed from the authored fallback list.
553        fallback_index: u32,
554    },
555    /// An activity was cancelled as an explicit cancellation outcome.
556    ActivityCancelled {
557        /// Recording metadata for this event.
558        envelope: EventEnvelope,
559        /// Activity that was cancelled.
560        activity_id: ActivityId,
561        /// One-based activity attempt number that was cancelled (NOI-0).
562        ///
563        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so the
564        /// cancellation terminal is attributable to a specific attempt exactly like
565        /// [`Event::ActivityFailed`] is.
566        ///
567        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
568        /// `ActivityCancelled` events. Decode defaults the missing value to
569        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
570        /// differs run-to-run. The encoding of the existing fields is untouched.
571        #[serde(default = "legacy_activity_attempt")]
572        attempt: u32,
573    },
574    /// A timer was scheduled to fire at a deterministic timestamp.
575    TimerStarted {
576        /// Recording metadata for this event.
577        envelope: EventEnvelope,
578        /// Timer selected by workflow code or assigned by the engine.
579        timer_id: TimerId,
580        /// UTC timestamp at which the timer becomes eligible to fire.
581        fire_at: DateTime<Utc>,
582    },
583    /// A timer fired.
584    TimerFired {
585        /// Recording metadata for this event.
586        envelope: EventEnvelope,
587        /// Timer that fired.
588        timer_id: TimerId,
589    },
590    /// A timer was cancelled as an explicit cancellation outcome.
591    TimerCancelled {
592        /// Recording metadata for this event.
593        envelope: EventEnvelope,
594        /// Timer that was cancelled.
595        timer_id: TimerId,
596        /// Who retired the timer. Decides reopen behavior: a
597        /// [`TimerCancelCause::CancelTeardown`] cancellation is re-armed when the
598        /// run is reopened; a [`TimerCancelCause::WorkflowIntent`] cancellation is
599        /// permanent.
600        ///
601        /// Replay-safety: histories recorded before this field existed have no
602        /// `cause` key. Decode defaults the missing value to
603        /// [`TimerCancelCause::WorkflowIntent`] via `#[serde(default)]` — the
604        /// pre-field behavior (never resurrected), never panics, never differs
605        /// run-to-run. The encoding of the existing fields is untouched.
606        #[serde(default)]
607        cause: TimerCancelCause,
608    },
609    /// A `with_timeout` operation reached a durable terminal outcome.
610    WithTimeoutCompleted {
611        /// Recording metadata for this event.
612        envelope: EventEnvelope,
613        /// Timer that bounded the operation.
614        timer_id: TimerId,
615        /// Recorded timeout outcome.
616        outcome: WithTimeoutOutcome,
617        /// JSON-encoded BEAM term payload for completed operation results.
618        result: Option<Payload>,
619    },
620    /// A signal was delivered to the workflow.
621    SignalReceived {
622        /// Recording metadata for this event.
623        envelope: EventEnvelope,
624        /// Signal name selected by the sender.
625        name: String,
626        /// Opaque signal payload.
627        payload: Payload,
628    },
629    /// A signal was sent by this workflow to another workflow.
630    SignalSent {
631        /// Recording metadata for this event.
632        envelope: EventEnvelope,
633        /// Target workflow identifier selected by workflow code.
634        target_workflow_id: WorkflowId,
635        /// Signal name selected by workflow code.
636        name: String,
637        /// Opaque signal payload.
638        payload: Payload,
639    },
640    /// A child workflow was started.
641    ChildWorkflowStarted {
642        /// Recording metadata for this event.
643        envelope: EventEnvelope,
644        /// Child workflow identifier.
645        child_workflow_id: WorkflowId,
646        /// Child workflow type selected by the parent.
647        workflow_type: String,
648        /// Opaque child workflow input payload.
649        input: Payload,
650        /// Package version resolved for the child at record time.
651        ///
652        /// The crash-repair sweep and the child's own start use exactly this
653        /// recorded version, so the crash path resolves identically to the
654        /// crash-free path.
655        package_version: PackageVersion,
656    },
657    /// A child workflow completed successfully.
658    ChildWorkflowCompleted {
659        /// Recording metadata for this event.
660        envelope: EventEnvelope,
661        /// Child workflow that produced the result.
662        child_workflow_id: WorkflowId,
663        /// Opaque child workflow result payload.
664        result: Payload,
665    },
666    /// A child workflow failed terminally.
667    ChildWorkflowFailed {
668        /// Recording metadata for this event.
669        envelope: EventEnvelope,
670        /// Child workflow that failed.
671        child_workflow_id: WorkflowId,
672        /// Terminal child workflow failure.
673        error: WorkflowError,
674    },
675    /// A child workflow was cancelled as an explicit cancellation outcome.
676    ChildWorkflowCancelled {
677        /// Recording metadata for this event.
678        envelope: EventEnvelope,
679        /// Child workflow that was cancelled.
680        child_workflow_id: WorkflowId,
681    },
682    /// A schedule resource was created.
683    ScheduleCreated {
684        /// Recording metadata for this event.
685        envelope: EventEnvelope,
686        /// Schedule resource that was created.
687        schedule_id: ScheduleId,
688        /// Persisted schedule configuration.
689        config: ScheduleConfig,
690    },
691    /// A schedule resource was updated.
692    ScheduleUpdated {
693        /// Recording metadata for this event.
694        envelope: EventEnvelope,
695        /// Schedule resource that was updated.
696        schedule_id: ScheduleId,
697        /// Updated schedule configuration.
698        config: ScheduleConfig,
699    },
700    /// A schedule resource was paused.
701    SchedulePaused {
702        /// Recording metadata for this event.
703        envelope: EventEnvelope,
704        /// Schedule resource that was paused.
705        schedule_id: ScheduleId,
706    },
707    /// A paused schedule resource was resumed.
708    ScheduleResumed {
709        /// Recording metadata for this event.
710        envelope: EventEnvelope,
711        /// Schedule resource that was resumed.
712        schedule_id: ScheduleId,
713    },
714    /// A schedule resource was deleted.
715    ScheduleDeleted {
716        /// Recording metadata for this event.
717        envelope: EventEnvelope,
718        /// Schedule resource that was deleted.
719        schedule_id: ScheduleId,
720    },
721    /// A schedule tick started a workflow execution.
722    ScheduleTriggered {
723        /// Recording metadata for this event.
724        envelope: EventEnvelope,
725        /// Schedule resource that fired.
726        schedule_id: ScheduleId,
727        /// Workflow execution started by the schedule tick.
728        workflow_id: WorkflowId,
729        /// Run started by the schedule tick.
730        run_id: RunId,
731    },
732    /// An engine-side cadence window fired for a workloop (workloop brief
733    /// R1.4/R4.3): the dead-man clock ticked and the fire was durably recorded
734    /// through the loop's single Recorder, exactly as any other asynchronous
735    /// arrival. Nonterminal and status-invisible.
736    CadenceFired {
737        /// Recording metadata for this event.
738        envelope: EventEnvelope,
739        /// One-based sequence number of the cadence window that fired.
740        window_seq: u64,
741    },
742    /// A workloop iteration closed its bounded history generation (R3.1). The
743    /// iteration's terminal routes land as health samples against the loop's
744    /// invariants per the declared confirms mapping (R3.3). Nonterminal and
745    /// status-invisible: the accompanying [`Event::WorkflowContinuedAsNew`]
746    /// carries the generation boundary itself.
747    IterationClosed {
748        /// Recording metadata for this event.
749        envelope: EventEnvelope,
750        /// Routes the iteration took, in order; the last is its terminal.
751        routes: Vec<String>,
752        /// Health samples derived from the routes against the loop's
753        /// invariants — every invariant is sampled on the same tick (R2.2).
754        health_samples: Vec<crate::HealthSample>,
755    },
756    /// A workloop was retired: the declared, recorded way to stop that is not
757    /// failure (R2.5). Nonterminal and status-invisible on its own — the
758    /// terminal is the [`Event::WorkflowCompleted`] recorded in the SAME
759    /// append, so retirement reads as an intentional stop, never an outage,
760    /// without any new terminal machinery or status variant.
761    LoopRetired {
762        /// Recording metadata for this event.
763        envelope: EventEnvelope,
764        /// Declared retirement reason, named at the point of stopping.
765        reason: String,
766    },
767    /// A detached top-level workflow was hatched by this run (R13.1). NOT a
768    /// child: no lifecycle tie, no supervision edge, no awaited terminal — the
769    /// hatched workflow outlives the hatching iteration and owes it nothing.
770    /// Nonterminal and status-invisible.
771    WorkflowHatched {
772        /// Recording metadata for this event.
773        envelope: EventEnvelope,
774        /// Deterministic identity of the hatched workflow, derived by
775        /// [`crate::hatch_workflow_id`] from (namespace, workflow type, key) —
776        /// so a retry or replay re-mints the SAME id and a duplicate hatch is
777        /// a recorded no-op returning it.
778        child_workflow_id: WorkflowId,
779        /// Dedupe key derived from the observed subject (e.g. a task id).
780        key: String,
781    },
782    /// The ONE alarm path (R4.2): an invariant is not confirmed held. A missed
783    /// window, a red sample, a dead loop, and duration-form silence are all
784    /// THIS event — cause is a field, never a separate alarm channel.
785    /// Nonterminal and status-invisible; trigger selectors (Leg 3) arm on
786    /// named causes as allowlists (R5.2a).
787    InvariantUnconfirmed {
788        /// Recording metadata for this event.
789        envelope: EventEnvelope,
790        /// Invariant that is not confirmed held.
791        invariant: String,
792        /// Why confirmation is missing — the evidence class, named.
793        cause: crate::AlarmCause,
794        /// Cadence window at which tolerance was exceeded; `None` on a
795        /// signal-only loop, which has no windows.
796        window_seq: Option<u64>,
797        /// When the invariant was last confirmed, if ever — part of the
798        /// alarm's completeness claim (R4.4): what was sampled and when.
799        last_confirmed_at: Option<DateTime<Utc>>,
800        /// Consecutive unconfirmed samples/windows observed at alarm time —
801        /// the other half of the completeness claim.
802        consecutive_unconfirmed: u64,
803    },
804}
805
806/// Durable terminal outcome for a `with_timeout` operation.
807#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
808pub enum WithTimeoutOutcome {
809    /// The operation closure returned before the deadline.
810    OperationCompleted,
811    /// The deadline fired before the operation completed.
812    TimedOut,
813}
814
815/// Who retired a durable timer, recorded on [`Event::TimerCancelled`].
816///
817/// The distinction decides reopen semantics. A timer the WORKFLOW retired —
818/// an SDK `cancel_timer` call or a `with_timeout` scope settling because the
819/// racing operation won — is a business fact: reopen must never resurrect it.
820/// A timer the ENGINE retired while tearing down a cancelled run
821/// (`Engine::cancel`'s in-flight timer cleanup) is bookkeeping: the deadline
822/// itself was never reached or waived, so reopening the run re-arms it at its
823/// original `fire_at`.
824#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, Default, PartialEq, Eq)]
825pub enum TimerCancelCause {
826    /// Workflow code retired the timer (SDK cancel or a settled timeout scope).
827    ///
828    /// The serde default: histories recorded before this field existed decode
829    /// as workflow intent, preserving their pre-field never-resurrected
830    /// behavior.
831    #[default]
832    WorkflowIntent,
833    /// The engine retired the timer while cancelling its workflow run.
834    CancelTeardown,
835}
836
837impl Event {
838    /// Returns the envelope recorded with this event.
839    #[must_use]
840    pub const fn envelope(&self) -> &EventEnvelope {
841        match self {
842            Self::WorkflowStarted { envelope, .. }
843            | Self::WorkflowCompleted { envelope, .. }
844            | Self::WorkflowFailed { envelope, .. }
845            | Self::WorkflowCancelled { envelope, .. }
846            | Self::WorkflowTimedOut { envelope, .. }
847            | Self::WorkflowContinuedAsNew { envelope, .. }
848            | Self::WorkflowReopened { envelope, .. }
849            | Self::WorkflowPaused { envelope, .. }
850            | Self::WorkflowResumed { envelope, .. }
851            | Self::SearchAttributesUpdated { envelope, .. }
852            | Self::ActivityScheduled { envelope, .. }
853            | Self::ActivityStarted { envelope, .. }
854            | Self::ActivityLeased { envelope, .. }
855            | Self::ActivityAdoptionOffered { envelope, .. }
856            | Self::ActivityCompleted { envelope, .. }
857            | Self::ActivityFailed { envelope, .. }
858            | Self::ActivityAdvisoryExhausted { envelope, .. }
859            | Self::ActivityFallbackRouted { envelope, .. }
860            | Self::ActivityCancelled { envelope, .. }
861            | Self::TimerStarted { envelope, .. }
862            | Self::TimerFired { envelope, .. }
863            | Self::TimerCancelled { envelope, .. }
864            | Self::WithTimeoutCompleted { envelope, .. }
865            | Self::SignalReceived { envelope, .. }
866            | Self::SignalSent { envelope, .. }
867            | Self::ChildWorkflowStarted { envelope, .. }
868            | Self::ChildWorkflowCompleted { envelope, .. }
869            | Self::ChildWorkflowFailed { envelope, .. }
870            | Self::ChildWorkflowCancelled { envelope, .. }
871            | Self::ScheduleCreated { envelope, .. }
872            | Self::ScheduleUpdated { envelope, .. }
873            | Self::SchedulePaused { envelope, .. }
874            | Self::ScheduleResumed { envelope, .. }
875            | Self::ScheduleDeleted { envelope, .. }
876            | Self::ScheduleTriggered { envelope, .. }
877            | Self::CadenceFired { envelope, .. }
878            | Self::IterationClosed { envelope, .. }
879            | Self::LoopRetired { envelope, .. }
880            | Self::WorkflowHatched { envelope, .. }
881            | Self::InvariantUnconfirmed { envelope, .. } => envelope,
882        }
883    }
884
885    /// Returns the monotonic sequence number recorded for this event.
886    #[must_use]
887    pub const fn seq(&self) -> u64 {
888        self.envelope().seq
889    }
890
891    /// Returns the deterministic recorded timestamp for this event.
892    #[must_use]
893    pub const fn recorded_at(&self) -> &DateTime<Utc> {
894        &self.envelope().recorded_at
895    }
896
897    /// Returns the workflow history that owns this event.
898    #[must_use]
899    pub const fn workflow_id(&self) -> &WorkflowId {
900        &self.envelope().workflow_id
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use std::collections::HashMap;
907
908    use chrono::{DateTime, Utc};
909    use serde_json::json;
910
911    use super::{
912        DEFAULT_TASK_QUEUE, Event, EventEnvelope, LEGACY_ACTIVITY_ATTEMPT, TimerCancelCause,
913    };
914    use crate::{
915        ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
916        Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
917        WorkerAttribution, WorkerTransport, WorkflowError, WorkflowId,
918    };
919
920    fn package_version() -> PackageVersion {
921        PackageVersion::new("a".repeat(64))
922    }
923
924    fn recorded_at() -> DateTime<Utc> {
925        DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
926    }
927
928    fn envelope(seq: u64) -> EventEnvelope {
929        EventEnvelope {
930            seq,
931            recorded_at: recorded_at(),
932            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
933        }
934    }
935
936    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
937        Payload::from_json(&json!({ "label": label }))
938    }
939
940    fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
941        Ok(ScheduleConfig {
942            trigger: TriggerSpec::Cron {
943                expression: String::from("0 0 * * *"),
944            },
945            overlap_policy: OverlapPolicy::Skip,
946            catch_up_policy: CatchUpPolicy::One,
947            workflow_type: String::from("checkout"),
948            input: payload(label)?,
949            search_attributes: HashMap::from([(
950                String::from("aion.namespace"),
951                crate::SearchAttributeValue::String(String::from("tenant-a")),
952            )]),
953        })
954    }
955
956    fn workflow_error(message: &str) -> WorkflowError {
957        WorkflowError {
958            message: String::from(message),
959            details: None,
960        }
961    }
962
963    fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
964        ActivityError {
965            kind,
966            message: String::from(message),
967            details: None,
968        }
969    }
970
971    fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
972        let json = serde_json::to_string(event)?;
973        let decoded = serde_json::from_str::<Event>(&json)?;
974        assert_eq!(*event, decoded);
975        Ok(())
976    }
977
978    /// NSTQ-3: a recorded `ActivityScheduled` carries its `task_queue` through the durable JSON
979    /// wire so reopen/recovery can re-target the same pool.
980    #[test]
981    fn activity_scheduled_records_and_reads_back_its_task_queue()
982    -> Result<(), Box<dyn std::error::Error>> {
983        let event = Event::ActivityScheduled {
984            envelope: envelope(6),
985            activity_id: ActivityId::from_sequence_position(6),
986            activity_type: String::from("charge-card"),
987            input: payload("activity-input")?,
988            task_queue: String::from("claude"),
989            node: None,
990        };
991
992        let json = serde_json::to_string(&event)?;
993        let decoded = serde_json::from_str::<Event>(&json)?;
994
995        match decoded {
996            Event::ActivityScheduled { task_queue, .. } => {
997                assert_eq!(
998                    task_queue, "claude",
999                    "the recorded task queue must survive the round-trip"
1000                );
1001            }
1002            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
1003        }
1004        Ok(())
1005    }
1006
1007    /// NSTQ-3 replay-safety (the load-bearing test): an OLD recorded history that has no
1008    /// `task_queue` key on its `ActivityScheduled` events MUST still decode, defaulting the missing
1009    /// value to the named `"default"` task queue, deterministically — never panic, never differ
1010    /// run-to-run. The old wire form is the exact pre-field bytes: the current serialization with
1011    /// the `task_queue` key removed.
1012    #[test]
1013    fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
1014    -> Result<(), Box<dyn std::error::Error>> {
1015        // Build a current event, serialize, then strip the `task_queue` key to reconstruct exactly
1016        // what a history recorded before the field existed looks like on the wire.
1017        let current = Event::ActivityScheduled {
1018            envelope: envelope(6),
1019            activity_id: ActivityId::from_sequence_position(6),
1020            activity_type: String::from("charge-card"),
1021            input: payload("activity-input")?,
1022            task_queue: String::from("ignored-when-stripped"),
1023            node: Some(String::from("ignored-when-stripped")),
1024        };
1025        let mut value = serde_json::to_value(&current)?;
1026        let data = value
1027            .get_mut("data")
1028            .and_then(serde_json::Value::as_object_mut)
1029            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
1030        assert!(
1031            data.remove("task_queue").is_some(),
1032            "the current wire form must contain task_queue before we strip it"
1033        );
1034
1035        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
1036        // back the named default, deterministically.
1037        let old_wire = serde_json::to_string(&value)?;
1038        for _ in 0..4 {
1039            let decoded = serde_json::from_str::<Event>(&old_wire)?;
1040            match &decoded {
1041                Event::ActivityScheduled { task_queue, .. } => {
1042                    assert_eq!(
1043                        task_queue, DEFAULT_TASK_QUEUE,
1044                        "a missing task_queue must default to the named default queue"
1045                    );
1046                    assert_eq!(task_queue, "default");
1047                }
1048                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
1049            }
1050        }
1051        Ok(())
1052    }
1053
1054    /// NODE-3: a recorded `ActivityScheduled` carries its OPTIONAL `node` affinity through the
1055    /// durable JSON wire so reopen/recovery can re-target the same node.
1056    #[test]
1057    fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
1058    {
1059        let event = Event::ActivityScheduled {
1060            envelope: envelope(6),
1061            activity_id: ActivityId::from_sequence_position(6),
1062            activity_type: String::from("charge-card"),
1063            input: payload("activity-input")?,
1064            task_queue: String::from("claude"),
1065            node: Some(String::from("box-7")),
1066        };
1067
1068        let json = serde_json::to_string(&event)?;
1069        let decoded = serde_json::from_str::<Event>(&json)?;
1070
1071        match decoded {
1072            Event::ActivityScheduled { node, .. } => {
1073                assert_eq!(
1074                    node.as_deref(),
1075                    Some("box-7"),
1076                    "the recorded node affinity must survive the round-trip"
1077                );
1078            }
1079            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
1080        }
1081        Ok(())
1082    }
1083
1084    /// NODE-3 replay-safety (the load-bearing test): an OLD recorded history that has no `node` key
1085    /// on its `ActivityScheduled` events MUST still decode, defaulting the missing value to `None`
1086    /// (no affinity) deterministically — never a sentinel, never panic, never differ run-to-run.
1087    /// The old wire form is the exact pre-field bytes: the current serialization with the `node`
1088    /// key removed.
1089    #[test]
1090    fn activity_scheduled_decodes_old_history_without_node_as_none()
1091    -> Result<(), Box<dyn std::error::Error>> {
1092        // Build a current event with a node set, serialize, then strip the `node` key to
1093        // reconstruct exactly what a history recorded before the field existed looks like on the
1094        // wire.
1095        let current = Event::ActivityScheduled {
1096            envelope: envelope(6),
1097            activity_id: ActivityId::from_sequence_position(6),
1098            activity_type: String::from("charge-card"),
1099            input: payload("activity-input")?,
1100            task_queue: String::from("default"),
1101            node: Some(String::from("ignored-when-stripped")),
1102        };
1103        let mut value = serde_json::to_value(&current)?;
1104        let data = value
1105            .get_mut("data")
1106            .and_then(serde_json::Value::as_object_mut)
1107            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
1108        assert!(
1109            data.remove("node").is_some(),
1110            "the current wire form must contain node before we strip it"
1111        );
1112
1113        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
1114        // back `None`, deterministically.
1115        let old_wire = serde_json::to_string(&value)?;
1116        for _ in 0..4 {
1117            let decoded = serde_json::from_str::<Event>(&old_wire)?;
1118            match &decoded {
1119                Event::ActivityScheduled { node, .. } => {
1120                    assert_eq!(
1121                        *node, None,
1122                        "a missing node must default to None (no affinity)"
1123                    );
1124                }
1125                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
1126            }
1127        }
1128        Ok(())
1129    }
1130
1131    /// NOI-0 positive round-trip: `ActivityStarted`, `ActivityCompleted`, and `ActivityCancelled`
1132    /// each carry a genuine one-based `attempt` through the durable JSON wire, so replay reads back
1133    /// the same attempt that was recorded — a completed activity has one consistent attempt readable
1134    /// off BOTH its start and its terminal (the invariant the NOI design keys on).
1135    #[test]
1136    fn activity_lifecycle_records_and_reads_back_its_attempt()
1137    -> Result<(), Box<dyn std::error::Error>> {
1138        let started = Event::ActivityStarted {
1139            envelope: envelope(7),
1140            activity_id: ActivityId::from_sequence_position(6),
1141            attempt: 3,
1142        };
1143        let completed = Event::ActivityCompleted {
1144            envelope: envelope(8),
1145            activity_id: ActivityId::from_sequence_position(6),
1146            result: payload("activity-result")?,
1147            attempt: 3,
1148        };
1149        let cancelled = Event::ActivityCancelled {
1150            envelope: envelope(9),
1151            activity_id: ActivityId::from_sequence_position(6),
1152            attempt: 3,
1153        };
1154
1155        for event in [&started, &completed, &cancelled] {
1156            round_trip(event)?;
1157        }
1158
1159        // Read the attempt back off each decoded terminal — it must be the recorded value, not the
1160        // legacy sentinel.
1161        match serde_json::from_str::<Event>(&serde_json::to_string(&started)?)? {
1162            Event::ActivityStarted { attempt, .. } => assert_eq!(attempt, 3),
1163            other => return Err(format!("expected ActivityStarted, got {other:?}").into()),
1164        }
1165        match serde_json::from_str::<Event>(&serde_json::to_string(&completed)?)? {
1166            Event::ActivityCompleted { attempt, .. } => assert_eq!(attempt, 3),
1167            other => return Err(format!("expected ActivityCompleted, got {other:?}").into()),
1168        }
1169        match serde_json::from_str::<Event>(&serde_json::to_string(&cancelled)?)? {
1170            Event::ActivityCancelled { attempt, .. } => assert_eq!(attempt, 3),
1171            other => return Err(format!("expected ActivityCancelled, got {other:?}").into()),
1172        }
1173        Ok(())
1174    }
1175
1176    /// NOI-0 replay-safety (the load-bearing negative control): an OLD recorded history that has no
1177    /// `attempt` key on its `ActivityStarted` / `ActivityCompleted` / `ActivityCancelled` events MUST
1178    /// still decode without panic, defaulting the missing value to the legacy sentinel
1179    /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) deterministically — never differ run-to-run. Because real
1180    /// attempts are one-based, `0` can never collide with a genuine attempt. The old wire form is the
1181    /// exact pre-field bytes: the current serialization with the `attempt` key removed.
1182    #[test]
1183    fn activity_lifecycle_decodes_old_history_without_attempt_as_legacy_sentinel()
1184    -> Result<(), Box<dyn std::error::Error>> {
1185        // One current event per variant, each with a NON-sentinel attempt so we can prove the strip
1186        // (not the value) is what drives the default on decode.
1187        let started = Event::ActivityStarted {
1188            envelope: envelope(7),
1189            activity_id: ActivityId::from_sequence_position(6),
1190            attempt: 5,
1191        };
1192        let completed = Event::ActivityCompleted {
1193            envelope: envelope(8),
1194            activity_id: ActivityId::from_sequence_position(6),
1195            result: payload("activity-result")?,
1196            attempt: 5,
1197        };
1198        let cancelled = Event::ActivityCancelled {
1199            envelope: envelope(9),
1200            activity_id: ActivityId::from_sequence_position(6),
1201            attempt: 5,
1202        };
1203
1204        // Strip the `attempt` key from each to reconstruct exactly what a pre-NOI-0 history looks
1205        // like on the wire, then decode the stripped form repeatedly: it must succeed and always read
1206        // back the legacy sentinel, deterministically.
1207        for current in [&started, &completed, &cancelled] {
1208            let mut value = serde_json::to_value(current)?;
1209            let data = value
1210                .get_mut("data")
1211                .and_then(serde_json::Value::as_object_mut)
1212                .ok_or("activity lifecycle event must serialize to a tagged object with `data`")?;
1213            assert!(
1214                data.remove("attempt").is_some(),
1215                "the current wire form must contain attempt before we strip it"
1216            );
1217            let old_wire = serde_json::to_string(&value)?;
1218            for _ in 0..4 {
1219                let decoded = serde_json::from_str::<Event>(&old_wire)?;
1220                let attempt = match &decoded {
1221                    Event::ActivityStarted { attempt, .. }
1222                    | Event::ActivityCompleted { attempt, .. }
1223                    | Event::ActivityCancelled { attempt, .. } => *attempt,
1224                    other => {
1225                        return Err(
1226                            format!("expected an activity lifecycle event, got {other:?}").into(),
1227                        );
1228                    }
1229                };
1230                assert_eq!(
1231                    attempt, LEGACY_ACTIVITY_ATTEMPT,
1232                    "a missing attempt must default to the legacy sentinel (0)"
1233                );
1234                assert_eq!(attempt, 0);
1235            }
1236        }
1237        Ok(())
1238    }
1239
1240    /// Replay-safety proof for the `cause` field on `TimerCancelled` (#222):
1241    /// a history recorded BEFORE the field existed has no `cause` key and MUST
1242    /// decode without panic, defaulting to `WorkflowIntent` — the pre-field
1243    /// behavior (a reopen never resurrects it) — deterministically. The old
1244    /// wire form is the exact pre-field bytes: the current serialization with
1245    /// the `cause` key removed.
1246    #[test]
1247    fn timer_cancelled_decodes_old_history_without_cause_as_workflow_intent()
1248    -> Result<(), Box<dyn std::error::Error>> {
1249        // A NON-default cause proves the strip (not the value) drives the default.
1250        let cancelled = Event::TimerCancelled {
1251            envelope: envelope(7),
1252            timer_id: TimerId::named("deadline")?,
1253            cause: TimerCancelCause::CancelTeardown,
1254        };
1255
1256        let mut value = serde_json::to_value(&cancelled)?;
1257        let data = value
1258            .get_mut("data")
1259            .and_then(serde_json::Value::as_object_mut)
1260            .ok_or("TimerCancelled must serialize to a tagged object with `data`")?;
1261        assert!(
1262            data.remove("cause").is_some(),
1263            "the current wire form must contain cause before we strip it"
1264        );
1265        let old_wire = serde_json::to_string(&value)?;
1266        for _ in 0..4 {
1267            let decoded = serde_json::from_str::<Event>(&old_wire)?;
1268            match &decoded {
1269                Event::TimerCancelled { cause, .. } => assert_eq!(
1270                    *cause,
1271                    TimerCancelCause::WorkflowIntent,
1272                    "a missing cause must default to WorkflowIntent (never resurrected)"
1273                ),
1274                other => {
1275                    return Err(format!("expected TimerCancelled, got {other:?}").into());
1276                }
1277            }
1278        }
1279        Ok(())
1280    }
1281
1282    /// Pause/resume (#204) round-trip: the two new NON-terminal lifecycle markers
1283    /// carry plain fields and survive the durable JSON wire unchanged.
1284    #[test]
1285    fn pause_resume_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1286        let events = vec![
1287            Event::WorkflowPaused {
1288                envelope: envelope(2),
1289                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1290                reason: Some(String::from("operator hold")),
1291                operator: Some(String::from("tom")),
1292            },
1293            Event::WorkflowPaused {
1294                envelope: envelope(3),
1295                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1296                reason: None,
1297                operator: None,
1298            },
1299            Event::WorkflowResumed {
1300                envelope: envelope(4),
1301                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1302                operator: Some(String::from("tom")),
1303            },
1304        ];
1305        for event in &events {
1306            round_trip(event)?;
1307        }
1308        Ok(())
1309    }
1310
1311    /// GATE-6 back-compat: an OLD history serialized before pause/resume existed
1312    /// decodes byte-identically — it simply never contains the new variants. We
1313    /// prove the whole event enum still decodes an old-shape history with no new
1314    /// variants present (the decode round-trip test the brief requires), and that
1315    /// adding the variants did not change the encoding of any existing variant.
1316    #[test]
1317    fn old_history_without_pause_resume_decodes_unchanged() -> Result<(), Box<dyn std::error::Error>>
1318    {
1319        let started = Event::WorkflowStarted {
1320            envelope: envelope(1),
1321            workflow_type: String::from("checkout"),
1322            input: payload("input")?,
1323            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1324            parent_run_id: None,
1325            parent_workflow_id: None,
1326            package_version: package_version(),
1327        };
1328        let completed = Event::WorkflowCompleted {
1329            envelope: envelope(2),
1330            result: payload("result")?,
1331        };
1332        // Serialize an old-shape history and decode it back: no new variant is
1333        // present, and every existing variant round-trips exactly.
1334        let history = vec![started, completed];
1335        let json = serde_json::to_string(&history)?;
1336        let decoded = serde_json::from_str::<Vec<Event>>(&json)?;
1337        assert_eq!(history, decoded);
1338        Ok(())
1339    }
1340
1341    /// #144: the start-time task queue projects from the `aion.task_queue`
1342    /// search attribute recorded by `SearchAttributesUpdated`, mirroring the
1343    /// `aion.namespace` projection. A later update overrides an earlier value.
1344    #[test]
1345    fn start_time_task_queue_projects_from_recorded_attribute()
1346    -> Result<(), Box<dyn std::error::Error>> {
1347        use super::{START_TIME_TASK_QUEUE_ATTRIBUTE, start_time_task_queue};
1348        use crate::SearchAttributeValue;
1349
1350        let events = vec![
1351            Event::WorkflowStarted {
1352                envelope: envelope(1),
1353                workflow_type: String::from("checkout"),
1354                input: payload("input")?,
1355                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1356                parent_run_id: None,
1357                parent_workflow_id: None,
1358                package_version: package_version(),
1359            },
1360            Event::SearchAttributesUpdated {
1361                envelope: envelope(2),
1362                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1363                attributes: HashMap::from([(
1364                    START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
1365                    SearchAttributeValue::String(String::from("gpu")),
1366                )]),
1367            },
1368        ];
1369
1370        assert_eq!(start_time_task_queue(&events).as_deref(), Some("gpu"));
1371        Ok(())
1372    }
1373
1374    /// #144 back-compat: a history with no recorded `aion.task_queue` attribute
1375    /// projects `None`, so callers fall back to the named default.
1376    #[test]
1377    fn start_time_task_queue_is_none_without_the_attribute()
1378    -> Result<(), Box<dyn std::error::Error>> {
1379        use super::start_time_task_queue;
1380
1381        let events = vec![Event::WorkflowStarted {
1382            envelope: envelope(1),
1383            workflow_type: String::from("checkout"),
1384            input: payload("input")?,
1385            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1386            parent_run_id: None,
1387            parent_workflow_id: None,
1388            package_version: package_version(),
1389        }];
1390
1391        assert_eq!(start_time_task_queue(&events), None);
1392        Ok(())
1393    }
1394
1395    /// #211: the display name projects from the `aion.display_name` search
1396    /// attribute recorded by `SearchAttributesUpdated`, mirroring the
1397    /// `aion.task_queue` projection.
1398    #[test]
1399    fn display_name_projects_from_recorded_attribute() -> Result<(), Box<dyn std::error::Error>> {
1400        use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
1401        use crate::SearchAttributeValue;
1402
1403        let events = vec![
1404            Event::WorkflowStarted {
1405                envelope: envelope(1),
1406                workflow_type: String::from("checkout"),
1407                input: payload("input")?,
1408                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1409                parent_run_id: None,
1410                parent_workflow_id: None,
1411                package_version: package_version(),
1412            },
1413            Event::SearchAttributesUpdated {
1414                envelope: envelope(2),
1415                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1416                attributes: HashMap::from([(
1417                    DISPLAY_NAME_ATTRIBUTE.to_owned(),
1418                    SearchAttributeValue::String(String::from("Nightly settlement")),
1419                )]),
1420            },
1421        ];
1422
1423        assert_eq!(display_name(&events).as_deref(), Some("Nightly settlement"));
1424        Ok(())
1425    }
1426
1427    /// #211 back-compat: a history with no recorded `aion.display_name`
1428    /// attribute projects `None`, so the unnamed run renders as its bare UUID.
1429    #[test]
1430    fn display_name_is_none_without_the_attribute() -> Result<(), Box<dyn std::error::Error>> {
1431        use super::display_name;
1432
1433        let events = vec![Event::WorkflowStarted {
1434            envelope: envelope(1),
1435            workflow_type: String::from("checkout"),
1436            input: payload("input")?,
1437            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1438            parent_run_id: None,
1439            parent_workflow_id: None,
1440            package_version: package_version(),
1441        }];
1442
1443        assert_eq!(display_name(&events), None);
1444        Ok(())
1445    }
1446
1447    /// #211 rename invariant: a later `SearchAttributesUpdated` overrides the
1448    /// earlier name (last write wins) while BOTH remain in history — a rename
1449    /// is a recorded event, never an overwrite of the past.
1450    #[test]
1451    fn display_name_later_update_overrides_earlier() -> Result<(), Box<dyn std::error::Error>> {
1452        use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
1453        use crate::SearchAttributeValue;
1454
1455        let name_event = |seq: u64, name: &str| Event::SearchAttributesUpdated {
1456            envelope: envelope(seq),
1457            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1458            attributes: HashMap::from([(
1459                DISPLAY_NAME_ATTRIBUTE.to_owned(),
1460                SearchAttributeValue::String(String::from(name)),
1461            )]),
1462        };
1463        let events = vec![
1464            Event::WorkflowStarted {
1465                envelope: envelope(1),
1466                workflow_type: String::from("checkout"),
1467                input: payload("input")?,
1468                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1469                parent_run_id: None,
1470                parent_workflow_id: None,
1471                package_version: package_version(),
1472            },
1473            name_event(2, "first name"),
1474            name_event(3, "second name"),
1475        ];
1476
1477        assert_eq!(display_name(&events).as_deref(), Some("second name"));
1478        // NOTE: the "history keeps both names" half of this invariant is NOT
1479        // asserted here. `events` is this test's own literal, so counting it
1480        // would measure the `vec!` rather than any behaviour. It is proven
1481        // where it can be — against events a real recorder appended — by
1482        // `aion/tests/rename_e2e.rs`
1483        // `renaming_twice_supersedes_while_history_keeps_both_names`.
1484        Ok(())
1485    }
1486
1487    #[test]
1488    fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
1489        let workflow_id = WorkflowId::new_v4();
1490        let recorded_at = recorded_at();
1491        let envelope = EventEnvelope {
1492            seq: 17,
1493            recorded_at,
1494            workflow_id: workflow_id.clone(),
1495        };
1496        let event = Event::WorkflowStarted {
1497            envelope,
1498            workflow_type: String::from("checkout"),
1499            input: payload("input")?,
1500            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1501            parent_run_id: None,
1502            parent_workflow_id: None,
1503            package_version: package_version(),
1504        };
1505
1506        assert_eq!(event.seq(), 17);
1507        assert_eq!(event.recorded_at(), &recorded_at);
1508        assert_eq!(event.workflow_id(), &workflow_id);
1509        Ok(())
1510    }
1511
1512    #[test]
1513    fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1514        let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
1515        let events = vec![
1516            Event::WorkflowStarted {
1517                envelope: envelope(1),
1518                workflow_type: String::from("checkout"),
1519                input: payload("workflow-input")?,
1520                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1521                parent_run_id: None,
1522                parent_workflow_id: None,
1523                package_version: package_version(),
1524            },
1525            Event::WorkflowCompleted {
1526                envelope: envelope(2),
1527                result: payload("workflow-result")?,
1528            },
1529            Event::WorkflowFailed {
1530                envelope: envelope(3),
1531                error: workflow_error("workflow failed"),
1532            },
1533            Event::WorkflowCancelled {
1534                envelope: envelope(4),
1535                reason: String::from("caller requested cancellation"),
1536            },
1537            Event::WorkflowTimedOut {
1538                envelope: envelope(5),
1539                timeout: String::from("execution"),
1540            },
1541            Event::ActivityScheduled {
1542                envelope: envelope(6),
1543                activity_id: ActivityId::from_sequence_position(6),
1544                activity_type: String::from("charge-card"),
1545                input: payload("activity-input")?,
1546                task_queue: String::from("claude"),
1547                node: Some(String::from("box-7")),
1548            },
1549            Event::ActivityStarted {
1550                envelope: envelope(7),
1551                activity_id: ActivityId::from_sequence_position(6),
1552                attempt: 1,
1553            },
1554            Event::ActivityCompleted {
1555                envelope: envelope(8),
1556                activity_id: ActivityId::from_sequence_position(6),
1557                result: payload("activity-result")?,
1558                attempt: 1,
1559            },
1560            Event::ActivityFailed {
1561                envelope: envelope(9),
1562                activity_id: ActivityId::from_sequence_position(6),
1563                error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
1564                attempt: 1,
1565            },
1566            Event::ActivityCancelled {
1567                envelope: envelope(10),
1568                activity_id: ActivityId::from_sequence_position(6),
1569                attempt: 1,
1570            },
1571            Event::TimerStarted {
1572                envelope: envelope(11),
1573                timer_id: TimerId::anonymous(11),
1574                fire_at,
1575            },
1576            Event::TimerFired {
1577                envelope: envelope(12),
1578                timer_id: TimerId::anonymous(11),
1579            },
1580            Event::TimerCancelled {
1581                envelope: envelope(13),
1582                timer_id: TimerId::named("reminder")?,
1583                cause: TimerCancelCause::WorkflowIntent,
1584            },
1585            Event::SignalReceived {
1586                envelope: envelope(14),
1587                name: String::from("approve"),
1588                payload: payload("signal")?,
1589            },
1590            Event::SignalSent {
1591                envelope: envelope(15),
1592                target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
1593                name: String::from("approve"),
1594                payload: payload("signal-sent")?,
1595            },
1596        ];
1597
1598        for event in events {
1599            round_trip(&event)?;
1600        }
1601        Ok(())
1602    }
1603
1604    #[test]
1605    fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1606        let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
1607        let events = vec![
1608            Event::ChildWorkflowStarted {
1609                envelope: envelope(16),
1610                child_workflow_id: child_workflow_id.clone(),
1611                workflow_type: String::from("fulfillment"),
1612                input: payload("child-input")?,
1613                package_version: package_version(),
1614            },
1615            Event::ChildWorkflowCompleted {
1616                envelope: envelope(16),
1617                child_workflow_id: child_workflow_id.clone(),
1618                result: payload("child-result")?,
1619            },
1620            Event::ChildWorkflowFailed {
1621                envelope: envelope(17),
1622                child_workflow_id: child_workflow_id.clone(),
1623                error: workflow_error("child failed"),
1624            },
1625            Event::ChildWorkflowCancelled {
1626                envelope: envelope(18),
1627                child_workflow_id,
1628            },
1629        ];
1630
1631        for event in events {
1632            round_trip(&event)?;
1633        }
1634        Ok(())
1635    }
1636
1637    #[test]
1638    fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1639        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
1640        let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
1641        let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
1642        let events = vec![
1643            Event::WorkflowContinuedAsNew {
1644                envelope: envelope(19),
1645                input: payload("continued-input")?,
1646                workflow_type: Some(String::from("checkout-v2")),
1647                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
1648            },
1649            Event::SearchAttributesUpdated {
1650                envelope: envelope(20),
1651                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1652                attributes: HashMap::from([(
1653                    String::from("customer_id"),
1654                    SearchAttributeValue::String(String::from("cust-123")),
1655                )]),
1656            },
1657            Event::ScheduleCreated {
1658                envelope: envelope(20),
1659                schedule_id: schedule_id.clone(),
1660                config: schedule_config("schedule-created")?,
1661            },
1662            Event::ScheduleUpdated {
1663                envelope: envelope(21),
1664                schedule_id: schedule_id.clone(),
1665                config: schedule_config("schedule-updated")?,
1666            },
1667            Event::SchedulePaused {
1668                envelope: envelope(22),
1669                schedule_id: schedule_id.clone(),
1670            },
1671            Event::ScheduleResumed {
1672                envelope: envelope(23),
1673                schedule_id: schedule_id.clone(),
1674            },
1675            Event::ScheduleDeleted {
1676                envelope: envelope(24),
1677                schedule_id: schedule_id.clone(),
1678            },
1679            Event::ScheduleTriggered {
1680                envelope: envelope(25),
1681                schedule_id,
1682                workflow_id: triggered_workflow_id,
1683                run_id: triggered_run_id,
1684            },
1685        ];
1686
1687        for event in events {
1688            round_trip(&event)?;
1689        }
1690        Ok(())
1691    }
1692
1693    #[test]
1694    fn workloop_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1695        let events = vec![
1696            Event::CadenceFired {
1697                envelope: envelope(26),
1698                window_seq: 41,
1699            },
1700            Event::IterationClosed {
1701                envelope: envelope(27),
1702                routes: vec![String::from("sweep"), String::from("start")],
1703                health_samples: vec![crate::HealthSample {
1704                    invariant: String::from("serving"),
1705                    status: crate::HealthStatus::Confirmed,
1706                    window_seq: Some(41),
1707                }],
1708            },
1709            Event::LoopRetired {
1710                envelope: envelope(28),
1711                reason: String::from("queue decommissioned; drained on operator signal"),
1712            },
1713            Event::WorkflowHatched {
1714                envelope: envelope(29),
1715                child_workflow_id: crate::hatch_workflow_id("default", "process_task", "task-7")?,
1716                key: String::from("task-7"),
1717            },
1718            Event::InvariantUnconfirmed {
1719                envelope: envelope(30),
1720                invariant: String::from("serving"),
1721                cause: crate::AlarmCause::WindowMissed,
1722                window_seq: Some(42),
1723                last_confirmed_at: Some(recorded_at()),
1724                consecutive_unconfirmed: 4,
1725            },
1726            Event::InvariantUnconfirmed {
1727                envelope: envelope(31),
1728                invariant: String::from("serving"),
1729                cause: crate::AlarmCause::UnconfirmedUnknown,
1730                window_seq: None,
1731                last_confirmed_at: None,
1732                consecutive_unconfirmed: 0,
1733            },
1734        ];
1735
1736        for event in events {
1737            round_trip(&event)?;
1738        }
1739        Ok(())
1740    }
1741
1742    fn attribution(transport: WorkerTransport) -> WorkerAttribution {
1743        WorkerAttribution {
1744            identity: String::from("worker-a@host-1"),
1745            task_queue: String::from("billing"),
1746            node: Some(String::from("n1")),
1747            deployment: Some(String::from("billing-workers")),
1748            instance_id: Some(String::from("i-42")),
1749            transport,
1750        }
1751    }
1752
1753    /// WA-010 R1: a lease round-trips the durable worker names and the SAME
1754    /// `(activity, attempt)` identity its `ActivityStarted` anchored, on both
1755    /// transports. The decoded event is read back field by field so a silently
1756    /// dropped name would fail here, not in a console.
1757    #[test]
1758    fn activity_leased_round_trips_its_worker_names_on_both_transports()
1759    -> Result<(), Box<dyn std::error::Error>> {
1760        for transport in [WorkerTransport::Grpc, WorkerTransport::Liminal] {
1761            let leased = Event::ActivityLeased {
1762                envelope: envelope(8),
1763                activity_id: ActivityId::from_sequence_position(6),
1764                attempt: 3,
1765                worker: attribution(transport),
1766            };
1767            round_trip(&leased)?;
1768            let wire = serde_json::to_value(&leased)?;
1769            assert_eq!(wire["type"], "ActivityLeased");
1770            assert_eq!(wire["data"]["attempt"], 3);
1771            assert_eq!(wire["data"]["worker"]["identity"], "worker-a@host-1");
1772            assert_eq!(wire["data"]["worker"]["task_queue"], "billing");
1773            assert_eq!(wire["data"]["worker"]["node"], "n1");
1774            assert_eq!(wire["data"]["worker"]["deployment"], "billing-workers");
1775            assert_eq!(wire["data"]["worker"]["instance_id"], "i-42");
1776            assert_eq!(
1777                wire["data"]["worker"]["transport"],
1778                serde_json::to_value(transport)?["transport"]
1779            );
1780            match serde_json::from_value::<Event>(wire)? {
1781                Event::ActivityLeased {
1782                    activity_id,
1783                    attempt,
1784                    worker,
1785                    ..
1786                } => {
1787                    assert_eq!(activity_id, ActivityId::from_sequence_position(6));
1788                    assert_eq!(attempt, 3);
1789                    assert_eq!(worker, attribution(transport));
1790                }
1791                other => return Err(format!("expected ActivityLeased, got {other:?}").into()),
1792            }
1793        }
1794        Ok(())
1795    }
1796
1797    /// A lease carries no field that could hold a server-process worker id:
1798    /// every name on the wire is one of the six durable names, and nothing
1799    /// else. A seventh key appearing here is exactly the counter leaking in.
1800    #[test]
1801    fn activity_leased_carries_only_durable_names() -> Result<(), Box<dyn std::error::Error>> {
1802        let wire = serde_json::to_value(Event::ActivityLeased {
1803            envelope: envelope(8),
1804            activity_id: ActivityId::from_sequence_position(6),
1805            attempt: 1,
1806            worker: attribution(WorkerTransport::Grpc),
1807        })?;
1808        let worker = wire["data"]["worker"]
1809            .as_object()
1810            .ok_or("worker must encode as an object")?;
1811        let mut keys: Vec<&str> = worker.keys().map(String::as_str).collect();
1812        keys.sort_unstable();
1813        assert_eq!(
1814            keys,
1815            [
1816                "deployment",
1817                "identity",
1818                "instance_id",
1819                "node",
1820                "task_queue",
1821                "transport"
1822            ]
1823        );
1824        Ok(())
1825    }
1826
1827    /// WA-010 R1 replay-safety: a history recorded before the lease event
1828    /// existed decodes to the same events and re-encodes to the SAME BYTES.
1829    /// The fixture is the exact wire form of a dispatch-and-complete history
1830    /// (start, schedule, dispatch, completion) as the pre-lease code emitted
1831    /// it — adding a variant to an adjacently-tagged enum cannot change any
1832    /// other variant's encoding, and this pin makes that a checked claim
1833    /// rather than an argument, for this change and every one after it.
1834    #[test]
1835    fn history_without_a_lease_decodes_byte_identically() -> Result<(), Box<dyn std::error::Error>>
1836    {
1837        const PRE_LEASE_HISTORY: &str = concat!(
1838            r#"[{"type":"WorkflowStarted","data":{"envelope":{"seq":1,"recorded_at":"2023-11-14T22:13:21Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"workflow_type":"checkout","input":{"content_type":"Json","bytes":[123,34,111,114,100,101,114,34,58,52,50,125]},"run_id":"00000000-0000-0000-0000-000000000001","parent_run_id":null,"parent_workflow_id":null,"package_version":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"#,
1839            r#"{"type":"ActivityScheduled","data":{"envelope":{"seq":2,"recorded_at":"2023-11-14T22:13:22Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"activity_id":2,"activity_type":"charge-card","input":{"content_type":"Json","bytes":[123,34,97,109,111,117,110,116,34,58,53,125]},"task_queue":"billing","node":"n1"}},"#,
1840            r#"{"type":"ActivityStarted","data":{"envelope":{"seq":3,"recorded_at":"2023-11-14T22:13:23Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"activity_id":2,"attempt":1}},"#,
1841            r#"{"type":"ActivityCompleted","data":{"envelope":{"seq":4,"recorded_at":"2023-11-14T22:13:24Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"activity_id":2,"result":{"content_type":"Json","bytes":[123,34,111,107,34,58,116,114,117,101,125]},"attempt":1}}]"#,
1842        );
1843        let decoded: Vec<Event> = serde_json::from_str(PRE_LEASE_HISTORY)?;
1844        assert_eq!(decoded.len(), 4);
1845        assert!(
1846            !decoded
1847                .iter()
1848                .any(|event| matches!(event, Event::ActivityLeased { .. })),
1849            "a pre-lease history must not grow a lease on decode"
1850        );
1851        assert!(matches!(
1852            decoded.get(2),
1853            Some(Event::ActivityStarted { attempt: 1, .. })
1854        ));
1855        assert_eq!(serde_json::to_string(&decoded)?, PRE_LEASE_HISTORY);
1856        Ok(())
1857    }
1858}