statecraft_

The state machine that trusts the column

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.

Get started GitHub

Coming from statesman? One command converts your transitions table in place — the migration recipe →

app/state_machines/order_flow.rb
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

Why another state machine

Statesman and AASM taught a generation of Rails apps two different pains. statecraft keeps their ergonomics and removes the architecture that caused the pain.

  1. The column is the truth. 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.
  2. Concurrency by compare-and-swap. Of N parallel writers exactly one wins; the rest get a deterministic TransitionConflict, and the log records exactly one row.
  3. The log is a first-class audit. Append-only, write-once jsonb metadata: what the guards checked is byte-for-byte what the log stored.
  4. Nothing new to learn. after_commit follows Active Record transaction-callback semantics everywhere, including transactional tests. AASM-style in-memory surprises are not part of the deal.
  5. An ActiveRecord gem, not a Rails gem. No railties at runtime, enforced by a test rather than a promise. Anything that can establish_connection gets the full pipeline.

02 · pipeline

One strict pipeline

Every transition walks the same six steps. No other write path exists.

1persisted? unsaved records cannot transition: initial state comes from the column default
2normalize + freeze metadata round-trips through jsonb and is deep-frozen: guards see what the log will store
3guards edge guards always, event guards on their event; two natures — the record layer judges the record alone, the input layer judges the metadata
4CAS update UPDATE … WHERE state = expected; zero rows means somebody else won
5log INSERT same transaction, past the log model's validations and callbacks
6after_commit registered on the outermost real commit. Active Record semantics, no exceptions

03 · concurrency

Three writers, one truth

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

An audit you can put in front of a lawyer

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.

rails console
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

A payment service, end to end

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.

app/state_machines/order_flow.rb
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
app/models/order.rb
class Order < ApplicationRecord
  state_machine OrderFlow, changed_at: true, helpers: true, scopes: true
end
app/services/pay_order.rb
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

Two commands in

bundle add statecraft
bin/rails generate statecraft:machine Order

The 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

Also in the box

introspection

Honest availability answers

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.

offering

Buttons the machine offers

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.

migration

A door out of statesman

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.

migration

A door out of aasm

rails g statecraft:from_aasm Order reads the live machine: the real event names and the state column come over, symbol guards arrive as record_guard: delegates, and one migration adds the history aasm never kept. An event that branches from one state is refused with the split recipe rather than guessed at.

strictness

Questions that refuse to guess

Mark a guard input_guard: and a prediction asked with no metadata raises MetadataRequired naming it, instead of feeding it an empty hash and answering a false "no". strict! adds the other half at compile time: every declared state must be reachable from the initial one.

rspec

Matchers that explain the refusal

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

Stale views refused, not applied

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.

telemetry

Conflict rates on a dashboard

transition.statecraft and transition_failed.statecraft with the failure reason: contention becomes a metric, not a mystery.

bypass

Explicit escape hatch

Direct transitions over event-guarded edges are refused; the bypass is explicit and logged as event: nil.

sti

Inheritance that just works

Mount on the base class; subclasses inherit the machine, helpers and scopes, and guards receive the actual subclass.

multi-db

The log lives next to its model

Connection identity is checked at mounting; shards follow automatically.

chains

Chains with a ceiling

Launch the next transition from after_transition; accidental cycles die loudly at depth 16 with the chain printed.