A workflow resource normally keeps only state and state_entered_at — the
current position, with nothing behind it. The transition log is an opt-in
resource that records every workflow event as it happens, so you can answer
"what state was this workflow in at time Y" for one record, or "how many
records were in state S at time Y" across the whole table.
Enabling it
Declare a transition_log inside workflow, naming a resource module you own:
workflow do
transition_log MyApp.TicketTransition
step :triage do
transition :escalate, to: :urgent_queue
end
endThe log resource is not generated by a transformer — Ash validates domain registration and AshPostgres migrations in ways a transformer can't drive cleanly, so scaffolding is a separate step. Generate it with:
mix ash_workflow.gen.transition_log MyApp.Ticket
This creates the log resource with the required schema below, wired to
MyApp.Ticket with a belongs_to relationship, registers it in the same
domain, and adds the transition_log block to the workflow itself. It matches
the workflow's data layer, so a resource on AshPostgres gets a log resource on
the same repo.
Two options adjust what it generates:
mix ash_workflow.gen.transition_log MyApp.Ticket \
--log-module MyApp.TicketHistory \
--actor MyApp.Accounts.User
--log-module names the generated resource, which otherwise defaults to the
workflow's name with Transition appended. --actor adds the
belongs_to_actor configuration described below, along with the matching
relationship on the log resource.
Running the task again on a resource that already declares a transition_log
leaves the DSL alone and warns instead of duplicating the block.
Because there's no module to point at until the generator has run, this
feature cannot default to on — you always declare transition_log explicitly
once the resource exists.
Capturing the actor
To record who triggered each event, add a belongs_to_actor inside
transition_log, following the same shape AshPaperTrail uses:
transition_log MyApp.TicketTransition do
belongs_to_actor :user, MyApp.Accounts.User
endThis tells AshWorkflow.Changes.RecordEvent to populate the actor relationship
from context.actor on every row it writes. It's configured rather than
guessed, because the library has no way to know your actor type — your log
resource needs a matching belongs_to :user, MyApp.Accounts.User relationship
for this to validate.
The actor must be a record with a single-attribute primary key. An action run
as a plain-struct actor, such as a system actor for scheduled or internal work,
fails when it writes its log row. An actor of another resource has its primary
key written to the foreign key. Set skip_other_actors? to record nil for
any actor that is not a destination struct:
transition_log MyApp.TicketTransition do
belongs_to_actor :user, MyApp.Accounts.User, skip_other_actors?: true
endA row written by a skipped actor looks the same as a row written with no actor.
Schema
The generator creates a resource with these attributes:
| Attribute | Type | Notes |
|---|---|---|
workflow_id | belongs_to | to the workflow resource |
from_state | :atom | nil on the :initial row |
to_state | :atom | equal to from_state for an every firing |
transition_name | :atom | the action that ran |
occurred_at | :utc_datetime_usec | |
triggered_by | :atom | :initial | :manual | :automatic | :timeout | :error_path |
A compile-time verifier checks this schema is in place whenever
transition_log is configured, so a missing or mistyped attribute fails the
build rather than failing silently at runtime. Because the log is your
resource, you're free to add columns of your own (a reason text field, a
denormalised note) — the verifier only requires the attributes above.
Every event, not just state changes
The log writes one row per workflow event, and not every event is a
transition. Every timeout that names an action, and every every, appends a
row with from_state == to_state and triggered_by: :timeout. A one-shot
timeout :nudge, fire_after: {3, :days}, action: :send_nudge writes a row the
first and only time it fires, and a recurring
every :follow_up do interval {3, :days}; action :send_follow_up end writes
one on every scheduler cycle while the workflow stays in that state.
This looks redundant at first — nothing about the state changed — but it's what makes the history complete. "Reminder sent three times, then escalated" is only visible in the log if the reminders are in it.
Firing an every writes its own last-fired column (see
Timeouts and deadlines), not state_entered_at,
so logging the firing is not what keeps any anchor derivable — it's purely a
record of the event.
state_entered_at
state_entered_at is the resource's own anchor, written only on a genuine
step entry: a manual transition, an automatic step completing, a transition
timeout, an undo, or the initial create. Neither an action timeout nor an
every firing touches it. Prefer it for "when did the record enter this
step"; it's what the generated Oban triggers filter on.
A transition into the step the record is already in is still a step entry, so
it updates state_entered_at, but it logs a row with
from_state == to_state. If you want the entry time the log attests to
instead, derive it from history/1:
record
|> MyApp.Ticket.history()
|> Enum.filter(&(&1.from_state != &1.to_state))
|> List.last()
|> then(&(&1 && &1.occurred_at))That reads the log per record, so avoid it across a list load.
Querying
Point query: what state was it in at time Y
state_at/2 walks a record's log entirely in Elixir, so it's portable across
data layers — it doesn't depend on a "latest row" query the data layer would
need to express natively:
MyApp.Ticket.state_at(ticket, ~U[2026-08-01 09:00:00Z])
#=> :awaiting_reviewIt returns nil if at is before the earliest logged row for that record.
The full history
MyApp.Ticket.history(ticket)
#=> [%MyApp.TicketTransition{from_state: nil, to_state: :triage, triggered_by: :initial}, ...]Rows come back ordered by occurred_at ascending, including every rows.
The transitions relationship
Configuring transition_log adds a public has_many :transitions from the
workflow resource to the log resource. Its destination_attribute is the log's
belongs_to foreign key back to the workflow. Use it anywhere Ash accepts a
relationship: loads, filters, aggregates, and calculations.
Ash.load!(ticket, transitions: [sort: [occurred_at: :asc]])
MyApp.Ticket
|> Ash.Query.filter(exists(transitions, triggered_by == :timeout))
|> Ash.read!()A :transitions relationship you define yourself takes precedence, and the
extension adds nothing.
Aggregate query: how many were in state S at time Y
This is "latest row per workflow, at or before a timestamp" — a query shape Ash's query language can't express portably across data layers. It's documented here rather than shipped as a code-interface function. On PostgreSQL:
SELECT DISTINCT ON (workflow_id) workflow_id, to_state
FROM ticket_transitions
WHERE occurred_at <= $1
ORDER BY workflow_id, occurred_at DESC;Filter the result by to_state = 'awaiting_review' (or push that into the
query as an outer WHERE) to get your count. DISTINCT ON is a Postgres
extension with no equivalent on ETS, which is exactly why this is a documented
recipe and not a state_counts_at/1 API on the library — there's no portable
implementation to give you.
Limits
Be plain-eyed about what this does and doesn't give you:
History starts when you enable the log. Nothing before that point is recoverable.
Backfill is approximate. For records that predate the log, run:
mix ash_workflow.backfill_transition_log MyApp.TicketIt seeds one
:initialrow per record with no history, built from its currentstateandstate_entered_at, and skips records that already have rows, so it's safe to run more than once. That row can't reconstruct the transitions that actually happened before logging existed — it's a starting point, not real history.ETS has no transactions. On PostgreSQL, the state update and the log append commit or roll back together in the same
after_actionhook. ETS doesn't support that, so a crash between the two can drop a log row without rolling back the state change.
Undoing a logged transition
The log is also what makes undo possible: an undo rewinds to the
state on the previous row, and records the rewind as a new row pointing at
the one it reverses. Nothing here is ever mutated or deleted, so both accounts
stay derivable from the same rows — history/2 and state_at/3 take an
effective: true option that omits reversed rows, and answer literally without
it.
What this is not
Not an audit trail. The log records workflow events — state transitions and timeout firings — not attribute-level changes or who edited which field. If you need to know who changed a value and what it was before, that's AshPaperTrail's job, and the two compose cleanly: AshPaperTrail on the attribute-editing actions, the transition log on the workflow's state history.
Not event sourcing. state stays a plain column on the workflow resource; the
log describes how it got there, but the workflow isn't reconstructed by
replaying the log, and there's no replay API.