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. 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

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:

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.

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.