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.

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.