Skip to content

Releases: supostat/statecraft

v0.10.0

Choose a tag to compare

@supostat supostat released this 30 Aug 06:20

A second door out: rails g statecraft:from_aasm Order.

aasm already keeps the current state in a column, so this conversion is
the opposite shape of the statesman one — nothing is backfilled, and the
migration adds the history aasm never kept.

More survives the trip than from statesman. The generator reads the
live machine through reflection: the states, the initial state, the real
event names (statesman had none, so its skeleton arrived unnamed) and the
state column, which becomes the mounting's column:. A second argument
names one machine of a model that declares several.

Guards come over by nature. An aasm guard judges the record and never
sees what the caller submitted — which is exactly record_guard:. A
symbol guard:/if: therefore arrives as a record_guard: with a
generated one-line delegate, because a guard symbol resolves on the
machine here, not on the record:

event :cancel, from: :pending, to: :cancelled, record_guard: :not_locked?

private

def payable?(record) = record.payable?
def not_locked?(record) = !record.locked?

unless: becomes a negating delegate. A lambda guard carries its own
closure and cannot be moved: it arrives as a TODO with its file:line.

One graph shape is refused rather than guessed. aasm lets a single
event branch from one state and picks the first transition whose guard
passes; statecraft makes an event a partial function, so from is unique
within an event. The generator stops, names the branching events and asks
for the split — the pay / fail_payment pattern. Naming the halves is a
domain decision, and a bad name would outlive the migration.

One migration, creating the log table with its cascade FK and adding
an index on the existing state column. It deliberately does not tighten
that column: NOT NULL, a default and a CHECK on a live table mean long
locks and an explosion on any legacy row outside the state list. The
recipe for doing it later, in the NOT VALIDVALIDATE CONSTRAINT
style, sits in the migration's own header.

The reflection is grounded rather than assumed: every call the generator
makes was verified against aasm 5.5.2 — including the trap that a model
with named machines answers the unnamed .aasm with an empty :default
graph, which the generator now refuses while naming the machines that do
exist.

Also in this release. The README gained a section on which rules
belong in a guard at all: a guard earns the input it reads by comparing it
against the record, or by protecting a log row about to become permanent,
while shape and format belong to whoever assembled the hash. Metadata is
frozen before the transaction opens, so an in-guard check of it is no more
atomic than the caller's — form validation belongs in the form.

v0.9.0

Choose a tag to compare

@supostat supostat released this 30 Aug 06:20

Two opt-in strictnesses, both turning a silent falsehood into a loud one.

The problem input_guard: solves. A guard that reads metadata made
the question surface lie. Asked order.may_cancel? with no arguments, the
gem fed the guard an empty hash; the guard said no for want of input, and
the answer came back false — while the real call, with the form filled
in, would have passed. The button was hidden because the question was
asked without data, and the machine could not tell "no" from "I don't
know". Until now that was covered by a line of README convention.

The third nature. input_guard: declares that a guard's answer
without the input would be a false no. Its handler must take exactly
(record, metadata) — the compiler refuses any other arity, mirroring the
arity-1 promise record_guard: already makes — and the question surface
honours the declaration:

order.may_cancel?                             # Statecraft::MetadataRequired
order.may_cancel?(metadata: {})               # false — "my input is empty"
order.may_cancel?(metadata: { reason: "x" })  # true

The error names the guards and the fix. The default of can_fire?,
may_*?, available_events and available_transitions is now a sentinel
rather than an empty hash, so an omitted argument is distinguishable from
a deliberate one. Execution is untouched: fire! without metadata is
still a legitimate call with empty input, refused by the guard as before.
offerable_events and refusals_for ask the record layer only and never
raise — they remain the honest channel for rendering buttons before any
input exists.

strict! closes the graph. By default an unreachable state compiles
silently, and deliberately so: the column is written by more than the gem,
so a state without inbound edges may be perfectly legitimate. A machine
that claims a closed graph now says it:

class OrderFlow < ApplicationMachine
  strict!
  state :pending, initial: true
  ...
end

Compilation then requires every declared state to be reachable from the
initial one, walking edges only — guards are not consulted, exactly like
transitions_from. Dead ends stay legal in strict mode too: terminal
states are the norm.

Both are additive. Machines that declare neither behave exactly as they
did, and the whole existing suite passes unchanged.

v0.8.0

Choose a tag to compare

@supostat supostat released this 30 Aug 06:20

The matchers learn the version column: under versioning: a transition
is no longer proven by the state move alone.

The block matcher folds in a third fact. A matching transition must
also have incremented the version, checked from the same before/after
snapshot it already takes for the state and the log:

expect { order.fire!(:pay, seen: order[:state_version]) }
  .to transition(order).from(:pending).to(:paid).via_event(:pay)

A version that did not move with the state fails with both numbers named:
the state_version column went from 0 to 7, expected 1. On an unversioned
mounting nothing changes and no new chain appears — the matcher reads
version_column off the mounting and stays silent when there is none.

Every failure message carries the version. The shared standing line
becomes Order in state :pending (state_version 3), so a red spec about
any matcher shows which revision of the row it was looking at.

Staleness stays an exception, exactly as in production. The prediction
matchers were deliberately left alone: allow_event and refuse_event
answer about guards, and a stale seen: token is a conflict, which the
question surface has never predicted. Assert it where it lives:

expect { order.cancel!(seen: stale_token) }
  .to raise_error(Statecraft::StaleTransition)

Teaching can_fire? to take a token would have widened the introspection
contract to cover a race it cannot see; the boundary "prediction equals
guards" is worth more than the shorthand.

v0.7.1

Choose a tag to compare

@supostat supostat released this 26 Aug 22:27

A patch on the hostile-input path of the seen: token.

The token is rendered into a form and comes back from the browser, so a
tampered seen=abc or an array from seen[]=1 is hostile input — not a
programmer error. Until now Integer() let its ArgumentError and
TypeError escape the whole Statecraft hierarchy: a forged field slipped
past every rescue_from and became a 500.

Both are now refused as Statecraft::StaleTransition with
expected_version: nil — nothing was compared — so the controller that
already maps staleness to 409 handles them for free:

rescue Statecraft::StaleTransition
  head :conflict

The error message stays honest about which refusal happened: an unreadable
token reads "the token "abc" is not a readable version" instead of
claiming the row moved on.

Fixed alongside: token normalization ran before the pipeline's telemetry
block, so this refusal would have been invisible to
transition_failed.statecraft subscribers. It now publishes with reason
:stale like every other staleness.

The example app also drops two literal reads of the state column in favour
of the mounting's own column, renders the token through its reader as the
README recipe shows, and the statesman runbook now points at the versioning
section for the one metadata-only migration that buys the protection after
a conversion.

v0.7.0

Choose a tag to compare

@supostat supostat released this 26 Aug 21:08

Opt-in protection against ABA transitions: a state that went away and
came back is no longer the state your page rendered.

The problem. The CAS compares the state's valuepending that
travelled through paid and returned passes for the pending an open
page showed minutes ago, so an operator acting on a stale card silently
succeeds.

The fix — versioning: true:

class Order < ApplicationRecord
  state_machine OrderFlow, versioning: true  # the state_version column
end

Every transition now compares-and-swaps on the pair of state and
version and increments the version in the same UPDATE — the textbook
tagged CAS. A returned state no longer matches even without any token.
true names the column <column>_version; a symbol overrides it. Reads
stay join-free: the version lives on the parent row next to the state.

The seen: token carries what the caller's form actually rendered:

<input type="hidden" name="seen" value="<%= order.state_version %>">

order.cancel!(metadata: ..., seen: params[:seen])
rescue Statecraft::StaleTransition
  head :conflict  # 409

seen: rides all four surface forms and the helper verbs; a string from
params is normalized with Integer(), garbage raises loudly. Its refusal
is Statecraft::StaleTransition — a TransitionConflict subclass with
expected_version and seen, telemetry reason :stale. Without a token
a version mismatch stays the ordinary TransitionConflict; under
lock: true the stale token is refused deterministically right after the
reload; seen: on a mounting without versioning: fails with a
CompilationError naming the fix.

The column ships with the machine: rails g statecraft:machine Order --versioning adds it to both migration shapes and mounts with the option;
for an existing table one add_column ... default: 0 is enough — constant
defaults are metadata-only on PostgreSQL 11+.

The wire spec executes the generated migration and catches live staleness;
the example app shows the whole pattern — hidden token, controller rescue,
the "outdated card" flash — proven on PostgreSQL in CI.

v0.6.0

Choose a tag to compare

@supostat supostat released this 26 Aug 17:35

Opt-in RSpec matchers over the whole introspection surface. One require
in your spec helper — require "statecraft/rspec" — includes seven
matchers into every example group; RSpec never becomes a runtime
dependency of the gem, and an isolated-process probe keeps it that way.

The question matchers consult the guards with the metadata your
production call will carry:

  • allow_event(:pay).with_metadata(...) over can_fire?
  • refuse_event(:cancel).because_of(:guard) — the refusal with its
    reason, named from refusals_for
  • allow_transition_to(:paid).via(:pay) / .directly over
    available_transitions
  • have_transitioned_to(:paid) — strictly log-based

The class-level pair answers the graph's shape, guards untouched:
have_edge(:pending, :cancelled).via(:cancel) and
have_initial_state(:pending).

The block matcher asserts the transition itself — the state move AND
the appended log row in one expression:

expect { order.fire!(:pay, metadata: { "amount" => 100 }) }
  .to transition(order).from(:pending).to(:paid)
      .via_event(:pay).with_metadata("amount" => 100)

A failing matcher explains itself from the same introspection the
pipeline consults: the current state, the reachable edges, the refusing
guard with its layer. Two honest limits, stated in the README: bang-form
exceptions fly through like with change, and because_of names
record-layer guards only — an input-reading guard: has no name there,
and the failure message says so instead of guessing.

The example app's suite now exercises the matchers in a real Rails
application on every CI run.

v0.5.0

Choose a tag to compare

@supostat supostat released this 26 Aug 11:50

The statesman conversion becomes production-safe. Dogfooding it against a
live database proved the single migration unusable: add_column's
ACCESS EXCLUSIVE lock lives until the end of the transaction, and the
full-table backfills inside it kept both tables unreadable — even for
SELECTs — for 26 minutes.

statecraft:from_statesman now generates three migrations along the
lock boundaries:

  • _ddl — nullable columns only; on PostgreSQL 11+ the lock lasts
    milliseconds.
  • _backfill — batched over parent-id ranges outside any DDL
    transaction, touching only rows still NULL: idempotent, rerunnable any
    time to catch up rows statesman wrote in between.
  • _finalize — NOT NULL through NOT VALIDVALIDATE CONSTRAINT
    (readers never blocked), indexes built CONCURRENTLY — including the
    previously missing index on state the reference schema promises — a
    validated cascade FK, and the statesman columns dropped last.

The README recipe grows into a five-step runbook (ddl → backfill → switch
the code → catch-up backfill → finalize) with the lock mechanics explained
and one honest limit named: a text metadata column is still rewritten
under a lock during finalize.

Full Changelog: v0.4.1...v0.5.0

v0.4.1

Choose a tag to compare

@supostat supostat released this 26 Aug 08:30

Patch for the statesman conversion (#1): the generator mounts with
changed_at: true, but the 0.4.0 conversion migration never created the
state_changed_at column — the first transition after converting died on
the option's missing-column error.

The conversion now adds the column and backfills it with the honest value
the import already holds: the created_at of the last recorded transition.
Rows that never transitioned stay NULL, exactly like a freshly created
record.

Found by dogfooding the migration path end to end — thanks for reading
this far; the pre-flight checks in the generated migration header still
apply.

Full Changelog: v0.4.0...v0.4.1

v0.4.0

Choose a tag to compare

@supostat supostat released this 26 Aug 07:11

The road off statesman: rails generate statecraft:from_statesman Order
converts a statesman setup in place — the table you already have becomes
the statecraft log, history included.

  • The generator reflects the live statesman machine and writes the
    conversion migration: the model gains its state column backfilled from
    the last transition by sort_key, the transitions table gains
    from_state (a LAG window along the chain) and a cascade FK, a text
    metadata column becomes native json(b) in one move, and statesman's
    columns and unique indexes retire last.
  • A machine skeleton arrives with the graph copied and the human parts
    honestly left open: statesman has no events — name them yourself — and
    guard bodies cannot be extracted, so each becomes a TODO carrying its
    original file:line.
  • Refusals are loud: a missing machine class, a class that does not quack
    like statesman, a machine with no initial state.
  • The README's new "Migrating from statesman" recipe carries the reasons,
    the two pre-flight checks on live data (id order vs sort_key order;
    text metadata holding valid JSON), and the hand-finished cleanup list.

Full Changelog: v0.3.0...v0.4.0

v0.3.0

Choose a tag to compare

@supostat supostat released this 25 Aug 18:24

The machine draws itself: OrderFlow.to_mermaid renders the compiled
graph as Mermaid stateDiagram-v2 text.

  • The shape only — the initial marker, event-labeled edges in declaration
    order (several events on one edge share a single arrow), a bare arrow
    for a direct edge — and no guards, mirroring the shape-only answer
    transitions_from gives.
  • Deterministic output: repeated calls return byte-identical text, so
    golden-testing your own machines is trivial.
  • The README's Introspection section shows the quick-start machine as a
    live diagram — GitHub renders mermaid fences natively — and the
    landing page's console card shows the literal output.

Full Changelog: v0.2.0...v0.3.0