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, 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    /// Recovery offered a previously dispatched, still-dangling activity
427    /// attempt back to the worker adoption path.
428    ///
429    /// This is deliberately an OFFER, not a claim that the worker found a live
430    /// holder: the engine cannot observe a harness process directly. It records
431    /// the truthful recovery decision — retain and redeliver the same execution
432    /// identity instead of falsely terminating it as server death. The worker's
433    /// single-flight/spawn gate then either joins the holder or starts it when no
434    /// holder survived.
435    ActivityAdoptionOffered {
436        /// Recording metadata for this event.
437        envelope: EventEnvelope,
438        /// Activity whose dangling attempt is being offered for adoption.
439        activity_id: ActivityId,
440        /// One-based attempt identity retained across the recovery redelivery.
441        attempt: u32,
442    },
443    /// An activity completed successfully.
444    ActivityCompleted {
445        /// Recording metadata for this event.
446        envelope: EventEnvelope,
447        /// Activity that produced the result.
448        activity_id: ActivityId,
449        /// Opaque activity result payload.
450        result: Payload,
451        /// One-based activity attempt number that produced this completion (NOI-0).
452        ///
453        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so a
454        /// completed activity carries one consistent `attempt` readable off both its start and its
455        /// terminal — the negative-control invariant NOI-0 gates on.
456        ///
457        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
458        /// `ActivityCompleted` events. Decode defaults the missing value to
459        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
460        /// differs run-to-run. The encoding of the existing fields is untouched.
461        #[serde(default = "legacy_activity_attempt")]
462        attempt: u32,
463    },
464    /// An activity attempt failed.
465    ///
466    /// The `attempt` field together with [`ActivityError`]'s retryable or terminal classification
467    /// lets replay distinguish a retryable interim failure from a terminal one for the same
468    /// [`ActivityId`].
469    ActivityFailed {
470        /// Recording metadata for this event.
471        envelope: EventEnvelope,
472        /// Activity whose attempt failed.
473        activity_id: ActivityId,
474        /// Classified activity failure.
475        error: ActivityError,
476        /// One-based activity attempt number that produced this failure.
477        attempt: u32,
478    },
479    /// An ADVISORY activity spent its whole attempt budget and failed for good
480    /// (RUNTIME-OPERATIONS.md R5).
481    ///
482    /// The warning the class promises: an advisory action is a side channel
483    /// (a heartbeat, a notification), so its exhaustion never faults the
484    /// calling step — but it must never be silent either. This event is that
485    /// visibility. It ACCOMPANIES the activity's honest terminal
486    /// [`Event::ActivityFailed`]; it never replaces it, because the activity
487    /// really did fail and history says so.
488    ///
489    /// Non-terminal for the workflow: it says nothing about the run's
490    /// outcome, so status projection deliberately ignores it.
491    ActivityAdvisoryExhausted {
492        /// Recording metadata for this event.
493        envelope: EventEnvelope,
494        /// Advisory activity whose attempt budget was spent.
495        activity_id: ActivityId,
496        /// Activity type of the exhausted advisory activity.
497        activity_type: String,
498        /// The last attempt's failure reason, verbatim — the same string the
499        /// accompanying terminal [`Event::ActivityFailed`] carries.
500        reason: String,
501        /// One-based number of the attempt that spent the budget.
502        attempt: u32,
503    },
504    /// A policy-refused activity execution was durably routed to a fallback queue.
505    ///
506    /// This event is nonterminal and status-invisible. Its recorded destination
507    /// is the durable replay answer; recovery reuses it rather than choosing again.
508    ActivityFallbackRouted {
509        /// Recording metadata for this event.
510        envelope: EventEnvelope,
511        /// Activity whose refusing execution caused the hop.
512        activity_id: ActivityId,
513        /// One-based execution attempt whose policy refusal caused this hop.
514        attempt: u32,
515        /// Queue the refusing execution was dispatched on.
516        from_task_queue: String,
517        /// Queue the next execution is dispatched on.
518        to_task_queue: String,
519        /// Zero-based position consumed from the authored fallback list.
520        fallback_index: u32,
521    },
522    /// An activity was cancelled as an explicit cancellation outcome.
523    ActivityCancelled {
524        /// Recording metadata for this event.
525        envelope: EventEnvelope,
526        /// Activity that was cancelled.
527        activity_id: ActivityId,
528        /// One-based activity attempt number that was cancelled (NOI-0).
529        ///
530        /// Matches the `attempt` on the [`Event::ActivityStarted`] of the SAME attempt, so the
531        /// cancellation terminal is attributable to a specific attempt exactly like
532        /// [`Event::ActivityFailed`] is.
533        ///
534        /// Replay-safety: histories recorded before this field existed have no `attempt` key on their
535        /// `ActivityCancelled` events. Decode defaults the missing value to
536        /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) via `#[serde(default = ...)]` — never panics, never
537        /// differs run-to-run. The encoding of the existing fields is untouched.
538        #[serde(default = "legacy_activity_attempt")]
539        attempt: u32,
540    },
541    /// A timer was scheduled to fire at a deterministic timestamp.
542    TimerStarted {
543        /// Recording metadata for this event.
544        envelope: EventEnvelope,
545        /// Timer selected by workflow code or assigned by the engine.
546        timer_id: TimerId,
547        /// UTC timestamp at which the timer becomes eligible to fire.
548        fire_at: DateTime<Utc>,
549    },
550    /// A timer fired.
551    TimerFired {
552        /// Recording metadata for this event.
553        envelope: EventEnvelope,
554        /// Timer that fired.
555        timer_id: TimerId,
556    },
557    /// A timer was cancelled as an explicit cancellation outcome.
558    TimerCancelled {
559        /// Recording metadata for this event.
560        envelope: EventEnvelope,
561        /// Timer that was cancelled.
562        timer_id: TimerId,
563        /// Who retired the timer. Decides reopen behavior: a
564        /// [`TimerCancelCause::CancelTeardown`] cancellation is re-armed when the
565        /// run is reopened; a [`TimerCancelCause::WorkflowIntent`] cancellation is
566        /// permanent.
567        ///
568        /// Replay-safety: histories recorded before this field existed have no
569        /// `cause` key. Decode defaults the missing value to
570        /// [`TimerCancelCause::WorkflowIntent`] via `#[serde(default)]` — the
571        /// pre-field behavior (never resurrected), never panics, never differs
572        /// run-to-run. The encoding of the existing fields is untouched.
573        #[serde(default)]
574        cause: TimerCancelCause,
575    },
576    /// A `with_timeout` operation reached a durable terminal outcome.
577    WithTimeoutCompleted {
578        /// Recording metadata for this event.
579        envelope: EventEnvelope,
580        /// Timer that bounded the operation.
581        timer_id: TimerId,
582        /// Recorded timeout outcome.
583        outcome: WithTimeoutOutcome,
584        /// JSON-encoded BEAM term payload for completed operation results.
585        result: Option<Payload>,
586    },
587    /// A signal was delivered to the workflow.
588    SignalReceived {
589        /// Recording metadata for this event.
590        envelope: EventEnvelope,
591        /// Signal name selected by the sender.
592        name: String,
593        /// Opaque signal payload.
594        payload: Payload,
595    },
596    /// A signal was sent by this workflow to another workflow.
597    SignalSent {
598        /// Recording metadata for this event.
599        envelope: EventEnvelope,
600        /// Target workflow identifier selected by workflow code.
601        target_workflow_id: WorkflowId,
602        /// Signal name selected by workflow code.
603        name: String,
604        /// Opaque signal payload.
605        payload: Payload,
606    },
607    /// A child workflow was started.
608    ChildWorkflowStarted {
609        /// Recording metadata for this event.
610        envelope: EventEnvelope,
611        /// Child workflow identifier.
612        child_workflow_id: WorkflowId,
613        /// Child workflow type selected by the parent.
614        workflow_type: String,
615        /// Opaque child workflow input payload.
616        input: Payload,
617        /// Package version resolved for the child at record time.
618        ///
619        /// The crash-repair sweep and the child's own start use exactly this
620        /// recorded version, so the crash path resolves identically to the
621        /// crash-free path.
622        package_version: PackageVersion,
623    },
624    /// A child workflow completed successfully.
625    ChildWorkflowCompleted {
626        /// Recording metadata for this event.
627        envelope: EventEnvelope,
628        /// Child workflow that produced the result.
629        child_workflow_id: WorkflowId,
630        /// Opaque child workflow result payload.
631        result: Payload,
632    },
633    /// A child workflow failed terminally.
634    ChildWorkflowFailed {
635        /// Recording metadata for this event.
636        envelope: EventEnvelope,
637        /// Child workflow that failed.
638        child_workflow_id: WorkflowId,
639        /// Terminal child workflow failure.
640        error: WorkflowError,
641    },
642    /// A child workflow was cancelled as an explicit cancellation outcome.
643    ChildWorkflowCancelled {
644        /// Recording metadata for this event.
645        envelope: EventEnvelope,
646        /// Child workflow that was cancelled.
647        child_workflow_id: WorkflowId,
648    },
649    /// A schedule resource was created.
650    ScheduleCreated {
651        /// Recording metadata for this event.
652        envelope: EventEnvelope,
653        /// Schedule resource that was created.
654        schedule_id: ScheduleId,
655        /// Persisted schedule configuration.
656        config: ScheduleConfig,
657    },
658    /// A schedule resource was updated.
659    ScheduleUpdated {
660        /// Recording metadata for this event.
661        envelope: EventEnvelope,
662        /// Schedule resource that was updated.
663        schedule_id: ScheduleId,
664        /// Updated schedule configuration.
665        config: ScheduleConfig,
666    },
667    /// A schedule resource was paused.
668    SchedulePaused {
669        /// Recording metadata for this event.
670        envelope: EventEnvelope,
671        /// Schedule resource that was paused.
672        schedule_id: ScheduleId,
673    },
674    /// A paused schedule resource was resumed.
675    ScheduleResumed {
676        /// Recording metadata for this event.
677        envelope: EventEnvelope,
678        /// Schedule resource that was resumed.
679        schedule_id: ScheduleId,
680    },
681    /// A schedule resource was deleted.
682    ScheduleDeleted {
683        /// Recording metadata for this event.
684        envelope: EventEnvelope,
685        /// Schedule resource that was deleted.
686        schedule_id: ScheduleId,
687    },
688    /// A schedule tick started a workflow execution.
689    ScheduleTriggered {
690        /// Recording metadata for this event.
691        envelope: EventEnvelope,
692        /// Schedule resource that fired.
693        schedule_id: ScheduleId,
694        /// Workflow execution started by the schedule tick.
695        workflow_id: WorkflowId,
696        /// Run started by the schedule tick.
697        run_id: RunId,
698    },
699}
700
701/// Durable terminal outcome for a `with_timeout` operation.
702#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
703pub enum WithTimeoutOutcome {
704    /// The operation closure returned before the deadline.
705    OperationCompleted,
706    /// The deadline fired before the operation completed.
707    TimedOut,
708}
709
710/// Who retired a durable timer, recorded on [`Event::TimerCancelled`].
711///
712/// The distinction decides reopen semantics. A timer the WORKFLOW retired —
713/// an SDK `cancel_timer` call or a `with_timeout` scope settling because the
714/// racing operation won — is a business fact: reopen must never resurrect it.
715/// A timer the ENGINE retired while tearing down a cancelled run
716/// (`Engine::cancel`'s in-flight timer cleanup) is bookkeeping: the deadline
717/// itself was never reached or waived, so reopening the run re-arms it at its
718/// original `fire_at`.
719#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, Default, PartialEq, Eq)]
720pub enum TimerCancelCause {
721    /// Workflow code retired the timer (SDK cancel or a settled timeout scope).
722    ///
723    /// The serde default: histories recorded before this field existed decode
724    /// as workflow intent, preserving their pre-field never-resurrected
725    /// behavior.
726    #[default]
727    WorkflowIntent,
728    /// The engine retired the timer while cancelling its workflow run.
729    CancelTeardown,
730}
731
732impl Event {
733    /// Returns the envelope recorded with this event.
734    #[must_use]
735    pub const fn envelope(&self) -> &EventEnvelope {
736        match self {
737            Self::WorkflowStarted { envelope, .. }
738            | Self::WorkflowCompleted { envelope, .. }
739            | Self::WorkflowFailed { envelope, .. }
740            | Self::WorkflowCancelled { envelope, .. }
741            | Self::WorkflowTimedOut { envelope, .. }
742            | Self::WorkflowContinuedAsNew { envelope, .. }
743            | Self::WorkflowReopened { envelope, .. }
744            | Self::WorkflowPaused { envelope, .. }
745            | Self::WorkflowResumed { envelope, .. }
746            | Self::SearchAttributesUpdated { envelope, .. }
747            | Self::ActivityScheduled { envelope, .. }
748            | Self::ActivityStarted { envelope, .. }
749            | Self::ActivityAdoptionOffered { envelope, .. }
750            | Self::ActivityCompleted { envelope, .. }
751            | Self::ActivityFailed { envelope, .. }
752            | Self::ActivityAdvisoryExhausted { envelope, .. }
753            | Self::ActivityFallbackRouted { envelope, .. }
754            | Self::ActivityCancelled { envelope, .. }
755            | Self::TimerStarted { envelope, .. }
756            | Self::TimerFired { envelope, .. }
757            | Self::TimerCancelled { envelope, .. }
758            | Self::WithTimeoutCompleted { envelope, .. }
759            | Self::SignalReceived { envelope, .. }
760            | Self::SignalSent { envelope, .. }
761            | Self::ChildWorkflowStarted { envelope, .. }
762            | Self::ChildWorkflowCompleted { envelope, .. }
763            | Self::ChildWorkflowFailed { envelope, .. }
764            | Self::ChildWorkflowCancelled { envelope, .. }
765            | Self::ScheduleCreated { envelope, .. }
766            | Self::ScheduleUpdated { envelope, .. }
767            | Self::SchedulePaused { envelope, .. }
768            | Self::ScheduleResumed { envelope, .. }
769            | Self::ScheduleDeleted { envelope, .. }
770            | Self::ScheduleTriggered { envelope, .. } => envelope,
771        }
772    }
773
774    /// Returns the monotonic sequence number recorded for this event.
775    #[must_use]
776    pub const fn seq(&self) -> u64 {
777        self.envelope().seq
778    }
779
780    /// Returns the deterministic recorded timestamp for this event.
781    #[must_use]
782    pub const fn recorded_at(&self) -> &DateTime<Utc> {
783        &self.envelope().recorded_at
784    }
785
786    /// Returns the workflow history that owns this event.
787    #[must_use]
788    pub const fn workflow_id(&self) -> &WorkflowId {
789        &self.envelope().workflow_id
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use std::collections::HashMap;
796
797    use chrono::{DateTime, Utc};
798    use serde_json::json;
799
800    use super::{
801        DEFAULT_TASK_QUEUE, Event, EventEnvelope, LEGACY_ACTIVITY_ATTEMPT, TimerCancelCause,
802    };
803    use crate::{
804        ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
805        Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
806        WorkflowError, WorkflowId,
807    };
808
809    fn package_version() -> PackageVersion {
810        PackageVersion::new("a".repeat(64))
811    }
812
813    fn recorded_at() -> DateTime<Utc> {
814        DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
815    }
816
817    fn envelope(seq: u64) -> EventEnvelope {
818        EventEnvelope {
819            seq,
820            recorded_at: recorded_at(),
821            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
822        }
823    }
824
825    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
826        Payload::from_json(&json!({ "label": label }))
827    }
828
829    fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
830        Ok(ScheduleConfig {
831            trigger: TriggerSpec::Cron {
832                expression: String::from("0 0 * * *"),
833            },
834            overlap_policy: OverlapPolicy::Skip,
835            catch_up_policy: CatchUpPolicy::One,
836            workflow_type: String::from("checkout"),
837            input: payload(label)?,
838            search_attributes: HashMap::from([(
839                String::from("aion.namespace"),
840                crate::SearchAttributeValue::String(String::from("tenant-a")),
841            )]),
842        })
843    }
844
845    fn workflow_error(message: &str) -> WorkflowError {
846        WorkflowError {
847            message: String::from(message),
848            details: None,
849        }
850    }
851
852    fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
853        ActivityError {
854            kind,
855            message: String::from(message),
856            details: None,
857        }
858    }
859
860    fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
861        let json = serde_json::to_string(event)?;
862        let decoded = serde_json::from_str::<Event>(&json)?;
863        assert_eq!(*event, decoded);
864        Ok(())
865    }
866
867    /// NSTQ-3: a recorded `ActivityScheduled` carries its `task_queue` through the durable JSON
868    /// wire so reopen/recovery can re-target the same pool.
869    #[test]
870    fn activity_scheduled_records_and_reads_back_its_task_queue()
871    -> Result<(), Box<dyn std::error::Error>> {
872        let event = Event::ActivityScheduled {
873            envelope: envelope(6),
874            activity_id: ActivityId::from_sequence_position(6),
875            activity_type: String::from("charge-card"),
876            input: payload("activity-input")?,
877            task_queue: String::from("claude"),
878            node: None,
879        };
880
881        let json = serde_json::to_string(&event)?;
882        let decoded = serde_json::from_str::<Event>(&json)?;
883
884        match decoded {
885            Event::ActivityScheduled { task_queue, .. } => {
886                assert_eq!(
887                    task_queue, "claude",
888                    "the recorded task queue must survive the round-trip"
889                );
890            }
891            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
892        }
893        Ok(())
894    }
895
896    /// NSTQ-3 replay-safety (the load-bearing test): an OLD recorded history that has no
897    /// `task_queue` key on its `ActivityScheduled` events MUST still decode, defaulting the missing
898    /// value to the named `"default"` task queue, deterministically — never panic, never differ
899    /// run-to-run. The old wire form is the exact pre-field bytes: the current serialization with
900    /// the `task_queue` key removed.
901    #[test]
902    fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
903    -> Result<(), Box<dyn std::error::Error>> {
904        // Build a current event, serialize, then strip the `task_queue` key to reconstruct exactly
905        // what a history recorded before the field existed looks like on the wire.
906        let current = Event::ActivityScheduled {
907            envelope: envelope(6),
908            activity_id: ActivityId::from_sequence_position(6),
909            activity_type: String::from("charge-card"),
910            input: payload("activity-input")?,
911            task_queue: String::from("ignored-when-stripped"),
912            node: Some(String::from("ignored-when-stripped")),
913        };
914        let mut value = serde_json::to_value(&current)?;
915        let data = value
916            .get_mut("data")
917            .and_then(serde_json::Value::as_object_mut)
918            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
919        assert!(
920            data.remove("task_queue").is_some(),
921            "the current wire form must contain task_queue before we strip it"
922        );
923
924        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
925        // back the named default, deterministically.
926        let old_wire = serde_json::to_string(&value)?;
927        for _ in 0..4 {
928            let decoded = serde_json::from_str::<Event>(&old_wire)?;
929            match &decoded {
930                Event::ActivityScheduled { task_queue, .. } => {
931                    assert_eq!(
932                        task_queue, DEFAULT_TASK_QUEUE,
933                        "a missing task_queue must default to the named default queue"
934                    );
935                    assert_eq!(task_queue, "default");
936                }
937                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
938            }
939        }
940        Ok(())
941    }
942
943    /// NODE-3: a recorded `ActivityScheduled` carries its OPTIONAL `node` affinity through the
944    /// durable JSON wire so reopen/recovery can re-target the same node.
945    #[test]
946    fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
947    {
948        let event = Event::ActivityScheduled {
949            envelope: envelope(6),
950            activity_id: ActivityId::from_sequence_position(6),
951            activity_type: String::from("charge-card"),
952            input: payload("activity-input")?,
953            task_queue: String::from("claude"),
954            node: Some(String::from("box-7")),
955        };
956
957        let json = serde_json::to_string(&event)?;
958        let decoded = serde_json::from_str::<Event>(&json)?;
959
960        match decoded {
961            Event::ActivityScheduled { node, .. } => {
962                assert_eq!(
963                    node.as_deref(),
964                    Some("box-7"),
965                    "the recorded node affinity must survive the round-trip"
966                );
967            }
968            other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
969        }
970        Ok(())
971    }
972
973    /// NODE-3 replay-safety (the load-bearing test): an OLD recorded history that has no `node` key
974    /// on its `ActivityScheduled` events MUST still decode, defaulting the missing value to `None`
975    /// (no affinity) deterministically — never a sentinel, never panic, never differ run-to-run.
976    /// The old wire form is the exact pre-field bytes: the current serialization with the `node`
977    /// key removed.
978    #[test]
979    fn activity_scheduled_decodes_old_history_without_node_as_none()
980    -> Result<(), Box<dyn std::error::Error>> {
981        // Build a current event with a node set, serialize, then strip the `node` key to
982        // reconstruct exactly what a history recorded before the field existed looks like on the
983        // wire.
984        let current = Event::ActivityScheduled {
985            envelope: envelope(6),
986            activity_id: ActivityId::from_sequence_position(6),
987            activity_type: String::from("charge-card"),
988            input: payload("activity-input")?,
989            task_queue: String::from("default"),
990            node: Some(String::from("ignored-when-stripped")),
991        };
992        let mut value = serde_json::to_value(&current)?;
993        let data = value
994            .get_mut("data")
995            .and_then(serde_json::Value::as_object_mut)
996            .ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
997        assert!(
998            data.remove("node").is_some(),
999            "the current wire form must contain node before we strip it"
1000        );
1001
1002        // Decode the stripped (old-shape) wire form repeatedly: it must succeed and always read
1003        // back `None`, deterministically.
1004        let old_wire = serde_json::to_string(&value)?;
1005        for _ in 0..4 {
1006            let decoded = serde_json::from_str::<Event>(&old_wire)?;
1007            match &decoded {
1008                Event::ActivityScheduled { node, .. } => {
1009                    assert_eq!(
1010                        *node, None,
1011                        "a missing node must default to None (no affinity)"
1012                    );
1013                }
1014                other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
1015            }
1016        }
1017        Ok(())
1018    }
1019
1020    /// NOI-0 positive round-trip: `ActivityStarted`, `ActivityCompleted`, and `ActivityCancelled`
1021    /// each carry a genuine one-based `attempt` through the durable JSON wire, so replay reads back
1022    /// the same attempt that was recorded — a completed activity has one consistent attempt readable
1023    /// off BOTH its start and its terminal (the invariant the NOI design keys on).
1024    #[test]
1025    fn activity_lifecycle_records_and_reads_back_its_attempt()
1026    -> Result<(), Box<dyn std::error::Error>> {
1027        let started = Event::ActivityStarted {
1028            envelope: envelope(7),
1029            activity_id: ActivityId::from_sequence_position(6),
1030            attempt: 3,
1031        };
1032        let completed = Event::ActivityCompleted {
1033            envelope: envelope(8),
1034            activity_id: ActivityId::from_sequence_position(6),
1035            result: payload("activity-result")?,
1036            attempt: 3,
1037        };
1038        let cancelled = Event::ActivityCancelled {
1039            envelope: envelope(9),
1040            activity_id: ActivityId::from_sequence_position(6),
1041            attempt: 3,
1042        };
1043
1044        for event in [&started, &completed, &cancelled] {
1045            round_trip(event)?;
1046        }
1047
1048        // Read the attempt back off each decoded terminal — it must be the recorded value, not the
1049        // legacy sentinel.
1050        match serde_json::from_str::<Event>(&serde_json::to_string(&started)?)? {
1051            Event::ActivityStarted { attempt, .. } => assert_eq!(attempt, 3),
1052            other => return Err(format!("expected ActivityStarted, got {other:?}").into()),
1053        }
1054        match serde_json::from_str::<Event>(&serde_json::to_string(&completed)?)? {
1055            Event::ActivityCompleted { attempt, .. } => assert_eq!(attempt, 3),
1056            other => return Err(format!("expected ActivityCompleted, got {other:?}").into()),
1057        }
1058        match serde_json::from_str::<Event>(&serde_json::to_string(&cancelled)?)? {
1059            Event::ActivityCancelled { attempt, .. } => assert_eq!(attempt, 3),
1060            other => return Err(format!("expected ActivityCancelled, got {other:?}").into()),
1061        }
1062        Ok(())
1063    }
1064
1065    /// NOI-0 replay-safety (the load-bearing negative control): an OLD recorded history that has no
1066    /// `attempt` key on its `ActivityStarted` / `ActivityCompleted` / `ActivityCancelled` events MUST
1067    /// still decode without panic, defaulting the missing value to the legacy sentinel
1068    /// [`LEGACY_ACTIVITY_ATTEMPT`] (`0`) deterministically — never differ run-to-run. Because real
1069    /// attempts are one-based, `0` can never collide with a genuine attempt. The old wire form is the
1070    /// exact pre-field bytes: the current serialization with the `attempt` key removed.
1071    #[test]
1072    fn activity_lifecycle_decodes_old_history_without_attempt_as_legacy_sentinel()
1073    -> Result<(), Box<dyn std::error::Error>> {
1074        // One current event per variant, each with a NON-sentinel attempt so we can prove the strip
1075        // (not the value) is what drives the default on decode.
1076        let started = Event::ActivityStarted {
1077            envelope: envelope(7),
1078            activity_id: ActivityId::from_sequence_position(6),
1079            attempt: 5,
1080        };
1081        let completed = Event::ActivityCompleted {
1082            envelope: envelope(8),
1083            activity_id: ActivityId::from_sequence_position(6),
1084            result: payload("activity-result")?,
1085            attempt: 5,
1086        };
1087        let cancelled = Event::ActivityCancelled {
1088            envelope: envelope(9),
1089            activity_id: ActivityId::from_sequence_position(6),
1090            attempt: 5,
1091        };
1092
1093        // Strip the `attempt` key from each to reconstruct exactly what a pre-NOI-0 history looks
1094        // like on the wire, then decode the stripped form repeatedly: it must succeed and always read
1095        // back the legacy sentinel, deterministically.
1096        for current in [&started, &completed, &cancelled] {
1097            let mut value = serde_json::to_value(current)?;
1098            let data = value
1099                .get_mut("data")
1100                .and_then(serde_json::Value::as_object_mut)
1101                .ok_or("activity lifecycle event must serialize to a tagged object with `data`")?;
1102            assert!(
1103                data.remove("attempt").is_some(),
1104                "the current wire form must contain attempt before we strip it"
1105            );
1106            let old_wire = serde_json::to_string(&value)?;
1107            for _ in 0..4 {
1108                let decoded = serde_json::from_str::<Event>(&old_wire)?;
1109                let attempt = match &decoded {
1110                    Event::ActivityStarted { attempt, .. }
1111                    | Event::ActivityCompleted { attempt, .. }
1112                    | Event::ActivityCancelled { attempt, .. } => *attempt,
1113                    other => {
1114                        return Err(
1115                            format!("expected an activity lifecycle event, got {other:?}").into(),
1116                        );
1117                    }
1118                };
1119                assert_eq!(
1120                    attempt, LEGACY_ACTIVITY_ATTEMPT,
1121                    "a missing attempt must default to the legacy sentinel (0)"
1122                );
1123                assert_eq!(attempt, 0);
1124            }
1125        }
1126        Ok(())
1127    }
1128
1129    /// Replay-safety proof for the `cause` field on `TimerCancelled` (#222):
1130    /// a history recorded BEFORE the field existed has no `cause` key and MUST
1131    /// decode without panic, defaulting to `WorkflowIntent` — the pre-field
1132    /// behavior (a reopen never resurrects it) — deterministically. The old
1133    /// wire form is the exact pre-field bytes: the current serialization with
1134    /// the `cause` key removed.
1135    #[test]
1136    fn timer_cancelled_decodes_old_history_without_cause_as_workflow_intent()
1137    -> Result<(), Box<dyn std::error::Error>> {
1138        // A NON-default cause proves the strip (not the value) drives the default.
1139        let cancelled = Event::TimerCancelled {
1140            envelope: envelope(7),
1141            timer_id: TimerId::named("deadline")?,
1142            cause: TimerCancelCause::CancelTeardown,
1143        };
1144
1145        let mut value = serde_json::to_value(&cancelled)?;
1146        let data = value
1147            .get_mut("data")
1148            .and_then(serde_json::Value::as_object_mut)
1149            .ok_or("TimerCancelled must serialize to a tagged object with `data`")?;
1150        assert!(
1151            data.remove("cause").is_some(),
1152            "the current wire form must contain cause before we strip it"
1153        );
1154        let old_wire = serde_json::to_string(&value)?;
1155        for _ in 0..4 {
1156            let decoded = serde_json::from_str::<Event>(&old_wire)?;
1157            match &decoded {
1158                Event::TimerCancelled { cause, .. } => assert_eq!(
1159                    *cause,
1160                    TimerCancelCause::WorkflowIntent,
1161                    "a missing cause must default to WorkflowIntent (never resurrected)"
1162                ),
1163                other => {
1164                    return Err(format!("expected TimerCancelled, got {other:?}").into());
1165                }
1166            }
1167        }
1168        Ok(())
1169    }
1170
1171    /// Pause/resume (#204) round-trip: the two new NON-terminal lifecycle markers
1172    /// carry plain fields and survive the durable JSON wire unchanged.
1173    #[test]
1174    fn pause_resume_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1175        let events = vec![
1176            Event::WorkflowPaused {
1177                envelope: envelope(2),
1178                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1179                reason: Some(String::from("operator hold")),
1180                operator: Some(String::from("tom")),
1181            },
1182            Event::WorkflowPaused {
1183                envelope: envelope(3),
1184                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1185                reason: None,
1186                operator: None,
1187            },
1188            Event::WorkflowResumed {
1189                envelope: envelope(4),
1190                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1191                operator: Some(String::from("tom")),
1192            },
1193        ];
1194        for event in &events {
1195            round_trip(event)?;
1196        }
1197        Ok(())
1198    }
1199
1200    /// GATE-6 back-compat: an OLD history serialized before pause/resume existed
1201    /// decodes byte-identically — it simply never contains the new variants. We
1202    /// prove the whole event enum still decodes an old-shape history with no new
1203    /// variants present (the decode round-trip test the brief requires), and that
1204    /// adding the variants did not change the encoding of any existing variant.
1205    #[test]
1206    fn old_history_without_pause_resume_decodes_unchanged() -> Result<(), Box<dyn std::error::Error>>
1207    {
1208        let started = Event::WorkflowStarted {
1209            envelope: envelope(1),
1210            workflow_type: String::from("checkout"),
1211            input: payload("input")?,
1212            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1213            parent_run_id: None,
1214            parent_workflow_id: None,
1215            package_version: package_version(),
1216        };
1217        let completed = Event::WorkflowCompleted {
1218            envelope: envelope(2),
1219            result: payload("result")?,
1220        };
1221        // Serialize an old-shape history and decode it back: no new variant is
1222        // present, and every existing variant round-trips exactly.
1223        let history = vec![started, completed];
1224        let json = serde_json::to_string(&history)?;
1225        let decoded = serde_json::from_str::<Vec<Event>>(&json)?;
1226        assert_eq!(history, decoded);
1227        Ok(())
1228    }
1229
1230    /// #144: the start-time task queue projects from the `aion.task_queue`
1231    /// search attribute recorded by `SearchAttributesUpdated`, mirroring the
1232    /// `aion.namespace` projection. A later update overrides an earlier value.
1233    #[test]
1234    fn start_time_task_queue_projects_from_recorded_attribute()
1235    -> Result<(), Box<dyn std::error::Error>> {
1236        use super::{START_TIME_TASK_QUEUE_ATTRIBUTE, start_time_task_queue};
1237        use crate::SearchAttributeValue;
1238
1239        let events = vec![
1240            Event::WorkflowStarted {
1241                envelope: envelope(1),
1242                workflow_type: String::from("checkout"),
1243                input: payload("input")?,
1244                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1245                parent_run_id: None,
1246                parent_workflow_id: None,
1247                package_version: package_version(),
1248            },
1249            Event::SearchAttributesUpdated {
1250                envelope: envelope(2),
1251                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1252                attributes: HashMap::from([(
1253                    START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
1254                    SearchAttributeValue::String(String::from("gpu")),
1255                )]),
1256            },
1257        ];
1258
1259        assert_eq!(start_time_task_queue(&events).as_deref(), Some("gpu"));
1260        Ok(())
1261    }
1262
1263    /// #144 back-compat: a history with no recorded `aion.task_queue` attribute
1264    /// projects `None`, so callers fall back to the named default.
1265    #[test]
1266    fn start_time_task_queue_is_none_without_the_attribute()
1267    -> Result<(), Box<dyn std::error::Error>> {
1268        use super::start_time_task_queue;
1269
1270        let events = vec![Event::WorkflowStarted {
1271            envelope: envelope(1),
1272            workflow_type: String::from("checkout"),
1273            input: payload("input")?,
1274            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1275            parent_run_id: None,
1276            parent_workflow_id: None,
1277            package_version: package_version(),
1278        }];
1279
1280        assert_eq!(start_time_task_queue(&events), None);
1281        Ok(())
1282    }
1283
1284    /// #211: the display name projects from the `aion.display_name` search
1285    /// attribute recorded by `SearchAttributesUpdated`, mirroring the
1286    /// `aion.task_queue` projection.
1287    #[test]
1288    fn display_name_projects_from_recorded_attribute() -> Result<(), Box<dyn std::error::Error>> {
1289        use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
1290        use crate::SearchAttributeValue;
1291
1292        let events = vec![
1293            Event::WorkflowStarted {
1294                envelope: envelope(1),
1295                workflow_type: String::from("checkout"),
1296                input: payload("input")?,
1297                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1298                parent_run_id: None,
1299                parent_workflow_id: None,
1300                package_version: package_version(),
1301            },
1302            Event::SearchAttributesUpdated {
1303                envelope: envelope(2),
1304                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1305                attributes: HashMap::from([(
1306                    DISPLAY_NAME_ATTRIBUTE.to_owned(),
1307                    SearchAttributeValue::String(String::from("Nightly settlement")),
1308                )]),
1309            },
1310        ];
1311
1312        assert_eq!(display_name(&events).as_deref(), Some("Nightly settlement"));
1313        Ok(())
1314    }
1315
1316    /// #211 back-compat: a history with no recorded `aion.display_name`
1317    /// attribute projects `None`, so the unnamed run renders as its bare UUID.
1318    #[test]
1319    fn display_name_is_none_without_the_attribute() -> Result<(), Box<dyn std::error::Error>> {
1320        use super::display_name;
1321
1322        let events = vec![Event::WorkflowStarted {
1323            envelope: envelope(1),
1324            workflow_type: String::from("checkout"),
1325            input: payload("input")?,
1326            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1327            parent_run_id: None,
1328            parent_workflow_id: None,
1329            package_version: package_version(),
1330        }];
1331
1332        assert_eq!(display_name(&events), None);
1333        Ok(())
1334    }
1335
1336    /// #211 rename invariant: a later `SearchAttributesUpdated` overrides the
1337    /// earlier name (last write wins) while BOTH remain in history — a rename
1338    /// is a recorded event, never an overwrite of the past.
1339    #[test]
1340    fn display_name_later_update_overrides_earlier() -> Result<(), Box<dyn std::error::Error>> {
1341        use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
1342        use crate::SearchAttributeValue;
1343
1344        let name_event = |seq: u64, name: &str| Event::SearchAttributesUpdated {
1345            envelope: envelope(seq),
1346            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1347            attributes: HashMap::from([(
1348                DISPLAY_NAME_ATTRIBUTE.to_owned(),
1349                SearchAttributeValue::String(String::from(name)),
1350            )]),
1351        };
1352        let events = vec![
1353            Event::WorkflowStarted {
1354                envelope: envelope(1),
1355                workflow_type: String::from("checkout"),
1356                input: payload("input")?,
1357                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1358                parent_run_id: None,
1359                parent_workflow_id: None,
1360                package_version: package_version(),
1361            },
1362            name_event(2, "first name"),
1363            name_event(3, "second name"),
1364        ];
1365
1366        assert_eq!(display_name(&events).as_deref(), Some("second name"));
1367        // NOTE: the "history keeps both names" half of this invariant is NOT
1368        // asserted here. `events` is this test's own literal, so counting it
1369        // would measure the `vec!` rather than any behaviour. It is proven
1370        // where it can be — against events a real recorder appended — by
1371        // `aion/tests/rename_e2e.rs`
1372        // `renaming_twice_supersedes_while_history_keeps_both_names`.
1373        Ok(())
1374    }
1375
1376    #[test]
1377    fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
1378        let workflow_id = WorkflowId::new_v4();
1379        let recorded_at = recorded_at();
1380        let envelope = EventEnvelope {
1381            seq: 17,
1382            recorded_at,
1383            workflow_id: workflow_id.clone(),
1384        };
1385        let event = Event::WorkflowStarted {
1386            envelope,
1387            workflow_type: String::from("checkout"),
1388            input: payload("input")?,
1389            run_id: RunId::new(uuid::Uuid::from_u128(1)),
1390            parent_run_id: None,
1391            parent_workflow_id: None,
1392            package_version: package_version(),
1393        };
1394
1395        assert_eq!(event.seq(), 17);
1396        assert_eq!(event.recorded_at(), &recorded_at);
1397        assert_eq!(event.workflow_id(), &workflow_id);
1398        Ok(())
1399    }
1400
1401    #[test]
1402    fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1403        let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
1404        let events = vec![
1405            Event::WorkflowStarted {
1406                envelope: envelope(1),
1407                workflow_type: String::from("checkout"),
1408                input: payload("workflow-input")?,
1409                run_id: RunId::new(uuid::Uuid::from_u128(1)),
1410                parent_run_id: None,
1411                parent_workflow_id: None,
1412                package_version: package_version(),
1413            },
1414            Event::WorkflowCompleted {
1415                envelope: envelope(2),
1416                result: payload("workflow-result")?,
1417            },
1418            Event::WorkflowFailed {
1419                envelope: envelope(3),
1420                error: workflow_error("workflow failed"),
1421            },
1422            Event::WorkflowCancelled {
1423                envelope: envelope(4),
1424                reason: String::from("caller requested cancellation"),
1425            },
1426            Event::WorkflowTimedOut {
1427                envelope: envelope(5),
1428                timeout: String::from("execution"),
1429            },
1430            Event::ActivityScheduled {
1431                envelope: envelope(6),
1432                activity_id: ActivityId::from_sequence_position(6),
1433                activity_type: String::from("charge-card"),
1434                input: payload("activity-input")?,
1435                task_queue: String::from("claude"),
1436                node: Some(String::from("box-7")),
1437            },
1438            Event::ActivityStarted {
1439                envelope: envelope(7),
1440                activity_id: ActivityId::from_sequence_position(6),
1441                attempt: 1,
1442            },
1443            Event::ActivityCompleted {
1444                envelope: envelope(8),
1445                activity_id: ActivityId::from_sequence_position(6),
1446                result: payload("activity-result")?,
1447                attempt: 1,
1448            },
1449            Event::ActivityFailed {
1450                envelope: envelope(9),
1451                activity_id: ActivityId::from_sequence_position(6),
1452                error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
1453                attempt: 1,
1454            },
1455            Event::ActivityCancelled {
1456                envelope: envelope(10),
1457                activity_id: ActivityId::from_sequence_position(6),
1458                attempt: 1,
1459            },
1460            Event::TimerStarted {
1461                envelope: envelope(11),
1462                timer_id: TimerId::anonymous(11),
1463                fire_at,
1464            },
1465            Event::TimerFired {
1466                envelope: envelope(12),
1467                timer_id: TimerId::anonymous(11),
1468            },
1469            Event::TimerCancelled {
1470                envelope: envelope(13),
1471                timer_id: TimerId::named("reminder")?,
1472                cause: TimerCancelCause::WorkflowIntent,
1473            },
1474            Event::SignalReceived {
1475                envelope: envelope(14),
1476                name: String::from("approve"),
1477                payload: payload("signal")?,
1478            },
1479            Event::SignalSent {
1480                envelope: envelope(15),
1481                target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
1482                name: String::from("approve"),
1483                payload: payload("signal-sent")?,
1484            },
1485        ];
1486
1487        for event in events {
1488            round_trip(&event)?;
1489        }
1490        Ok(())
1491    }
1492
1493    #[test]
1494    fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1495        let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
1496        let events = vec![
1497            Event::ChildWorkflowStarted {
1498                envelope: envelope(16),
1499                child_workflow_id: child_workflow_id.clone(),
1500                workflow_type: String::from("fulfillment"),
1501                input: payload("child-input")?,
1502                package_version: package_version(),
1503            },
1504            Event::ChildWorkflowCompleted {
1505                envelope: envelope(16),
1506                child_workflow_id: child_workflow_id.clone(),
1507                result: payload("child-result")?,
1508            },
1509            Event::ChildWorkflowFailed {
1510                envelope: envelope(17),
1511                child_workflow_id: child_workflow_id.clone(),
1512                error: workflow_error("child failed"),
1513            },
1514            Event::ChildWorkflowCancelled {
1515                envelope: envelope(18),
1516                child_workflow_id,
1517            },
1518        ];
1519
1520        for event in events {
1521            round_trip(&event)?;
1522        }
1523        Ok(())
1524    }
1525
1526    #[test]
1527    fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
1528        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
1529        let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
1530        let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
1531        let events = vec![
1532            Event::WorkflowContinuedAsNew {
1533                envelope: envelope(19),
1534                input: payload("continued-input")?,
1535                workflow_type: Some(String::from("checkout-v2")),
1536                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
1537            },
1538            Event::SearchAttributesUpdated {
1539                envelope: envelope(20),
1540                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
1541                attributes: HashMap::from([(
1542                    String::from("customer_id"),
1543                    SearchAttributeValue::String(String::from("cust-123")),
1544                )]),
1545            },
1546            Event::ScheduleCreated {
1547                envelope: envelope(20),
1548                schedule_id: schedule_id.clone(),
1549                config: schedule_config("schedule-created")?,
1550            },
1551            Event::ScheduleUpdated {
1552                envelope: envelope(21),
1553                schedule_id: schedule_id.clone(),
1554                config: schedule_config("schedule-updated")?,
1555            },
1556            Event::SchedulePaused {
1557                envelope: envelope(22),
1558                schedule_id: schedule_id.clone(),
1559            },
1560            Event::ScheduleResumed {
1561                envelope: envelope(23),
1562                schedule_id: schedule_id.clone(),
1563            },
1564            Event::ScheduleDeleted {
1565                envelope: envelope(24),
1566                schedule_id: schedule_id.clone(),
1567            },
1568            Event::ScheduleTriggered {
1569                envelope: envelope(25),
1570                schedule_id,
1571                workflow_id: triggered_workflow_id,
1572                run_id: triggered_run_id,
1573            },
1574        ];
1575
1576        for event in events {
1577            round_trip(&event)?;
1578        }
1579        Ok(())
1580    }
1581}