statecraft

Gem Version CI MIT License

Website → · RubyGems · Issues

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 plain WHERE on 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 deterministic Statecraft::TransitionConflict, and the log records exactly one row.
  • The log carries first-class, write-once jsonb metadata: 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 trueupdated_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:

  1. persisted? check — transitioning an unsaved record raises Statecraft::UnsavedRecordError: initial state comes from the column default, so there is nothing to transition.
  2. Metadata normalization and deep-freeze (see Metadata).
  3. Edge resolution and the bypass policy check.
  4. Dirty check on lock: true edges — unsaved changes raise Statecraft::DirtyRecordError instead of being silently destroyed by the reload.
  5. transaction(requires_new: true) — a savepoint inside your transaction, a real transaction otherwise: optional SELECT ... FOR UPDATE + reload (a state that changed under the lock raises Statecraft::TransitionConflict), guards, before_transition callbacks, the CAS update (touching updated_at and the changed_at column in the same statement), the log INSERT, after_transition.
  6. after_commit callbacks 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_commit before its parent does, so on commit the chain's callbacks run innermost-first (fulfill before pay). This is documented, tested semantics — do not "fix" it.
  • The chain depth ceiling is 16. Deeper nesting raises Statecraft::ChainDepthExceeded with 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:

  1. Declare both states in the machine with duplicated edges; widen the CHECK constraint to both names (adding a value is cheap).
  2. Batch-UPDATE the column. Races are loud by construction: a row repainted under a live transition makes its CAS miss and raise TransitionConflict — retry from the new name. For a large table, add the widened constraint as NOT VALID first and VALIDATE CONSTRAINT after the cleanup.
  3. 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:

  1. Run _ddl any time: nullable columns only — on PostgreSQL 11+ its lock lasts milliseconds.
  2. Run _backfill any 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 by sort_key — deliberately not most_recent, which drifts often enough that statesman ships a repair task for it — state_changed_at from that transition's created_at, and from_state from a LAG window along the chain.
  3. Switch the code: port the skeleton, deploy statecraft in place of statesman.
  4. 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 runner with a load of the migration file and .new.up does it without touching schema_migrations.
  5. Run _finalize: NOT NULL lands through the NOT VALIDVALIDATE CONSTRAINT pair (readers never blocked), the state and [foreign_key, id] indexes build CONCURRENTLY, the cascade FK is validated the same way, and the statesman columns are dropped last. Honest limit: a text metadata column 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:

  1. References, not values. Store { user_id: 42 }, not an email. Erasure then touches the referent, never the log.
  2. Hard delete. destroy cascades to the log rows for free.
  3. 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.timestamps 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])
      authorize! :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])
      authorize! :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])
      authorize! :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.message.capitalize + "."
    end

    private

    def fire(event_name)
      @order = Order.find(params[:id])
      authorize! 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.message}"
      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

License

MIT. See LICENSE.txt.