Rails scopes and ordering
Status: Implemented for the bounded Predicate and Ordering shapes below. Other shapes are listed gaps.
Each admitted Predicate lowers to a named, chainable Active Record scope, and each admitted Ordering lowers to a
named order scope with a matching index. A bounded recursive visitor turns resolved and type-checked Expressions
into ordinary where hashes or one checked Arel condition. Separate projections admit relationship-existence
scopes and two current-Account scopes once the relationship catalog is fixed. An Entity's implicit order sets
implicit_order_column, which affects ordered finders only. Unsupported Predicates and Orderings are listed gaps,
and a consumer that selects one keeps a precise consumer gap. The Queries owner states
what Predicates, Expressions, and Orderings mean.
On this page
- Predicates
- Staged Predicate qualification
- Existence scopes
- Current-Account scopes
- Named Orderings
- Ordering composition
- Ordered finders
- Query behavior
- Scope rendering and target checks
Predicates
The current public Compiler reuses exact resolved and type-checked Expression evidence for one bounded local recursive
SQL visitor. Direct emitted-Field comparisons admit scalar literals, current_date, and current_time; literal-set
in, optional-Field is_null, Boolean and/or/not, and same-Entity matches_predicate retain one checked Arel
condition with complete dependency evidence. The application uses equivalent where/where.not hashes, non-null
</<=/>= ranges, and ordered conjunctions. Straightforward Predicate compositions call the final names of
actually emitted scopes. A checked dependency without an emitted method remains expanded. Arel stays for disjunction,
general negation, strict >, singleton or NULL-containing membership, and shared clock values that independent scope
calls would recapture. Singleton hash membership would introduce an equality-derived creation default; NULL hash
membership would add an IS NULL alternative. Repeated-attribute conjunctions retain their authored order and all
restrictions. Scalar literals and sets remain inert Ruby source. Named scopes stay chainable and preserve SQL
true/false/unknown behavior. See the Rails comparison.
Each required date or time environment value is captured once per scope evaluation; each root stops lowering and
remains listed after 4,096 expanded Expression visits or 262,144 bytes of generated condition source. An unnormalized
realized citext Field admits only direct equals or not_equals against a String literal; membership, a normalized
citext operand, Field operands, mixed comparison modes, contains, starts-with, and ends-with remain target-support
gaps. Unrealized derivation and encryption remain gaps as well.
Broader relationship exists, Association paths inside other Expressions, record evaluators, broader Association
consumers, and broader Scaffold Predicate consumers remain unsupported and listed. Admitted Scaffold definitions and
admitted collection projections consume their exact selected Predicate entries; an unsupported selection stays an
exact consumer gap rather than becoming an unreported unfiltered query. Relation#include? is not a record evaluator.
Direct comparisons, literal-set in, optional-Field is_null, Boolean groups, negation, and same-Entity Predicate
reuse compose only when every nested child and referenced Field is lowerable. Required date and time environment
values are each captured once per scope evaluation. A root is withheld after 4,096 expanded Expression visits or
262,144 bytes of generated condition source.
Predicate admission still withholds authored query normalization, unrealized derivation and encryption, Field
operands, pattern operators, other relationship shapes or current-Account comparisons, and broader Association
consumers. Scaffold lowering binds admitted scopes at exact definition and collection-projection use sites; an
unsupported selected Predicate remains a precise consumer gap rather than an unreported unfiltered query. An
unnormalized realized citext Field admits only direct equals or not_equals with a String literal. Simple named
Ordering admission may use an emitted normalized or citext Field because assignment and ordering operate on the
stored value; realized comparison composes with the same matching index. An all-Field Ordering may omit explicit
terminal id only when its exact term set matches an admitted unconditional uniqueness tuple. Record evaluators and
broader generated query indexes remain gaps.
Staged Predicate qualification
Predicate qualification is staged to avoid a dependency cycle. The Compiler first derives local ExpressionSql
scopes. DomainReference.catalog may consume only those entries while admitting a predicated Association. After that
exact relationship catalog is fixed, a second projection may append root unfiltered relationship-existence scopes.
Collection entries reuse exact DomainAssociation rows. One singular entry reuses the exact emitted
DomainReference behind its derived forward Association. An appended entry cannot feed back into Association
admission. After Domain, relationship, public-index, and exact Account projections are fixed, a third projection may
append only the two current-Account relationship scopes. It consumes the same-generation public Account lowering,
Field-only registration inputs, Account self, relationship catalog, and typed Expression evidence. Final Predicate
diagnostic suppression occurs only after this Account-dependent projection, and the appended entries cannot feed
back into Domain, relationship, or Account admission.
Existence scopes
One separate Predicate projection may then admit root unfiltered exists over an emitted cross-Entity direct required
immutable collection. A predicated entry reuses the exact Association and calls its emitted child scope, which owns
any required clock capture. For example, it emits where(id: Post.published.select(:author_id)). An unpredicated
entry uses the full child relation. Both forms select the emitted foreign key in a subquery matched against the owner
primary key, so outer records are naturally deduplicated in one SELECT. This is not a SQL EXISTS claim. One
separate singular-reference form may retain only an exact same-generation emitted cross-Entity optional immutable
non-one-to-one DomainReference whose deletion action is nullify_reference, plus its mechanically derived forward
Association. It checks the owner foreign-key slot through Active Record's public where.not hash form. Foreign-key
integrity makes a non-null slot equivalent to a present target. Without an admitted inverse for dependent: :nullify,
the ordinary non-deferrable foreign key rejects target deletion while the referencing row remains, and the missing
authored transition remains an exact gap. This is an outer-row IS NOT NULL check, not SQL EXISTS. Required,
mutable, restricted, cascading, one-to-one, self, authored-inverse, indirect, filtered, nested, negated, grouped,
reused, and all broader singular existence shapes remain listed gaps. For this standalone relation-level leaf, public
where.not preserves the required Relation semantics. Recursive ExpressionSql retains the checked Arel condition
and provenance while emitting ordinary Rails filters and calls to admitted named Predicates where equivalent. Grouped
disjunction, general negation, singleton/NULL membership, and shared clock values retain condition-level rendering;
the expression rendering notes explain those cases and creation
defaults.
Once local ExpressionSql scopes and this exact relationship catalog are fixed, a root unfiltered exists may
reuse an emitted cross-Entity direct required immutable has_many. The existing predicated representative is
user.has_published_posts over user.published_posts. It emits
where(id: Post.published.select(:author_id)); the called child scope captures Time.current once and applies
the publication filter. The subquery selects the emitted author_id and matches User id. Outer Users are
naturally deduplicated. Runtime proof pins exact IDs and one SELECT; it
does not claim SQL EXISTS lowering. The unpredicated representative is user.follows_anyone over
user.outgoing_follows. It renders the equivalent of where(id: Follow.select(:follower_id)), using the same
owner-primary-key/child-foreign-key pattern without a child condition. The appended existence scopes cannot feed
back into Association admission.
One separate singular-reference shape is admitted only for a root unfiltered exists over the exact
same-generation emitted cross-Entity optional immutable non-one-to-one derived-forward DomainReference whose
deletion action is nullify_reference.
Photogram's feed_occurrence.sourced_by_follow retains feed_occurrence.source_follow and emits
where.not(source_follow_id: nil). Foreign-key integrity makes non-null slot presence equivalent
to target presence. The ordinary non-deferrable foreign key rejects target deletion while the referencing row
remains because no admitted inverse can carry dependent: :nullify; the authored nullification is an exact target
gap. The scope proves stored presence, not that missing deletion transition. This is an outer-row IS NOT NULL
condition, not SQL EXISTS. For this
unfiltered singular-presence projection, required, mutable, restricted, cascading, one-to-one, self,
authored-inverse, indirect, filtered, nested, negated, grouped, reused, and every broader singular shape remain
target-support gaps.
Current-Account scopes
Two final relationship scopes require the exact selected valid public Account lowering, its Field-only
registration projection, and its successful Account-self surface. Each root has the exact resolved and typed
nested comparison current_record == current_account. Photogram's
feed_occurrence.belongs_to_current_viewer(current_account:) uses the required immutable viewer Reference and
filters viewer_id. post.liked_by_current_account(current_account:) uses the emitted unpredicated direct
post.likes collection plus required immutable like.user, then matches Post id against
Like.where(user_id: current_account.id).select(:post_id). A nil, wrong-class, unpersisted, or destroyed argument
returns none. Generated models read no implicit request, global, or thread-local account state. An emitted index
already leads with each filtered Reference column, so no new index is emitted. Other relationship topologies,
comparison directions or operands, nesting, Boolean composition, and Predicate reuse remain omitted and listed.
Named Orderings
As the general rule before the two structural patterns below, retain every imported Ordering as an immutable semantic
entry, then lower only the first conservative subset. An admitted Ordering has at least two homogeneous-direction
terms, carries no null-placement override, and is stable because it either ends explicitly in system id or its
all-Field term set exactly matches one admitted unconditional Entity uniqueness tuple. It selects only created_at,
id, or required emitted queryable Fields whose normalization, comparison, derivation, and encryption behavior is
already realized. Its key must be a safe Active Record scope name and must not collide with an emitted Predicate
scope. The model emits scope :name, -> { order(first: :asc, second: :asc) }; the migration and schema emit one
ordinary composite B-tree index over the same columns unless an admitted unique index already subsumes the same
ordered-column index. PostgreSQL may scan that index backward for a homogeneous descending order, so the index does
not encode direction.
Central-capture composition adds two structurally admitted patterns (support inventory
row (repository-only)) through ordinary Rails APIs. A
required emitted ordinal enum Field descending followed by system created_at and id ascending, without null
overrides, uses in_order_of(:priority, ["high", "normal", "low"], filter: false) with the
actual Field key and reversed authored enum keys. Its expression index preserves the same rank expression and
ascending tie-breaks. An optional emitted datetime Field descending with nulls last, followed by descending system
id without a null override, uses typed Arel descending/nulls-last order and a matching composite
index. Both render as ordinary Rails t.index declarations. Both require storage without
encryption, derivation, normalization, or comparison modifiers; available model scope names; and no Predicate-name
collision. Equivalent admitted index claims share one index. Incompatible physical names withhold the affected
advanced Orderings, preserving existing ordinary Ordering, implicit-order, Reference, and uniqueness indexes. The
current naming path allocates no alternative name. Either pattern may emit without the other. Capture identity still
binds each result to its submitted Head and reviewed GapSet. No fixed application SHA, subject UUID, or Oscar name
determines support; existing scope and index collision rules still apply. Structural scopes join ordinary scopes in
enum-helper allocation and AASM fixed-API admission. Serialization and unrelated metadata do not change support. The
independent-Plan qualification (repository-only) covers those dimensions. Direct
rendering without a capture does not run the additional composition. Scaffold consumption remains a separate gap with
id fallback. Outside these two patterns, mixed direction, nullable Fields, authored null placement, enum rank, Tree
values, consumer bindings, and broader combinations remain target-support gaps. Repeated semantic sources are a
blocking Plan error rather than a support gap.
Ordering composition
One narrow OrderingComposition derives from the exact CompilationInput::Result used by a Compile; the generation
record and RenderingContext receive that same immutable object. Analysis derives the same pure result from its own
exact captured input. It admits the two structural patterns described by Ordering
lowering: descending required ordinal rank with ascending
created_at, id, and descending optional datetime with nulls last and descending id. The leaf results retain exact
capture provenance; no particular application SHA, UUID, or readable path controls support. Composition combines their
existing entries deterministically after resolving incompatible physical index claims. Model rendering consumes each
leaf's existing scope declaration; schema rendering consumes its structured columns, expression, and orders. State
Machine fixed-method checks derive these admitted scope names from the same captured input. ScaffoldLowering remains
unchanged and does not consume either entry.
Ordered finders
Set the inherited Entity fallback to created_at. Active Record's implicit_order_column
API accepts one column or an array. Its ordered-finder implementation appends the query
constraints or primary key and deduplicates the result, producing effective created_at, id
behavior for this profile. The implemented target Analyzer derives that default when an Entity has no authored rows.
It requires every authored Field to be required, unencrypted, and queryable, and it requires an authored order to end
with the system id Field. Rails could append that primary key itself, but keeping id explicit makes the Plan's
authored sequence independent of hidden framework repair. Captured public Analysis reports these existing semantic
errors before GapSet construction. Diagnostic locations use the captured submitted-source index, including after
import pruning; legacy raw-input analysis is unchanged. At this boundary, queryable uses the frozen Compiler's
Field-kind catalog: every current kind except attachment, image, JSON, and rich text, provided it is not encrypted at
rest. Unit coverage pins that complete catalog; the frozen corpus oracle exercises only the positive authored
message.sent_at path. The live graph does not yet retain Field derivations, so the Analyzer cannot yet reject a
semantically queryable Field whose Rails lowering does not produce a stored queryable column. That eligibility check
follows derivation persistence and lowering. The capture-bound Compiler lowers Case Chat's exact message.sent_at, id
authored order to self.implicit_order_column = [:sent_at, :id] and emits its ordinary composite index. Generated
migration and fresh schema-load proof exercise the resulting finder behavior. This affects ordered finders such as
first and last, not ordinary Relation or Association loading. Associations do not carry Ordering in the current
Plan. Rails' merged but not yet profile-supported default_order API is not approximated with an ordinary scope
order.
Query behavior
Keep Relation pagination, appropriate indexes, useful preloads, and Bullet plus focused query-growth checks on important paths. Ordinary association reads remain available. The verification policy (repository-only) distinguishes detection from query-growth coverage. The Case Chat (repository-only), Photogram (repository-only), and ignore-and-list (repository-only) runtime smokes exercise the representative scopes after schema loading.
Scope rendering and target checks
Expression rendering
The current Rails Compiler lowers the bounded local recursive subset to ordinary named scopes:
scope :active, -> { where(status: "active") }
scope :wants_all_activity, -> { active.where(notification_level: "all_activity") }
The immutable ExpressionSql projection retains a checked Arel condition and its Field, Predicate, and environment
dependencies. It separately renders equivalent Rails where and where.not hashes, non-null range bounds for
<, <=, and >=, and ordered conjunction chains. Repeated attributes remain separate conditions. An explicit
matches_predicate calls the dependency's final named scope only when that scope is emitted. An unavailable public
name retains condition-level expansion. Keyword-shaped names and names used by clock locals receive self..
Rails' array handler turns a singleton hash array into equality, which would
add a creation default, and turns NULL membership into an IS NULL alternative. Neither is the existing IN
behavior. General negation keeps its grouping and cannot negate caller restrictions. The renderer preserves each
conjunct's order because Rails takes the last equality for a repeated attribute when deriving creation defaults.
It uses neither merge to combine filters nor invert_where to negate a named Predicate.
Each required Date.current or Time.current value is captured once per evaluation. One called child can own the
capture. If a called child and another condition need the same value, the parent captures it once and renders
the combined Arel condition instead of calling scopes independently. No new public scope arguments are introduced.
Scalar
literals remain Ruby-dumped data, and SQL three-valued truth is preserved.
The immutable lowering
consumes the existing same-generation resolution and type evidence; it does not interpret authored Expression JSON
again. GapSet removal follows the Predicate subject IDs actually admitted.
The original direct-scope implementation (repository-only) mapped every comparison to Arel, and recursive lowering (repository-only) extended that common representation to Boolean groups and Predicate reuse. Keeping that checked representation inside the Compiler does not require exposing it in every emitted equality. The current renderer uses ordinary Rails syntax where equivalent, without broadening admission or changing the reviewed GapSet. Issue #706 (repository-only) records the preceding 16 paired Oscar Party model/database comparisons, including the NULL-membership difference. Those probes retain their original boundary.
Rails documents the scope-chaining pattern used above. Four preceding Case Chat model/database
comparisons preserved SQL, IDs, creation defaults, AASM build behavior, and one SELECT across caller restrictions
and contradictory conditions. Separate framework probes confirmed that merge can replace a same-attribute
condition and active.invert_where can negate caller filters; Rails warns about the latter.
Issue #707 (repository-only) retains those bounded comparisons and the implementation history. Neither
comparison turns a condition-level fallback into an unavailable named-scope call.
Predicate reuse by Associations
A no-cardinality referenced-side direct Association over a required immutable non-one-to-one Reference may consume
one of those scopes when the Predicate belongs to the result Entity. The qualified Association retains the exact
same-generation PredicateScope entry. Its Rails lambda calls that entry's emitted name, for example
has_many :unwatched_bookmarks, -> { unwatched }, class_name: "Bookmark". An application owner edits the filter in
one place. The Compiler retains checked identity and dependency
evidence without emitting a second condition implementation. Every broader Association shape remains independently
listed.
The condition has one application definition. Changing the named filter changes its referencing Association, matching the Plan's explicit reuse. The original association implementation (repository-only) inlined a modified condition to suppress creation defaults. ADR 0021 (repository-only) removed that modification and restored ordinary Rails behavior; the later named-scope change removes the duplicated spelling. Three preceding paired Oscar Party model/database comparisons preserved IDs, creation defaults, and one SELECT per side, including a keyword-named scope. Issue #707 (repository-only) retains that evidence. Owner restrictions, writers, AASM protections, preloads, and through traversal remain separate application behavior to preserve when changing a scope.
Existence entries
A root unfiltered exists may become a PredicateScope::CollectionExistence only when it
names an admitted cross-Entity direct required immutable has_many. A predicated entry also retains the local
Predicate dependency lineage and calls the emitted child scope; an unpredicated entry selects from the full child
relation.
The original collection-existence implementation (repository-only) copied the checked child condition,
binding its table and required date/time values in the parent scope. Calling Post.published keeps that condition
and its time capture in one application definition. Six preceding Photogram model/database comparisons preserved
SQL, IDs, creation defaults, one SELECT, and one clock capture across time boundaries, additional parent filtering,
and duplicate qualifying children. Issue #707 (repository-only) retains that evidence; it is not a new
qualification of the combined application or dependency bundle.
A separate PredicateScope::ReferenceExistence may retain only the exact same-generation emitted cross-Entity
optional immutable non-one-to-one DomainReference whose deletion action is nullify_reference, plus its
mechanically derived forward Association.
Ordering entries
The named-Ordering lowering similarly consumes captured normalized rows. GapSet removal follows the Ordering subject
IDs actually admitted; every other retained root remains independently listed. ScaffoldLowering binds an admitted
Ordering entry at its exact index or admitted collection use. When a selected Ordering is unsupported, that consumer
retains an exact gap and may use deterministic id fallback without claiming the authored order.
A record-level consumer may also require a Boolean method. A Policy may require record and relation projections. Broader Scaffold and Association consumers of named Orderings remain open.
Target checks
Before emission, target validation checks:
- generated scope names against the target-owned forbidden Active Record class and Relation namespace;
- generated instance-predicate names against Fields, Associations, enum helpers, and State Machine helpers;
- operand types and Field comparison compatibility;
- Association path cardinality and endpoints beyond the Predicate checks already implemented;
- Policy Expression dependency cycles;
- required record and SQL capabilities;
- queryability and index needs; and
- bounded complexity, depth, and relationship traversal.
For rails-sketch/2026-09-bookmark-assets, that scope check consumes one frozen 707-name union derived from pinned
Rails 8.1.3.1 with the maximal supported Account, Policy, and State Machine dependency set. Predicate and Ordering
admission does not reflect whichever Active Record methods happen to be loaded in the First Draft service. The
in-place pre-alpha repair advances the Analyzer and Compiler release fence; the profile owner retains extraction
provenance and exact digests. This does not claim complete instance-method or capability-local name coverage.
The final item prevents an otherwise valid recursive tree from becoming unexpectedly expensive. Dedicated limit diagnostics plus relationship depth and traversal budgets remain Compiler implementation work.