AshWorkflow steps come in four flavours: automatic, manual, wait states, and terminal.
You never declare which kind a step is. The kind is inferred from its shape:
| The step declares | It is |
|---|---|
an action | automatic |
one or more transition entries | manual |
neither, plus a timeout with transition_to | a wait state |
| nothing at all | terminal |
Automatic steps
Automatic steps run background work via Oban as soon as the workflow enters that state. You define them with an action (referencing a user-defined update action) and on_success (the next state):
step :process_application do
action :process_application
on_success :review
on_error :processing_failed
endHow it works
- The extension generates an Oban trigger with
where: expr(state == :process_application) - When the scheduler fires, it finds records in that state and runs
:process_application - The extension has injected
transition_state(:review)into your action, so on success the state advances - If the action raises or returns an error, and
on_erroris set, the state moves there instead
Fanning out with multiple on_success entries
on_success is a repeatable entity, not a scalar option — declaring it more
than once, with a when condition, fans a record out to different states
based on what the step's action computed. When an automatic step's outcome is
itself a business decision — an AI screening step that either advances a
candidate or rejects them, say — modelling the rejection as on_error is
wrong: it wasn't a failure, it was an outcome, and on_error fires from
actual failures (exceptions, validation errors). Declare more than one
on_success instead, each with a when:
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
endEntries are evaluated in declaration order, first match wins. An on_success
with no when is unconditional — it always matches, so at most one is
allowed per step, and if conditional entries are also present the
unconditional one must be declared last, as the fallback:
step :triaging do
action :run_triage
on_success :escalated, when: expr(priority == :high)
on_success :queued # fallback — no `when`
endDeclaring an unconditional on_success before conditional ones is a
compile-time error: it would always match first and shadow everything after
it.
A step whose on_success entries are all conditional — no trailing
unconditional fallback — must declare another way out: on_error, or a
timeout with transition_to. Without one, a record whose action succeeds but
matches none of the conditions stays in the step, and the action is retried on
every scheduler cycle forever, so AshWorkflow.Verifiers.ValidateWorkflow
rejects the step at compile time rather than let that surface only when the
data hits it. At runtime, a no-match is a distinct failure, not a silent "stay
put": AshWorkflow.Changes.ConditionalOnSuccess adds
AshWorkflow.Errors.NoMatchingRoute to the changeset, naming the step and the
action, and the failure runs down on_error the same way any other action
failure does — see documentation/topics/error-handling.md.
Conditions follow SQL's three-valued logic, not Elixir's: comparing a nil
attribute (one the action never set) evaluates to nil, not false. A route
with a nil condition simply doesn't match — it falls through to the next
route, or to the no-match error if there isn't one — the same as an
explicitly false condition would. There's no separate "unknown" outcome to
handle.
An on_success route can target the step it's declared on, or any earlier
step — both are legitimate retry patterns, not mistakes, and neither is
rejected at compile time. Reachability validation only asks whether a step
can be reached from the workflow's initial step, not whether the path to it
is acyclic:
step :attempting do
action :run_attempt
on_success :attempting, when: expr(attempts < 3) # retry, same step
on_success :succeeded, when: expr(attempts >= 3)
endThe inline shorthand still works for the common single-target case:
step :process_application, action: :process_application, on_success: :review, on_error: :processing_failedon_success conditions run after the step's own action, against whatever
that action computed (screen_score, in the example above). The entire reason
to route on on_success is to branch on what the action produced, which does
not exist until the action has run. A manual transition's route conditions
see less: the record as it was loaded, plus the attributes the transition
accepts. An attribute the action's own changes write is not visible to them,
so a route can ask what the record looked like when the call arrived.
User-defined actions
You must define the update action yourself with your business logic. The extension appends transition_state and state_entered_at changes to it:
actions do
update :process_application do
accept []
change MyApp.Changes.ParseResume
change MyApp.Changes.CheckDuplicates
end
endIf the action doesn't exist on the resource, compilation fails with a clear error.
Wait states
A wait state runs nothing on entry and offers no transition anybody can call. Records sit in it until a timeout moves them on, which makes it the step to reach for when the only thing you are waiting on is the clock:
step :cooling_off do
timeout :period_elapsed do
fire_after {14, :days}
transition_to :active
end
endBecause a timeout is the only exit, a wait state must declare at least one timeout with transition_to. One with only an action-style timeout would trap records forever, so the extension rejects it at compile time. on_success and on_error are also rejected: with no action to succeed or fail, neither could ever fire.
A wait state's deadline can come from the record rather than the clock, using field. That is how you give each record its own delay:
step :scheduled do
timeout :due do
fire_after {1, :minutes}
field :run_at
transition_to :running
end
endSee AshWorkflow.workflow.step.timeout and Timeouts and Deadlines for the full timeout surface.
Manual steps
Manual steps wait for a human (or external system) to call a transition action. Any step that declares one or more named transition entries is treated as manual:
step :review do
transition :approve, to: :next_step
transition :reject, to: :rejected
endA step with transitions must not also declare an action, on_success, or on_error — those belong to automatic steps.
Generated actions
Each transition becomes an Ash update action with transition_state baked in. The extension also generates code interface functions:
# These are generated and callable:
MyResource.approve(record)
MyResource.reject(record)
# Or via Ash directly:
Ash.update(record, action: :approve)Accepting inputs on transitions
By default, generated transition actions don't accept any inputs. Use accept to allow callers to pass data when triggering a transition:
step :review do
transition :approve, to: :approved
transition :reject, to: :rejected, accept: [:reason]
endThe generated :reject action will accept the :reason attribute:
MyResource.reject(record, %{reason: "Not qualified"})If you need more control (custom changes, validations), define the action yourself — the extension merges its changes into your action.
Shared transition names
The same transition name can be used across multiple steps. They merge into a single Ash action. If the targets are the same, the state machine handles routing via from: lists. If the targets differ, the action automatically routes based on the current state at runtime:
# Same name, same target — one action, state machine enforces valid from: states
step :screening do
transition :reject, to: :rejected
end
step :interview do
transition :reject, to: :rejected
end
# Same name, different targets — one action, routes based on current state
step :initial_review do
transition :complete, to: :detailed_review
end
step :detailed_review do
transition :complete, to: :done
endConditional transitions
A transition can route to different states based on record attributes using conditional routes. The workflow evaluates conditions at runtime and picks the first match:
step :review do
transition :complete_review do
route :fast_track, when: expr(priority == :urgent)
route :standard_processing, when: expr(priority == :normal)
end
transition :reject_review, to: :rejected
endThe user calls :complete_review — the workflow checks priority and routes accordingly. If no condition matches, the action fails with a clear error.
Conditions use expr() — the same Ash expression syntax used in filters and policies. They have access to all record attributes.
Terminal steps
Terminal steps are end states. A step that declares no action, no transitions, no timeouts, no on_success and no on_error has no way out, so AshWorkflow.Entities.Step.terminal?/1 reports it as terminal whether or not it says so:
step :approved
step :rejectedterminal: true states the intent, and AshWorkflow.Verifiers.ValidateWorkflow then rejects the step if it grows an outgoing declaration:
step :approved, terminal: trueEither way, a step nothing transitions to is rejected as unreachable, so a name typo'd in one place and not the other does not become a silent end state.
Initial state
By default, the first non-terminal step in declaration order becomes the initial state. You can override this with initial true:
workflow do
step :intake, action: :run_intake, on_success: :review
step :review do
initial true # ← this is the initial state, despite being declared second
transition :approve, to: :done
end
step :done, terminal: true
endAt most one step can have initial true. If none do, the first non-terminal step is used.
The extension sets default_initial_state and initial_states on the generated state machine.