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. A cascade or nullification with no authored inverse gets the conventional inverse,
and so does a restrict outcome when a generated delete reaches its target; ambiguous inverses and destructive
cascade cycles are listed as partial gaps. Three
first-level indirect forms and one nested form lower to has_many ..., through:. Immutable References add model-level
attr_readonly. A Reference with several targets lowers to the Rails polymorphic association or exclusive arc its
Plan chooses; see Multi-target References. The References
owner states what References, Associations, and deletion mean.
On this page
- Admitted shapes
- References and direct Associations
- Reference deletion
- Multi-target References
- 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, 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 any admitted Reference, mutable or
immutable, required or optional, as has_many or has_one; see
Immutable collections for the writers that immutability does not cover. A no-cardinality
direct Association may name a Predicate whose exact PredicateScope condition was admitted on the result Entity in
the same CompilationInput generation. A predicated multi-target Association remains an explicit target-support
gap. A multi-target Reference and its
unpredicated referenced-side direct Associations are admitted as Multi-target
References describes.
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; the polymorphic and exclusive-arc realizations below expose the same
semantic reader over different physical storage. For one target, the current Compiler-exercised slice admits no
realization, boolean values for one_to_one and immutable, boolean requiredness, and that exact mechanically
derived forward Association. A Reference with a default keeps its storage, belongs_to, and consumers, and its
default is left out. A literal or reference_record default stays behind a model example that saves a record without
one. A default read from the signed-in Account stays behind a Test coverage to complete entry for the create request.
A literal null default is what a Reference without one already does. 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. A child carrying an emitted Counter counter_cache
retains :destroy, because Rails decrements the count only when it destroys each child row. A child with an emitted
image or attachment Field retains :destroy too. Pinned Active Storage 8.1.4
has_one_attached declares its attachment with dependent: :destroy, and the
attachment purges its blob after that destroy commits. Bulk deletion would leave the
attachment row and stored file behind. A child with an emitted secure token Field also retains :destroy, because
has_secure_token installs after_initialize under the profile's Rails 8.1 defaults. The
detector counts both callbacks. Rich-text and cardinality 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 ambiguous set of inverses or a destructive cascade
cycle does not authorize choosing a carrier or a 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. This is the Rails-first boundary selected by
Decision 0024 (repository-only) and the
emitted-code audit (repository-only).
A Reference with no authored inverse gets the conventional public inverse, named after the referencing model's
table, as a developer adds it after rails generate model. A Trip's owner Reference that deletes Trips gives the
owner this declaration, and a one-to-one Reference gives has_one:
has_many :trips, dependent: :delete_all
A cascade or nullification needs the inverse to happen at all, so it gets one wherever the target is deleted. A
restrict outcome is enforced by the foreign key either way, but without a carrier an authorized destroy shows an
error page. It gets the inverse, with dependent: :restrict_with_error, when the target Entity has a generated
destroy action, or a realized cascade from an Entity that has one reaches it. A target that no generated delete
reaches keeps the foreign key alone for a restriction. The owner chose the restrict inverse in the
round-2 emitted-code review (repository-only), and the cascade and nullify
inverses in the 2026-10-12 refinement (repository-only).
A referencing Entity with more than one Reference to the same target prefixes the Reference key, as in
has_many :landlord_leases, class_name: "Lease", foreign_key: "landlord_id". The name must be free on the target
model. When a Field, Reference, Association, Account, or State Machine member already uses it, a restriction stays a
gap and that destroy raises ActiveRecord::InvalidForeignKey. A cascade or nullification is left out behind a
failing example in the target's model spec, which deletes the target while a record references it and fails with
that ActiveRecord::InvalidForeignKey. The README lists it under Behavior to write. A one-to-one immutable
nullification is left out the same way, because Rails' has_one nullifies through update_columns, which refuses a
readonly attribute. A child that gains an inferred inverse retains :destroy on its own parent's cascade, as for
any emitted direct Association, except for the self-referencing restriction described below.
Pinned Rails 8.1.4 adds each dependency as a before_destroy callback and runs those
callbacks in declaration order. A :destroy child that refuses raises from destroy!, which aborts the parent. The
model declares restrictions first, as the advice on a Rails issue
suggests, except that a sibling removing the records a restriction or a cascaded child's restriction would find goes
before it. A Household that deletes its Meals and Dinners, where each Dinner restricts its Meal, declares
has_many :dinners before has_many :meals. A comment above each declaration that this order requires names the
reason, so a later reordering does not break deletion:
# Keep before :meals: each Dinner restricts deleting its meal.
has_many :dinners, dependent: :delete_all
has_many :meals, dependent: :destroy
Order cannot help when a child restricts rows of its own table, such as a Category whose parent restricts deleting
the parent Category. Destroying a Store's Categories one at a time would refuse a parent whose child is also being
deleted. A collection cascade therefore keeps :delete_all when such self-referencing restrictions are the child's
only lifecycle work: no other emitted Association, State Machine, upload, secure token, or counter. One DELETE then
removes the whole tree before PostgreSQL checks the foreign key, as a
Rails maintainer suggested for records deleted together. The generated
.active_record_doctor.rb lists that Association under incorrect_dependent_option with the reason, so the detector
no longer checks it; a callback added to such a child later needs the exception rechecked by hand. A Category of
another Store whose parent is in the deleted tree still makes that delete raise ActiveRecord::InvalidForeignKey, so
the GapSet lists the restriction as partially generated. A one-to-one cascade deletes a single row and keeps
:destroy.
Rails' has_one destroys its child with a non-bang destroy and aborts the parent
without an error, so a refusal below a one-to-one cascade keeps every record but leaves the
refusal alert empty. The GapSet lists each restriction that a generated delete
reaches through a one-to-one cascade as partially generated.
The detector remains enabled so a later child callback makes it recommend :destroy on its parent Association,
except on an Association the generated configuration lists. Pinned
Rails 8.1.4
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 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. Ambiguous restrict carriers disclose the
missing model-level restrict_with_error error surface. 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.
Multi-target References
A Reference with several targets lowers to the shape its realization.rails_association names. Either shape keeps
the authored facts true: the closed target set, requiredness, one-to-one multiplicity, immutability, and one
deletion outcome for the whole set. The Plan's derived forward Association stays the one semantic reader.
A polymorphic Reference stores a type and an ID. Comment's commentable targets Post and Photo:
# create_comments
t.string "commentable_type", null: false
t.uuid "commentable_id", null: false
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
validates :commentable_type, inclusion: {in: ["Post", "Photo"]}, allow_nil: true
end
class Post < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
Both columns follow requiredness. The one index is Rails' default for a polymorphic reference, unique for a one-to-one Reference, which also adds the logical uniqueness validation. Rails cannot add a foreign key to the column pair, as the pinned source raises, so model rules take its place:
- The type list. The inclusion validation keeps the type column to the Plan's targets. Rails'
belongs_tohas no allowlist option and its generator adds none; the polymorphic research (repository-only) and Discourse'sBookmarkadd this validation by hand.allow_nil: trueleaves a missing target to the requiredbelongs_to, which reports "must exist" once. - A carrier on every target. No foreign key can refuse deleting a target, so each target carries the authored
outcome on its inverse. The authored inverse carries it when the target has exactly one unpredicated inverse;
otherwise the Compiler adds the conventional
has_many ..., as:, orhas_onefor a one-to-one Reference, named as the restrict inference above names it. Rails':nullifyclears both columns. Decision 0024 (repository-only) records this exception to its rule against inferring lifecycle inverses. The bulk-deletion exception for a self-restricting child does not apply either, because no foreign key would refuse a referencing row outside the deleted set. A cascade into such a child keeps:destroy, which can refuse depending on the order Rails destroys the rows, and the GapSet lists it. - A refusal where a target has no carrier. A target cannot carry the outcome when it has several inverses, a
destructive cascade cycle reaches it, or its conventional name is taken. Neither can a target of a one-to-one
immutable nullification, because Rails'
has_oneclears both columns withupdate_columns, which refuses a readonly attribute. Such a target refuses its own deletion instead, as the foreign key does for a single-target Reference.
Photo's Field comments takes the name its inverse would use, so Photo refuses deleting a Photo that a Comment
references:
class Photo < ApplicationRecord
before_destroy :ensure_not_referenced_by_comments
private
# Comment.commentable is polymorphic, so no foreign key refuses this delete.
def ensure_not_referenced_by_comments
return unless Comment.exists?(commentable: self)
record = Comment.model_name.human.pluralize.downcase
errors.add(:base, :"restrict_dependent_destroy.has_many", record:)
throw :abort
end
end
The callback adds the error Rails' restrict_with_error adds, then halts the destroy as
the Rails callbacks guide shows. It queries the referencing table by type and ID, which the
polymorphic index serves, so it needs no inverse and no public name. Its private name comes from the model helper
allocator, and storage, the belongs_to, and the type list stay generated.
Decision 0024's refinement (repository-only) records the choice.
The refusal completes a restrict outcome. A cascade or nullification stays unwritten: the target refuses while a
referencing row remains, the outcome's deletion spec fails, README.md's Behavior to write names it, and the GapSet
lists it as partially generated. Several authored inverses stay as plain readers without dependent:. A cycle refuses
only on its cyclic targets. When Comments reply to Posts and to other Comments, Post keeps
has_many :comments, as: :commentable, dependent: :destroy and a Comment refuses while it has replies. A Comment with
replies, and a Post whose Comments have replies, cannot be deleted until application code writes the cascade. A
single-target self-referencing cascade refuses the same delete through its foreign key.
Like a restriction, the refusal comes first among the model's deletion callbacks, after any sibling that deletes the
rows it would find. A parent that cascades into a refusing child keeps :destroy, because :delete_all would skip
the callback; active_record_doctor counts it as a destroy callback too. A destroy action rescues the
refusal, and a refusal below a one-to-one cascade leaves that alert empty, as
above. Bulk delete, delete_all, and raw SQL skip the callback, as they skip restrict_with_error, and a
referencing row inserted between the check and the delete can slip past both.
An exclusive arc stores one nullable foreign key per target. Like's target targets Post and Comment:
# create_likes; AddForeignKeys adds an ordinary foreign key for each column
t.uuid "post_id"
t.uuid "comment_id"
t.index ["post_id"], name: "index_likes_on_post_id", where: "(post_id IS NOT NULL)"
t.index ["comment_id"], name: "index_likes_on_comment_id", where: "(comment_id IS NOT NULL)"
t.check_constraint "num_nonnulls(post_id, comment_id) = 1", name: "likes_target_exclusive_arc"
class Like < ApplicationRecord
belongs_to :post, optional: true
belongs_to :comment, optional: true
validate :target_is_exactly_one
def target
post || comment
end
def target=(record)
unless record.nil? || record.is_a?(Post) || record.is_a?(Comment)
raise ActiveRecord::AssociationTypeMismatch, "Post or Comment expected, got #{record.class}"
end
self.post = (record if record.is_a?(Post))
self.comment = (record if record.is_a?(Comment))
end
private
def target_is_exactly_one
targets = [post, comment].compact
errors.add(:target, :required) if targets.empty?
errors.add(:target, :invalid) if targets.many?
end
end
The validation checks the associated records, as Rails' required belongs_to does, so an unsaved target counts.
An optional arc checks only for more than one target, and its constraint uses <= 1. The CHECK backs that
validation for writes that skip it, the narrow exception to DB8 (repository-only). The writer
raises as Rails' typed belongs_to does for a record of another class. Each column's index is partial because most
rows leave it NULL; a one-to-one arc keeps whole unique indexes, because Rails and
active_record_doctor count only a whole unique index as backing a uniqueness validation.
Each target's inverse is an ordinary has_many :likes over its own column, so the deletion rules above apply per
target: a target without a carrier keeps the foreign key, which refuses the delete. No maintained gem provides this
shape; polymorpheus, the one the research found, supports Active Record below 7.1.
The research report (repository-only) lists the polymorphic layout's failure modes. Generated code guards
the one a request could reach: the type list rejects a type that names another model, and no generated input
accepts a type. A type that names no constant still raises NameError from the required belongs_to check before
the type list reports. Ordinary Rails behavior covers the rest. A wrapped record such as a SimpleDelegator drops
the type from where(commentable: ...), and generated code passes records. Rails cannot join through the edge and
preloads one query per target type. A delete that skips callbacks leaves polymorphic rows pointing at nothing, where
an arc's foreign keys refuse it; Decision 0024 keeps such writes outside the claim. The generated AGENTS.md names
each Reference's convention and how to add a target.
Other consumers still treat a multi-target Reference as omitted and list their own gaps: Scaffold inputs, selects,
projections, and associated creation; descriptors; Policies, Predicates, and Orderings; counters and positions;
indirect Associations through the edge; and uniqueness Validations scoped by it. Under the code-worth-keeping
requirement, a surface that uses such a Predicate or Ordering already calls its unwritten
scope, which raises, and a surface that uses such a Policy stays behind a Policy
stub. A counter, position, or Validation will stay out behind a spec that fails until
it is written; stub emission (repository-only) tracks that change. A standalone
create that needs a required multi-target Reference has no input for it and cannot succeed, so it is omitted. A
required multi-target Reference on the Account Entity is emitted as a required single-target one is: signup has no
input for it, so its validation rejects new Accounts, and the GapSet lists the Account as partially generated. The
owner decided that format 0.24 will reject (repository-only) a required Account
Reference that signup cannot supply. Development data assigns a polymorphic Reference through its belongs_to and an
exclusive arc through the matching target's belongs_to. It does not seed a one-to-one multi-target Reference, whose
seed uniqueness lookups read one column, and lists that gap.
Immutable collections
The Compiler emits the authored or inferred inverse of an immutable Reference as plain Rails has_many or has_one,
with the dependent: option that carries the authored outcome. Creation through the inverse assigns the final foreign
key before persistence. Reparenting an existing child invokes its readonly model writer and raises
ActiveRecord::ReadonlyAttributeError. So does replacing a has_one record without a cascade lifecycle carrier,
because Rails detaches the old one through that writer; a dependent: :delete or :destroy carrier deletes the old
record and saves the replacement instead. Without a cascade lifecycle carrier, a collection's delete, delete_all,
clear, replace, collection writer, and IDs writer bypass the model writer and assign NULL with update_all. For
a required Reference, with records preloaded where Rails needs to read them, database NOT NULL rejects each
statement, and Active Record rolls it back.
For an optional Reference nothing rejects that NULL, so those writers detach a child despite attr_readonly. The
Compiler emits the reader anyway, as a developer would, and the generated AGENTS.md names each such collection and
its writers so later code calls them only where the product means to detach a record. No generated code calls them. An
indirect collection over an optional immutable source gets the same note for its
delete_all(:nullify). The owner approved this trade in the 2026-10-12 refinement (repository-only); an
earlier release omitted the whole Association, with every projection, form, and Policy that used it.
Rails' has_one nullifies its record with update_columns, which raises for a readonly attribute, so a one-to-one
immutable Reference whose outcome is nullification has no carrier. Its has_one is emitted without dependent:,
and the outcome is left out behind a failing model example, which the foreign key fails with
ActiveRecord::InvalidForeignKey.
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.
Predicated direct Associations
A no-cardinality direct Association may name a Predicate on the Reference-owning result Entity, over any admitted
single-target Reference, as has_many or has_one. 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. Over a mutable Reference, removing a record through the alias
detaches it as an unfiltered collection would. Predicates on indirect, referencing-side, multi-target,
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.
Decided: Decision 0021 (repository-only) covers that
case, with no second state-write guard.
Generating AASM for the remaining machine shapes (repository-only) is tracked
separately.
Indirect Associations
The Compiler admits three first-level unqualified indirect Association forms. An unpredicated admitted direct
has_many, including an immutable inverse, may be the through step. Its source may be a derived belongs_to, 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. 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; over an optional immutable source it bypasses attr_readonly, the
writer caveat that Immutable collections describes. 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. 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. 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 let it null the key. 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 every mechanically knowable family
in the design-and-parity corpus, including the exclusive_arc family for an arc's generated reader and writer.
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, predicated multi-target and cardinality-qualified Associations, the
multi-target consumers listed above, 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, 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
DomainMultiTargetReference (repository-only) admits multi-target References, and
ReferenceDeletion (repository-only) plans each target as its own edge. The multi-target runtime
smoke (repository-only) compiles both realizations, required and optional, under every deletion outcome, plus two
arcs on one long-named table and two refusing targets. After migration it checks that a fresh dump equals the
emitted schema and that every active_record_doctor detector passes, and the generated specs fail only on the
cascade a refusing target leaves unwritten.
It then checks traversal, one preload query per target, the type list, the arc's validation, writer, CHECK, and
foreign keys, each carrier, inferred ones included, and each refusal.
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.