AshWorkflow lets you define multi-step workflows — combining human actions, background jobs, and time-based deadlines — as a single Ash resource. This guide walks you through building a simple document approval workflow.
Installation
You need an existing Ash project. Install AshWorkflow with Igniter:
mix igniter.install ash_workflow
That adds ash_workflow, ash_oban and ash_state_machine as dependencies, composes ash_oban.install to configure Oban and its cron plugin, adds the :workflow queue that generated triggers publish to, and imports the workflow DSL into .formatter.exs. Pass --queue and --queue-concurrency to change the queue it adds. See mix ash_workflow.install.
Add ash_workflow to your dependencies and nothing else. Do not add AshStateMachine to the resource's extensions — AshWorkflow.Transformers.AddStateMachine adds it and writes its DSL for you. Add AshOban yourself, because the scheduler that generates its triggers is one choice among several; see AshWorkflow.Scheduler.
Installing by hand
Without Igniter, add the dependencies yourself and configure Oban with a queue for workflow triggers:
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 10, workflow: 5]The queue name must match the workflow's queue option, which defaults to :workflow. A trigger publishing to a queue Oban does not run inserts jobs that never execute.
Define the workflow
Create a resource with the AshWorkflow and AshOban extensions.
defmodule MyApp.DocumentApproval do
use Ash.Resource,
domain: MyApp.Documents,
data_layer: AshPostgres.DataLayer,
extensions: [AshWorkflow, AshOban]
workflow do
step :auto_check do
action :run_checks
on_success :review
on_error :check_failed
end
step :review do
transition :approve, to: :approved
transition :reject, to: :rejected
timeout :reminder, fire_after: {3, :days}, action: :send_reminder
end
step :approved, terminal: true
step :rejected, terminal: true
step :check_failed, terminal: true
end
code_interface do
define :create
end
attributes do
uuid_v7_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :author, :string, allow_nil?: false
end
actions do
create :create do
accept [:title, :author]
end
# Automatic steps need user-defined actions.
# The extension injects transition_state + state_entered_at changes.
update :run_checks do
accept []
change MyApp.Changes.ValidateDocument
end
update :send_reminder do
accept []
change MyApp.Changes.NotifyReviewer
end
end
endWhat this generates
From that DSL, AshWorkflow generates:
- A state machine with states
:auto_check,:review,:approved,:rejected,:check_failed - An Oban trigger for
:auto_checkthat fires whenstate == :auto_check - An Oban trigger for the reminder timeout that fires when
state == :reviewandstate_entered_at <= ago(3, :day) - Transition actions
:approveand:rejectas update actions withtransition_state - A primary read action with keyset pagination
- Code interface functions for the manual transitions:
approve/1,reject/1 - A
state_entered_atattribute to track when the current state was entered - The resource's own create action initializes the workflow using the initial step and
state_entered_atdefaults
You define the create action
AshWorkflow does not generate one. A workflow record's creation inputs are specific to your application, so the extension leaves create to you and only adds the changes that set the initial state and state_entered_at. A resource with a workflow block and no create action compiles, and then nothing can start a workflow.
:auto_check becomes the initial state because it is the first step by declaration order that is not terminal. AshWorkflow.Entities.Step.find_initial/1 picks it. To name the initial step instead of relying on declaration order, mark it:
workflow do
step :review do
transition :approve, to: :approved
end
step :intake do
initial true
transition :submit, to: :review
end
step :approved, terminal: true
endWithout initial true that workflow would start in :review, because :review is declared first. A step that declares transitions has to set initial inside its block: step :intake, initial: true do ... end does not compile, because the DSL macro takes either options or a block.
Use the workflow
# Create a new workflow instance
{:ok, doc} = MyApp.DocumentApproval.create(%{title: "Q1 Report", author: "alice"})
# doc.state => :auto_check
# The auto_check step runs automatically via Oban.
# If your run_checks action succeeds, the state moves to :review.
# A reviewer approves:
{:ok, doc} = MyApp.DocumentApproval.approve(doc, actor: reviewer)
# doc.state => :approvedUsing AshPhoenix forms
Because workflow initialization happens through your normal create action, an AshPhoenix.Form targets that action directly:
form =
AshPhoenix.Form.for_create(
MyApp.DocumentApproval,
:create,
domain: MyApp.Documents,
as: "document_approval"
)Submitting that form creates the workflow record with your form values, while AshWorkflow fills in the initial state and state_entered_at automatically. That means the first workflow step is implicit: the form just creates the resource, and the workflow starts in its initial step.
Adding authorization
To restrict who can trigger transitions, add a policy to the step and configure the authorizer:
use Ash.Resource,
domain: MyApp.Documents,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshWorkflow, AshOban]
workflow do
step :review do
policy actor_attribute_equals(:role, :reviewer)
transition :approve, to: :approved
transition :reject, to: :rejected
end
# ...
endNow only actors with role: :reviewer can call :approve or :reject. Other workflow actions, including create actions without an explicit policy, are allowed by the extension's default allow-all policy for uncovered workflow actions.
Oban queue configuration
All generated Oban triggers use the :workflow queue by default. Make sure it's configured:
config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 10, workflow: 5]Querying available actions
AshWorkflow provides two ways to discover what actions can be taken at a given step.
Static introspection
Use AshWorkflow.Info to look up available transitions at compile time or from a module:
AshWorkflow.Info.available_actions(MyApp.DocumentApproval, :review)
#=> [:approve, :reject]
AshWorkflow.Info.available_actions(MyApp.DocumentApproval, :approved)
#=> []Step metadata calculations
Every workflow resource also gets :steps and :current_step calculations:
doc = Ash.load!(doc, [:steps, :current_step])
doc.steps
#=> [:auto_check, :review, :approved, :rejected, :check_failed]
doc.current_step
#=> :reviewThese are useful for rendering progress bars, step lists, or workflow visualisations.
Runtime calculation
Every workflow resource gets a generated :available_actions calculation. Load it on a record to get the transitions for its current state:
doc = Ash.load!(doc, :available_actions)
doc.available_actions
#=> [:approve, :reject]When an actor is provided, the calculation filters to only the actions that actor is authorized to perform:
doc = Ash.load!(doc, :available_actions, actor: current_user)
doc.available_actions
#=> [:approve] # only actions this user can performThis is useful for building dynamic UIs that only show relevant buttons, or for agents and APIs that need to discover available actions without hard-coding workflow knowledge.
Where a transition would go
A conditional transition picks its target when it runs. To label a button by where it leads, load :transition_targets. It maps each transition of the current step to the step it would move the record to:
doc = Ash.load!(doc, :transition_targets)
doc.transition_targets
#=> %{approve: :approved, reject: :rejected}For one transition, call AshWorkflow.Info.transition_target/3:
AshWorkflow.Info.transition_target(doc, :approve)
#=> {:ok, :approved}Both resolve routes through the same code the generated action runs, so the preview and the transition agree. A transition maps to nil when no route matches. transition_target/3 also returns {:ok, nil} for a transition the current step does not declare, and {:error, error} when a route fails to evaluate.
A preview has no action input. A route that reads an accepted attribute sees the value on the record, not the one a caller is about to submit. To preview a particular call, pass the input: AshWorkflow.Info.transition_target(doc, :decide, %{decision: :approve}).
transition_target/3 loads nothing, and a route that reads an unloaded relationship sees nil. The generated action reads the record it is called on the same way. The calculation loads the fields its routes reference.
Stale transitions are refused
A transition decides from the record it is called on. It checks the step the record is in and picks a route from the record's attributes, then writes by primary key. A page loaded before someone else moved the record hands the action an old copy. Without a check, the transition would run from a step the record has already left, or take a route from values that have since changed. Of two reviewers who click at once, the second would overwrite the first.
AshWorkflow refuses such a transition. Take this step:
workflow do
step :screening do
transition :advance do
route :interviewing, when: expr(is_nil(path_type) or path_type == :agency)
route :compliance, when: expr(path_type == :family)
end
transition :reject, to: :rejected
end
endEach transition writes only if the row still holds what the transition decided from: the state, and every attribute of the resource that a route of the loaded step reads. Here that is state and path_type. A nil value must still be nil. When the row has moved on, the call fails with Ash.Error.Changes.StaleRecord and the row stays as it was. Reload the record and try again.
{:error, %Ash.Error.Invalid{errors: errors}} = Ash.update(stale_doc, action: :advance)
Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1))
#=> trueThe struct a transition was called on is itself stale once the transition writes. Run the next transition on the struct it returned.
A change to an attribute no route reads does not make the copy stale. An attribute the call supplies through accept is not checked, since the route reads the supplied value.
- Covered: every manual transition, including one merged into an action you define, and
undo. Undo from an old copy would otherwise reverse a transition the caller never saw. - Not covered: automatic step actions, timeouts and error paths. The scheduler reads the record just before it runs them. A refusal on an automatic step would also count as the step failing and send the record down
on_error. - Not checked: a route reading a relationship, such as
sponsor.name, or a calculation or aggregate. They are not values on the row. Copy the value onto the resource if a route must be protected against it changing. - Data layers: the check needs a data layer that can filter an update, such as
AshPostgres.DataLayerorAsh.DataLayer.Ets. On one that cannot, such asAsh.DataLayer.Simple, nothing is checked. It works on both the atomic and the non-atomic update path, sorequire_atomic?does not change it.
Next steps
- See Automatic vs Manual Steps for a deeper dive
- See Timeouts and Deadlines for time-based workflow control
- See Authorization for policy patterns