Rails References and Associations
Status: Implemented for the bounded relationship slice below. Other relationship shapes are listed gaps.
A Reference lowers to a UUID foreign-key column, a useful index, and an ordinary database foreign key. Its
mechanically derived forward Association lowers to belongs_to, and an authored inverse lowers to has_many or
has_one. Rails' own loading, inverse, and dependency options carry the behavior, while the database foreign key
stays an integrity backstop. An authored deletion outcome rides on exactly one admitted inverse through a
conventional dependent: option; without one eligible inverse, the outcome is listed as a partial gap rather than
synthesized. Three first-level indirect forms and one nested form lower to has_many ..., through:. Immutable
References add model-level attr_readonly. The References owner states what
References, Associations, and deletion mean.
On this page
- Admitted shapes
- References and direct Associations
- Reference deletion
- Immutable collections
- Predicated direct Associations
- Indirect Associations
- Nested indirect Associations
- Cardinality and Rails inference
- Model declaration composer
- Code and checks
Admitted shapes
The first Domain query objects and renderers admit plain-scalar Entities and one narrow ordinary Reference shape.
DomainEntity derives each model constant, model path, table name, migration class, and migration path with the
pinned Rails profile inflector. DomainReference reuses the target-independent Association graph, Rails method
family, and Reference-storage resolutions to admit exactly one closed target, no realization or default, boolean
values for one_to_one and immutable, and the mechanically derived same-key unqualified referencing-side
Association.
The relationship qualifier admits an authored referenced-side direct Association over a mutable Reference. It also
admits a direct has_many inverse over an immutable Reference when that Reference is required and not one-to-one.
attr_readonly rejects reparenting, while database NOT NULL rejects and rolls back collection APIs that try to
detach a child by nullifying its foreign key. Optional immutable has_many and every immutable has_one remain
explicit target-support gaps. Within the required immutable has_many shape, one no-cardinality direct Association
may name a Predicate whose exact PredicateScope condition was admitted on the result Entity in the same
CompilationInput generation.
Every other predicated relationship shape remains an explicit target-support gap.
References and direct Associations
A Reference lowers to foreign-key storage and integrity. Its required same-key, unqualified referencing-side direct
Association normally lowers to belongs_to; polymorphic and exclusive-arc realizations must expose the same semantic
reader despite different physical storage. The current Compiler-exercised slice admits exactly one closed target, no
realization or Reference default, boolean values for one_to_one and immutable, boolean requiredness, and that
exact mechanically derived forward Association. It omits class_name when the profile's pinned inflections reproduce
Rails' target class inference. Required References use the profile's Rails 8.1 required-by-default belongs_to;
optional References emit optional: true. It omits foreign_key only when Rails' belongs_to inference from the
Association reader produces the exact Reference column; otherwise it emits the derived option. It emits a UUID
<key>_id column whose nullability matches requiredness, a useful leading index, and a database foreign key. A wider
compatible generated index can supply that leading prefix only for a nonunique Reference; a one-to-one Reference
retains its unique index. An immutable Reference additionally emits attr_readonly for that foreign-key attribute.
With the profile's Rails 8.1 defaults, assigning it after persistence raises ActiveRecord::ReadonlyAttributeError;
this is model-level write-once behavior, not a database trigger or a fence against direct SQL. A one-to-one Reference
emits a unique index plus logical Association uniqueness validation with allow_nil: true, so requiredness separately
owns a missing-target error. Every foreign key is added after all Entity tables in one deterministic migration, which
supports self-References and cycles. Every Domain Reference keeps an ordinary non-deferrable foreign key as an
integrity backstop. Rails omits on_delete, so PostgreSQL's default NO ACTION preserves referential integrity
without owning the authored deletion behavior. The Compiler also admits a direct, unqualified, referenced-side
Association over a mutable Reference. It emits has_one when one_to_one is true and has_many otherwise, with
class_name only when Rails cannot infer the intended target and foreign_key when owner inference would not
reproduce the Reference column. When exactly one admitted, unpredicated inverse exists, that same public Association
carries on_referenced_deleted through conventional Rails dependent: options. Restriction uses
:restrict_with_error; nullification uses :nullify. Deleting referencing records uses collection :delete_all or
singular :delete when the emitted child has no relevant callbacks or downstream Association work, and :destroy
otherwise. An emitted direct Association on the child retains :destroy. Rails installs callbacks when that
Association has a dependency option. Even without one, the detector treats its ordinary NO ACTION foreign key as a
downstream dependency. For example, a predicated noncarrier may have no destroy callback while its foreign key still
rejects parent destruction. Indirect paths already require an admitted direct through Association; no additional
callback or dependency-graph detector is needed. Emitted AASM 6.0.0 installs
after_initialize,
which the pinned doctor
detector
also counts. Selection uses the admitted State Machine behavior, not merely an authored state Field. Save-only
validations do not require destruction. Image, rich-text, cardinality, and Counter fragments that are not emitted by
this path do not create a callback requirement.
Reference deletion
Every Domain Reference now keeps an ordinary non-deferrable foreign key as the database integrity backstop. Rails
omits on_delete, so PostgreSQL's default NO ACTION preserves referential integrity without owning the authored
deletion behavior. An exact admitted, unpredicated inverse Association carries on_referenced_deleted through
conventional Active Record dependency options. Restriction uses :restrict_with_error; nullification uses :nullify.
Callback-free children use collection :delete_all or singular :delete; emitted callbacks or downstream work retain
:destroy. These Associations retain ordinary Rails loading without a strict_loading override. The Rails profile
owns callback selection and the pinned dependency behavior. An absent inverse, an ambiguous set of inverses, or a
destructive cascade cycle does not authorize a synthetic reader or scheduler. The Compiler preserves Reference storage
and traversal, emits the ordinary foreign key, and adds one precise
foundation_plan.gap.reference.deletion_not_generated record at the authored outcome. A lone restrict outcome is
fully backed by foreign-key integrity even without an inverse. This is the Rails-first boundary selected by Decision
0024 (repository-only) and the emitted-code
audit (repository-only).
The detector remains enabled so a later child callback makes it recommend :destroy on its parent Association. Pinned
Rails 8.1.3.1
has_many
loads targets for :destroy, checks existence for restriction, and uses set-based deletion or nullification. Its
has_one
loads the target for every supported dependency. Generated models use ordinary Rails loading, so none of these
dependencies needs a strict_loading override. Collection rendering retains useful preloads. Foundation
quality (repository-only) describes Bullet detection and the remaining
query-growth example coverage. Requalify dependency selection when the pinned Rails, detector, or emitted callback
vocabulary changes.
Where no eligible inverse exists, several eligible inverses are ambiguous, or cascade References form a
destructive cycle, no inverse reader or callback scheduler is synthesized. Storage and traversal remain behind the
ordinary foreign key, and the exact on_referenced_deleted subject becomes a partial target-support gap.
A lone restrict outcome remains realized by foreign-key integrity; ambiguous restrict carriers disclose the
missing model-level restrict_with_error error surface. A carrier-less generated Active Record destroy that
reaches that backstop raises the ordinary ActiveRecord::InvalidForeignKey. Callback-bypassing Relation and raw
SQL operations remain outside the lifecycle claim. The ordinary foreign key rejects referenced-record deletion
while referencing rows remain instead of performing the missing authored behavior. The
emitted-code audit (repository-only) retains the ecosystem
comparison. Generated-app verification exercises ordinary, self-referential, and one-to-one mutable inverse
traversal.
Immutable collections
The Compiler also emits direct has_many for an immutable Reference when it is required and not one-to-one.
Creation through that collection assigns the final foreign key before persistence. Reparenting an existing child
invokes its readonly model writer and raises ActiveRecord::ReadonlyAttributeError. Without a cascade
lifecycle carrier, the collection's delete, delete_all, clear, replace, collection
writer, and IDs writer bypass that model writer but try to assign NULL. With records preloaded where Rails
needs to read them, database NOT NULL rejects each statement, and Active Record rolls it back.
A cascade lifecycle carrier instead follows conventional Rails collection semantics. Its selected :delete_all or
:destroy makes delete, replacement, the collection writer, and the IDs writer remove referencing rows rather than
nullify or reparent their immutable foreign keys. Only :destroy runs child callbacks. Explicit collection
delete_all and clear bulk-delete even for a :destroy Association, so those operations remain outside the
callback-preservation claim. The guarantee does not cover an explicit relation-level update_all, so it remains
model-plus-schema write-once behavior rather than a database immutability fence. Optional immutable has_many and
every immutable has_one remain target-support gaps.
Predicated direct Associations
Within that required immutable has_many shape, a no-cardinality direct Association may name a Predicate on the
Reference-owning result Entity. The Predicate must already have an admitted scope in the same CompilationInput
generation. The association lambda calls that exact PredicateScope entry's final emitted name, preserving
ordinary Rails scoped-Association behavior and one application definition of the filter. Keyword-shaped names
use an explicit receiver, such as -> { self.if }. A focused generated-model runtime check evaluates a
Predicate scope named if. Its equality filters the
reader and contributes the Predicate Field value to scope_for_create; studio.active_movies.build.active is
therefore true. Case Chat's
conversation.open_threads and Photogram's user.published_posts exercise the admitted shape after migration and
schema loading.
These aliases are filtered read traversals, not Predicate-enforcing mutation APIs. Creating through one can yield a
qualifying child when supplied or defaulted attributes satisfy the Predicate, but conflicting explicit attributes
can persist a row outside the alias's membership. Predicates on mutable, singular, indirect, referencing-side,
cardinality-qualified, or otherwise unsupported relationship shapes remain gaps. When an equality targets a
behavior-emitted State Machine Field, AASM's no_direct_assignment rejects that ordinary creation default. A
storage-only machine keeps ordinary assignment, and its reviewed gaps disclose the missing workflow behavior.
Indirect Associations
The Compiler admits three first-level unqualified indirect Association forms. An unpredicated admitted direct
has_many, including a required immutable inverse, may be the through step. Its source may be a derived
belongs_to backed by a mutable or required immutable Reference, or a predicated direct has_many backed by a
required immutable Reference; Rails makes the collection-source form read-only. A predicated admitted direct
has_many may instead be the through step when its source is a derived belongs_to. The resolved result family
is has_many. Through and source immutability are independent. An optional immutable source remains unsupported.
The Compiler emits
has_many ..., -> { distinct }, through: with source: only when Rails cannot unambiguously infer the authored
source, and no dependent option. The scope enforces the semantic
distinct-record guarantee in both ordinary and eager-loaded traversal.
Generated Active Record collection behavior is the primary implementation. For the derived-belongs_to source,
creation assigns both final join foreign keys before the new row's first save. Retained removal and replacement
qualification uses a fresh explicitly strict owner with both the indirect target and the through-source chain
preloaded. Under that tested state, default
deletion and destruction delete whole join Entity rows, and replacement deletes old rows and creates new ones.
These branches neither reparent a persisted readonly slot nor delete the final target. delete_all(:nullify)
writes the source foreign key directly. A required immutable source remains safe because database NOT NULL
rejects that branch; an optional immutable source would bypass attr_readonly, which is why it remains a gap.
Direct inverse nullification remains rejected by the same required database backstop. This proof does not cover
every unpreloaded writer branch. Ordinary Rails loading is available, while an explicitly strict Relation can
still reject a read required by a writer. Eager traversal alone does not qualify mutation, and relation-level
update_all remains outside the generated contract. The Compiler adds no association-loading override,
disable_joins option, or second mutation API; applications use ordinary Active Record writers.
DomainIndirectAssociation reuses those admitted relationships and the target-independent Association and
method-family results. Its Rails-specific gate admits an unqualified has_many whose through is an admitted direct
referenced-side has_many and whose source is a mechanically derived forward belongs_to backed by a mutable or
required immutable Reference. The through collection may be predicated or unpredicated. A predicated through step
retains ordinary Rails scoped-Association semantics; it is not treated as a Predicate-enforcing mutation API. An
optional immutable source remains a gap. A semantic path or cycle failure remains the authoritative Association
diagnostic; this final gate checks only the Rails reflection shape.
For an unpredicated required immutable through join in this derived-forward-source form, ordinary creation gives the
new join Entity row both final foreign keys before its first save. Removal and replacement writers are qualified only
after a fresh strict owner preloads both
the indirect target and the through-source chain. Under that exact state, default deletion, destruction, and
replacement create and delete whole join rows without reparenting a persisted readonly slot or deleting the final
target. delete_all(:nullify) writes the source foreign key directly; requiredness makes that a PostgreSQL
NOT NULL failure. An optional immutable source would bypass attr_readonly, so it is not admitted. Direct inverse
nullification remains a NOT NULL failure. Unrestricted or unpreloaded indirect collection mutation is not claimed;
depending on the operation and association-cache state, the profile guard may raise. Ordinary eager traversal alone
does not qualify mutation. The declaration has no
dependent option; generated model source owns the behavior while database constraints remain integrity backstops.
Case Chat's predicated-through form emits exactly
has_many :members, -> { distinct }, through: :active_participants, source: :user. It returns distinct Users with
one association SELECT from a loaded Conversation, three total for a flat two-owner preload, and none after
caching. build and new construct only an unsaved User. Invalid create reports ordinary User validation feedback.
Valid create and append attempt to build an active Participant and roll back when emitted AASM rejects direct state
assignment. Conventional removal, replacement-removal, collection, and IDs writers delete only the Participant join
row, preserving the User and Conversation; equal-set assignments are no-ops. Adding or swapping a User raises the
same AASM error and restores the original membership. The authored leave! event retains the Participant but removes
its User from the filtered reader. The ordinary invitation path creates through conversation.participants, starts
at invited, and enters the member reader after accept!. These are pinned Rails and AASM behaviors, not a
Compiler-defined wrapper. If machine behavior were not emitted, ordinary state assignment would remain possible and
the reviewed State Machine gaps would disclose the incomplete workflow behavior.
DomainCollectionSourceIndirectAssociation admits the parallel unqualified has_many only when its through is the
same unpredicated direct collection and its source is a predicated direct has_many over a required immutable
Reference. Case Chat emits exactly has_many :participants, -> { distinct }, through: :conversations, source: :active_participants. Active Record makes this source-collection reflection read-only. From an already loaded owner,
ordinary access uses one association SELECT; a flat two-owner preload uses two; cached access uses none. Its source
scope contributes status: "active" to construction. Because Participant has emitted AASM behavior with
no_direct_assignment: true, build, new, create, and create! raise AASM::NoDirectAssignmentError before
validation or persistence. Ordinary bulk writers reach the read-only reflection. An explicitly strict, unpreloaded
owner can instead raise ActiveRecord::StrictLoadingViolationError while loading it. Existing-record writers on an
exactly preloaded owner raise ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection, with persisted
Cases, Conversations, and Participants unchanged.
Nested indirect Associations
DomainNestedIndirectAssociation admits one further exact unqualified has_many: its through must already be an
admitted DomainCollectionSourceIndirectAssociation, and its source must be a mechanically derived forward
belongs_to backed by a required immutable non-one-to-one Reference. Case Chat emits exactly
has_many :members, -> { distinct }, through: :participants, source: :user. From an already loaded owner, ordinary
access uses one association SELECT; a flat two-owner preload uses three total SELECTs; cached access uses none.
build, new, create, and create!
raise ActiveRecord::HasManyThroughNestedAssociationsAreReadonly before target validation, even for invalid
attributes. Ordinary bulk operations reach that read-only reflection; an explicitly strict, unpreloaded owner can
raise a strict-loading error first. On an exactly preloaded owner, nontrivial append,
delete, destroy, bulk, replacement, collection-writer, and IDs-writer changes raise the nested-readonly exception.
Equal-set replacement, collection assignment, and IDs assignment are accepted no-ops. Exact row snapshots prove
that rejected branches preserve every Case, Conversation, Participant, and User.
Cardinality and Rails inference
A mutable one_to_one Reference makes any authored referenced-side direct Association has_one; the Compiler emits
that exact direct, unqualified inverse with class_name or foreign_key only when Rails cannot infer the intended
target or column. When it is the sole admitted unpredicated inverse, it also carries the authored deletion outcome as
described under Reference deletion above. Association cardinality remains an additional constraint on a named
traversal. An Association's final ID component names the generated Rails reader; its human-facing name does not.
Class inference follows the pinned Rails reflection implementation using the
profile's existing inflector: has_many singularizes then camelizes the reader, while belongs_to and has_one only
camelize it. A collection reader whose singular form is empty retains class_name because Rails cannot infer its
target. Thus belongs_to :movie and has_many :people targeting Person omit class_name, while belongs_to :author, class_name: "User" and has_one :movies, class_name: "Movie" retain it. Required References omit optional: false because the target loads Rails 8.1 defaults; the pinned belongs_to builder uses
that required-by-default setting. Through-source inference follows the pinned Rails source-reflection
lookup: singularize the reader using the selected profile's inflector, then consider
that name and the original reader once each. Keep only names emitted on the through target, including Account-owned
runtime Associations. Omit source: only when exactly one candidate remains and it is the authored source. Thus
has_many :series, -> { distinct }, through: :movies omits source: :series, while bookmarked_movies retains
source: :movie. If both tag and tags exist on the through target, tags retains its explicit source. An omitted
target Association cannot make inference ambiguous. This declaration choice preserves the authored path, scopes,
distinctness, preloads, writers, authorization and GapSet; it changes no admission rule. The through-source
proof (repository-only) records Compiler-output checks and fresh emitted-app
traversal/preload checks, including the actual Account runtime ambiguity. The local association-default
proof (repository-only) exercises fresh Compilation, unsaved associated records,
and both PostgreSQL database setup paths. The Compiler emits foreign_key when Rails would infer a different column:
belongs_to infers from the reader, while has_one and has_many infer from the owner model class. Broader lowering
would also derive polymorphic as and foreign_type options when Rails cannot infer them from that reader name. An
indirect Association's through and source both name Associations. Active Record permits any non-polymorphic
through step for has_many :through, while has_one :through requires a supported singular, non-polymorphic step.
The target-independent Analyzer checks endpoint ownership, multiplicity, and cycles. When a semantically coherent path
violates a Rails-specific restriction, omit that Association and its dependents and list the exact target gap rather
than rejecting the whole Plan. The executable Rails-target method-family
resolver (repository-only) keeps method shape
separate from both Rails lowerability and current Compiler coverage. It selects all 152 mechanically knowable families
in the design-and-parity corpus; only the two exclusive-arc forward readers remain unresolved because their custom
method family is not yet specified. fully_resolved? describes only this calculation and is not a Compile gate. An
indirect Association remains unresolved when either traversal step has no method family. A blocked row may retain a
provisional family, so consumers expand method names only from resolved_resolutions. The current Domain gate admits
the three exact first-level reflection shapes and one exact nested reflection above after semantic analysis; broader
target analysis must still decide other paths against Rails-specific restrictions, while versioned Compiler coverage
separately records what an actual release emitted. Referencing-side aliases, indirect Associations with their own
Predicate or broader nested indirect paths, optional immutable has_many, every immutable has_one, optional
immutable indirect sources, broader predicated and cardinality-qualified Associations, multi-target, defaulted, or
realized References, polymorphism, exclusive arcs, and Association Primary Descriptor shapes remain outside current
Compiler coverage except for the one-hop descriptor shape. Public record display,
public Scaffold Reference selects, and the private Policy-gated index consume that same bounded descriptor.
Model declaration composer
The sole complete-file model renderer also reads the qualified Predicate scopes, named Ordering scopes, and Validation
declarations for its Entity. Its small declaration composer emits belongs_to, direct has_many or has_one over
mutable References, direct required immutable has_many, all three first-level indirect has_many forms, and the
exact nested case.members shape. It also emits named Active Record scopes and Rails validates calls in
deterministic sections, then places necessary condition predicates under one private boundary. Reference uniqueness
uses the logical association with native Rails uniqueness and physical scope columns. The indirect declaration is
has_many ..., -> { distinct }, through: with source: only when the profile's Rails
inference cannot select the authored source unambiguously. distinct preserves the
Foundation Plan's record-identity set semantics for both ordinary queries and Active Record eager preloading. An
admitted predicated direct Association adds a lambda that calls its exact PredicateScope entry's emitted name. The
existing name admission keeps that call unambiguous; keyword-shaped names use an explicit receiver. It does not render
the Predicate Expression again. The generated declaration intentionally preserves ordinary Rails semantics: a simple
equality contributes its Field value to scope_for_create, while more complex conditions retain the framework's
conventional behavior.
Code and checks
Generated Case Chat and Photogram checks prove ordinary and eager traversal, inverse creation, reparenting rejection, and the applicable noncarrier failures; the separate Reference-deletion and reviewed-Plan checks cover conventional lifecycle-carrier destruction after migration and schema loading.
Synthetic generated-runtime checks use required immutable References for both join endpoints. They prove ordinary
creation, the explicit strict-Relation preload before removal and replacement, default delete and destroy,
delete_all(:nullify), replacement, readonly endpoints, distinct ordinary traversal, and multi-owner eager loading
after migration and schema loading. The References owner records why the
representative GapSet matrix does not exercise this path.
Case Chat exercises the first-level collection-source form with exact declaration
has_many :participants, -> { distinct }, through: :conversations, source: :active_participants. Rails joins and
loads the scoped has_many source with the through query, so a flat two-owner preload uses two SELECTs. The
derived-belongs_to Director.credited_series path uses three because Rails loads its source targets separately.
From an already loaded owner, ordinary access uses one association SELECT; cached access uses none. The source
scope contributes status: "active" to construction. Because Participant's machine behavior is emitted with
no_direct_assignment: true, build, new, create, and create! raise AASM::NoDirectAssignmentError before
model validation or persistence. An explicitly strict Relation still rejects unpreloaded bulk writers. Ordinary
loading reaches the read-only reflection; the harness checks unpreloaded bulk operations alongside writers on an
exactly preloaded owner for
ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection. Persisted Cases, Conversations, and
Participants remain unchanged.
Case Chat exercises the predicated-through form with exact declaration
has_many :members, -> { distinct }, through: :active_participants, source: :user. From a loaded Conversation,
ordinary access uses one association SELECT; a flat two-owner preload uses three total; cached access uses none.
build and new construct only an unsaved User, and invalid create returns ordinary User validation feedback.
Valid create, append, and other additions attempt an active Participant and roll back when emitted AASM rejects
direct state assignment. Conventional removal and replacement-removal delete only the Participant join, preserving
User and Conversation; equal-set assignments are no-ops. leave! retains the Participant row while removing its
User from the filtered reader. The Compiler adds no collection wrapper or writer guard.
One additional unqualified collection is admitted only when its through is that exact
collection-source-indirect shape and its source is a derived forward belongs_to backed by a required immutable
non-one-to-one Reference. Case Chat emits
has_many :members, -> { distinct }, through: :participants, source: :user. From an already loaded owner, ordinary
access uses one association SELECT; a flat two-owner preload uses three total SELECTs; cached access uses none.
Active Record treats this nested
has_many :through as read-only. build, new, create, and create! raise
ActiveRecord::HasManyThroughNestedAssociationsAreReadonly before target validation, including for invalid
attributes. An explicitly strict Relation can reject an unpreloaded bulk operation before that reflection check.
The harness checks ordinary unpreloaded bulk operations alongside preloaded nontrivial append, delete, destroy,
bulk, replacement, collection-writer, and IDs-writer changes for the nested-readonly exception.
Equal-set replacement, collection assignment, and IDs assignment are
accepted no-ops. Persistence snapshots prove that rejected branches leave every Case, Conversation, Participant,
and User unchanged.