v0.7.0
Opt-in protection against ABA transitions: a state that went away and
came back is no longer the state your page rendered.
The problem. The CAS compares the state's value — pending that
travelled through paid and returned passes for the pending an open
page showed minutes ago, so an operator acting on a stale card silently
succeeds.
The fix — versioning: true:
class Order < ApplicationRecord
state_machine OrderFlow, versioning: true # the state_version column
end
Every transition now compares-and-swaps on the pair of state and
version and increments the version in the same UPDATE — the textbook
tagged CAS. A returned state no longer matches even without any token.
true names the column <column>_version; a symbol overrides it. Reads
stay join-free: the version lives on the parent row next to the state.
The seen: token carries what the caller's form actually rendered:
<input type="hidden" name="seen" value="<%= order.state_version %>">
order.cancel!(metadata: ..., seen: params[:seen])
rescue Statecraft::StaleTransition
head :conflict # 409
seen: rides all four surface forms and the helper verbs; a string from
params is normalized with Integer(), garbage raises loudly. Its refusal
is Statecraft::StaleTransition — a TransitionConflict subclass with
expected_version and seen, telemetry reason :stale. Without a token
a version mismatch stays the ordinary TransitionConflict; under
lock: true the stale token is refused deterministically right after the
reload; seen: on a mounting without versioning: fails with a
CompilationError naming the fix.
The column ships with the machine: rails g statecraft:machine Order --versioning adds it to both migration shapes and mounts with the option;
for an existing table one add_column ... default: 0 is enough — constant
defaults are metadata-only on PostgreSQL 11+.
The wire spec executes the generated migration and catches live staleness;
the example app shows the whole pattern — hidden token, controller rescue,
the "outdated card" flash — proven on PostgreSQL in CI.