Rails Scaffolds
Status: Implemented for the admitted Scaffold surfaces below. Other surfaces are listed gaps.
One immutable ScaffoldLowering result owns every admitted surface and precise omission. Scaffold
lowering lists the admitted representative surfaces, and the reviewed GapSet lists each omitted
surface or child. Generated controllers, views, and forms are ordinary Rails scaffold source:
resources routes, model-bound forms, Pagy pagination, .preload relations, and direct redirects. A surface the
target cannot realize is omitted without deleting its admitted siblings.
On this page
- Scaffold lowering
- Web files and routes
- Mutation shapes
- Record display
- Pagination
- Associated collections
- Projections
- Forms
- Associated creation
- Preload
- Navigation
- Mutation destinations
- Success messages
- HTML-only responses
- Generated source
- Code and checks
Scaffold lowering
ScaffoldLowering joins the complete retained Scaffold graph to exact submitted-source and same-generation Domain,
relationship, query, Account, Policy, route, and descriptor evidence once. Its frozen result owns admitted
definitions and concrete route consumers; typed inputs, bindings, recursive render nodes, authorization decisions,
query and cursor plans, associated forms, returns, nested preload paths, and source-addressed
omissions. Public, item-authorized, environment-gated, and projection-gated consumers remain distinct. Protected
collection and member lookup starts from the admitted Policy relation. Action and collection-wide gates authorize
before presentation loading; views guard protected content. Web profile decisions target current_account.
The renderer does not scan raw graph rows, synthesize an authorization scope, or infer a return route.
The admitted representative definitions are:
| Plan | Entity | Definitions | Selected Rails routes |
|---|---|---|---|
| Oscar Party | Movie, Person | index, show, create, update, destroy |
all seven resource routes |
| Oscar Party | Credit | create, update, destroy |
create, edit, update, destroy |
| Oscar Party | Bookmark | index, show, create, update, destroy |
all seven resource routes |
| Oscar Party | Rating | create, update, destroy |
create, edit, update, destroy |
| Case Chat | User | profile, update |
singular /account and /account/edit |
| Case Chat | Case | index, show |
index, show |
| Case Chat | Conversation | index, show |
index, show |
| Case Chat | ConversationThread | show |
show |
| Case Chat | Message | create |
create |
| Case Chat | Notification | index, show |
index, show |
| Photogram | none | none | none |
Admission can preserve safe partial meaning. A selected unsupported Predicate or Ordering remains an exact consumer
gap, and the index or admitted collection calls its unwritten scope, which raises until
it is written. Deterministic id ordering remains only for a selected Ordering that no unwritten scope stands in
for, and that fallback order is never reported as the authored Ordering. Current output accepts no
generic-text fallback for an unsupported Field kind and no unrestricted lookup before a protected decision.
Unsupported children are omitted without deleting admitted siblings, but an authored nonempty projection whose every
child fails never becomes an implicit descriptor view. An input or binding the target cannot lower, such as an
unsupported control type or relationship shape, is left out on its own; the rest of its form stays. A return the
target cannot emit falls back to the mutation's default destination. Unsupported route pairs, Policy ownership, and
associated forms omit their exact consumer. Each of these stays listed in the GapSet. Selected iOS and Android
clients reach these same Web pages, protected ones included, through the shared main navigation. The
Scaffold owner (repository-only) owns the detailed contract.
Lower each admitted authored definition through one exact-source, input-owned ScaffoldLowering result. Standard
resource routes use Rails resources; Case's Account-owned profile uses fixed Web-only /account and /account/edit
routes resolved from current_account. A retained definition needs a concrete consumer: index/show/destroy need their
matching route; create needs a create route or admitted associated-create consumer; update needs an edit/update route
or Account. new requires create, and edit requires update. A selected request-only create needs no New page,
associated form, or return destination. Public and otherwise supported protected creates use the same admission rule;
Policy shape controls access. Required-value coverage follows the actual consumer: each associated form may supply its
exact inverse Reference. A create that lacks a source for a required value in its inputs, bindings, or realized
defaults keeps its ordinary endpoint or associated form, with a partially_generated gap on that route or form.
Saving fails validation until the owner's agent supplies the value, and the README lists the successful create
example as test coverage to complete. Membership authorization adds no separate direct-input requirement to an
associated form.
A binding assigns its value in new and create before authorization, as @category.merchant = current_account.merchant, Date.current, or Time.current. A longer path, a literal, or a value of another kind
is left out and listed.
Routes and guards follow the code-worth-keeping requirement.
The Web renderer emits a narrow resources ... only: set from the authored resource_routes whose definitions
survive. A protected consumer whose Policy the Compiler cannot lower stays behind a
stubbed Policy that admits nobody. The
2026-10-08 (repository-only) and
2026-10-10 (repository-only) decisions record why.
Web files and routes
WebScaffoldFiles consumes the immutable input-owned Scaffold lowering and same-generation collision claims.
It selects one ordinary task per generated routes, navigation, controller, locale, pagination, Scaffold view, form,
profile, or integration-test path. Each task renders only its final path and keeps the normal task owner, SQL guard,
and failure identity. The generalized renderer replaces legacy public-index output whenever the admitted graph needs
protected authorization, recursive projections, query choices, bindings, associated forms, dynamic returns, cursor
pagination, or profile behavior. A legacy public-only shape may still take the byte-for-byte predecessor renderer,
but the two projections never claim the same authored surface or output path.
Core's Domain-routes and shared-navigation seams remain application-global. They combine admitted resource routes,
the Account route with optional authored profile details, without transferring ownership to Account, Policy, or
an Entity controller. Resource entries use conventional Rails controllers and helpers. The profile uses singular
/account routes and current_account; it is not a resources :users member. Duplicate human navigation labels
remain valid because stable identities and collision-admitted paths are distinct.
Each associated collection is a nested resource with a namespaced controller: resources :credits, only: %i[index new create], module: :movies inside resources :movies routes to Movies::CreditsController, whose views live in
app/views/movies/credits/. A parent whose plural names a constant the target already defines, such as a Rail's
Rails, nests its controllers under its own model instead, as module: :rail and Rail::InspectionsController,
so they stay out of the framework's module. An uncountable parent's plural is its model too, as in
Equipment::ChecksController. Such a model is also its nested controllers' module, and each of those controllers is
named after one of its Associations, so a target class that shares such a name is rooted:
has_many :inspection, class_name: "::InspectionController" beside Equipment::InspectionController. An
exclusive-arc writer on that model checks record.is_a?(::InspectionController) for the same reason. The main
navigation marks a nested page's parent section current, and the Account profile's collection pages mark Account.
Scoped inputs omit the URL-bound inverse Reference. Optional Plan overrides use direct routes.
Mutation shapes
The predecessor public-only projection admits a narrow shape: exact public indexes, a bounded public create/update form, an optional show, and a destroy that requires the complete show-bearing shape. It still feeds native qualification and the byte-for-byte predecessor renderer, and it explains anything else with a source-addressed warning. When analysis has the exact submitted source, the general Scaffold lowering above owns every page, and the GapSet gives each omission its own reason. Analysis then drops the projection's shape warnings and keeps only its route collisions, which still describe emitted routes. This projection reads Primary Descriptors only through reader chains, so it leaves out a record that a computed label describes.
Record display
Resolve each Entity's primary_descriptor into an explicit reader chain wherever generated UI needs a whole-record
label. A direct descriptor reads its Field or system Field. An Association descriptor follows a singular
referencing-side traversal and continues through the target Entity's descriptor. Do not override to_s; emitting
ordinary source at each use site lets one generated view evolve without changing every other view. A multi-target
Reference behind that Association branches over its closed targets when their descriptors differ. Semantic validation
still resolves every locator and rejects an optional Association and descriptor cycles. Descriptor use does not
cancel authored encryption or log-redaction behavior. The current Compiler reads a direct required emitted scalar
or system Field, or one required ordinary single-target forward Association hop ending in one, and preloads that
hop. Any other descriptor becomes a label each use site computes. An optional Field reads
movie.title.presence || "#{movie.model_name.human} ##{movie.id}", an enum Field shows its value's label, and
any other shape, such as a longer chain, shows the model name and id and is listed as partially generated.
Reference options for a computed label are ordered by id and are not searched.
Public Web admission consumes the same-generation semantic Primary Descriptor receipt. It accepts one required
ordinary single-target forward Association hop ending in a required emitted scalar or system Field, retains exact
source/provenance, and emits the explicit reader chain plus preload for index and show. Public Scaffold Reference
selects reuse that descriptor. The one-hop form eager-loads its Association, orders by terminal value and target id,
and maps labels and IDs from one result query. The private Policy-gated index authorizes first, then reuses the
descriptor reader, preload, and provenance. Multi-hop, multi-target, and broader consumer shapes, and any record a
computed label describes, are omitted and listed at their service, semantic, or target boundary. A foreign
target or profile is
caller misuse at this exact-profile boundary and fails before any projection is built. When whole-Application
qualification has already admitted an exact ordered Domain catalog, the web projection consumes that same catalog.
Pagination
Index relations apply the admitted authorization scope before Predicate, Ordering, preload, and pagination. Cursor
pagination requires an input-owned deterministic keyset; over an unwritten Ordering it serializes the timestamps that
the authored terms name. Root show/profile collection previews use Pagy Countless with explicit page: 1, limit: 5.
Pagy queries at most six rows and display five, using the extra row only to decide whether to offer View more….
This final list row uses an ordinary Rails link with a plain-text label rather than another heading. It opens the
dedicated collection page from its beginning, independent of its page size or pagination mode. The parent page's
page parameter cannot move a preview. Previews do not count the collection. Every admitted collection derives a
one-level parent-scoped read route and a separate paginated page, even without a target global index. That page is its
nested resource's index. Show/edit/update/destroy routes stay flat; associated New and create use the collection
URL. The full request repeats the parent show/profile authorization and the projection's gate or item scope before
querying its records. Its rows preserve the same Predicate, Ordering, supporting content, and separately authorized
destination links. Like a top-level index, the page loads its records once in the view with .load.any? and has two
empty states through shared/ui/empty_state. A collection labeled with its Entity's plural reads "No habits yet", as
an index does; any other label keeps its own case, "Nothing in Upcoming deadlines yet", read from the same translation
as the page's title. A page past the end, which Pagy returns empty, links back to page 1. A preview keeps one compact
line, because it is pinned to page 1.
This uses ordinary Rails Associations and named GET routes. The pinned Rails 8.1.4 route-name
check
rejects duplicate explicit names; the
mapper
accepts as: without changing the path. Pagy 43.6.1 supplies
offset pagination by default,
Countless for previews, and
keyset for an authored cursor
choice. No new pagination library or Plan setting is needed, and nesting stops at one level.
Cursor requests use Pagy's native decoder and Active Record
adapter. Malformed
Base64/JSON may recover to page one. Some crafted shapes, extreme values, or NUL-containing strings can still raise
server errors; the application promises neither exact-arity admission nor universal 400 responses. It adds no
duplicate decoder, per-column validator, size cap, or global exception rescue. Normal HTTP transport limits still
apply. Ordered timestamps use the documented serialization
hook to write iso8601(6) values;
Rails' default millisecond JSON representation can otherwise repeat or skip rows. Other cursor values keep native
serialization, including legitimate long text values.
Small shared/_pagination and shared/_cursor_pagination partials render pagy.series_nav and pagy.next_tag
directly with ERB's HTML-rendering syntax. Their relative translation scopes preserve labels and accessibility
text. The pinned Core styles their ordinary .pagination class. Pagy escapes its URL parameters and expects
application-supplied options to be
trusted or escaped, as its
safety guide explains. The emitted labels are
fixed translations. A second sanitizer allowlist and forwarding helper add no application behavior.
Select each pagination partial from emitted indexes and full collection pages, including Account profile
collections. Emit PAGE_LIMIT = 24 only in a controller with one of those actions; five-record previews keep
their inline limit. Every index and full collection page shares the shared.pagination.empty copy for a page past
the end, even when no offset partial is emitted. Preview collection copy includes View more; full collection pages
do not use that label.
Associated collections
Direct has_many and the admitted indirect direct-has_many/forward-Reference shape start from
parent.association. Rails retains its parent constraint and admitted intermediate Predicate; the emitted
indirect Association already uses distinct for one destination per identity. The same Relation receives the
selected authorization scope, presentation Predicate, Ordering, and preloads. Pagy's pinned Active Record adapter
qualifies cursor comparisons with the destination table, so these through queries need no outer ID subquery to
avoid ambiguous columns. The displayed Association supplies the final URL segment. Index and nested
collection projections, other indirect shapes, and conflicting browse paths remain explicit projection gaps.
A repeated Association presentation or the reserved edit segment cannot share one browse path.
A helper-name collision preserves the collection and its URL. Existing resource, profile, Account, and pinned
Core helpers retain their names. A scoped browse or New helper adds _collection only when its ordinary name is
occupied, repeating the suffix until it avoids both allocated helpers and other scoped helpers' preferred names.
Thus Order.items may use order_items_collection_path beside OrderItem's order_items_path, while their
paths remain /orders/:order_id/items and /order_items. Sibling scoped helpers follow the same allocation rule.
Nested resources would derive another helper from the segment, so a collection whose allocated helper differs,
such as this one or an Account's rent_due collection, which Rails would name account_rent_due_index, keeps
explicit routes to the same controller:
get "/orders/:order_id/items", to: "orders/items#index", as: :order_items_collection. A scoped form uses
polymorphic routing when model
inference selects its actual helper; aliases, collision suffixes, and inflection differences otherwise supply an
explicit compiled URL. This changes no authored meaning and adds no gap; conflicting browse paths still follow
the omission rule above.
Preview and full-page rows contain projected values and an optional destination link, with no edit/destroy
controls. Selected and authorized edit/destroy actions appear on the record's detail page when show is generated.
Otherwise their flat routes and edit form remain generated without a scaffold link; the application owner adds
an appropriate entry point. A Plan without show gets these routes from its authored resource_routes and
authorization, with no gap or compilation refusal. When the Compiler omits a selected show, for example because its
read Policy's class name is taken, the show definition's gap names the routes left without a link:
Selected edit, update, and destroy remain at /posts/:id/edit and DELETE /posts/:id with no generated link.
An omitted index likewise names the selected new and create routes that lose its New link. The gap does not name a
request-only create or update, which never had a link, or a destroy that a
one-tap toggle's undo button still reaches. A one-tap create that is not a toggle, such as a
check-in, has no undo button, so the gap names its destroy.
Empty and small collections have a browse route but no View more… row. An admitted associated create has an
Add link at every collection size on both surfaces;
both links open the same scoped New page with its direct Association/create binding. Only actual gates produce a
collection visibility decision; public and item-scoped cards render directly. A protected create checks its own
Policy after the collection gate permits presentation. Public create needs no permission flag or view branch.
Projections
Field projections use typed readers. Association projections use the qualified relationship and target Primary
Descriptor; omission of the entire projection requests descriptor fallback only when the author truly omitted it. A
nonempty authored projection whose every child is unsupported does not become an invented descriptor view. Index and
show descriptors use their typed reader or computed label, with Rails localization for date/datetime and
true/false copy for Boolean values. A required projected Field renders its value plainly, because
its NOT NULL column always holds one; optional projected Fields retain missing-value copy; projection and descriptor
headings call human_attribute_name. An admitted target show route turns an authored Association descriptor or
child-bearing projection into one separately authorized link; denial preserves the already-authorized text. The
renderer does not synthesize an unauthored reverse Association. Partial siblings survive. Oscar's Movie Credit
collection demonstrates that boundary: the collection and nested Person association survive with the admitted
billing_order scope, and required enum storage admits the Role display and authored-label input. The associated
Credit create form is admitted. Bookmark priority likewise admits create/update controls and index/show display, while
the index that selects its rank Ordering keeps a gap and deterministic id fallback beside the emitted scope. The
Movie index calls the unwritten highest_rated scope instead. Each emitted Counter is shown by
default: an explicit index, show, or collection-row projection gains the Counters it does not already select, after
its authored items.
Forms
Create and update inputs map Boolean, date, datetime, decimal, integer, language-code, long-text, short-text,
time-zone, URL, enum, and direct Association controls with exact requiredness, length maximum, option descriptor,
parameter, attribute, and numeric-validation facts. Raw-input integer and decimal validation runs before Active
Record coercion. A time-zone control displays human-readable Rails zone labels while submitting canonical TZInfo
identifiers, preserving an existing admitted identifier through edit. An enum control displays authored value
names and submits stable keys in authored order. Each required emitted destination has an admitted create-time source:
control, binding, a realized Field default, required State Machine initial-state source, a
Counter's counter_cache default of zero, or, only in associated-form context, the exact inverse Reference. An
authored Counter input is dropped and listed without omitting the rest of the form.
Conditional-presence owners remain actual controls in both forms. Derivations are not sources in this release, and
derived controls are omitted. New and associated New select the create form body; Edit selects the update body.
Those same consumers select the form partial, strict locals, dropdown loaders, and form copy. Request-only writes
retain their admitted strong parameters and response behavior. Within one Entity, contexts with identical ordered
controls share a concrete partial after omitting the route-bound inverse. Different controls or order emit separate
partials, selected explicitly by New or Edit and reused on failed submissions. Each partial has only its required
option locals and static error-field mapping; fields have no action or parent-type visibility branches.
Matching accepted parameter names share one <resource>_params method, regardless of control order or action
authorization, and a controller with one helper also names it <resource>_params. Different create and update
allowlists keep create_<resource>_params and update_<resource>_params. A nested controller's helper leaves out the
inverse its URL supplies; a Create action without editable inputs retains no parameter helper.
Index actions build and bind a New-link candidate only for authorization against that record, including its
required preloads. Public and current-Account New links need no candidate; ordinary New-page setup remains.
Model-bound form_with infers the ordinary scope, URL,
and HTTP method. Scoped creates
pass the parent and child models; helper aliases, inflection differences, and singular /account retain explicit
URLs. Forms use novalidate, so Rails' server validation owns every error message.
Each text, number, select, checkbox, and file control renders inside Core's shared/ui/field partial. The partial
owns the fieldset.field wrapper and the control's error list, and it yields the control's aria attributes; the
label and control stay in the form's ERB with Basecoat classes:
<%= render "shared/ui/field", form:, attribute: :name do |aria| %>
<%= form.label :name, class: "label" %>
<%= form.text_field :name, required: true, class: "input w-full", aria: %>
<% end %>
The list's ID is Rails' form.field_id(:name, :errors), and the control's aria-describedby names it only while the
attribute has errors. The date and Reference pickers keep their own partials and use the same ID scheme, and their
enhanced controls add _enhanced to the control's ID, with errors at <control ID>_enhanced_errors. Forms render no
namespace, because no page renders a form twice; the error summary's heading uses form.field_id(:errors).
A date control receives the tightest inclusive ISO min and max from emitted unconditional date-literal
comparisons. Strict bounds move one calendar day inward. Bounds outside the control's four-digit year range,
conditional comparisons, and unsupported comparisons supply no HTML or calendar restriction. The form's provenance
includes the Validation subjects that supply its selected bounds.
The native date input and shared React calendar use those bounds as input guidance; the generated Rails validators
remain the persistence boundary.
Generated locale entries use Rails activerecord.models, activerecord.attributes, and helpers.submit keys.
Ordinary model-bound labels use Rails form.label inference; application helpers.label overrides work normally.
Projection headings and shared controls retain explicit human_attribute_name lookups. References emit the
authored label for both the logical Association and _id attribute; shared controls explicitly select the logical
Association label so controls and errors share that name. A Reference select retains an empty
choice. A searching picker loads only the
current choice; any other loads every option: a direct descriptor uses scalar order and pluck;
tuples pass through unchanged unless boolean/date/datetime labels require localization. A one-hop descriptor
uses one eager-loaded query ordered by the terminal value and target
id, then maps labels and IDs without traversal queries. Only required References add the HTML required
attribute. Protected forms authorize their initialized record before querying options.
Reference option scope
A Reference input lists and accepts only the target records that one Policy connects to the signed-in Account. The
Policy under which the input's own Scaffold displays the same Association decides. Without a single such Policy, the
target's admitted show authorization decides, or its index authorization when the target selects no show; the
screens owner (repository-only) explains the order. When each Account owns its
Places, a Plant form searches the Places that PlacePolicy lets the signed-in Account manage and loads its own:
@plant_place_id_options = authorized_scope(Place.all, type: :active_record_relation, as: :manage, with: PlacePolicy)
.where(id: @plant.place_id).pluck(:name, :id)
After create or update authorizes its record, it assigns each submitted ID through that same relation, so an ID
outside it raises ActiveRecord::RecordNotFound and answers 404, as a hidden record's own URL does. A request-only
create and the Account profile edit check their submitted IDs the same way. A public target read keeps every record
and adds no lookup. A current_account gate adds nothing when the form's own authorization already proves it: the
same gate, or a gate that admits every signed-in Account on a form that requires one: one that authorizes
current_account or whose every Policy branch needs the Account, unlike visible == true or owner == current_account. Any other gate omits the input. A Policy whose other consumers are all gates or create forms still
gains its relation_scope for the input, as UserPolicy gains relation_scope(:read) when Lease pages display their
tenant under user.read. When that Policy is a stub, its scope raises, so the form's pages raise until it is
written. A target that has Policies but no Policy chosen this way omits the input, so the form never lists
records that those Policies might deny. An Account target gets an email field instead: the model keeps the typed
address in an attr_accessor, and create or update assigns User.find_by(email: params[...].to_s.strip.downcase)
when it is present. A required Reference that matches nothing fails belongs_to; an optional one gets a presence
error. A target without Policies keeps every record. Each omitted input is a listed scaffold.input.not_generated
gap, and a required Reference left without a create author follows the
incomplete creation (repository-only) rule.
Server bindings run before persistence. A value read from the Account needs a non-public Account-backed context,
so it never silently turns a public create into an authenticated request. For a guest it reads nil
(current_account&.merchant), and authorization then sends the guest to sign in.
Reference search
Implemented. A picker whose target has a stored text Primary Descriptor, direct or one hop away, searches the
server as the person types instead of loading every record (repository-only)
into the form. The controller that renders the form answers each page's search with an action beside the page's own,
routed as get "/plants/new/place_options", to: "plants#new_place_options", defaults: {format: :json}:
def new_place_options
@plant = Plant.new
@plant.owner = current_account
authorize! @plant, to: :manage?, with: PlantPolicy
pattern = "%#{Place.sanitize_sql_like(params[:q].to_s.delete("\0"))}%"
places = authorized_scope(Place.all, type: :active_record_relation, as: :manage, with: PlacePolicy)
.where(Place.arel_table[:name].matches(pattern))
.order(name: :asc, id: :asc).limit(20)
render json: places.pluck(:name, :id).map { |label, value| {label:, value:} }
end
It runs the page's callbacks and checks, as edit_place_options runs set_plant and the update's, an associated New
page's search its parent's rule and collection gate, and the profile's its sign-in, so a search admits exactly whom
its page admits. It then lists from the scope that the page's select used, which create and update also find each
submitted ID through. It skips verification only when its page does. matches is a case-insensitive ILIKE,
sanitize_sql_like keeps % and _ literal, a NUL byte is dropped, and results stop at 20.
A non-text or system-field descriptor, a redacted or encrypted descriptor Field, whose text a search would log, the legacy public-index renderer, and registration keep the full list. Without JavaScript, a searching select offers only the current choice and a blank, so a New page whose required Reference searches cannot create its record.
Associated creation
An associated-create form is limited to one direct unpredicated inverse has_many, one admitted target create
definition, no conflicting parent binding, and one unambiguous parent/target context. It derives its scoped New
and POST routes without requiring a selected standalone Create. Parent cards and full collection pages link to a
dedicated scoped New page, except for a no-input record. Their create-link authorization
builds and binds a child only when its Policy checks that child; an environment gate checks current_account
directly. Each parent's nested controller, such as Movies::CreditsController with movie_credits_path and
new_movie_credit_path, serves that parent's New and create; the child's own controller keeps only its standalone
actions. Its parent finder loads the parent, and each action authorizes it before building
the child through the parent's Association. Parents whose forms take the same inputs share one partial, such as
credits/_form, which each nested New page renders by that path with parent_record: @movie. A form that only
nested New pages render requires that local and builds form_with model: [parent_record, credit]; one that an Edit
or standalone New page also renders defaults it to nil and compacts the array.
The form and strong parameters omit exactly the route-bound inverse input;
other selected inputs and server bindings remain. Each form request owns its remaining option queries, so separate
parent forms may share target Association inputs without sharing form state. The form infers its POST route from
the parent and child models when that matches the selected helper; aliases and inflection differences use an
explicit compiled URL. Source pointers and form identifiers are not a generated request protocol.
Cancel and default success return to the scoped collection; only an explicit associated return_to overrides
success. A profile collection does not acquire this resource-member context. Predicated collections, indirect
creation, longer return chains, duplicate parent/target contexts, and omitted target definitions remain gaps.
The plain New page uses ordinary Rails navigation and can later be enhanced at the same URL; no modal, inline form,
or new-destination attachment workflow is inferred. Creating a Credit selects an existing Person plus the role.
No-input records
When the parent and the create bindings supply every value, the associated create has no form. The
Foundation Plan pattern shows the authored shape: a Like under a
Post whose Member comes from current_account. Both collection surfaces render
button_to Like.model_name.human, post_likes_path(post) in place of the Add link, behind the same create
permission. No scoped New route, new action, New page, or form partial is emitted for that context unless another
context or a standalone New still needs them. The nested create action redirects a successful request to
the parent's detail page, post_path(@post), unless the create_form authors return_to. A failed request
redirects to the same destination with errors.full_messages.to_sentence in the alert, because there is no form to
render again.
The button becomes a toggle when three conditions hold:
- one
current_accountbinding fills the signed-in Account's Reference, and the route fills the parent's; - an unconditional uniqueness Validation covers exactly that pair of References; and
destroyis selected and its record Policy compares the same Account Reference withcurrent_account.
The parent's show action and its nested controller's index then run one scoped query,
@post.likes.find_by(member: current_account), inside the existing collection permission. They keep the record
only when allowed_to?(:destroy?, record) passes. With a record, the card shows
button_to "Unlike", like_path(@own_like), method: :delete; without one, it shows the create button. The record
takes its Entity's name: a Product's saves keep @own_save, though Rails names that route safe_path. Two
toggles over one Entity on a page fall back to their collections' names, such as @own_follower_link. The undo
label is Un plus the lowercased name for follow, like, pin, star, subscribe, and watch, and Remove <Entity>
otherwise.
It lives in the show page's collections.undo locale entry and in the nested index's own undo. The destroy
request keeps its own lookup, authorization, and destination, so a Plan that wants Unlike to return to the Post
authors destroy.return_to through like.post. Collection rows still carry no edit or delete control; the toggle
belongs to the parent's collection header.
Generated request examples create through the scoped URL, follow the redirect, and expect the page to load; only an
authored return_to is named. A toggle also gets an example that creates, finds the undo form on the parent page,
destroys, finds the create form again, and creates once more. The browser example clicks the same buttons inside the
collection card.
Preload
Public, item-authorized, and gate authorizations remain distinct. A protected collection or member lookup starts
from the exact Policy relation scope when required. Environment gates authorize current_account; projection
gates authorize the parent scaffold record. Relations use ordinary .preload(...) with the nested graph needed
for Policy decisions, controls, and presentation. For example, a Message reply needs both its author for display
and its thread's Conversation members for the read decision. Rails batches those association loads for the
selected records. Denied associated records may enter server memory; allowed_to? still guards their content,
and destination-link and control checks retain their separate decisions.
Action and collection-wide gates authorize before presentation loading. A gate that needs the scaffold record
loads its decision dependencies first. Direct record preloading remains useful after that gate, for an existing
current_account, and for a newly built record; these contexts do not have a relation awaiting materialization.
Preload paths stay relative to the record that owns them. The nested graph avoids repeated lazy loads and batches
traversal queries; authorization scopes, member checks, mutation checks, Ordering, and pagination remain in place.
A full collection page preloads only what its parent's authorization reads, because it renders no parent presentation
beyond the descriptor. A child built through @conversation.messages.build already holds its Conversation when Rails
infers the inverse, so its preload leaves out a path that reaches only the parent or what
the parent's lookup already loaded.
Deeper non-public projection shapes remain omitted pending separate qualification. A representable preload graph
alone does not establish support or alter the reviewed GapSet.
The pinned Rails 8.1.4 preload API accepts nested Hashes and Arrays and batches separate association queries. Action Policy 0.7.6 supplies the record checks and controller API. These APIs preserve the separate choices of which records belong to a relation and which content or actions are authorized for each record.
Navigation
Web main navigation is target-specific. It admits public indexes, current-account-protected entries, explicit
current-account gates, and one authenticated /account destination with optional Policy-controlled profile details.
Selected iOS and Android clients lower this same navigation into native tabs, Account and protected entries
included. The Scaffold slice introduces no target-neutral navigation contract.
Web navigation consumes this exact Scaffold result. Public indexes link directly; item-authorized entries require
a current account; explicit gates call the selected gate rule; and profile links only after the profile rule.
Fixed /account remains separate. Ordinary iOS and Android clients consume their public_only: true navigation
projections, do not inherit profile or protected Web entries, and receive precise partial rows when the Web-only
consequence needs disclosure.
No target-neutral Scaffold navigation projection is invented.
Mutation destinations
Derive the Screens defaults (repository-only) from the emitted interaction. Standalone
New/Edit redirects to the saved record; scoped associated create returns to its collection, and a
no-input one returns to the parent's detail page. Delete controls appear on
record details, and destroy defaults to that record's collection. Without one, it lands on the detail page of the
record's sole parent: the one Entity whose detail page lists this Entity through a required Reference. When the
deleter may not read that parent, or no sole parent exists, destroy lands on the existing home route. The emitted
destroy checks the parent's show authorization with allowed_to? before deleting, unless that check is the Account
gate the destroy already passed. If a preferred form-return detail is unavailable, use its admitted collection,
then home. Profile editing returns to profile_path.
Optional Plan return_to overrides retain their admitted resource meaning. Emit direct route expressions, including
explicit overrides. Resolve a record-dependent destroy destination before deletion. Omitted override routes and
current-location overrides remain gaps; they do not silently become defaults.
Supported redirects consume no submitted destination URL and need no validated_return_to, hidden URL, or
mutation-record placeholder token. Ordinary New/Edit links have no navigation query parameter. Scoped forms
derive their parent only from the URL and preserve its lookup, authorization, inverse assignment, and
validation-error rendering without submitted context fields. Destroy retains its authored record lookup and
authorization; it consumes no parent context. The removed collection-row controls no longer need a separate
contextual-delete helper. Supplied URLs cannot override these server-owned destinations. Referer and session
history play no role. Rails' public url_from remains appropriate if future supported behavior
needs a submitted location; the current slice does not need one.
Failed form-backed creates and updates render the same form with values, errors, and parent context. Successful
form-backed mutations use 303 See Other. A selected standalone create without New or an explicit return uses
empty 201 Created/422 Unprocessable Content responses even when associated forms share its definition. The
scoped invocation retains its form, parent, and redirect. Authored standalone returns retain their redirects
without a form; a request-only update returns empty 422 on failure.
Destroy emits ordinary destroy! and its 303 redirect. A rescue covers a :restrict_with_error or polymorphic
before_destroy refusal, reached directly or through emitted dependent: :destroy Associations:
def destroy
@household.destroy!
redirect_to households_path, status: :see_other,
success: t(".success", descriptor: @household.name.truncate(60))
rescue ActiveRecord::RecordNotDestroyed => error
redirect_to households_path, alert: error.record.errors.full_messages.to_sentence, status: :see_other
end
Only a completed delete reaches its success message. A refusal redirects to the same destination
with the alert alone. error.record is the refusing record. Rails rolls an indirect refusal back without copying the
restricted child's errors onto its parent, so the parent's own errors would leave the alert empty. The review of a
Rails pull request points to exception.record.errors for this. Through
a one-to-one cascade, pinned Rails' has_one calls the child's non-bang destroy and
aborts the parent without an error, so error.record is the requested record with no errors: the refusal keeps every
record, and the alert is empty. The GapSet lists each restriction reached that way.
Save validations, AASM initialization, and database-only restrictions do not add the rescue, and no runtime callback
inspection or broad exception rescue is emitted. A developer adding a destroy restriction later may also need to add
controller feedback. This follows the
direct-route structure of the Rails 8.1.4 scaffold; the
decision (repository-only) records the comparison and retained context.
Success messages
Each generated create, update, and destroy whose success ends in redirect_to sets a Rails flash after status:,
as the Account update does:
redirect_to goal_path(@goal), status: :see_other,
success: t(".success")
Failure branches and head responses keep their current shape. The key follows the
Rails I18n guide's lazy lookup:
<controller_path>.<action>.success, such as goals.create.success, in config/locales/scaffolds.en.yml. A missing
key fails the generated request specs through raise_on_missing_translations. The copy is short and past tense:
"Goal created.", "Goal updated.", and "Goal deleted." Only destroy names the record, and only when the Entity's
primary descriptor is a short text Field on the record itself. It reads that Field through
@habit.title.truncate(60) into "Habit “%{descriptor}” deleted.", because the deleted record is no longer on the
page it lands on.
A one-tap toggle stays silent: its create, and the destroy behind its undo button, redirect without a message, because the button's state already shows the result. A one-tap create that is not a toggle, such as a check-in, does send its message. When one create serves both a toggle parent and a form-backed parent, the toggle branch alone stays silent.
The Delete confirmation keeps its title and adds one static sentence per Entity naming the Associations a delete
removes with the record, such as "Deleting this goal also deletes its habits." The Compiler derives it from the
cascading dependent: Associations that ReferenceDeletion emits, :delete_all and :delete included because they
remove the same rows. It names no individual records. The UI owner describes the
toast that shows these messages.
HTML-only responses
Generated controllers are HTML-only: the shape of the Rails 8.1.4 scaffold without jbuilder,
with no respond_to blocks and no .json.jbuilder views. No Plan can request a JSON surface, so applications do
not ship the jbuilder gem either; Rails frameworks explains why. Other
formats get ordinary Rails answers:
- A JSON read of an HTML action, such as
GET /goals.json, answers 406 Not Acceptable, because the action has only an HTML template. - A JSON write runs the HTML action once it passes Rails' CSRF check, which answers 422 without the session's token.
A successful write redirects as it does for HTML. A failed validation renders the action's HTML template, which
has no JSON variant, so
ActionView::MissingTemplateanswers 500. - A Policy that denies a request in another format answers an empty 403. This includes an Account gate such as
authorize! current_account, to: :use_app?.ApplicationControllerrenders the forbidden page or redirects to sign-in only for HTML and Turbo Stream requests. A record that the Policy scope hides answers 404 in every format. - The Account page's
authenticatecallback is Rodauth'srequire_account, which redirects a guest to/loginin every format. ErrorsControlleranswers a JSON request with a JSON body and the error's status, as error pages describes.RodauthControlleranswers other formats with 406.
The Compiler adds no application-wide format restriction, which would answer the Policy denials above with 406
instead of 403. An owner who adds an API can run bundle add jbuilder, which also brings back its scaffold
generator, or use render json: as the Rails API guide does.
Generated source
Local names
Generated resource vocabulary stays conventional. The Compiler preserves authored public model, controller, route,
instance-variable, parameter-scope, helper, and table projections; it does not globally rename every record to a
the_... form.
Public index record scopes and one-hop Reference-option maps allocate their compiler-private bindings. The mutation
index declares its unparenthesized link_to helper; its remaining helpers are parenthesized or use _path-suffixed
names that cannot equal the unsuffixed record binding. A nested controller's parent keeps its own name,
such as @goal from set_goal; a parent of the child's own Entity becomes @parent_comment. Other generated
Scaffold scopes use fixed compiler-private locals.
Generalized Scaffold output uses @cases.each do |case_record| for reserved case, preserving public Case
vocabulary. The retained legacy public-index and private Policy-gate allocator lets an authored-derived local win
unless it is a target-profile Ruby keyword or collides in scope; later helpers move only when needed. There, Movie
uses movie, Case uses the_case, and a helper already named the_case becomes the_the_case. An Entity named
Descriptor may keep descriptor while a same-scope display helper becomes the_descriptor. The mutation index
reserves link_to, so Entity Link To uses the_link_to there while retaining public LinkTo, /link_tos, and
link_to_path. The current Analyzer still blocks generated constants colliding with Class, Module, or Thread
as an implementation exception; the target rule treats them as gaps and omits or partially generates affected
output unless no bootable residual exists. The Rails profile owns the exact keyword
set and allocation rule.
Redirects and Cancel links use route expressions rather than shared return_to or cancel_to instance variables.
A form partial takes its record under the Entity's own name, as the Rails scaffold does: posts/new renders
render "form", post: @post, cancel_to: posts_path, and _form declares <%# locals: (post:, cancel_to:) %>. The
same allocator protects that local: it gains the_ when the name is a Ruby keyword or one of the form's other names
(aria, cancel_to, form, form_url, or parent_record), so Case forms take the_case:. Scaffold, Account,
and the retained public-index forms share this rule, as does the Account page's registration_details partial.
The renderer emits ordinary inspectable Rails source from the immutable admitted projection. Resource routes use the
exact helper, path, controller, action, and parameter names that target qualification checked. Singular profile
routes are separate from resources; omitted or incoherent route and definition pairs do not enter task discovery
and remain listed gaps.
Projections and display
A projection recursively renders admitted Fields and Associations in authored order. A singular Association without children displays its qualified target Primary Descriptor. Root show and profile collections receive their own scoped query for direct Associations and the admitted indirect shape. A singular item immediately inside a collection may retain its own item Policy. Collection projections on indexes or below another Association projection, other indirect query shapes, and deeper non-public presentation remain precise omissions.
Date and datetime values use Rails localization with the standard :long format, including the year in the default
English locale. Applications can override the locale's date and time formats. A URL property links to its address in
a new tab, as URLs describes; a URL Primary Descriptor still links to its record. False remains
“No” and zero remains 0.
A required Field renders plainly: <%= l(event.starts_at, format: :long) %>, number_to_currency(order.total), or
a Yes/No ternary. Only a nullable Field keeps missing-value copy, in a (event.ends_at).then { |display_value| … }
block that shows t("shared.values.missing") for nil. Reference option labels follow the same rule; their Primary
Descriptors are always required.
Enum labels go through one helper, enum_label(post, :status), which the Compiler adds to ApplicationHelper, with a
spec, only when a generated page shows an enum. It reads the enums.<model>.<field>.<key> entry with I18n.t
rather than the t view helper, so a value key ending in _html still renders as escaped text. No enum translation
library or model wrapper is generated. A State Machine whose model declares its AASM behavior shows
post.aasm(:status).human_state, which reads the authored State name from AASM 6.0.0's translation
keys,
activerecord.attributes.<model>.<field>/<state>, and falls back to a humanized state name instead of raising. A
storage-only State Machine, such as an optional one, has no AASM machine for its Field, so its view
reads the same key with I18n.t. The scalar Field label stays beside those keys, so the attribute name and state
names can both be translated without a nested-key collision. A nullable enum or State Machine Field checks nil?
before any of these calls.
Details and new/edit forms use the index card width. A details card has one h1, then right-aligned ghost actions
with matching icons: an explicitly named index destination such as “All movies”, Edit, and Delete. Native requests
omit the web index-return control. A card without any of these actions has no toolbar, and a collection card without
an Add or one-tap control has no action list. The scalar primary descriptor is not repeated below its title. Other
properties use one-column dt/dd markup without attribute dividers; top-level associated collections occupy
sibling cards.
Form action buttons use the stock large preset. A standard Tailwind container query stacks full-width form actions
in narrow cards and uses natural-width actions at the named @md breakpoint; fields and actions have no divider.
An authored Association projection contributes a details destination only when its target show route and exact authorization survive admission. Its descriptor is an ordinary link; selected child properties may carry a View control. A row whose projection omits its own descriptor keeps a separately authorized View control for its own show route. This keeps multiple authored Association destinations usable without nested links or choosing one destination for the entire row. The renderer never invents an unauthored reverse Association or removes explicit join properties.
Rails collection rendering composes entity-owned row partials. An index uses, for example, credits/_list_item;
a matching associated projection reuses it. A parent's own projection keeps the Entity's name in the nested resource's
directory, such as movies/credits/_credit and people/credits/_credit, shared by that parent's preview and full
collection page. Both name it by its root-qualified path, as Campfire's account page renders
accounts/users/_user and Herb's actionview-no-implicit-partial rule asks.
render @credits would find the file only from the nested controller, and only when the segment is the table name;
a Unit's requests collection lists Maintenance requests. A show page composes its main record
partial, such as movies/_movie, and sibling collection cards. Only selected presentations are emitted. Strict locals
name the record and its presentation options; DOM IDs distinguish a main record from its rows in each collection.
This provides reusable Rails and Turbo targets without adding automatic live updates.
Inputs and copy
Create and update accept only admitted controls and strong parameters. An enum select and every admitted read-only
enum projection, through enum_label, look up the same enums.<model>.<field>.<key> entry in
config/locales/scaffolds.en.yml. The
generated English catalog preserves each authored value name exactly; select options retain authored key order and the
empty choice. Editing that locale or adding another Rails locale changes both displays without recompiling the Plan.
Submitted and stored values remain stable keys. Enum admission and State Machine presentation are unchanged.
Screen, action, form-submit, and pagination copy follows the emitted pages and controls. Model, attribute,
Reference, State, and error labels remain available, as do translations used by retained Core components. Enum
labels, the Yes/No and “Not set” value copy, and the navigation label are written only where an emitted page, form,
controller, or helper reads them. With an Account, authentication.en.yml labels the navigation beside its sign-in
links. An Edit form can also remove a stored optional upload.
Account edit/form/update copy follows the selected editable self surface. A custom profile's Association View and
collection labels follow those rendered projections.
Server bindings run after submitted inputs; current_account is valid only for an admitted Account Reference.
Associated create builds through the authorized parent's collection, applies bindings, authorizes that initialized
record, and then saves it. The scoped form omits a selected inverse-parent input and excludes its submitted value;
standalone and other-parent forms retain their remaining inputs. A create whose context accepts no editable input
calls ordinary build or new without a parameter helper, so a normal submission need not include a model
parameter; each controller decides this for its own create. A server binding cannot repeat the parent assignment.
Static collection/profile returns carry no record. A mutation-record show may use no step or one required Reference step; the latter uses its foreign key without loading an unneeded record. Destroy cannot return to the deleted record's own show.
Controller callbacks
Entity controllers follow the Rails 8.1.4 scaffold: callbacks first, then public actions, then
private methods. Oscar Party's MoviesController opens with:
before_action :authorize_manage_movie_catalog, only: %i[index new create edit update destroy]
before_action :set_movie, only: %i[show edit update destroy]
verify_authorized
The private set_movie runs @movie = Movie.find(params.expect(:id)). A member finder appears when two or more of
show, edit, update, and destroy run the identical lookup, Policy scope and preloads included. Its only: list names
exactly those actions in scaffold order. An action whose lookup differs keeps its own, as the public show beside
scoped writes does in the Rails Wishlists guide. A show that preloads presentation data the writes
do not read also keeps its own lookup, so edit, update, and destroy load nothing they ignore. Route IDs read
params.expect, as the scaffold does. Record authorization stays in each action. The finder only looks the record up,
so a record outside the Policy scope still answers 404 before any authorize! runs.
An Account gate such as authorize! current_account, to: :manage_movie_catalog? becomes a callback named for its
rule. It is declared before any finder, so it still runs before the lookup it guards, and it covers every action that
opens with that gate. A gate that follows a lookup, such as a collection gate, stays in its action.
A nested controller, such as Goals::HabitsController, loads its parent for every action in before_action :set_goal from params.expect(:goal_id), after any Account gate its parent or create opens with. The lookup goes
through the parent's Policy scope when its show page's does, and each action then authorizes the parent as that page
does. A parent whose show page is public or gated keeps that page's unscoped find, and its gate runs as there.
index reads the collection, and new and create call @goal.habits.build.
The Account profile's AccountsController declares before_action :set_account after before_action :authenticate,
as the editable Account page does, and so does each profile collection's controller, such as
Accounts::LeasesController. Generated applications set raise_on_missing_callback_actions, so each only:
list names emitted actions; a callback that covers every action omits only:.
Authorization in generated source
Every protected mutation authorizes before writing. Generated controls repeat the action's consumer rule.
Account always resolves current_account. The Account link and credential forms require authentication.
Remaining target gaps include unsupported Field/control kinds, longer or optional return traversals, broader relationship query plans, richer formatting, state-machine controls, and deeper protected nested descendants. The reviewed GapSet fixtures hold the exact representative matrix.
Code and checks
A separate narrow browser-only Movie Catalog qualification uses a one-hop Association Primary Descriptor for Reference
inputs. Its option query eager-loads that hop, orders by the terminal descriptor and target id, and maps label and
ID pairs from one result query. The target Entity's public form selects the descriptor Association explicitly. The
script materializes that application, verifies its exact manifest, migrates and schema-loads fresh databases,
and repeats the same request and persistence checks after both setup paths. It proves create with the required
Director and either no Consulting Director or a selected Consulting Director; nil-to-target, target-to-another-target,
and target-to-nil Consulting Director updates; logical conditional-presence errors and corrective resubmission;
required and optional HTML requiredness; one-query Association-descriptor option ordering, strict eager loading,
zero-query cached label reads, deterministic descriptor options, and no option query during successful writes. The
generated forms do not expose unselected attributes. Model-invalid writes render logical errors with 422; valid
writes persist only admitted inputs and redirect with 303 to the selected same-Entity index or mutation-record show.
A submitted non-nil optional Reference ID whose target no longer exists can still reach the database foreign key and
raise; friendly stale-option handling remains later work. Index titles reach the detail page. That page renders the
independent Movie descriptor followed by notes and title in authored order, preserves multiline text, escapes authored
HTML, omits unselected data, and includes back and edit links. Ordinary missing records return HTTP 404; index/edit
links and forms carry no return URL. The former unsafe destination 400 checks were retired because the controller no
longer consumes a submitted location. The separate redirect-default
smoke (repository-only) checks that an extra submitted URL cannot change the
server-owned destination. The generated iPhone project remains outside this browser-only show proof.