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
end

The 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
end

This 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:

AttributeTypeNotes
workflow_idbelongs_toto the workflow resource
from_state:atomnil on the :initial row
to_state:atomequal to from_state for repeats
transition_name:atomthe 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_at forward (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 truth state_entered_at projects 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.

ValueDefinitionMoved by a repeat?
state_entered_atoccurred_at of the most recent row, any rowYes
entered_current_state_atoccurred_at of the most recent row where from_state != to_stateNo

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_review

It 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.Ticket
    

    It seeds one :initial row per record with no history, built from its current state and state_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_action hook. 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.