Rails state transitions
Status: Implemented for every required, unconditional machine, including several on one Entity, with a stub for each event whose effects it cannot generate. An optional machine keeps a plain column, with a stub for each event and an empty applicability callback.
A State Machine lowers to AASM over an explicit stored state Field. The current Compiler pins AASM 6.0.0 and emits workflow behavior for each required, unconditional machine. An event whose effects it cannot generate becomes an event stub. An optional machine keeps its storage column and closed-domain validation without AASM, and the reviewed GapSet discloses the missing workflow behavior. Emitted AASM keeps its native scopes, unless a state's scope name would stop the model loading, 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
- Event stubs
- Several machines on one Entity
- Optional machines
- 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. Several machines on one Entity each become a namespaced AASM machine. Optional machines retain plain storage, stubbed events, and precise gaps; an event with broader effects is a stub. 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. A generated event may be
effect-free or assign one current_time or literal-null Value to an emitted optional mutable non-derived local
datetime Field; any other event is a stub.
Dynamic helper collisions select the deterministic Field namespace; fixed, class, or both-form collisions keep only
storage. A state whose automatic scope name Rails refuses, or another machine on the Entity already has, keeps the
machine's events and turns off its state scopes. 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 for each required, unconditional State Machine. For each
behavior-emitted machine it keeps native state scopes unless Rails refuses one or another machine has it, 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. It also compares the state predicates and event helpers with Ruby 4.0.5's
own private Object methods, such as fail, open, and initialize. AASM defines its helpers as public model
methods, so an unnamespaced event keyed initialize would replace the initializer that Model.new calls, and one
keyed fail would answer every implicit-receiver fail in the model. The Compiler freezes that list rather than
reading the running process, whose loaded gems add more. An event keyed raise already selected the namespace,
because the profile protects raise. The Analyzer warns at each event key that selects the namespace this way, with
foundation_plan.rails_target.state_machine.event_name_collision. Its message offers the rename only when those
events are the namespace's sole cause; a state predicate such as iterator? also selects it, without a warning.
Private Validation methods allocate afterwards around AASM's chosen API.
The enum allocator reserves the ordinary and namespaced state scopes that AASM creates. 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.
Event stubs
An event whose effects the Compiler cannot generate does not take its machine out of AASM. Such an event sets a Field
on another Entity, a required, immutable, derived, or non-datetime Field, or a Value other than the current time or
nil, or it has more than one effect. The machine keeps its AASM block, initialization, validation, and
no_direct_assignment, which do not depend on the event, and the block lists only the generated events. A machine
whose events are all stubs still gets its block.
The stubbed event keeps the names AASM 6.0.0 would give it: <m>, <m>!, and may_<m>?, where <m> is the event
key, or the event key and the Field key when the machine is namespaced. The namespace is chosen once per machine over
every authored state and event, and the census still reserves all four AASM names per event, so the event the agent
later adds to the block receives exactly those names. For example, an unnamespaced participant.status.remove:
def remove
Kernel.raise NotImplementedError,
"participant.status.remove is not written yet. README.md describes it; spec/models/... covers it."
end
# remove! and may_remove? have the same body
All three methods raise, may_remove? included. No generated page, control, or route fires an event, so nothing
needs a stubbed predicate to answer, and a false would read like a real answer. Each body calls Kernel.raise,
because an event keyed raise would otherwise answer the call. The stubs follow the model's declarations and precede
private. AASM defines its event methods with define_method on the model, and a method the class body defines
later wins. If the agent adds the event but leaves a stub in place, the stub shadows the event and its examples keep
failing with the stub's message. AASM's *_without_validation! helper gets no stub; it is AASM's escape hatch, and
nothing calls it before the event exists.
AASM's own introspection, such as aasm.events(permitted: true), asks may_*? only of declared events, so it never
reaches a stub. Development seeds and Policy spec setup search only generated events, so a seeded record or an
example whose state needs a stub is left out with the reason that names it. The generated
tests keep the event's allowed and rejected examples, which fail through the
stub. The GapSet keeps the transition's state_transition.effects_not_generated record and its effect records, with
a reason naming the effect this release cannot generate and a consequence naming the stubs.
Several machines on one Entity
An Entity may own several State Machine Fields. Each required, unconditional one becomes its own AASM machine,
named after its Field and namespaced by the Field key, as AASM's README advises for several
machines. The blocks follow Field position order. Shinar's Membership has a required status
and notification_level:
aasm :status, column: :status, namespace: :status, no_direct_assignment: true do
state :active, initial: true
# ...
event :leave do
transitions from: :active, to: :left
end
end
aasm :notification_level, column: :notification_level, namespace: :notification_level, no_direct_assignment: true do
# ...
end
Since AASM 6.0.0 (#880), a namespaced machine defines its event methods with the Field key as a
suffix and defines no plain form: leave_status!, leave_status, may_leave_status?, and
leave_status_without_validation!. State predicates and constants carry the key as a prefix: status_left? and
STATE_STATUS_LEFT. The Compiler namespaces every machine on such an Entity, even when no names collide, so two
machines can share an event key such as reset. The count includes optional and conditional machines, so a
machine's names do not depend on which of its siblings this release lowers. Adding a second machine to an Entity
renames the first machine's events. The Analyzer's event_name_collision warning stays with a single machine whose
event key selected the namespace; it is not repeated for this rule.
Each machine keeps its own column, initialization, validation, no_direct_assignment, and callbacks. AASM's
README cautions that the library "has been built with one state machine per class in mind." The several-machine
runtime smoke observed firing one machine's events, with their effects and guards, leave the other machines' states
and callbacks alone, against PostgreSQL.
The census runs across the machines in Field position order. A machine's state predicates and four event helpers
must differ from the Entity's members and from every earlier machine's names, and its STATE_ constants from
theirs. AASM would otherwise define the later name over the earlier one, or skip the constant. A machine that fails
keeps only storage, with today's gaps. A state scope name that an earlier machine already has is different: AASM
skips a scope the class already answers, so the later machine would silently lose it to the earlier one. That
machine keeps its events and gets create_scopes: false, as for a refused name. When a
review machine shares the state key held with an earlier shipping machine:
# The shipping machine above already has a scope named "held", so AASM creates no state scopes for this machine.
aasm :review, column: :review, namespace: :review, no_direct_assignment: true, create_scopes: false do
The Analyzer warns at each such state key with foundation_plan.rails_target.state_machine.state_scope_collision.
Development seeds and Policy spec setup reach each machine's states through that machine's events, so one record can
take events on two machines.
Optional machines
An optional State Machine Field must carry settings.when, the condition under which it applies. Shinar's
membership.role applies only to group chats; a direct chat's Membership has no role. AASM 6.0.0 cannot hold that
absent state. Its after_initialize callback fills every blank state column with the
initial state, on new and loaded records alike, and it treats the first declared state as initial when none is
marked. A probe against Active Record 8.1.3.1 observed a loaded NULL role read as member, and another machine's
event on that record then saved member to the database. A Proc initial state that returns nil raises
AASM::UndefinedState. Overriding AASM's private aasm_column_is_blank? would keep the column nil, but AASM still
reports the initial state for a new record and raises AASM::UndefinedState from events on a nil one.
The Compiler therefore declares no aasm block for an optional machine. The column stays nullable without a default,
with the closed-domain validation that admits nil, and stays writable. Each event is an event stub
under the names AASM would give it, namespaced on an Entity with several machines: promote_role, promote_role!,
and may_promote_role?. The applicability is write-path behavior the Compiler leaves out behind an empty callback:
before_validation :assign_initial_role, on: :create
private
# membership.role.when is not written yet: where it holds, a new record begins in
# member, and elsewhere role stays nil. README.md describes it;
# spec/models/membership_state_machine_spec.rb covers it.
def assign_initial_role
end
Until it is written, every new record keeps a nil state, which is right where the machine does not apply. The
generated tests pin the callback and the events. The census still reserves
AASM's names for the machine, including the callback's, so its events can later move into an aasm block if the
agent handles the absent state. Development seeds and Policy spec setup never assign an optional machine's state.
What applicability means when the condition changes after creation is still an open question in the State
Machines owner (repository-only).
Automatic state scopes
The Compiler leaves AASM's ordinary automatic scopes on for both namespaced and unnamespaced machines. AASM defines
them from the authored machine declaration, as Rails enum defines its scopes; no emitted line exists only to create
them. Authored queries remain independently generated; concrete method collisions follow the allocation rules above.
AASM 6.0.0 declares a state's scope only when the model class does not already answer
its name. A state keyed new, all, first, or find therefore gets plain AASM: the class answers the name, and
AASM skips that one scope. For a name the class does not answer, AASM calls scope, and Rails 8.1.4's
scope raises ArgumentError while the model class loads when the name is a class method
Active Record defines, such as private, or an ActiveRecord::Relation instance method, such as load. A machine
with such a state keeps its events and gets create_scopes: false, which turns off its prefixed and unprefixed state
scopes. A state scope that an earlier machine on the Entity already has takes the same
path. A comment names the refused scope:
# Rails refuses a scope named "private", so AASM creates no state scopes for this machine.
aasm :visibility, column: :visibility, no_direct_assignment: true, create_scopes: false do
The Compiler freezes the refused names as the profile's forbidden named-scope
namespace less the names that a model on bare Active Record 8.1.4 with AASM
6.0.0 publicly answers, and a test recomputes that list. Starting from the forbidden namespace keeps gem-added names
that still raise, such as Action Policy's private policy_name. It also turns off scopes for a few gem-added names
that a generated model answers, such as Active Storage's has_one_attached. The GapSet records nothing for the
missing scopes, because they are an AASM convenience rather than authored meaning. The Analyzer warns at the state
key with foundation_plan.rails_target.state_machine.state_scope_collision; renaming the key restores the scopes.
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. The AASM namespaced-scope issue (repository-only) records the
reproduction and upstream work. Every machine on an Entity with several is namespaced, so more machines carry these
prefixed scopes. No generated model, page, Policy, seed, or spec calls an AASM state scope: generated queries use
authored Predicates and Orderings. Only code the agent writes can reach the wrong prefixed scopes.
Leaving those broken prefixed scopes enabled conflicts with the
code-worth-keeping requirement and is a
known Compiler defect (repository-only). 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.