Skip to content

v0.7.0

Choose a tag to compare

@supostat supostat released this 26 Aug 21:08
· 7 commits to main since this release

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 valuepending 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.