Current state lives in a plain column guarded by compare-and-swap. History is an append-only log with write-once metadata. Built for ActiveRecord; Rails is optional.
Coming from statesman? One command converts your transitions table in place — the migration recipe →
class OrderFlow < ApplicationMachine
state :pending, initial: true
state :paid
state :refunded
state :cancelled
event :pay, from: :pending, to: :paid, guard: :payable?
event :refund, from: :paid, to: :refunded, record_guard: :refundable?
transition from: :pending, to: :cancelled
after_commit :enqueue_receipt, event: [:pay]
private
def payable?(order, metadata)
metadata["amount"].to_i.positive?
end
def refundable?(order) = order.refundable?
end
order.pay!(metadata: { amount: 100 }) # => the created OrderTransition row
Order.paid.count # plain WHERE, zero joins
01 · why
Statesman and AASM taught a generation of Rails apps two different pains. statecraft keeps their ergonomics and removes the architecture that caused the pain.
in_state? and scopes are a flat
WHERE on an indexed column: zero joins. No Statesman-style
most_recent rows, no log-derived current state.TransitionConflict, and the log records
exactly one row.after_commit follows Active Record
transaction-callback semantics everywhere, including transactional tests. AASM-style
in-memory surprises are not part of the deal.establish_connection
gets the full pipeline.02 · pipeline
Every transition walks the same six steps. No other write path exists.
UPDATE … WHERE state = expected; zero rows means somebody else won03 · concurrency
Same record, same moment. The CAS statement is the referee: no locks needed for correctness, no lost updates, no double logging.
log rows written: exactly 1
A rescued conflict is clean by construction: the savepoint has already rolled the pipeline back, your outer transaction survives, and you retry from fresh state, or take another branch.
04 · audit
Every transition appends one row: from, to, the event (or an explicit
event: nil for audited bypasses), and write-once metadata that survived
the same jsonb round-trip the guards validated.
The log model is a read-model: persisted rows refuse update! and
destroy; the pipeline is the single writer.
order.history.last
# => #<OrderTransition
# from_state: "pending", to_state: "paid",
# event: "pay",
# metadata: { "amount" => 100 },
# created_at: …>
order.transitioned_to?(:paid) # => true, strictly log-based
puts OrderFlow.to_mermaid # the shape as a Mermaid diagram
stateDiagram-v2
[*] --> pending
pending --> paid : pay
pending --> cancelled : cancel
05 · in practice
The division of labor: the machine owns invariants and reactions to facts; the model mounts it in one line; the service orchestrates the outside world. External effects stay outside transactions, the transition records the fact, a lost race is compensated and re-read.
class OrderFlow < ApplicationMachine
state :pending, initial: true
state :paid
state :cancelled
event :pay, from: :pending, to: :paid, guard: :charged_for_full_amount?
event :cancel, from: :pending, to: :cancelled
after_commit :enqueue_receipt, event: [:pay]
private
# the machine's invariant: the recorded fact must match the order,
# on every path to :paid — console included
def charged_for_full_amount?(order, metadata)
metadata["amount_cents"].to_i == order.total_cents
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
class PayOrder
Result = Data.define(:paid?, :order, :failure)
def initialize(gateway: PaymentGateway.new)
@gateway = gateway
end
def call(order, card_token:)
intent = { amount_cents: order.total_cents }
# a cheap honest refusal before the external call — a snapshot, not a guarantee
return Result.new(false, order, :not_payable) unless order.may_pay?(metadata: intent)
# the external effect lives outside any transaction, idempotent per order
charge = @gateway.charge(card_token, amount: order.total_cents,
idempotency_key: "order-#{order.id}")
# the transition records the fact; references in metadata, never PANs
order.pay!(metadata: intent.merge(charge_id: charge.id))
Result.new(true, order, nil)
rescue Statecraft::GuardFailed
@gateway.refund(charge.id) if charge
Result.new(false, order, :not_payable)
rescue Statecraft::TransitionConflict
# somebody won the race — compensate, then read reality
@gateway.refund(charge.id)
order.reload
Result.new(order.in_state?(:paid), order, order.in_state?(:paid) ? nil : :lost_race)
end
end
The receipt goes out from the machine's after_commit with a
reference to the exact audit row, and only after the outermost real commit: a worker
never races an invisible record.
06 · quick start
bundle add statecraftbin/rails generate statecraft:machine OrderThe generator creates the migration — state column, cascade-FK log table, a CHECK constraint for fresh tables — plus the machine class, the readonly log model, and mounts it all into your model.
07 · in the box
available_transitions tells you where and how:
via events whose guards pass, plus :direct for unguarded edges;
to_mermaid draws the shape as a Mermaid diagram.
offerable_events is the graph filtered by the record layer — an input
guard never hides the form its input arrives through — and
refusals_for names the guard that said no.
rails g statecraft:from_statesman Order converts the transitions table
you already have — history stays in place — through three production-safe
migrations: instant DDL, a batched backfill, then indexes built concurrently.
One require "statecraft/rspec" and specs read the machine:
allow_event, refuse_event(:cancel).because_of(:guard),
and a block matcher asserting the state move and the appended log row at once.
A red spec prints the state, the reachable edges and the guard that said no.
versioning: true makes the CAS compare state and version, so a
state that went away and came back no longer passes for the one the page rendered.
Send the rendered token as seen: and a late click raises
StaleTransition — your 409 — instead of quietly succeeding.
transition.statecraft and transition_failed.statecraft
with the failure reason: contention becomes a metric, not a mystery.
Direct transitions over event-guarded edges are refused; the bypass is
explicit and logged as event: nil.
Mount on the base class; subclasses inherit the machine, helpers and scopes, and guards receive the actual subclass.
Connection identity is checked at mounting; shards follow automatically.
Launch the next transition from after_transition; accidental cycles
die loudly at depth 16 with the chain printed.