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).
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 (with edge re-resolution from the fresh state), 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 means exactly "a guard said no or the edge is not
declared" (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.
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.
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.transitioned_to?(:paid) # strictly log-based
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.
A guard that reads metadata makes may_*? depend on the metadata you pass.
For a UI "is this button available" question, either do not hang input
validation on a guard, or pass the same metadata to may_*? that you will
collect for fire!.
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.
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.
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 that connection's migration path. 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.
License
MIT. See LICENSE.txt.