Packages

Declarative workflow orchestration for the Ash Framework — multi-step workflows combining human actions, background jobs, and time-based deadlines.

Current section

Files

Jump to
ash_workflow documentation topics undo.md
Raw

documentation/topics/undo.md

# Undo

Undo rewinds a workflow record to the state it occupied before its most recent
state change. It is built entirely on the [transition
log](workflow-history.md) — undo has nothing to rewind to without one.

## Enabling it

Undo is opt-in twice. The `undo` block turns the feature on; `undoable?: true`
marks the individual transitions that may be rewound.

```elixir
workflow do
  transition_log MyApp.IncidentTransition do
    belongs_to_actor :user, MyApp.Accounts.User
  end

  undo do
    within {30, :minutes}
    same_actor? true
  end

  step :triage do
    transition :escalate, to: :responding, undoable?: true
    transition :close, to: :closed
  end

  step :responding do
    transition :resolve, to: :resolved, undoable?: true
  end

  step :closed, terminal: true
  step :resolved, terminal: true
end
```

Here `escalate` and `resolve` can be taken back; `close` cannot. Nothing is
undoable by default, on purpose — see [What undo does not
do](#what-undo-does-not-do).

If your transition log predates undo, regenerate it — `mix
ash_workflow.gen.transition_log` now emits the self-referencing `undoes`
relationship that undo rows point through, and a compile-time verifier requires
it.

## Using it

```elixir
incident = MyApp.Incident.escalate!(incident, actor: user)
incident.state
#=> :responding

{:ok, incident} = MyApp.Incident.undo(incident, actor: user)
incident.state
#=> :triage
```

Two helpers answer the same question without writing, for driving UI:

```elixir
MyApp.Incident.undoable?(incident, user)   #=> true
MyApp.Incident.undo_target(incident, user) #=> :triage
```

`undoable?/2` asks whether there is anything to undo. Ash's code interface
separately generates `can_undo?/2`, which asks whether the actor is
*authorized* to call the action — a different question, and both are worth
checking before showing a button.

## An undo is a new row, not an erasure

This is the design decision everything else follows from. Undoing `escalate`
does not touch the row that recorded it. It appends a row carrying
`triggered_by: :undo` and an `undoes_id` pointing back at what it reverses:

| # | from | to | transition | triggered_by | undoes |
|---|---|---|---|---|---|
| 1 | — | `:triage` | `:create` | `:initial` | |
| 2 | `:triage` | `:responding` | `:escalate` | `:manual` | |
| 3 | `:responding` | `:triage` | `:escalate` | `:undo` | 2 |

So undo moves *backward in state* and *forward in the log*. The record really
was `:responding` for a while — perhaps long enough for a notification to go
out — and the log still says so. What the undo row records is that the decision
was **superseded**, not that it never happened.

Keeping both facts means both readings stay available from the same rows:

```elixir
# What actually happened.
MyApp.Incident.history(incident)
#=> rows 1, 2, 3

# What stands after corrections.
MyApp.Incident.history(incident, effective: true)
#=> rows 1, 3

MyApp.Incident.state_at(incident, during_the_escalation)
#=> :responding

MyApp.Incident.state_at(incident, during_the_escalation, effective: true)
#=> :triage
```

Use the literal reading for audit, and for explaining why a side effect
happened. Use the effective reading for timeline UIs, where a briefly-occupied
state usually reads as noise. Had undo instead flagged row 2 as undone, only
the second reading would exist and the first would be unrecoverable — see
`documentation/design/workflow-undo.md` for why that trade was refused.

### Redo

Undoing an undo re-applies the transition, and needs no extra API:

```elixir
{:ok, incident} = MyApp.Incident.undo(incident, actor: user)  # back to :triage
{:ok, incident} = MyApp.Incident.undo(incident, actor: user)  # forward to :responding again
```

Row 3 is now itself superseded by row 4, so the effective history shows the
redo standing. The rule — "drop every row some other row points at" — handles
chains of any length without a special case.

## What can be undone

Only the most recent **state change**, and only when it came from a transition
marked `undoable?: true`.

Rows where `from_state == to_state` are skipped while looking for that change.
An `every` writes one every time it re-arms; it moved nothing, so a pending
reminder does not block undo of the transition before it.

Everything else is refused, with a reason on
`AshWorkflow.Errors.UndoNotPermitted`:

| Reason | When |
|---|---|
| `:not_undoable` | The last state change was not an undoable transition. Automatic steps, timeouts, error paths and the initial row all land here. |
| `:no_history` | The record has not transitioned yet. |
| `:window_expired` | The transition is older than `within`. |
| `:different_actor` | `same_actor?` is set and the actor differs. |
| `:no_actor` | `same_actor?` is set and either side has no actor. |
| `:undo_not_enabled` | The workflow has no `undo` block. |

### Automatic steps close the window

A transition whose target is an *automatic* step is undoable only until that
step runs. Once its Oban trigger fires, the head of the log is the automatic
step's own row — which is not an undoable transition — and undo refuses with
`:not_undoable`.

This is intended: by then the automatic step's action has already done whatever
it does. But it means an undo button on a transition into an automatic step has
a lifetime measured by your `check_interval`, and is worth designing around
rather than discovering.

## Authorization

Step policies do not apply to undo. An undo spans two states, and which step it
rewinds into is known only once the log has been read, so there is no step
whose policy could correctly govern it. Give the action its own check:

```elixir
undo do
  policy actor_attribute_equals(:role, :supervisor)
end
```

Without a `policy`, the generated `undo` action falls under the extension's
default-allow, like every other generated action. `same_actor?` is a separate,
narrower control: it constrains *whose* transition you may undo, not who may
call undo at all.

## What undo does not do

It does not restore attributes. The log records states, not the values a
transition with `accept` wrote. Undoing a transition that set `priority: :high`
leaves `priority` as `:high`.

It does not compensate side effects. An email sent by an action stays sent;
a payment taken stays taken. AshWorkflow has no rollback (see [Error
handling](error-handling.md)), and undo does not add one.

Both are why `undoable?` defaults to false. Mark a transition undoable when
rewinding the *state* is the whole of what needs to happen. When it isn't,
model the reversal explicitly as a forward transition with its own action, so
the compensating work has somewhere to live:

```elixir
step :responding do
  transition :stand_down, to: :triage  # runs an action that cancels the page
end
```