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 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 vs entered_current_state_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.
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. Most step
entries write a row with from_state != to_state (except the :initial row,
where from_state is nil), and an action timeout or every always writes
from_state == to_state, so the two values usually agree: entered_current_state_at
walks the log to the same instant state_entered_at already holds.
They diverge in two cases. The first is the log's own stated limit: a record
whose state_entered_at was set by something other than a logged event —
imported data, or mix ash_workflow.backfill_transition_log's single
:initial row standing in for history that predates the log. The second is a
manual transition or automatic step whose target is the step the record is
already in: that still touches state_entered_at — it's a genuine step
entry, not an every or timeout firing — but writes a log row with
from_state == to_state, which entered_current_state_at filters out the
same as any other same-state row. In either case state_entered_at reports
whatever the column actually holds, and entered_current_state_at reports
what the log — possibly missing history, or filtering out a same-state entry
— can account for. Prefer state_entered_at for scheduling, since it's what
the generated Oban triggers filter on, and entered_current_state_at when
you specifically want the value the log attests to.
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.
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.