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 an every firing
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, 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_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 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.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.