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