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

On this page

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

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.

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.