Declarative workflow orchestration for Ash Framework. Define multi-step workflows that combine human actions, background jobs, and time-based deadlines — all as a single, readable DSL.

AshWorkflow generates ash_state_machine states and transitions, ash_oban triggers for automatic steps, and Ash actions for manual transitions. You write the workflow; it handles the wiring.

Concepts

  • Step — a state the workflow can be in. Some steps run automatically (background work via Oban), others wait for a human to trigger a transition.
  • Transition — a named outcome from a manual step that moves the workflow to a new state. Each transition becomes a callable Ash action. The same transition name can be used across multiple steps — they merge into a single action that routes based on the current state.
  • Timeout — a time-based rule: "if the workflow has been in this state for N days, do X." Timeouts can run actions (reminders) or force transitions (escalations).
  • Terminal step — an end state. A step that declares no action, no transitions and no timeouts has no way out, so it is terminal without saying so. terminal: true states the intent and makes the verifier hold you to it.
  • Transition log — an opt-in resource recording one row per workflow event, which becomes the source of truth for history.
  • Undo — rewinding a record to the state before its last transition, for transitions marked undoable?: true. Recorded as a new log row pointing at the one it reverses, never by erasing history.

Example: ATS Candidate Pipeline

defmodule MyApp.CandidatePipeline do
  use Ash.Resource,
    domain: MyApp.Recruiting,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshWorkflow, AshOban]

  workflow do
    step :process_application do
      action :process_application
      on_success :recruiter_review
      on_error :application_failed
    end

    step :recruiter_review do
      policy actor_attribute_equals(:role, :recruiter)

      transition :approve, to: :phone_screen
      transition :reject_application, to: :rejected

      timeout :reminder, fire_after: {2, :days}, action: :send_review_reminder
      timeout :escalation, fire_after: {7, :days}, transition_to: :escalated_review
    end

    step :phone_screen do
      action :schedule_phone_screen
      on_success :awaiting_screen_result
    end

    step :awaiting_screen_result do
      policy actor_attribute_equals(:role, :recruiter)

      transition :pass, to: :onsite_interview
      transition :fail, to: :rejected
      transition :reschedule, to: :phone_screen

      timeout :nudge, fire_after: {5, :days}, action: :remind_interviewer
    end

    step :onsite_interview do
      action :schedule_onsite
      on_success :awaiting_onsite_result
    end

    step :awaiting_onsite_result do
      policy actor_attribute_equals(:role, :hiring_manager)

      transition :offer, to: :send_offer
      transition :reject_candidate, to: :rejected
    end

    step :send_offer do
      action :send_offer_email
      on_success :awaiting_offer_response
    end

    step :awaiting_offer_response do
      transition :accept, to: :onboarding
      transition :decline, to: :offer_declined
      transition :negotiate, to: :send_offer

      timeout :expire, fire_after: {14, :days}, transition_to: :offer_expired
    end

    step :onboarding do
      action :start_onboarding_tasks
      on_success :hired
    end

    step :hired, terminal: true
    step :rejected, terminal: true
    step :offer_declined, terminal: true
    step :offer_expired, terminal: true
    step :application_failed, terminal: true
    step :escalated_review, terminal: true
  end

  attributes do
    uuid_v7_primary_key :id
    attribute :candidate_name, :string, allow_nil?: false
    attribute :position, :string, allow_nil?: false
  end

  # Automatic steps need user-defined actions with business logic.
  # The extension injects transition_state and state_entered_at changes.
  actions do
    update :process_application do
      accept []
      change MyApp.Changes.ParseResume
    end

    update :schedule_phone_screen do
      accept []
      change MyApp.Changes.SendCalendlyLink
    end

    update :schedule_onsite do
      accept []
      change MyApp.Changes.SendOnsiteInvite
    end

    update :send_offer_email do
      accept []
      change MyApp.Changes.GenerateAndSendOffer
    end

    update :start_onboarding_tasks do
      accept []
      change MyApp.Changes.CreateOnboardingChecklist
    end

    update :send_review_reminder do
      accept []
      change MyApp.Changes.NotifyRecruiter
    end

    update :remind_interviewer do
      accept []
      change MyApp.Changes.NudgeInterviewer
    end
  end
end

Automatic vs Manual Steps

Automatic steps (the default) run via Oban as soon as the workflow enters that state. You define the update action with your business logic; the extension injects transition_state and state_entered_at changes into it and generates an Oban trigger:

step :send_offer do
  action :send_offer_email
  on_success :awaiting_offer_response
  on_error :offer_send_failed
end

# You define this action — the extension appends transition changes to it:
actions do
  update :send_offer_email do
    accept []
    change MyApp.Changes.GenerateAndSendOffer
  end
end

Manual steps wait for a human (or external system) to call a transition action. Any step that declares one or more transition entries is a manual step:

step :recruiter_review do
  transition :approve, to: :phone_screen
  transition :reject_application, to: :rejected
end

Each transition becomes a generated Ash update action. You can call them through the code interface:

CandidatePipeline.approve(workflow, actor: current_user)
CandidatePipeline.reject_application(workflow, actor: current_user)

Conditional Transitions

Transitions can route to different states based on record attributes:

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
end

The user calls :complete_review — the workflow evaluates conditions at runtime using Ash.Expr and routes to the first match. If no condition matches, the action fails with a clear error.

Conditions read the record as it was loaded, plus the attributes the transition accepts, so a transition can accept the value it routes on:

step :review do
  transition :decide do
    accept [:decision]
    route :approved, when: expr(decision == :approve)
    route :rejected, when: expr(decision == :reject)
  end
end

CandidatePipeline.decide(workflow, %{decision: :approve}) lands in :approved. An attribute written by one of the action's own changes is not visible to the routes, so a route can still ask what the record looked like before the call.

Authorization

Authorization works at two levels.

Step-level policies

Use policy inside a step to restrict all transitions in that step to a particular kind of actor. The policy is applied to every generated transition action for that step. Supports any {module, opts} tuple implementing Ash.Policy.Check:

step :awaiting_onsite_result do
  policy actor_attribute_equals(:role, :hiring_manager)

  transition :offer, to: :send_offer
  transition :reject_candidate, to: :rejected
end

This generates:

policies do
  policy action([:offer, :reject_candidate]) do
    authorize_if actor_attribute_equals(:role, :hiring_manager)
  end
end

Important: When using step-level policies, add authorizers: [Ash.Policy.Authorizer] to your resource. The extension generates a default "allow all" policy scoped to the workflow actions that have no explicit policy of their own (the generated read action, automatic step actions, and timeout actions), so only the step-level actions require the specified check.

Resource-level policies

For anything more complex — relationship-based checks, multi-condition rules, custom policy modules — define policies directly on the resource. The generated transition actions have predictable names (the transition name), so you can target them:

policies do
  policy action(:approve) do
    authorize_if relates_to_actor_via(:assigned_recruiter)
  end
end

When the transformer detects that you've defined policies targeting a transition action, it skips generating policies for that action from the step-level policy declaration.

Timeouts and Deadlines

Timeouts let you react to a workflow being stuck in a state. They're implemented as Oban triggers that poll on a cron schedule (default: every minute) and check whether the workflow has been in the expected state long enough.

step :recruiter_review do
  transition :approve, to: :phone_screen
  transition :reject_application, to: :rejected

  # Send a reminder after 2 days, but stay in the same state
  timeout :reminder, fire_after: {2, :days}, action: :send_review_reminder

  # Force a transition after 7 days
  timeout :escalation, fire_after: {7, :days}, transition_to: :escalated_review
end

Action timeouts run an Ash action but don't change state. Use these for reminders, notifications, or logging.

Transition timeouts force the workflow into a new state. Use these for escalations, expirations, or SLA enforcement.

Timeouts are implemented as polling, not as scheduled jobs: each timeout — and each automatic step — gets its own Oban cron scheduler that queries for records past their deadline. check_interval controls how often, and defaults to every minute. Set it once per workflow, and override individual timeouts as needed:

workflow do
  check_interval "0 * * * *"

  step :awaiting_review do
    transition :approve, to: :approved

    timeout :nudge, fire_after: {2, :days}, action: :send_nudge

    timeout :daily_check do
      fire_after {3, :days}
      action :check_status
      check_interval "0 9 * * *"
    end
  end
end

The cost scales with the number of triggers on the resource, not the number of records — eight triggers at the default interval is 480 scheduler queries an hour, whether or not anything is waiting. For workflows measured in days, an hourly interval behaves the same to users at a fraction of the cost. See Timeouts and deadlines for details.

The extension auto-manages a state_entered_at timestamp attribute on the resource to track when the current state was entered. Timeout durations are calculated from this timestamp.

Supported duration units: :seconds, :minutes, :hours, :days.

What Gets Generated

From the workflow DSL, the extension generates:

LayerWhatHow
ExtensionsAshStateMachineAuto-added via add_extensions. Add AshOban yourself — see Scheduling
State machineStates, transitions, initial stateVia ash_state_machine DSL injection
Scheduled workOne Scheduler.Work per automatic step and timeoutHanded to the selected AshWorkflow.Scheduler
Oban triggersOne trigger per unit of workAshWorkflow.Scheduler.Oban, the default
ActionsOne update per transition, plus a read actionAsh actions with transition_state change
Timeout actionsHidden __timeout_* update actionsFor timeouts with transition_to
PoliciesStep-level policy declarationsAsh policies on generated transition actions
Code interfaceOne function per transition nameAsh code interface definitions
Calculations:steps, :current_step, :available_actionsWorkflow introspection
Attributesstate_entered_atAdded if not already defined

The initial state is the step with initial true, or the first non-terminal step by declaration order if none is marked. Declaration order is easy to trip over, so mark the step when the reading order is not the running order:

workflow do
  # Starts in :intake. Without `initial true` it would start in :review,
  # the first non-terminal step by declaration order.
  step :review do
    transition :approve, to: :approved
  end

  step :intake do
    initial true
    transition :submit, to: :review
  end

  step :approved, terminal: true
end

The current step lives in the state attribute. A resource that already has a lifecycle column of its own renames it with state_attribute on the workflow section, and everything generated follows the new name:

workflow do
  state_attribute :status

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

  step :approved, terminal: true
end

state_entered_at keeps its name.

All generation follows a generate-if-missing pattern: if you've already defined a read action, policies targeting specific actions, or code interface definitions, the transformers won't overwrite them. The generated read is called :read, or :__workflow_read if your resource already has an action by that name.

Workflows are started through your own create action — AshWorkflow does not generate one. A newly created record enters the initial step implicitly, because state_entered_at defaults on create.

Installation

mix igniter.install ash_workflow

That adds the dependency and sets up everything the generated DSL needs to run: Oban in your supervision tree via AshOban.config/2, the cron plugin, the :workflow queue that generated triggers publish to, :ash_domains config, and the formatter import for the workflow DSL.

Pass --queue and --queue-concurrency to change the queue it configures.

Manual installation Add `ash_workflow` to your dependencies in `mix.exs`: ```elixir def deps do [ {:ash_workflow, "~> 0.5"} ] end ``` Then configure Oban with a `:workflow` queue and the cron plugin, and start it with `AshOban.config/2`. See the [ash_oban documentation](https://hexdocs.pm/ash_oban) for details.

ash_state_machine and ash_oban come in as dependencies of ash_workflow, so do not declare them in mix.exs yourself. On the resource, add AshWorkflow and AshOban to extensions, and leave AshStateMachine out: AshWorkflow.Transformers.AddStateMachine adds that extension and writes its DSL. AshOban stays explicit because the scheduler that generates its triggers is one choice among several. See AshWorkflow.Scheduler.

Demos

Runnable applications live in demos/, each with its own test suite that CI runs:

DemoWhat it shows
atsA Phoenix LiveView app you can click through
document_approvalConditional routes: two admins must sign off, so one :approve action does not advance the workflow the first time
order_fulfilmentError handling across a long automatic chain, with recovery looping back into it
subscription_dunningRepeating timeouts, and deadlines measured against a date on the record
support_ticket_slaPriority routing, one transition name meaning different things per step, per-queue SLAs

Contributing

Bug reports and pull requests are welcome — see CONTRIBUTING.md. This project follows the Contributor Covenant.

License

MIT — see LICENSE.