Supporting reference · Working design and evidence, not a promise of complete support.

On this page

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

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:

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.

References marked “repository-only” name implementation or internal material outside this public guide. They are intentionally not links. Publishing a design does not prove its implementation.