Rails state transitions
Status: Implemented for one required, unconditional machine per Entity. Other machines keep storage and precise gaps.
A State Machine lowers to AASM over an explicit stored state Field. The current Compiler pins AASM 6.0.0 and emits workflow behavior only when an Entity owns exactly one required, unconditional machine with supported transitions. Every other machine keeps its storage column, initial-state default, and closed-domain validation, and the reviewed GapSet discloses the missing workflow behavior. Emitted AASM keeps its native scopes and forbids ordinary direct assignment. The State Machines owner (repository-only) states what states, events, guards, and transition effects mean.
On this page
- Direction
- Storage and behavior
- AASM machine
- Automatic state scopes
- State initialization and validation
Direction
Support: see the support inventory row (repository-only).
Use a plain closed value when no transitions exist. Generate modeled workflows with AASM over an explicit stored state Field. Oscar Party and Case Chat supply the current corpus proof on both database setups. Optional, broader-effect, and multiple-per-Entity machines retain storage and precise gaps. Framework-owned account status may still be realized by the selected authentication system rather than a second state machine. State initialization and validation explains why behavior-emitted columns use AASM initialization while storage-only columns retain their defaults.
Storage and behavior
State Machine Fields receive the generator-path storage partial. Storage-only required machines get a non-null string
column and an initial-state default; optional machines get nullable storage without a default. Storage-only machines
receive a closed-domain validation, with nil admitted only for optional machines. Each transition may be effect-free
or assign one current_time or literal-null Value to an emitted optional mutable non-derived local datetime Field.
Dynamic helper collisions select the deterministic Field namespace; fixed, class, or both-form collisions keep only
storage. Every omitted applicability condition, transition, and effect is listed in the GapSet; admitted behavior
suppresses only its corresponding records.
AASM machine
The implemented slice's State Machine contributor declares a complete immutable AASM 6.0.0 Gemfile block, direct
requirement, empty prerequisite list, and source provenance to the one ApplicationTaskPlan-owned dependency plan.
Core needs no projector; every nonempty selection is inserted once at Core's dependency marker, projected exactly
once by the exact vendored Bundler 4.0.10, and closure-verified against the maximal universe without a production
overlay lookup. The Compiler emits one named machine only for a required, unconditional State Machine on an Entity
that owns no second machine. For that behavior-emitted machine it keeps native state scopes, forbids
direct assignment, and admits the exact
AASM 6.0.0 surface: fixed public and private instance helpers, fixed class helpers, state predicates and constants,
all four generated methods per event, automatic state scopes, and the storage writer.
Naming the AASM machine after its Field binds it to the authored storage explicitly. no_direct_assignment prevents
ordinary model assignment from bypassing emitted
events. A storage-only machine has neither that AASM control nor a Compiler substitute; its GapSet records the
unrealized behavior.
Admission compares that surface with the pinned Rails target methods and the emitted Field, Reference, Association,
Predicate-scope, and Ordering-scope names. Private Validation methods allocate afterwards around AASM's chosen API.
The enum allocator reserves the ordinary and namespaced state scopes. An authored query with the same name as an
automatic state scope is declared later and retains its meaning; fixed AASM APIs keep their existing gap handling.
On a realized Account Entity, the census also includes the fixed Account runtime members. A dynamic collision first
selects AASM's Field-key namespace, preserving the existing member owner; a collision in both forms leaves behavior
ungenerated. An admitted Ordering still owns
its class name: an Ordering named aasm remains emitted while State behavior and its dependency are omitted.
Generated model tests exercise the selected event and predicate names, allowed and rejected transitions, closed-state
validation, effect assignment, and direct-assignment rejection after migration and schema loading. The Compiler's
Oscar runtime qualification checks the AASM method census used by collision admission. Generated tests do not freeze
private helpers, configuration, or the absence of AASM's bypass helper.
The intended lowering includes:
- a stored value with an explicit initial state and closed domain;
- one named AASM machine per state-machine Field;
- generated events and transition definitions;
- typed guards once the Plan can represent them;
- ordered typed
set_fieldeffects; - target-safe event names or deterministic namespaces;
- model and request tests for allowed, denied, and invalid transitions; and
- a later structured request binding before generating controls or endpoints.
Each admitted effectful event assigns Time.current or nil in its transition callback before AASM's ordinary
validated save. The transaction preserves persisted state and effects together, and exceptions propagate. Failed
validation can leave an unsaved effect value; an application callback exception can also leave the attempted state
in memory. The caller may intentionally reload or reset the object before retrying. Reload discards other unsaved
changes. The Compiler adds no snapshot, restoration callback, automatic reload, or replacement persistence API.
AASM's explicit *_without_validation! helper remains available, including for effectful and namespaced events.
That library escape hatch persists only the state column; timestamp effects and other dirty attributes stay
unpersisted. The owner accepted that behavior for this conventional starting point. Ordinary bang events continue
to save the state and effects together. One effect per transition is the current bound; authored ordering across
several effects is not yet generated.
AASM callback source, its transaction wrapper, and its validation-bypass helper pin the inspected revision. The Case Chat runtime proves non-bang in-memory behavior, bang persistence, null clearing, invalid-transition stability, and database rollback. The qualification also exercises intentional reload and retry after a callback exception, initialization, and native state-only bypass on both database setups. Those are model/persistence boundaries; Scaffolds still emit no event request controls.
AASM is a current profile direction, not semantic Plan vocabulary and not a permanent ecosystem verdict.
The source audits found that both AASM and state_machines provide guard-aware event enumeration. AASM won the
historical generation comparison on conventionality, agent legibility, conservative compatibility, and the
Compiler's ability to prevent event-name collisions. Statesman remains strongest when a persisted transition
history is the central requirement, but it has no first-class event concept.
No transition-history or actor-audit shape is current. AASM has no built-in audit trail. Revisit history when an application requires "who moved this record, when, and with what metadata" as structured product meaning.
Automatic state scopes
The Compiler enables AASM's ordinary automatic scopes for both namespaced and unnamespaced machines. The native query API is a useful starting point even when the Plan does not explicitly request each method. Authored queries remain independently generated; concrete method collisions follow the allocation rules above.
AASM 6.0.0 has a known bug in namespaced scopes. For example, Conversation.status_active queries the stored value
status_active, although the state is active. AASM also emits an unprefixed scope when that name is available.
An emitted-model/PostgreSQL probe reproduced the prefixed bug; ordinary unnamespaced scopes returned the expected
records. Issue #692 (repository-only) records the reproduction and upstream work.
The owner accepts this library limitation in a starting application. The user's agent can adjust an affected query
if encountered. Enabling scopes does not depend on an upstream fix, and does not add a generated workaround, fork,
or special namespace-based suppression. no_direct_assignment: true remains enabled. A scoped builder may raise
even when assigning the initial state. Use ordinary creation followed by the named event where needed: constructing
an active Case Chat Participant directly would skip accept! and its joined_at assignment.
State initialization and validation
For required state columns whose AASM behavior is emitted, the Compiler retains NOT NULL, omits the database
default, and lets AASM assign the initial state. Its migration guidance explains that a database
default can skip initial-state entry callbacks. AASM's own state validation replaces the extra generated presence
or inclusion validator for that column.
An internally assigned nil can pass AASM's validator and raise ActiveRecord::NotNullViolation. For this internally
managed value, that exposes an application bug rather than a form error the user can correct. The
validation guidance explains the distinction.
This does not make NOT NULL a nonblank or state-membership check. AASM can accept an empty string, and the
database constraint does not reject it. A PostgreSQL nonblank CHECK is explicitly deferred; revisit it when a
concrete application write path establishes a need. No replacement callback, validation, or persistence wrapper is
generated.
Storage-only state columns have neither AASM initialization nor AASM's membership validation. Their requiredness, default, and inclusion handling remains unchanged by this decision. The automatic-scope direction and retained direct-assignment option are described above.