# Rules for working with AshWorkflow

## Understanding AshWorkflow

AshWorkflow is a declarative workflow orchestration extension for Ash Framework. It lets you define multi-step workflows — combining human actions, background jobs, and time-based deadlines — as a single DSL block. From your workflow declaration, it automatically generates:

- **State machine** states and transitions (via `ash_state_machine`)
- **Background job triggers** for automatic steps (via `ash_oban`)
- **Ash actions** for manual transitions, plus a read action
- **Timeout scheduling** for reminders and escalations

## Setting Up AshWorkflow

Add AshWorkflow to the extensions list on your Ash resource:

```elixir
use Ash.Resource,
  domain: MyApp.Domain,
  data_layer: AshPostgres.DataLayer,
  extensions: [AshWorkflow, AshOban]
```

AshWorkflow adds `AshStateMachine` for you. **You must add `AshOban` yourself**, as shown above, because the default scheduler generates AshOban triggers. Omitting it is a compile error naming the fix.

The reason it is not added for you: the scheduler is pluggable, and a workflow whose deadlines are run by something other than Oban should not carry the ash_oban DSL. See "Scheduling" below.

## Scheduling

Automatic steps and timeouts are run by a scheduler, chosen on the `workflow` block:

```elixir
workflow do
  scheduler AshWorkflow.Scheduler.Oban        # the default
  # or, with options:
  scheduler {AshWorkflow.Scheduler.Oban, check_interval: "0 * * * *"}
end
```

Set it once for an application instead:

```elixir
config :ash_workflow, scheduler: {AshWorkflow.Scheduler.Oban, queue: :workflow}
```

`AshWorkflow.Scheduler.Oban` polls: it turns each automatic step and each timeout into an AshOban trigger whose `where` clause finds eligible records. Polling is what puts a floor under accuracy — cron cannot ask for less than a minute, which is why a sub-minute `fire_after` is rejected.

`AshWorkflow.Scheduler.Precise` arms a timer per deadline instead, so its floor is a millisecond and a sub-minute `fire_after` compiles. Select it in the `workflow` block and start `AshWorkflow.Scheduler.Precise.Timeline` with the resources it recovers deadlines for:

```elixir
workflow do
  scheduler AshWorkflow.Scheduler.Precise
end

# in your supervision tree
{AshWorkflow.Scheduler.Precise.Timeline, resources: [MyApp.Order]}
```

A timer lives in memory, so it gives up Oban's durability: a deadline is recovered by the look-ahead sweep after a node dies, late by at most `:look_ahead_ms`. Across more than one node, pass `leader: {AshWorkflow.Scheduler.Leader.Oban, name: Oban}`, or every node arms the same timers. In tests call `AshWorkflow.Scheduler.Precise.run_due/2` rather than waiting for a timer, since the timeline holds no sandbox connection.

To write your own, implement `AshWorkflow.Scheduler`. It has one required callback:

```elixir
defmodule MyApp.Scheduler do
  use AshWorkflow.Scheduler

  @impl AshWorkflow.Scheduler
  def transform(dsl, works, opts) do
    # `works` is every AshWorkflow.Scheduler.Work this workflow declared.
    # Add whatever you need to the resource, or nothing.
    {:ok, dsl}
  end
end
```

Each `AshWorkflow.Scheduler.Work` describes one unit of scheduled work without reference to Oban. Two of its fields carry the same fact in different shapes, so opposite strategies both work:

- `match` — an Ash expression selecting records eligible **now**. All a polling scheduler needs.
- `deadline` — `%{field:, fire_after:}`, the rule for computing the exact instant. All a scheduler that arms timers needs. `nil` for an automatic step, which is eligible as soon as a record occupies it.

Call `AshWorkflow.Scheduler.execute/3` when the moment arrives. It runs the action and routes failure to the step's `on_error`, so swapping schedulers changes when work happens and never what it does.

## Defining a Workflow

All workflow configuration goes inside a single `workflow do ... end` block. Steps are declared in order — the first non-terminal step becomes the initial state.

A step that declares no action, no transitions, no timeouts, no `on_success` and no `on_error` is terminal, whether or not it sets `terminal: true`. Setting it states the intent, and the verifier then rejects the step if it grows an outgoing declaration.

```elixir
workflow do
  step :process do
    action :run_processing
    on_success :review
    on_error :failed
  end

  step :review do
    transition :approve, to: :approved
    transition :reject, to: :rejected
  end

  step :approved, terminal: true
  step :rejected, terminal: true
  step :failed, terminal: true
end
```

## Step Types

There are exactly three kinds of steps:

### Automatic Steps

Run in the background via Oban when a record enters this state. You must define the referenced action as an update action on the resource.

```elixir
step :send_offer do
  action :send_offer_email
  on_success :awaiting_response
  on_error :send_failed
end
```

- `action` (required): References a user-defined update action on the resource.
- `on_success` (required): The step to transition to when the action succeeds. Repeatable — see below.
- `on_error` (optional): Step to transition to on failure. If omitted, the record stays in the current state on error.
- Must NOT have `transitions` or `terminal true`.

#### Retry

An automatic step or a timeout can declare a `retry` block, the failure policy for its generated work:

```elixir
step :send_offer do
  action :send_offer_email
  on_success :awaiting_response
  on_error :send_failed

  retry do
    max_attempts 3
    backoff :exponential
  end
end
```

- `max_attempts` (optional, default `1`): how many times the action runs before giving up. The default of 1 means no retry, which is why `on_error` moves the step on the first failure.
- `backoff` (optional, default `:exponential`): a duration tuple such as `{10, :seconds}` for a fixed delay between attempts, or `:exponential` (grows with the attempt number). Has no effect while `max_attempts` is 1.
- `on_error` only runs after the final attempt has failed.
- Rejected at compile time on a manual step, a wait state, or a terminal step — nothing is scheduled for those, so `retry` would do nothing.

#### Conditional `on_success` routing

`on_success` is a repeatable entity, not a scalar option. Declare it more
than once, with a `when` condition, to fan an automatic step out to different
states based on what its action computed — reusing the same `route` entity
conditional transitions use:

```elixir
step :screening do
  action :run_screening
  on_success :interview,      when: expr(screen_score >= 5)
  on_success :rejected_by_hr, when: expr(screen_score < 5)
  on_error :screening_failed
end
```

The common single-target case still has an inline shorthand:

```elixir
step :send_offer, action: :send_offer_email, on_success: :awaiting_response, on_error: :send_failed
```

- Entries are evaluated in declaration order; the first matching one wins.
- An `on_success` with no `when` is unconditional — it always matches. At
  most one unconditional `on_success` is allowed per step, and if conditional
  entries are also present, the unconditional one must be declared **last**,
  as the fallback:

  ```elixir
  step :triaging do
    action :run_triage
    on_success :escalated, when: expr(priority == :high)
    on_success :queued                                    # fallback
  end
  ```

  Declaring an unconditional `on_success` before conditional ones is a
  compile-time error — it would shadow everything after it.
- If every `on_success` entry is conditional (no trailing unconditional
  fallback), the step must declare another way out: `on_error`, or a timeout
  with `transition_to`. A compile-time error otherwise. Without one, a record
  whose action succeeds but matches no condition stays in the step and the
  action is retried on every cycle. A timeout that only names an `action` does
  not count — it never changes state.
- **Timing**: `on_success` conditions are evaluated *after* the step's own
  action has run — including any attributes that action computed. This is
  the opposite of a manual transition's `route` conditions (see `route` under
  `transition`), which are evaluated against the record *before* the update
  they guard is applied, since there is no "after" for a transition that
  hasn't happened yet. The whole point of routing on `on_success` is to
  branch on what the action produced, so it has to run after.
- If every `on_success` is conditional and none matches at runtime, that is a
  distinct failure, not a silent no-op: `AshWorkflow.Errors.NoMatchingRoute`
  is added to the changeset, naming the step and the action, and it runs down
  `on_error` the same as any other action failure.
- Every `on_success` target must be a step declared elsewhere in the workflow.
- Conditions use SQL-style three-valued logic: comparing a `nil` attribute
  evaluates to `nil`, not `false`. A route whose condition evaluates to `nil`
  simply doesn't match, same as `false` — it falls through to the next route
  or the no-match error.
- A route may target its own step (a same-step retry loop) or any earlier
  step (a multi-step retry loop). Neither is rejected at compile time —
  reachability validation only checks whether a step is reachable from the
  initial step, not whether the path is acyclic.

### Manual Steps

Wait for a human (or external system) to trigger one of the declared transitions. Any step that declares one or more `transition` entries is a manual step — there is no separate `manual` flag. Each transition becomes a callable Ash update action.

```elixir
step :manager_review do
  transition :approve, to: :approved
  transition :reject, to: :rejected
  transition :request_changes, to: :drafting
end
```

- Must have at least one `transition`.
- Must NOT have `action`, `on_success`, or `on_error`.
- Transitions are called via the generated code interface: `MyResource.approve(record)`.

### Terminal Steps

End states with no outgoing transitions. Use the inline syntax:

```elixir
step :completed, terminal: true
step :failed, terminal: true
```

- Must NOT have `action`, `on_success`, `on_error`, `transitions`, or `timeouts`.

## Transitions

Each transition declared inside a manual step becomes an Ash update action. The same transition name can be used across multiple steps — they merge into a single action.

```elixir
# Same name, same target — merged into one action
step :review do
  transition :approve, to: :approved
  transition :reject, to: :rejected
end

step :escalated_review do
  transition :approve, to: :approved
  transition :reject, to: :rejected
end
```

```elixir
# Same name, different targets — auto-routes based on current state
step :initial_review do
  transition :complete, to: :detailed_review
end

step :detailed_review do
  transition :complete, to: :done
end
```

### Accepting Input on Transitions

If a transition needs to accept user input, define a matching update action:

```elixir
workflow do
  step :review do
    transition :reject, to: :rejected
  end
end

actions do
  update :reject do
    accept [:rejection_reason]
  end
end
```

AshWorkflow will inject its state-transition changes into the action you define. You only need to define actions that accept additional attributes — transitions with no extra input don't need a user-defined action.

### Previewing Where a Transition Goes

To find out which step a transition would move a record to without running it, call `AshWorkflow.Info.transition_target/3` or load the generated `:transition_targets` calculation. Do not re-implement route matching: both resolve routes through the same code the generated action runs.

```elixir
AshWorkflow.Info.transition_target(record, :advance)
#=> {:ok, :compliance}

record = Ash.load!(record, :transition_targets)
record.transition_targets
#=> %{advance: :compliance, reject: :rejected}
```

- `transition_target/3` returns `{:ok, step}`, `{:ok, nil}` when the current step does not declare the transition or no route matches, and `{:error, error}` when a route fails to evaluate. It raises `ArgumentError` for a name no step declares.
- It loads nothing. A route that reads an unloaded relationship sees `nil`, in the preview and in the real action alike.
- A route that reads accepted input sees it only when the transition runs. Pass the input as the third argument to preview a particular call.
- `:transition_targets` has one key per transition of the current step, is not filtered by authorization, and maps a transition to `nil` when no route matches or a route fails to evaluate. It is an empty map at an automatic or terminal step. It loads the fields its routes reference.

### Refusing Stale Transitions

A transition decides from the record it is called on, then writes by primary key. AshWorkflow refuses a transition built from an old copy, so it cannot run from a step the record has left or route on values that have since changed. This is always on. Do not hand-write `Ash.Changeset.filter` changes on transition actions for this.

- Every manual transition and `undo` filter their update on the state attribute and on each attribute of the resource that a route of the loaded step reads in its `when`. A `nil` value is matched with `is_nil/1`.
- A stale update fails with `Ash.Error.Changes.StaleRecord`, inside `Ash.Error.Invalid`, and the row is left as it was. This holds on the atomic and non-atomic update paths. Reload the record and try again.
- Do not run a second transition on the struct you ran the first on. Use the struct the first returned, or reload.
- A change to an attribute no route reads does not make a copy stale. An attribute the call supplies through `accept` is not pinned.
- Not pinned: references through a relationship (`sponsor.name`), calculations and aggregates. Copy the value onto the resource if a route must be protected against it changing.
- Not covered: automatic step actions, timeouts and error paths. The scheduler reads the record just before it runs them.
- On a data layer that cannot filter an update, such as `Ash.DataLayer.Simple`, nothing is pinned.

## Timeouts

Timeouts fire when a workflow stays in a step longer than a specified duration. They are checked via Oban cron jobs.

### Action Timeout (stay in state, run side-effect, fires once)

```elixir
timeout :reminder, fire_after: {3, :days}, action: :send_reminder
```

- `action`: References a user-defined update action. The workflow stays in the current state.

### Recurring Action (`every`, fires on every interval)

```elixir
every :follow_up do
  interval {7, :days}
  action :send_follow_up
end
```

`every` is a sibling entity to `timeout`, not an option on it. Use it instead of `timeout` when the action should keep firing on the same interval for as long as the record stays in the step. It always requires `action` and has no `transition_to`.

`until` bounds `every` to a fixed amount of wall-clock time, after which it stops firing:

```elixir
every :follow_up do
  interval {7, :days}
  action :send_follow_up
  until {28, :days}
end
```

`until` is measured against `state_entered_at` directly — `interval` measures against the `every`'s own last-fired column instead, so firing never moves it. `until` must be strictly longer than `interval`. See `usage-rules/timeouts.md` for the full explanation.

### Transition Timeout (force state change)

```elixir
timeout :escalation, fire_after: {7, :days}, transition_to: :escalated
timeout :expire, fire_after: {14, :days}, transition_to: :expired
```

- `transition_to`: Forces the workflow to move to the specified step.

### Workflow-Level Options

Set on the `workflow` block, applying to every generated trigger on the resource:

```elixir
workflow do
  state_attribute :status
  queue :fulfilment
  check_interval "0 * * * *"

  step :packing do
  end
end
```

- `state_attribute` (optional): the attribute the current step is stored in. Defaults to `:state`. AshWorkflow passes it down to `ash_state_machine`, so set it here rather than in a `state_machine` block. Everything generated follows it: the `match` expression on each unit of scheduled work, the `current_step`, `available_actions`, `transition_targets` and `pending_deadlines` calculations, and the recommended indexes. `state_entered_at` keeps its name either way.
- `queue` (optional): the Oban queue for all generated triggers. Defaults to `:workflow`. The queue MUST exist in your Oban config or Oban raises at boot.
- `check_interval` (optional): Oban cron expression controlling how often every trigger on the resource polls — automatic steps and timeouts alike. Defaults to `"* * * * *"` (every minute). Individual timeouts can override it.

Prefer raising `check_interval` over leaving the default when deadlines are measured in days. Every automatic step and every timeout gets its own scheduler, and each runs a query on every tick regardless of whether any record is waiting, so the cost scales with the number of triggers on the resource.

### Timeout Rules

- Each timeout must have EITHER `action` OR `transition_to` — not both, not neither.
- Supported duration units: `:seconds`, `:minutes`, `:hours`, `:days`.
- `check_interval` (optional): Oban cron expression for how often to poll. Defaults to the workflow-level `check_interval`, which itself defaults to `"* * * * *"` (every minute).
- A timeout can also declare a `retry` block, with the same `max_attempts` and `backoff` options as a step's — see [Retry](#retry) under Automatic Steps.

## Transition Log (Workflow History)

Declare an opt-in `transition_log` inside `workflow` to record one row per workflow event (transitions, automatic step completions, timeout firings) to a resource you own:

```elixir
workflow do
  transition_log MyApp.TicketTransition do
    belongs_to_actor :user, MyApp.Accounts.User
  end

  step :triage do
    transition :escalate, to: :urgent_queue
  end
end
```

- `transition_log` (arg `resource`, required): names the log resource module. Scaffold it with `mix ash_workflow.gen.transition_log MyApp.Ticket` — the log resource is user-owned, not generated by a transformer.
- `belongs_to_actor` (args `name`, `destination`, optional, nested inside `transition_log`): configures actor capture. Requires a matching `belongs_to` relationship on the log resource.
  - `skip_other_actors?` (default `false`): record `nil` for an actor that is not a `destination` struct. Set it when actions also run as a plain-struct system actor, which otherwise raises when its log row is written. Without it, an actor of another resource has its primary key written to the foreign key.

The log resource must define `from_state`, `to_state`, `transition_name`, `triggered_by` (all `:atom`), `occurred_at` (a datetime type), and a `belongs_to` back to the workflow resource — a compile-time verifier checks this.

When configured, the resource gains:

- `state_at/3` — the state a record was in at a given `DateTime`, resolved by walking the log.
- `history/2` — the full list of log rows for a record, ordered oldest first.

Both take an `effective: true` option, which omits rows a later undo reversed. See Undo below.

An `every` writes a log row with `from_state == to_state` — it's not a state change, but it's a recorded event, so it shows up in history even though it never touches `state_entered_at`. `state_entered_at` is written only on a genuine step entry; use it for "when did the record enter this step". See [Workflow history](documentation/topics/workflow-history.md) for the full explanation and the documented aggregate-query recipe for "how many records were in state S at time Y".

This is not an audit trail (attribute-level changes) — pair it with AshPaperTrail for that — and not event sourcing; `state` stays a plain column.

## Undo

Undo rewinds a record to the state it occupied before its most recent state change. It requires a `transition_log`, and it is opt-in twice: an `undo` block on the workflow, plus `undoable?: true` on each transition that may be rewound.

```elixir
workflow do
  transition_log MyApp.TicketTransition do
    belongs_to_actor :user, MyApp.Accounts.User
  end

  undo do
    within {30, :minutes}
    same_actor? true
  end

  step :triage do
    transition :escalate, to: :urgent_queue, undoable?: true
    transition :close, to: :closed  # not undoable
  end
end
```

- `undoable?` (on `transition`, default `false`): whether this transition may be rewound. Nothing is undoable unless you say so.
- `within` (optional): how long after a transition it may still be undone, e.g. `{30, :minutes}`. Defaults to no limit.
- `same_actor?` (optional, default `false`): restrict undo to the actor recorded on the transition. Requires `belongs_to_actor` on the log. With `skip_other_actors?`, a caller that is not a `destination` struct is refused with `:no_actor`.
- `policy` (optional): an `Ash.Policy.Check` tuple applied to the generated `undo` action. Step policies do not apply — an undo spans two states, and which step it rewinds into is only known at runtime.

An undo appends a row and never mutates or deletes one. The new row carries `triggered_by: :undo` and an `undoes_id` pointing at the row it reverses, so the log stays append-only and two readings of history stay derivable from the same rows:

- `history(record)` / `state_at(record, at)` — what actually happened, including states the record briefly occupied and rewound out of.
- `history(record, effective: true)` / `state_at(record, at, effective: true)` — what stands after corrections, with reversed rows omitted.

Prefer the literal reading for audit and for explaining side effects; prefer the effective reading for timeline UIs. Undoing an undo is a redo, and needs no special handling: it marks the undo row superseded in turn.

The resource gains a generated `undo` update action, plus `undoable?/2` (can this be undone) and `undo_target/2` (where it would land). Note that Ash's code interface separately generates `can_undo?/2`, which asks whether the *actor is authorized* to call `undo` — a different question.

Undo restores state, not attributes. A transition with `accept` does not have its accepted values rolled back, and side effects an action performed — an email sent, a payment taken — are not compensated. Mark a transition `undoable?: true` only when rewinding it is safe on its own.

Undo is refused, with a reason on `AshWorkflow.Errors.UndoNotPermitted`, when: the last state change was not an undoable transition (`:not_undoable` — this is what keeps automatic steps, timeouts and error paths out of reach), there is no state change to undo (`:no_history`), the `within` window has passed (`:window_expired`), or `same_actor?` does not match (`:different_actor` / `:no_actor`).

A transition whose target is an *automatic* step is undoable only until that step runs: once its Oban trigger fires, the head of the log is the automatic step's own row, and undo refuses with `:not_undoable`.

## Telemetry

Every state change emits a `[:ash_workflow, :transition]` span, and a conditional transition also emits `[:ash_workflow, :route_evaluation]`. `AshWorkflow.Changes.RecordEvent` emits the span, so one span covers a manual transition, an automatic step, a timeout, an error path, an undo and the initial create.

```elixir
:telemetry.attach("workflow-transitions", [:ash_workflow, :transition, :stop], &handle/4, nil)
```

Metadata carries `resource`, `workflow_id` (the primary key), `from_state`, `to_state`, `action`, `transition_name` and `triggered_by` (`:initial`, `:manual`, `:automatic`, `:timeout`, `:error_path`, `:undo`) — the same vocabulary the transition log records.

Two things to know when reading the events. A create's start event carries no `workflow_id`, because the record does not exist yet; its stop event carries it. A conditional transition's start event carries no `to_state`, because the route is chosen at runtime; its stop event carries the state the record landed in.

Nothing stores these. Use the transition log for durable history, and telemetry for a live stream. See `AshWorkflow.Telemetry`.

## Authorization

### Step-Level Policies

Apply a policy check to all transitions in a manual step:

```elixir
step :manager_review do
  policy actor_attribute_equals(:role, :manager)

  transition :approve, to: :approved
  transition :reject, to: :rejected
end
```

Available built-in checks (imported automatically):
- `actor_attribute_equals(attribute, value)`
- `relates_to_actor_via(relationship_path)`
- `actor_present()`

### Resource-Level Policies

For more complex authorization, use standard Ash policies targeting the generated action names:

```elixir
policies do
  policy action(:approve) do
    authorize_if actor_attribute_equals(:role, :manager)
  end

  policy action(:offer) do
    authorize_if relates_to_actor_via(:assigned_recruiter)
  end
end
```

Resource-level policies take precedence — if you define a policy targeting a specific transition action, the step-level policy is not applied for that action.

## Generated Artifacts

AshWorkflow generates these automatically — do NOT define them yourself:

| Artifact | Details |
|---|---|
| `:state` attribute | Added by `ash_state_machine`. Rename it with `state_attribute` |
| `:state_entered_at` attribute | `utc_datetime_usec`, tracks when current state was entered |
| Primary `:read` action | Added if automatic steps exist (needed for Oban triggers) |
| Transition update actions | One per manual transition |
| Timeout transition actions | Hidden `__timeout_<step>_<name>` actions |
| Oban triggers | Schedulers and workers for automatic steps and timeouts |
| State machine DSL | States, transitions, initial state |

## What You Must Define

- **Attributes**: Your resource's domain attributes (id, name, etc.)
- **Update actions for automatic steps**: The action referenced by `action :my_action` must be defined as an update action.
- **Update actions for transitions that accept input**: Only needed if the transition takes additional parameters.
- **Update actions for action timeouts**: The action referenced by `timeout :name, action: :my_action` must be defined.
- **Domain**: The resource must belong to an Ash domain.
- **Data layer**: Typically `AshPostgres.DataLayer`.

## Common Patterns

### Loopback / Revision Cycles

Steps can transition back to earlier steps:

```elixir
step :draft do
  transition :submit, to: :review
end

step :review do
  transition :approve, to: :approved
  transition :request_changes, to: :draft  # loops back
end
```

### Mixed Automatic and Manual Pipelines

Chain automatic processing with human decision points:

```elixir
workflow do
  step :intake do
    action :process_intake
    on_success :triage
    on_error :intake_failed
  end

  step :triage do
    transition :assign, to: :processing
    transition :reject, to: :rejected
  end

  step :processing do
    action :run_processing
    on_success :review
    on_error :processing_failed
  end

  step :review do
    transition :complete, to: :completed
    transition :retry, to: :processing  # send back for reprocessing
  end

  step :completed, terminal: true
  step :rejected, terminal: true
  step :intake_failed, terminal: true
  step :processing_failed, terminal: true
end
```

### Escalation with Timeout

```elixir
step :awaiting_approval do
  transition :approve, to: :approved

  timeout :reminder, fire_after: {2, :days}, action: :send_approval_reminder
  timeout :escalate, fire_after: {5, :days}, transition_to: :escalated
end
```

## Compile-Time Validation

AshWorkflow validates your workflow at compile time and raises clear errors for:

- Missing steps (no non-terminal steps defined)
- Invalid step configuration (e.g., a step with both `action` and `transitions`, or with neither)
- Timeouts with both `action` and `transition_to` (or neither)
- References to undefined steps (in `on_success`, `on_error`, `transition :to`, `timeout :transition_to`)
- An automatic step declaring no `on_success`
- More than one unconditional `on_success` on a step, or an unconditional `on_success` declared before a conditional one (it would shadow it)
- An automatic step whose `on_success` is entirely conditional (no trailing unconditional fallback) and declares neither `on_error` nor a timeout with `transition_to`
- Unreachable steps (not connected to the first step via any path)

## Important Caveats

- **Do not add `AshStateMachine` or `AshOban` to your extensions list** — AshWorkflow injects their DSL automatically. Adding them manually will cause conflicts.
- **Shared transition names merge** — same-named transitions across steps become one action that routes based on current state.
- **The first non-terminal step is the initial state** — order matters for the first step.
- **Automatic steps use Oban** — ensure your application has Oban configured and running.
- **`state_entered_at` is managed automatically** — do not set it manually in your actions.
