statecraft
A state machine for ActiveRecord where the current state lives in a column as the single source of truth, history is an append-only per-model log with write-once metadata, and every transition is guarded by a compare-and-swap update inside a savepoint. Event-aware guards, an explicit bypass policy, and telemetry for both successes and refusals.
statecraft is an ActiveRecord gem, not a Rails gem: its runtime depends on
activerecord and activesupport only. Anything that can call
ActiveRecord::Base.establish_connection gets the full pipeline; Rails adds
the generator as a progressive enhancement.
Why
in_state?and state scopes are a plainWHEREon a column — zero joins, unlike log-derived current state (most_recent-style schemas).- Concurrency safety comes from a CAS
UPDATE ... WHERE state = :expected: of N concurrent writers exactly one wins, the rest get a deterministicStatecraft::TransitionConflict, and the log records exactly one row. - The log carries first-class, write-once
jsonbmetadata: what the guards checked is byte-for-byte what the log stored.
Installation
gem "statecraft"
Requires Ruby >= 3.3 and ActiveRecord/ActiveSupport >= 7.2, < 9. PostgreSQL is the first-class database; SQLite works for development and tests (see SQLite limits); MySQL is out of scope for v0.
Quick start
bin/rails generate statecraft:machine Order
The generator creates the migration (state column, state_changed_at, the
log table with a cascade FK — and a CHECK constraint when the table is
freshly created), the machine class, the readonly log model, and mounts the
machine into the model. By hand it looks like this:
class OrderFlow < ApplicationMachine
state :pending, initial: true
state :paid
state :cancelled
event :pay, from: :pending, to: :paid, guard: :payable?
transition from: :pending, to: :cancelled
after_commit :enqueue_receipt, event: [:pay]
private
def payable?(order, )
["amount"].to_i.positive?
end
def enqueue_receipt(order, transition)
ReceiptJob.perform_later(order.id, transition.log_record.id)
end
end
class Order < ApplicationRecord
state_machine OrderFlow, changed_at: true, helpers: true, scopes: true
end
order = Order.create! # born :pending via the column default
order.pay!(metadata: { amount: 100, reason: :web }) # => the created OrderTransition row
order.in_state?(:paid) # => true
Order.paid.count # plain WHERE, zero joins
Mounting options: log: (defaults to the <Model>Transition convention),
column: (default :state), changed_at: (off by default; true derives
<column>_changed_at), touch: (default true — updated_at is written in
the same CAS update), helpers: and scopes: (both off by default; the
generator turns them on for new code).
Example app
A complete, working web store lives in example/: a storefront
in human words over a two-zone Rails app, with the gem's full mechanics on
the operator side and an e2e suite on top. Inside it:
bundle install && bin/rails db:setup && bin/rails s — PostgreSQL only.
The transition pipeline
transition_to! / fire! run one strict order:
persisted?check — transitioning an unsaved record raisesStatecraft::UnsavedRecordError: initial state comes from the column default, so there is nothing to transition.- Metadata normalization and deep-freeze (see Metadata).
- Edge resolution and the bypass policy check.
- Dirty check on
lock: trueedges — unsaved changes raiseStatecraft::DirtyRecordErrorinstead of being silently destroyed by the reload. transaction(requires_new: true)— a savepoint inside your transaction, a real transaction otherwise: optionalSELECT ... FOR UPDATE+ reload (a state that changed under the lock raisesStatecraft::TransitionConflict), guards,before_transitioncallbacks, the CAS update (touchingupdated_atand thechanged_atcolumn in the same statement), the log INSERT,after_transition.after_commitcallbacks are registered on the outermost real transaction's commit.
A transition is not a record save: model validations and model callbacks
do not run, and unsaved changes on other attributes are neither saved nor
(without lock:) touched. Guards are the transition's validations; machine
callbacks are the transition's callbacks.
Bang variants return the created log record. Non-bang variants return it too,
or false — and false covers exactly three refusals: a guard said no, the
edge is not declared, or the bypass policy refused a direct transition over
an event-guarded edge (GuardFailed / InvalidTransition). Everything
else — including TransitionConflict — always raises, in both variants.
Guards, events and the bypass policy
A guard is attached to (from, to, event-or-nil): an edge guard always runs,
an event guard runs only when the transition goes through that event. Within
one event, from is unique — an event is a partial function from state to
edge, so fire! is structurally deterministic. Branching by outcome means
two events (pay and fail_payment), not one event with two branches.
A guard also declares its nature. guard: judges the input: it receives
(record, metadata) and belongs to execution and prediction. record_guard:
judges the record alone: its handler must take exactly one argument — the
compiler refuses any other arity — so it physically cannot read the input.
Execution runs both layers, record first; the split exists for the offering
introspection below, which may ask the record layer before any input exists.
A good record_guard: is a one-line delegation to a domain predicate on the
model (def customer_cancellable?(record) = record.customer_cancellable?):
the machine keeps the registry "event → predicate", the model keeps the fact.
Calling transition_to! directly over an edge that carries event guards is
refused — the guards would be silently skipped. The escape hatch is explicit:
transition_to!(:paid, bypass_events: true) skips event guards (edge guards
still run) and records event: nil in the log, so audited bypasses stay
visible.
Callbacks and chains
before_transition, after_transition and after_commit accept from:,
to: and event: filters (arrays welcome). Handlers — symbols resolving to
machine instance methods, or callables — receive (record, transition) where
transition carries from, to, event, metadata and (after the INSERT)
log_record.
Launching the next transition from after_transition is a supported pattern:
the chain writes one log row per hop and every after_commit waits for the
outermost commit. Two facts to know:
- The after-commit order is inverted. A nested transition registers its
after_commitbefore its parent does, so on commit the chain's callbacks run innermost-first (fulfillbeforepay). This is documented, tested semantics — do not "fix" it. - The chain depth ceiling is 16. Deeper nesting raises
Statecraft::ChainDepthExceededwith the printed chain, which makes an accidental cycle visible at a glance.
Transitioning the same record from a guard or from before_transition
raises Statecraft::NestedTransitionError immediately — the CAS would
otherwise reject itself and masquerade as a phantom race. Transitioning from
after_commit is an independent pipeline and is always legal.
after_commit and transactional tests
after_commit follows Active Record transaction-callback semantics
everywhere, including transactional tests. Inside your own transaction,
"the transition succeeded" does not mean "after_commit ran" — the callback
waits for the outermost real commit and silently never runs if that
transaction rolls back, exactly like a model's after_commit. In a
transactional test, order.pay! behaves the way record.save with a model
after_commit callback behaves in that same test — one invariant, no special
cases to learn. Exceptions raised inside after_commit propagate as-is: the
transition is already committed, so they are handler errors, not transition
errors.
Concurrency and isolation
The CAS update is the concurrency contract: on the default READ COMMITTED
isolation a race deterministically produces TransitionConflict for every
loser, with the expected from in the error. Rescuing the conflict inside
your transaction is safe: the savepoint has already rolled back the
pipeline's effects, your outer work stays intact, and you may retry from the
fresh state or take another branch.
lock: true on an edge or event adds SELECT ... FOR UPDATE plus a reload
before the guards. The row lock lives until your outermost transaction
commits — releasing the savepoint does not release it.
On stricter isolation levels (SERIALIZABLE) the same race may surface as
ActiveRecord::SerializationFailure before the CAS ever sees it — up to and
including commit time. statecraft passes the whole
ActiveRecord::TransactionRollbackError family (including Deadlocked)
through untouched: those errors mean "restart the whole transaction", a
different protocol than the conflict's "continue from clean state", and the
retry policy belongs to whoever chose the isolation level.
Stale transitions: versioning against ABA
The CAS above compares the state's value — so a state that went away and
came back (pending → paid → … → pending) passes for the state an open
page rendered minutes ago. That is the ABA problem, and versioning: true
closes it:
class Order < ApplicationRecord
state_machine OrderFlow, versioning: true # the state_version column
end
With the option every transition compares-and-swaps on the pair of state
and version and increments the version in the same UPDATE — a returned
state no longer matches, even without any token. true names the column
<column>_version; a symbol overrides the name. The column is a plain
bigint NOT NULL DEFAULT 0: rails g statecraft:machine Order --versioning
generates it, and for an existing table one step is enough — on
PostgreSQL 11+ an add_column with a constant default is metadata-only,
no table rewrite:
add_column :orders, :state_version, :bigint, null: false, default: 0
The user-facing half is the seen: token: render the version into the
form, send it back, and the pipeline refuses the action when the row has
moved on — Statecraft::StaleTransition, a TransitionConflict subclass
carrying expected_version and seen, published as reason :stale:
<input type="hidden" name="seen" value="<%= order.state_version %>">
def cancel
order = Order.find(params[:id])
order.cancel!(metadata: , seen: params[:seen])
redirect_to order_path(order), notice: "Cancelled."
rescue Statecraft::StaleTransition
head :conflict # the API mapping: 409
# a form flow redirects instead:
# redirect_to order_path(order),
# alert: "This order changed while the page was open."
end
seen: rides all four surface forms and the helper verbs. A string token
straight from params is fine — the pipeline normalizes it with Integer().
The field comes back from the browser, so it is hostile input: a token that
cannot be read at all — a tampered seen=abc, an array from seen[]=1 —
is refused as StaleTransition too, with expected_version: nil because
nothing was compared. A broken or forged form never turns into a 500.
The honest limits: staleness
exists only where a token was given — without seen: a version mismatch
is the ordinary TransitionConflict; the early deterministic check runs
only under lock: true, where the reload holds the row's real version —
without the lock the CAS itself is the check, since a fresh token can
outrun a stale in-memory record; and seen: on a mounting without
versioning: raises a CompilationError naming the fix instead of
silently skipping the protection. Reads stay join-free: the version lives
on the parent row, right next to the state.
Introspection
order.can_fire?(:pay, metadata: { amount: 100 }) # would the guards pass right now?
order.may_pay?(metadata: { amount: 100 }) # alias, with helpers: true
order.available_events(metadata: { amount: 100 }) # => [:pay]
order.available_transitions(metadata: {}) # => [#<to: :cancelled, via: [:direct]>]
order.offerable_events # => [:pay, :cancel] — graph × record layer
order.refusals_for(:cancel) # => [#<event: :cancel, guard: :customer_cancellable?, layer: :event_record>]
order.transitioned_to?(:paid) # strictly log-based
OrderFlow.transitions_from(:pending) # => [{ to: :paid, events: [:pay] }, ...]
OrderFlow.to_mermaid # the graph as Mermaid stateDiagram-v2 text
transitions_from is class-level and answers the graph's shape, not a
prediction: no guards are consulted, a bare edge carries an empty events
list, and a state outside the graph owns no edges. Pair it with
available_transitions for the prediction.
The shape also draws itself: to_mermaid returns the graph as Mermaid
stateDiagram-v2 text — guards stay out of the picture, exactly like
transitions_from. Paste it into a markdown fence and GitHub renders the
diagram; the quick-start machine above comes out as:
stateDiagram-v2
[*] --> pending
pending --> paid : pay
pending --> cancelled
available_transitions tells you not only where you can go but how:
via lists the events whose guards pass, plus :direct when the edge is
free of event guards and its edge guards pass. Every answer is a snapshot —
CAS may still reject the transition a moment later.
For a UI "is this button available" question, ask offerable_events: the
graph filtered by the record layer only. An input-reading guard: never
hides a button — hiding it would hide the form its input arrives through —
while a record_guard: honestly strips an event this record is not offered.
refusals_for(:event) returns the refusing record-layer guards as frozen
structures (event, guard name, layer) and answers [] for an unknown event
or a missing branch. It carries names, never words: human-readable reasons
belong to the application's presentation layer, not to the machine.
A guard that reads metadata makes may_*? depend on the metadata you pass —
pass the same metadata to may_*? that you will collect for fire!.
RSpec matchers
One opt-in require gives your specs matchers over the whole introspection surface — RSpec never becomes a runtime dependency of the gem:
# spec_helper.rb, after rspec itself is loaded
require "statecraft/rspec"
# The record-level questions consult the guards, with the same metadata
# your production call will carry:
expect(order).to allow_event(:pay).("amount" => 100)
expect(order).to allow_transition_to(:cancelled).via(:cancel)
expect(order).to allow_transition_to(:archived).directly
expect(order).to have_transitioned_to(:paid) # strictly log-based
# The refusal with its reason — guard names come from refusals_for:
expect(order).to refuse_event(:cancel).because_of(:customer_cancellable?)
# The class-level pair answers the graph's shape; guards stay untouched:
expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
expect(OrderFlow).to have_initial_state(:pending)
# The transition itself: the state move AND the appended log row,
# asserted in one expression around fire!/transition_to!:
expect { order.fire!(:pay, metadata: { "amount" => 100 }) }
.to transition(order).from(:pending).to(:paid)
.via_event(:pay).("amount" => 100)
A failing matcher explains itself with the same introspection the pipeline
consults: the current state, the edges reachable from it, and the refusing
guard with its layer. A non-bang call that returned false fails the
transition matcher the same way; exceptions of the bang forms fly through
like with change — assert refusals with refuse_event or raise_error,
not with the block matcher.
because_of carries the same honest limit as refusals_for underneath it:
it names record-layer guards only. An input-reading guard: has no name
there, and the failure message says so instead of guessing.
Metadata
Metadata is normalized on pipeline entry with a full JSON round-trip —
symbol keys and values become strings, times become ISO-8601 strings — and
then deep-frozen: the guards see exactly what the log will store, and a
guard that mutates metadata dies with FrozenError in a transition and in a
check alike. Unserializable values (a Proc, a model instance) fail
instantly at the entrance, not inside the transaction — and so do NaN and
Infinity, which JSON cannot represent.
BigDecimal is rejected deliberately, not by omission: jsonb would hand it
back as a string or a float depending on the reader, silently breaking the
"what the guards checked is what the log stored" promise. Pass money and
other exact decimals as strings (metadata: { price: order.total.to_s })
and parse them in the guard.
Facts of the transition moment (a price snapshot, a rules version) are
collected by the caller: order.pay!(metadata: { price: order.total }).
There is no metadata schema mechanism — required fields are enforced by
guards — and the shape evolves by convention: carry a v: key when you need
versioned readers.
Initial state is not a transition
Creating a record is not a transition: rows enter the initial state through
the column default (which also covers insert_all, fixtures, seeds and ETL),
and the log stays silent about births — a consistently silent audit beats an
inconsistently chatty one.
| Question | Answer |
|---|---|
| When did the record enter the initial state? | created_at |
Has it ever transitioned to :pending? |
transitioned_to?(:pending) — false until a real transition (a loop or a return counts) |
Is it in :pending now? |
in_state?(:pending) / where(state: :pending) |
The log model is a read-model
Scopes, reading methods and serializers on the log class are yours. Writing
is not: the pipeline inserts rows through the insert path, so validations and
callbacks declared on the log model never run — guards and machine callbacks
are the single channel of truth. The generated readonly? makes persisted
rows reject update! and destroy; it protects against accidental edits,
not malicious ones (update_all and raw SQL still work — the real guarantee
would be database triggers, which are out of v0). Deleting readonly? in
your generated class is a supported customization.
Deleting the parent record cascades to its log rows at the database level
(ON DELETE CASCADE): an audit without its subject is not an audit. If your
requirement is "history survives deletion", the answer is soft-deleting the
record, not an FK mode — and swapping :cascade for :restrict in your
generated migration is supported if you disagree.
Configuration
There is none — deliberately. Every knob lives with its subject: mounting
options on state_machine, lock: on edges and events, schema choices in
your generated migration, readonly? in your generated log class. No
initializer, no Statecraft.configure. If a future feature genuinely needs
process-level configuration it will arrive as a designed decision, not as a
convenience knob.
Renaming a state
The log is never migrated: history is written in the words of its time, and
every reading API handles log rows whose state names are no longer in the
graph. To rename :pending to :awaiting_payment, follow the three-phase
rolling rename recipe on the column:
- Declare both states in the machine with duplicated edges; widen the CHECK constraint to both names (adding a value is cheap).
- Batch-
UPDATEthe column. Races are loud by construction: a row repainted under a live transition makes its CAS miss and raiseTransitionConflict— retry from the new name. For a large table, add the widened constraint asNOT VALIDfirst andVALIDATE CONSTRAINTafter the cleanup. - Drop the old state and its edges; narrow the CHECK back.
Migrating from statesman
The names already match: statesman's order_transitions table and
OrderTransition class are exactly what statecraft's log convention expects,
so the move is an in-place conversion of the table you already have — history
stays where it is, nothing is copied.
bin/rails generate statecraft:from_statesman Order
The generator reads the live statesman machine through reflection (pass the
class as a second argument when it is not OrderStateMachine; point at the
class that declares the DSL — statesman graphs are not inherited) and writes
three conversion migrations, a machine skeleton, and the state_machine
mounting line. Two things it honestly cannot write: statesman has no events,
so every edge arrives as a bare transition for you to name, and guard
bodies are anonymous blocks — each becomes a TODO comment carrying the
original file:line.
Three migrations, because the obvious single one is a production incident:
add_column takes an ACCESS EXCLUSIVE lock that PostgreSQL holds until the
end of the transaction, and a full-table backfill inside that same
transaction keeps both tables unreadable — even for SELECTs — for its whole
duration. The conversion splits along the lock boundaries instead, and the
runbook is five steps:
- Run
_ddlany time: nullable columns only — on PostgreSQL 11+ its lock lasts milliseconds. - Run
_backfillany time, even mid-day: batches over parent-id ranges outside any DDL transaction, touching only rows still NULL. The model's state comes from the last transition bysort_key— deliberately notmost_recent, which drifts often enough that statesman ships a repair task for it —state_changed_atfrom that transition'screated_at, andfrom_statefrom aLAGwindow along the chain. - Switch the code: port the skeleton, deploy statecraft in place of statesman.
- Catch up: the backfill is idempotent (
WHERE ... IS NULL), so run its class once more to pick up rows statesman wrote between steps 2 and 3 —bin/rails runnerwith aloadof the migration file and.new.updoes it without touchingschema_migrations. - Run
_finalize: NOT NULL lands through theNOT VALID→VALIDATE CONSTRAINTpair (readers never blocked), thestateand[foreign_key, id]indexes buildCONCURRENTLY, the cascade FK is validated the same way, and the statesman columns are dropped last. Honest limit: a textmetadatacolumn is rewritten under a lock here — unavoidable within this recipe; for very large tables the shadow-column dance is the escape.
The _ddl migration's header names two pre-flight checks on live data —
that the id order agrees with the sort_key order (statecraft reads history
by id), and that a text metadata column holds valid JSON in every row
(rows written by raw SQL may not survive the cast). Run both before
starting.
After finalize, finish by hand (the generator prints this list): drop
Statesman::Adapters::ActiveRecordTransition and the
after_destroy :update_most_recent callback from the transition model — they
read dropped columns — plus ActiveRecordQueries from the model and the
Statesman.configure initializer once no machine is left; then name your
events in the skeleton. One guarantee moves rather than disappears: the
race safety statesman derived from its unique (parent, sort_key) index is
statecraft's CAS on the state column.
Outside Rails, the same five steps work by hand — pair the runbook above with the reference schema in Outside Rails.
Once the conversion has settled, one more add_column buys protection
statesman never had: see Stale transitions
— the version column is a constant default, so adding it costs a
metadata-only migration on PostgreSQL 11+.
PII and erasure
Metadata is the only place personal data can live — from_state, to_state
and event never carry it, and telemetry payloads exclude metadata entirely.
Three layers, in order of preference:
- References, not values. Store
{ user_id: 42 }, not an email. Erasure then touches the referent, never the log. - Hard delete.
destroycascades to the log rows for free. - Soft delete + scrubbing. Administrative erasure works at the relation
level, past
readonly?:order.history.where(...).update_all(metadata: { scrubbed_at: Time.current.iso8601 }). The tombstone convention keeps the audit honest: "there was data here, erased on request" is a legally different statement than "there was nothing".
STI
Mounting a machine on an STI base class is promised behavior: subclasses
inherit the machine, helpers and scopes (CreditOrder.pending scopes the
subclass by type + state, exactly like an enum scope would), CAS and the
log FK hit the base class's table, and guards receive the actual subclass
instance. Two honest boundaries: name-conflict checks at mounting cover the
mounting class only — a subclass method shadowing a generated verb is plain
Ruby method overriding; and mounting a different machine in a subclass is
the multi-machine feature, out of v0 — it raises Statecraft::AlreadyMounted
at the threshold.
Multiple databases
The log lives next to its model, always — a cascade FK cannot cross
databases, so this is a definition, not a restriction. The generated log
class inherits the model's connection-owning ancestor (base, roles and
horizontal shards follow automatically), and the generator drops the
migration into the migration path configured for that connection's database
(db/migrate when none is configured). Mounting verifies connection
identity — same pool, same per-thread connection, one real transaction — and
raises Statecraft::ConnectionMismatch with a fix hint otherwise. Two
connects_to blocks pointing at one physical database are still two pools:
that also fails the check, correctly.
Outside Rails
No railties at runtime — the hygiene is enforced by a test, not a promise. Without the generator, create the schema by hand; the reference shape:
create_table :orders do |t|
t.string :state, null: false, default: "pending", index: true
t.datetime :state_changed_at
t. null: false
end
add_check_constraint :orders, "state IN ('pending')", name: "orders_state_check"
create_table :order_transitions do |t|
t.references :order, null: false,
foreign_key: { on_delete: :cascade }, index: false
t.string :from_state, null: false
t.string :to_state, null: false
t.string :event
t.jsonb :metadata, null: false, default: {}
t.datetime :created_at, null: false
t.index %i[order_id id]
end
SQLite limits
SQLite is a development and test database: metadata falls back to
json/text, concurrency specs skip (the concurrency proof runs on
PostgreSQL in CI), and lock: true degrades the way ActiveRecord itself
degrades — the locking clause is dropped, the reload still runs, and
statecraft warns once per machine per process that row-locking guarantees
require PostgreSQL.
Example app patterns
These blocks are copied verbatim from the example store and locked by
example/script/readme_drift_check.rb — the code is right, the README
catches up by hand. The machine behind an order:
class OrderFlow
include Statecraft::Machine
state :pending, initial: true
state :paid
state :refunded
state :cancelled
event :pay, from: :pending, to: :paid
event :refund, from: :paid, to: :refunded, record_guard: :refundable?
# One edge, the whole event layer: a guarded event, an unguarded privileged
# event and the bypass path all share pending -> cancelled — the log
# records HOW, not only WHAT. The cancel guards split by nature: the
# record layer judges the order (and the offering may ask it), the input
# layer judges what the operator typed (only fire! and the panel see it).
event :cancel, from: :pending, to: :cancelled,
record_guard: :customer_cancellable?, guard: :reason_present?
event :admin_override, from: :pending, to: :cancelled
private
# The machine keeps the registry "event -> predicate" and delegates the
# domain facts to the record.
def refundable?(record) = record.refundable?
def customer_cancellable?(record) = record.customer_cancellable?
def reason_present?(_record, )
["reason"].to_s.strip.present?
end
end
The operator order desk, whole: authorize! on entry and per event, thin actions over services, guard refusals local to their form:
# The operator's order desk — bang everywhere: an operator wants the gem's
# message for the flash, and a non-bang false carries no text. Staleness
# heals in ApplicationController; a guard refusal is local to this form.
class OrdersController < BaseController
def index
@orders = OrdersQuery.call(state: params[:state])
@active_state = params[:state].to_s
end
def show
@order = Order.find(params[:id])
@metadata = {}
end
def pay
fire(:pay)
end
def cancel
fire(:cancel)
end
def refund
fire(:refund)
end
# The non-mutating submit of the SAME fields: the panel recomputes from
# exactly the metadata a real fire would carry. Nothing is written.
def preview
@order = Order.find(params[:id])
@metadata =
flash.now[:notice] = "Preview only — nothing was written."
render :show
end
# The privileged event: a SECOND event on the same edge, without a
# guard — the log will name it admin_override.
def admin_override
order = Order.find(params[:id])
:admin_override, order
order.admin_override!(metadata: { "reason" => "admin override" })
redirect_to admin_order_path(order),
notice: "admin_override fired: the order is now #{order[:state]}."
end
# The bypass: the same edge with the event layer skipped — the log
# writes event: nil and the history renders the muted
# "direct (bypassed events)".
def bypass_cancel
order = Order.find(params[:id])
:bypass_cancel, order
order.transition_to!(:cancelled, bypass_events: true,
metadata: { "reason" => "bypassed by admin" })
redirect_to admin_order_path(order),
notice: "bypassed: the order is now #{order[:state]}."
end
def create_shipment
order = Order.find(params[:id])
:create_shipment, order
shipment = CreateShipment.call(order: order)
redirect_to admin_shipment_path(shipment), notice: "Shipment created."
rescue ArgumentError => error
redirect_to admin_order_path(order), alert: error..capitalize + "."
end
private
def fire(event_name)
@order = Order.find(params[:id])
event_name, @order
@metadata =
@order.fire!(event_name, metadata: @metadata, seen: params[:seen].presence)
redirect_to admin_order_path(@order),
notice: "#{event_name} fired: the order is now #{@order[:state]}."
rescue Statecraft::GuardFailed => error
# Local to the form: re-render THIS card with the panel computed from
# the metadata that were actually submitted — a guard refusal belongs
# to the scene, not to a global handler.
flash.now[:alert] = "Refused: #{error.}"
render :show, status: :unprocessable_entity
end
def
params.fetch(:metadata, {}).permit(:reason).to_h
end
end
The preview button — a non-mutating submit of the same fields the guard-aware panel predicts with, next to buttons that render only in the possibility-times-permission intersection:
<%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
<input type="hidden" name="seen" value="<%= @order.state_version %>">
<fieldset>
<legend>Metadata for the next action</legend>
<label>
reason
<input type="text" name="metadata[reason]" value="<%= @metadata["reason"] %>">
</label>
</fieldset>
<%= render "shared/transition_buttons",
record: @order,
fire_url: ->(event_name) { public_send("#{event_name}_admin_order_path", @order) } %>
<button type="submit" class="preview-button">preview</button>
<% end %>
Subscribing to the telemetry — the five-argument form is the one that publish-style events actually deliver to:
# The one executable example of subscribing to statecraft's telemetry: the
# Operations log feed is written here, with create!, into an ordinary table.
# The gem publishes with explicit start/finish, so subscribers take the
# five-argument block form. Payloads never carry metadata (the gem's PII
# decision) — whoever needs it reads the transition log record instead.
ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _started, _finished, _id, payload|
OperationEntry.create!(
record_class: payload[:record_class],
record_id: payload[:record_id].to_s,
from_state: payload[:from],
to_state: payload[:to],
event_name: payload[:event],
outcome: "transition"
)
end
# The failure payload names the record, the machine and the reason — it
# carries no from/to/event keys: the transition never happened.
ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
OperationEntry.create!(
record_class: payload[:record_class],
record_id: payload[:record_id].to_s,
outcome: "refused",
reason: payload[:reason].to_s
)
end
Seeding through the honest pipeline, refusal narrative included:
# The refusal scenario WITH its narrative: the rescue is part of the plot —
# a cancellation attempt without a reason lands in the operations feed as a
# refusal, then the reasoned retry succeeds.
def seed_disputed_order
order = place_order(number: "ORD-1009", customer: "Ivy Chen",
items: { "Ceramic vase" => 2 })
begin
order.cancel!(metadata: {})
rescue Statecraft::GuardFailed
# the refusal is the point: the feed keeps it
end
order.cancel!(metadata: { "reason" => "dispute resolved in the customer's favor" })
order
end
Running the tests
Natively, against SQLite:
bundle install
bundle exec rspec
Against PostgreSQL — where the concurrency proof actually runs — point
DATABASE_URL at a database of your own, or use the bundled containers, which
pin the same PostgreSQL major as CI and take the Ruby and ActiveRecord versions
as parameters, so any CI matrix cell reproduces locally:
docker compose run --rm test # sqlite, default Ruby
docker compose run --rm test-postgres # PostgreSQL 16
AR_VERSION=7.2 RUBY_VERSION=3.3 docker compose run --rm test-postgres
Links
- Landing page: https://supostat.github.io/statecraft/
- Benchmarks vs statesman/aasm: https://supostat.github.io/statecraft/compare.html
(reproducible scripts in
benchmark/) - RubyGems: https://rubygems.org/gems/statecraft
- Issues: https://github.com/supostat/statecraft/issues
License
MIT. See LICENSE.txt.