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.
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 repeats |
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 appends a row with
from_state == to_state and triggered_by: :timeout, whether it fires once
or repeats. A one-shot timeout :nudge, fire_after: {3, :days}, action: :send_nudge
writes a row the first and only time it fires, and a repeating
timeout :follow_up, ..., repeat: true 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 deliberate for two reasons:
- 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.
- It's what makes the timer anchor derivable at all. A repeat re-arms its own
Oban trigger by moving
state_entered_atforward (see Timeouts and deadlines). If repeat rows were excluded from the log, the log couldn't reproduce that value — the whole point of the log is that it's the source of truthstate_entered_atprojects from.
state_entered_at vs entered_current_state_at
This is the sharpest distinction the log makes possible, and it's easy to mix up because the two values agree everywhere except the case that matters.
state_entered_at is a timer anchor. It moves every time any event is
recorded for the workflow — including a repeat that changes nothing about the
state — because that movement is the mechanism that reschedules the next
Oban firing. It answers "when did the timer last reset", not "when did we
get here".
entered_current_state_at is a calculation, only added when a transition_log
is configured, that walks the log and returns the occurred_at of the most
recent row where from_state != to_state. It ignores repeat rows entirely.
It answers "when did we actually enter this state" — the question the
library couldn't answer before the log existed.
| Value | Definition | Moved by a repeat? |
|---|---|---|
state_entered_at | occurred_at of the most recent row, any row | Yes |
entered_current_state_at | occurred_at of the most recent row where from_state != to_state | No |
A workflow that's been sitting in :awaiting_review for nine days, sending a
reminder every two, reports a state_entered_at of two days ago and an
entered_current_state_at of nine days ago. Use state_entered_at for
scheduling — it's what the generated Oban triggers filter on — and
entered_current_state_at for anything you show a human or reason about as
"how long has this actually been waiting".
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 repeat rows.
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.