Rails Field lowering
Status: Implemented for ten scalar kinds, enums, Counters, and attachment and
image uploads. Money, position, secure token, and JSON Fields are implemented with generator-parity storage and
partial GapSet entries. Other Field kinds and modifiers are evidence-backed directions.
Each emitted scalar, enum, or Counter Field becomes one PostgreSQL column plus the model declarations that Rails
needs to read, write, validate, and display it. The Compiler emits ten ordinary scalar kinds, qualifying enums,
Counters, maintained by Rails counter_cache where it can, and uploads that Active
Storage records instead of a Domain column. For money, position, secure token, and JSON Fields it emits what
rails generate scaffold or a developer writing by hand would produce, and lists the kind's missing meaning as a
foundation_plan.gap.field_kind.partially_generated record. That record says the Field is entered or displayed only
where a generated form or page carries it. A Primary Descriptor that selects one of these Fields stays a
foundation_plan.gap.primary_descriptor.not_generated record. Other Field kinds are omitted and listed in
the reviewed GapSet. Modifiers lower one by one: immutability becomes attr_readonly, log redaction filters the
attribute, normalization becomes Rails normalizes, and case-insensitive comparison becomes citext. A modifier
the target cannot realize is listed on its own. Encryption and derived numbers remain directions. The
Field catalog states what each kind and modifier means, and the
support inventory (repository-only) records which kinds generate.
On this page
- Realized defaults and modifiers
- Defaults
- Required enum Fields
- Optional enum Fields
- Money
- Positions
- Secure tokens
- Immutable Fields
- Sensitive Fields
- Text normalization
- Text comparison
- Counters
- JSON values
- URLs
- Attachments
- Derived numeric values
Realized defaults and modifiers
Defaults are realized for compatible literals on emitted boolean, integer, decimal, money, short-text, and
long-text Fields, exact current_date and current_time on required date and datetime Fields, and in-domain literal
keys on admitted enums. On emitted ordinary scalar and admitted enum Fields, log redaction adds the attribute to
self.filter_attributes and registers only the model-qualified request parameter. The same key under another model
remains visible; arbitrary application, provider, and external logs are outside this boundary. Authored
normalizations are realized only on emitted scalar Fields. Immutability is realized on emitted scalar Fields and
admitted enum Fields; the encompassing gap for an unsupported Field shape covers that Field and its authored
modifiers.
Defaults
Support: see the support inventory (repository-only) rows for Field defaults.
A literal default on an emitted boolean, integer, decimal, money, short-text, or long-text Field becomes an ordinary
migration column default, such as t.boolean "finished", default: false, null: false. The canonical schema carries
the same default, and Active Record reads it from the column, so a new model exposes it before persistence. An
explicitly supplied value wins, including false, 0, and nil on an optional Field. The Analyzer's literal typing
decides compatibility. The target then adds its storage bounds: an integer must fit PostgreSQL integer, a decimal
must fit unconstrained numeric, and money must fit its numeric(12, 2) column. Rails' schema dumper writes a
decimal default with a fractional part, so "10" appears as default: "10.0", on money as on decimal. A column
default bypasses Rails normalizes, so a text literal that the Field's authored normalizations would change stays a
gap rather than storing an unnormalized value. Direct SQL inserts also receive the database default. A null literal
and literals on other Field kinds, including JSON, remain foundation_plan.gap.field_modifier.default records;
position and secure token Fields take no default.
The exact { "kind": "environment", "name": "current_time" } default on an emitted required datetime Field lowers
to a model-visible attribute :published_at, default: -> { Time.current } declaration, and current_date on a
required date Field to attribute :starts_on, default: -> { Date.current }. Omitting a type preserves the
schema-derived column type, including PostgreSQL timestamp(6) precision. Active Record materializes the callable
on a new model before ordinary presence validation; an explicit nil remains invalid and an explicitly supplied value
remains unchanged. The Domain migration and canonical schema retain NOT NULL with no database default, so direct
and bulk inserts must supply the value. The callable follows Active Record's new-record attribute lifecycle rather
than database-commit time. Optional environment defaults remain gaps. The separate enum rule below owns its
literal-key defaults.
Required enum Fields
Support: see the support inventory row (repository-only).
The current Compiler admits a required enum with its canonical nonempty ordered domain. The reviewed Plans contribute
Oscar Party's ordinal bookmark.priority and non-ordinal credit.role; Case Chat's case.status,
participant.role, participant.notification_level, and notification.kind; and Photogram's
feed_occurrence.reason. Each emits one PostgreSQL string column with null: false and an explicit stable-key
Rails enum mapping with validate: true and collision-aware native helper names, plus ordinary presence validation
for the required Field and generated schema-quality check. The target admits that enum only when Rails' pluralized
class mapping method is safe in both the frozen profile and selected dependencies, unique among sibling enums, and
distinct from authored Predicate and Ordering scope names. A collision omits the Field and records the exact
foundation_plan.gap.field_kind.not_generated reason. It emits no native PostgreSQL enum or database CHECK. Direct
SQL and validation-bypassing writes such as update_all therefore remain outside membership proof. Any admitted
required enum accepts a valid in-domain literal-key default. Oscar's bookmark.priority emits default: "normal";
Case Chat's case.status and participant.notification_level emit default: "open" and default: "all_activity" in
the model declaration, migration, and schema. Active Record materializes the model default, while an explicit
compatible key wins. A nonliteral default or a literal outside the closed domain remains a precise
foundation_plan.gap.field_modifier.default record. An enum Field the Domain does not emit keeps its encompassing
Field gap. Enums rejected by the mapping-name rule above use that same encompassing Field gap. Outside the descending
ordinal pattern in Named Orderings, other enum Primary Descriptors, Orderings,
Field-owned enum Validations, and ordinal rank comparisons likewise remain ungenerated and are disclosed through the
reviewed GapSet. Admitted enum Field projections and form labels use the same enums.<model>.<field>.<key> Rails I18n
entries seeded from authored names. Form values remain stable keys in authored order; display labels do not imply
ordinal rank semantics. Native helper naming follows the model helper allocation
rules. Admitted storage feeds the existing generic local ExpressionSql rule, but an ordinal comparison lowers only
when it does not depend on authored rank. An independently admitted predicated direct Association may consume a
non-ordinal scope under the relationship rule's existing gates; the reviewed Plans add no Association consumer.
An admitted required enum also emits an ordinary string column with null: false. Its model uses an explicit
stable-key Rails enum mapping with validate: true and collision-aware native helper names, plus ordinary presence
validation. The explicit presence validator lets the generated schema-quality check recognize the required Field;
active_record_doctor 2.0.1 misses Rails 8.1's string-keyed enum inclusion validator. Admission requires the plural
mapping name to be target-safe, unique among sibling enums, and distinct from authored Predicate and Ordering scope
names. It emits no native PostgreSQL enum or database CHECK; raw SQL and validation-bypassing writes remain outside
the membership proof. Every admitted required enum accepts an in-domain literal-key default. Oscar's
bookmark.priority emits default: "normal"; Case Chat's case.status and participant.notification_level emit
default: "open" and default: "all_activity" in the model declaration, migration, and schema. Active Record
materializes the model default on a new record, while an explicit compatible key wins. Nonliteral and out-of-domain
defaults remain exact modifier gaps. An enum Field the Domain does not emit keeps its encompassing Field gap. This
includes a required enum whose plural mapping name conflicts with the target class namespace, a sibling enum, or an
authored Predicate or Ordering scope. Outside the descending ordinal Ordering pattern, Field-owned enum Validations,
descriptors, other Orderings, and ordinal rank comparisons remain unsupported or omitted. A required
non-ordinal enum may participate only as the Field member in Credit's exact admitted Entity-owned uniqueness tuple.
Admitted enum Field projections and form option labels share Rails I18n entries seeded from authored value names.
Forms submit stable keys in authored order. Editable display labels do not change stored keys or imply ordinal rank
semantics. The ordinary attribute may feed the existing generic local Predicate rule and, after independent admission,
the existing predicated direct Association rule. The reviewed Plans add two non-ordinal Predicate scopes and no
Association consumer. On an admitted enum, the retained plural mapping method and collision-aware native value
helpers are target realization details and add no GapSet record.
The model provenance includes the Field and every contributing FieldValue; migration and schema provenance need only the Field because their bytes do not encode membership.
Optional enum Fields
Support: see the support inventory row (repository-only).
An optional enum uses the same mapping, naming, collision, and literal-default rules as a required one. Its string
column is nullable, and its declaration uses Rails' own nil-permitting validation from the pinned
enum source:
enum :failure_category, {"temporary" => "temporary", "invalid_input" => "invalid_input"},
validate: {allow_nil: true}
No presence validation is added. Forms keep the blank select option that every enum select already offers, and Active Record's enum type stores a submitted blank as nil. Read-only projections show the shared missing-value text for nil instead of looking up an enum label. Validation conditions and Orderings over an optional enum stay outside the admitted shapes listed above.
Money
Support: see the support inventory row (repository-only).
The owner chose generator-parity storage for this release instead of the documented money-rails direction. A money
Field becomes t.decimal precision: 12, scale: 2, a number_field with step: 0.01, and number_to_currency on
show and list pages. Its numericality: {greater_than: -10**10, less_than: 10**10} validation keeps amounts within
what that column stores, so an oversized amount is a form error instead of a failed save; ordinary numeric
validation explains the bound. A USD Field uses the locale's currency
format; another fixed currency is shown with its code. BigDecimal storage keeps amounts exact, but the generated app
has no Money object. Currency-aware arithmetic, allocation, and rounding remain the Field's partial gap, and a money
Field's own comparison Validations remain Validation gaps because decimal literal comparisons are not admitted.
Positions
Support: see the support inventory row (repository-only).
A position Field becomes a required integer with integer numericality validation and a number input when a
Scaffold lists it. Import records its settings.within terms. When a new record arrives without a position, the
model appends it to the end of its partition:
before_validation on: :create, if: -> { position_before_type_cast.nil? } do
self.position = (self.class.where(post_id:).maximum(:position) || 0) + 1
end
The callback lets a create form omit the position, as most Plans do. A save with validate: false skips it, so that
caller supplies the position. An admitted Ordering may sort by the column. Contiguous renumbering after deletes,
moves between partitions, concurrent appends, and drag reordering remain the Field's partial gap; the positioning
gem stays the direction for that work.
Secure tokens
Support: see the support inventory row (repository-only).
A secure token Field becomes a string column with a unique index and Rails
has_secure_token, which fills the token when the record is initialized. It is never a form
input or strong parameter. Show and list pages display it where the Plan projects it. A required token still gets
presence validation, and an immutable token joins attr_readonly. Factories and development records leave it to
Rails. Lookup by token, regeneration, expiry, and digest storage remain the Field's partial gap. Account
registration coverage for a token on the Account Entity is owned by the Account lowering.
Immutable Fields
An emitted scalar or admitted enum Field with immutable: true is included in the model's attr_readonly
declaration. Creation may assign the value or use any realized default. After persistence, ordinary
changed, same-value, and nil assignments raise ActiveRecord::ReadonlyAttributeError under the pinned Rails 8.1
defaults. This is model-level write-once behavior, not a database trigger or a fence against direct SQL. Current
generated-app proof covers case.reference_number, case.opened_at, and post.published_at, plus immutable
notification.kind and feed_occurrence.reason; a modifier on an ungenerated Field kind remains a target-support
gap.
An emitted scalar or admitted enum Field with immutable: true joins that model's attr_readonly declaration. Its
subject UUID joins the generated model's provenance. Creation still accepts an explicit value or a realized default.
After persistence, ordinary changed, same-value, and nil assignments raise ActiveRecord::ReadonlyAttributeError.
Like immutable Reference enforcement, this is a model-level write-once boundary rather than a database trigger or a
fence against direct SQL. An immutable modifier on a Field shape the Domain does not emit remains in the reviewed
GapSet.
Sensitive Fields
Use Active Record Encryption as the first direction for encrypted_at_rest. On emitted ordinary scalar and admitted
required enum Fields, redact_from_logs appends the attribute to self.filter_attributes and registers the
model-qualified request parameter. Case Chat filters notification.deduplication_key; the same key under another
model remains visible. This boundary does not encrypt the value or cover arbitrary application, provider, or external
logs. The modifier remains a reviewed gap on every other or unemitted Field. A Predicate, uniqueness rule, or future
lookup that reads an encrypted Field still requires a supported query realization rather than silently enabling
deterministic encryption.
Text normalization
Lower an explicit Field pipeline with Rails normalizes, grouping Fields with identical pipelines
under one readable inline lambda. The Field catalog owns
character semantics and the cleanup-before-blank admission rule. Use the public
StripAttributes.strip(value) formatter for adjacent trim, blank_to_null; trim alone passes
allow_empty: true. The generated dependency contributor pins strip_attributes to 2.0.1 only when a retained
normalization pipeline includes trim. Do not install its model callback. Native Rails value.squish realizes
collapse_whitespace, and Rails value.presence realizes blank_to_null. Append presence only when authored. Ruby
String#downcase supplies the full Unicode lowercase mapping. Preserve operation order and nil, including after an
intermediate blank-to-null result. No type-wide default or application formatter wrapper is added. Loading
StripAttributes adds its strip_attributes method to every model. Reserve that name only when retained trim actually
selects the dependency. If colliding Fields are the only trim selectors, retain their storage and decline each
complete normalization pipeline with the existing modifier gap. Otherwise, independently retained trim makes the
collision real: omit the affected member and dependent consumers through ordinary gaps. Native enum prefixes and AASM
namespaces resolve generated convenience-method collisions where available, and a Predicate or Ordering named
strip_attributes keeps its scope under the key plus the table name, as Scopes describes.
The collision qualification (repository-only) records the
library behavior, selection-cycle rationale, and runtime boundary. An authored Entity whose model constant is
StripAttributes makes the formatter unavailable application-wide. Preserve that Entity and its storage, omit every
complete trim-containing pipeline with a normalization modifier gap, and select no formatter gem. Rails-only pipelines
remain eligible. This handles the actual optional-module collision without reserving the Entity name in applications
that do not need the formatter. Trim removes Unicode whitespace and six additional invisible edge characters while
preserving NUL. Collapse preserves those invisibles but removes edge NUL; both retain interior joiners. These policies
intentionally differ. Ordinary scalar hash equality receives normalization and handles an operand that normalizes to
nil. Typed Arel casts non-null normalized values, but its null operator and hash-array membership are selected before
normalization. Generated Arel, array, untyped, and raw-SQL consumers must call Model.normalize_value_for first and
branch on nil.
Text comparison
The current Compiler lowers an emitted short_text Field with case_insensitive comparison to PostgreSQL
citext. One conditional migration installs the extension before every Domain table, and
db/schema.rb declares both the extension and t.citext. Entered case remains stored unless an authored
normalization changes it, while ordinary Active Record hashes, typed Arel equality, ordering, and B-tree indexes
compare case-insensitively. The generated-app qualification verifies that the extension's schema is on the current
search_path. Its pattern operators and several string functions are also case-insensitive; cast to text for a
deliberate exact operation. Pin or verify a supported database LC_CTYPE, or disclose it as an external prerequisite,
because that creation-time setting controls case folding. The bounded uniqueness slice inherits each admitted tuple
member's storage equality, including compound mixtures of citext, exact short_text, date, and foreign-key members.
Entity and Field comparison Validations with Field operands likewise require compatible comparison semantics rather
than relying on PostgreSQL's mixed citext/text resolution. Exclusion must inherit the Field's comparison behavior
instead of Ruby String membership accidentally becoming case-sensitive. Field-to-Field Expressions require the same
compatible comparison semantics before lowering. Nondeterministic ICU collations remain a future profile strategy if
representative multilingual identifiers require more complete Unicode case behavior.
Counters
Support: see the support inventory row (repository-only).
A Counter becomes an integer column with default: 0, null: false, maintained by Rails' built-in
counter_cache on the child's belongs_to. Photogram's post.likes_count counts
post.likes, whose Reference is like.post, so the Like model declares belongs_to :post, counter_cache: true.
When the Counter's key is not Rails' default <children>_count name, the option names the column, as in
belongs_to :followed, class_name: "User", counter_cache: :followers_count. Rails documents the
has_many :counter_cache option for exactly that custom-name case, so the emitted
has_many for the counted Association names the same column. Its size then reads the cache, and Rails knows
that the child's belongs_to already maintains the count.
The counted Association must be a direct, unpredicated, referenced-side Association over an ordinary single-target
Reference, and only one Counter may count through each Reference. A polymorphic
Reference works too when every target counts it in a column of the same
name: Rails updates that column on whichever target a record references. A Like whose target is a Post or a Comment
declares belongs_to :target, polymorphic: true, counter_cache: true when both count their likes in likes_count.
An exclusive arc declares one belongs_to per target, so each target's
belongs_to carries the counter_cache: of the Counter that counts it, as Dunbar150's post.likes_count and
comment.likes_count do. Generated-model checks create and destroy Likes on both targets for both realizations.
Every other Counter keeps its column, its projections, Orderings, and Predicates, and reads 0 until application code
maintains it. A Policy that reads it, directly or through a Predicate, would decide access from that 0, so it becomes
a Policy stub until the counting is written. Its maintenance is left out behind a
model example that creates one counted record and expects the column to read 1; the README lists it under Behavior to
write, and the GapSet lists it as partially generated with its reason. The reason for an indirect count names the
direct Association it goes through, because counting those join records gives the same number when each joined pair is
unique; its example creates one join record with both of its References set. A count whose example needs a Reference
the Compiler does not emit, or setup that breaks a conditional Validation, is listed under Test coverage to complete
instead. A filtered count needs a scoped maintenance path that counter_cache does not offer, so its example, which
cannot know which records the Predicate selects, is listed under Test coverage to complete instead. An earlier release
omitted the column and everything that read it.
Rails owns the value. A Counter is never a form input, permitted parameter, factory attribute, or development seed
value. An authored Counter input is dropped and listed in the GapSet while the rest of the form is kept. Explicit
index, show, and collection-row projections display each emitted Counter they do not already select, after the
authored items. An omitted projection keeps its descriptor-only presentation. A counter-backed Ordering follows the
ordinary Ordering lowering; Photogram's post.most_liked becomes a scope and its
composite index.
Rails 8.1 increments on create, moves the count when the foreign key changes, and decrements when it destroys the
child row. It skips the decrement on the parent that is itself being destroyed. A cascading deletion into a child that
carries a counter_cache therefore uses dependent: :destroy, so destroying a User decrements the like counts on
other Users' posts. Foreign keys stay NO ACTION, so no database cascade can bypass that path. The pinned
active_record_doctor does not see the decrement as a destroy callback, so the generated configuration lists those
Associations under incorrect_dependent_option rather than taking its delete_all suggestion.
Bulk writes such as delete_all, update_all, insert_all, and raw SQL skip the callbacks and leave counts stale;
Rails' reset_counters repairs one record. Generated repair tasks and drift detection remain deferred, with
Counter Culture still the audited candidate for that work.
JSON values
Support: see the support inventory row (repository-only).
The semantic json Field lowers to PostgreSQL jsonb, which PostgreSQL recommends for most applications and Active
Record maps to ordinary Ruby values. There is no json versus jsonb Plan choice. Keep stable application facts as
Fields, References, or Entities. If exact source bytes matter, store the raw input separately from the parsed JSON
value.
A form edits the document as JSON text in a textarea. Active Record's JSON type would store submitted text as a JSON
string, so the model writer parses a String and reports malformed text as an ordinary invalid error on the
Field. Show pages print the document with JSON.pretty_generate. Document structure and schema validation remain
the Field's partial gap.
Rich text
Support: see the support inventory row (repository-only).
Action Text is the direction for rich_text, and it needs owner sign-off because it adds framework-owned records,
direct uploads, and attachment authorization. Until then the Field lowers at generator parity with long_text: a
text column, a textarea, a presence validation when required, and plain-text display. Formatting, embedded
attachments, and sanitized HTML display remain the Field's partial gap.
URLs
Support: see the support inventory row (repository-only).
A url Field is a string column with a url_field input. Rails has no URL validator, and the Rails Guides document
a custom EachValidator as the extension point, so a Plan with a url Field gets
app/validators/url_validator.rb, as Mastodon has. It parses the value with Ruby's standard
URI.parse and adds a :url error unless the result is an http or https URI with a host. URI::InvalidURIError
is its parse-failure signal, so it rescues that error. The locale adds errors.messages.url: "is not a valid URL".
The validate_url gem that Forem uses was set aside: its last release, 1.0.15, was in May 2022.
The model declares validates :website, url: true and leaves a blank value to requiredness, as format and length
declarations leave nil: a blank required Field reports only "can't be blank", and a blank optional Field is valid. A
browser submits an empty URL input as an empty string, so the declaration adds allow_blank: true, as Mastodon's
status_page_url and Forem's profile website_url do. A Field whose
normalization includes blank_to_null stores nil for a blank and adds allow_nil: true instead. Mastodon's
Webhook pairs presence: true with a bare url: true, which reports both errors for a blank
address.
The validator accepts https://example.com, http://a.b/c?x=1, and http://localhost:3000. It rejects https://,
javascript:alert(1), example.com, ftp://x.y, a space in the host, and a raw Unicode host such as
https://bücher.example. Mastodon parses with Addressable and accepts that host; the standard library does not. An
owner who needs internationalized hosts can switch the validator to Addressable.
Only http and https addresses with a host can be stored, so a URL shown as a property on show pages, list rows,
public pages, and the Account page renders as link_to(value, value, target: "_blank", rel: "noopener nofollow ugc"). A blank optional value shows shared.values.missing. A URL Field chosen as the Primary Descriptor stays the
page heading and the link to its record's own page. Mastodon's link formatter, Forem's profile
header, and Discourse's user profile each open a user-entered address in a new tab
with noopener; Mastodon and Discourse add nofollow, and Forem and Discourse add ugc for untrusted users. Hotwire
Native opens the link in an in-app browser whether or not it targets a new window: iOS routes a new-window or other
external link to SFSafariViewController through its new-window
policy, and Android routes onCreateWindow to a Custom Tab.
A model example per Field checks an address, a blank value, and one rejected value against the :url message, and
spec/validators/url_validator_spec.rb checks the validator's accepted and rejected values. Factories and request
samples use https://example.com. A development seed keeps a URL literal only when the validator would accept it;
otherwise it omits the value and lists the omission. An Entity whose model constant is UrlValidator would collide
with the validator, and no Analyzer check covers that name yet.
Attachments
Support: see the support inventory row (repository-only).
Active Storage is the first-profile lowering for explicit uploaded Fields and Action Text embeds. These are profile consequences rather than per-Field choices. Shrine remains deferred until a demonstrated requirement such as resumable uploads justifies a second profile. See the attachment direction (repository-only).
An attachment or image Field lowers as the Rails attachment generator type would:
- Model:
has_one_attached :file, plusvalidates :file, presence: truefor a required Field andvalidates :file, image: truefor an image Field, which Image content type describes. No Domain column; the Active Storage migration and its UUID-keyed tables join the schema. They keep the framework's timestamp shape, so the generated active_record_doctor and Standard configuration exempt them. A parent whose deletion removes these records destroys each one, so Active Storage purges its file. - Scaffolds: an input renders
form.file_field(images addaccept: "image/*") and joins strong parameters. A stored file satisfies a required Field, so edit forms do not demand a new one. A projection renders an image withuploaded_image_tag(large on show pages, a thumbnail in list rows) or arails_blob_pathdownload link. Projections preload{file_attachment: :blob}. - Removal: an empty file picker keeps the stored file, and Rails has no removal flag of its own. An Edit form
that takes an optional upload therefore shows a “Remove File” checkbox once a file is stored, which it checks with
file_attachment.present?:file.attached?also counts a file chosen in a submission that failed validation, and a form that New shares also checkspersisted?. The checkbox submitsremove_file, the name CarrierWave's checkbox and Shrine'sremove_attachmentplugin use. The model declaresattribute :remove_file, :boolean, default: false, so an unchecked box changes nothing, andbefore_save { self.file = nil if remove_file? }. Assigningnilis Active Storage's own removal: the save detaches the file, and its blob is purged in a job after the transaction commits. A checked box wins over a new upload that passes validation, and a form that fails validation keeps the stored file and the checked box. The label reads “Remove” and the Field name. When another member of the Entity, such as a Field, an enum value, or a State Machine state or event, could define a method the attribute would define, the form offers no removal. - Image delivery: image Fields add
uploaded_image_tagand its specs to Core'sApplicationHelper, because the profile's constant census admits no new helper module. The helper requests each size as an Active Storage variant at twice the CSS box the view draws:resize_to_fill: [192, 192]for thumbnails andresize_to_limit: [896, nil]for details. An image that Active Storage cannot resize, such as SVG, goes toimage_tagas uploaded. - Cloudinary representations: Cloudinary's Active Storage guide transforms images on
delivery and says not to process variants.
ActiveStorage::CloudinaryRepresentationstherefore redirects a Cloudinary representation to a resized link without variant processing. The service mapsresize_to_limit,resize_to_fill, andresize_to_fitto Cloudinary's crops and raisesKeyErrorfor any other transformation. Images on Disk, such as test uploads and development samples, keep Active Storage's processing, which the profile's disabled variant processor leaves at their stored size. - Storage: the
attachmentdependency contributor adds thecloudinarygem.config/storage.ymlgains acloudinaryservice that development and production select; tests keep Core's Disk service. It stores uploads as Cloudinaryauthenticatedassets throughlib/active_storage/service/authenticated_cloudinary_service.rb, which returns download-API links that expire after Active Storage'sservice_urls_expire_in. Active Storage loads that service withrequire, soconfig/application.rbexcludeslib/active_storagefrom autoloading. WithoutCLOUDINARY_URL, the application still boots, andconfig/initializers/cloudinary.rbraises aKeyErrornaming the variable when a file is first stored on Cloudinary. Generated CI and the production smoke therefore need no Cloudinary account..env.example,DEPLOY.md,render.yaml, and the README document the variable. Active Storage itself loads only in an application with an upload Field; Rails frameworks lists what an application without one leaves out. - Seeds and tests: development seeds attach a placeholder image or text file to each required upload, stored on
Core's local Disk service, and the README names those uploads. An optional upload stays empty, and authored
development values for uploads are omitted. Factories, request specs, and system specs upload files from
spec/fixtures/files/. Request and system specs check each submitted upload withbe_attachedand compare only the other submitted values. A request spec submits each removal flag alone and checks that no file remains. - File access: see below.
- Namespace: uploads stay
field_kind.not_generatedgaps when the application or an Entity occupies a top-level constant the cloudinary closure adds, when an Entity occupies an Active Storage table, when another constant occupies the Entity's generated<Model>Policy, or when another member of the Entity uses a namehas_one_attacheddefines, such asfile_attachment. The profile still claims only the upload's reader, so the Compiler checks these names when it lowers the Field.
File access
Uploaded files follow the Policies of the pages that show them; the dated decision (repository-only) records why.
- Rule: in an application with Policies, the Compiler adds
show_files?to the Policy of every Entity with an upload Field. It allows anyone that a generated index, show, or profile page displaying one of those Fields allows. A page requires every authorization between it and the Field: its own, then each displayed Association's. A public decision adds nothing. An environment gate checks the signed-in Account. A record rule, or a gate on the page's own record, checks the file's record or an enclosing record. An enclosing record is reached through the Reference that a displayedhas_manyorhas_oneAssociation follows, as inallowed_to?(:change?, record&.request, with: MaintenanceRequestPolicy) && change?. A profile page also requires its record to be the signed-in Account. - Unreachable decisions: a forward Reference or indirect Association has no single enclosing record. A display
that needs one there grants nothing, and the GapSet lists
scaffold.projection.partially_generated. Because the rule covers every upload Field of the record, another page showing any of them can still allow the file. One record rule of the Entity's own becomesalias_rule :show_files?, to: :manage?. Files that no page shows are shown to no one. An Entity without authored Policies gets a<Model>Policywith only this rule, and authored Policies cannot use theshow_filesoperation. - Controllers:
config/initializers/active_storage.rbincludesActiveStorage::Authorizationin Active Storage's blob and representation redirect and proxy controllers. It finds the records that own a file, following a variant or preview image to its original, and serves the file when any of their Policies allowsshow_files?. Otherwise a guest page request goes to sign-in and any other request gets403. Representations wait for the Policy before processing, and proxy responses stay out of shared caches. - Public images:
uploaded_image_tagembeds a permanent signed CDN link with automatic format and quality when the record'sshow_files?allows a nil user. Other images link to Active Storage's representation route. In an application without Policies, every generated page is public, so every Cloudinary image uses the CDN link and the concern is not emitted. - One record per file: Active Storage attaches a file given its signed ID, which every file URL contains. A
uniqueness validation and a unique
blob_idindex keep each file with the first record that attaches it; a second attach re-renders the form with422. Rails' unique index onrecord_type,record_id,name, andblob_idstays, because it is the only index that finds a record's attachments. The pinned active_record_doctor'sextraneous_indexesdetector reports a unique index whenever a unique index on some of its columns exists, whatever lookups it serves, so the generated.active_record_doctor.rbexempts this one. - Markup types: Turbo renders any response whose type it reads as HTML, and browsers run scripts in SVG and XML.
Rails forces only exact types such as
text/htmlto download, so the initializer also forces HTML variants such astext/html;charset=utf-8, XHTML, SVG, XML, and non-ASCII declared types toapplication/octet-stream, as 37signals' Fizzy does.spec/models/active_storage/blob_spec.rbchecks the list. - Direct uploads: no generated form uploads directly, so
ActiveStorage::DirectUploadsControlleranswers403. Admittingrich_textmust accept them from signed-in Accounts and map Action Text's rich-text records to their owners inActiveStorage::Authorization. - Tests: a show page's request specs fetch each upload it displays through
rails_blob_pathwhen that page's authorization alone decidesshow_files?.
Download links expire, skip Cloudinary's CDN, and count bandwidth twice. Applications compiled before this lowering
keep public upload assets. Size limits, attachment types, multiplicity, and cleanup remain unimplemented, and no
GapSet entry lists them.
Image content type
Rails ships no attachment validators. 37signals' Fizzy checks avatar types by hand, and the
unreleased Rails guide shows a hand-written validate method. A Plan with an image
Field therefore gets app/validators/image_validator.rb, and each image Field declares validates :photo, image: true. The validator adds an :image error when a file is attached and Active Storage's
image? is false, that is, when the file's content_type does not start with image. That is
the image/* that the form's accept attribute asks the browser for. The locale adds errors.messages.image: "must be an image". The validator skips a missing file, so a blank required Field reports only "can't be blank".
Active Storage sets content_type when a file is assigned, before validation runs. It passes the bytes, the filename,
and the declared type to Marcel, and a signature found in the bytes wins. A PDF, HTML, or zip file
declared as image/png is therefore rejected. Active Storage uploads only after commit, so a rejected file never
reaches Cloudinary. A file with no recognizable signature keeps the type that its upload declares, or else the type
its filename suggests, so text sent as image/png passes. SVG is an image and passes; file access
describes how markup types are served. The checkbox that removes a stored optional image does not skip the check, so
a non-image chosen beside a checked box fails validation.
The active_storage_validations gem was set aside. Generated applications use one rule, which Rails' image?
and Marcel already cover. The gem's spoofing protection needs the file or Magika command-line tool, which Core's
Docker image does not install. A narrower list of formats would be a product choice, so it waits for Plan vocabulary.
A model example per image Field uploads the PNG fixture and expects no :image error, then uploads the text fixture
and expects one, so an application whose only uploads are images also gets firstdraft-file.txt.
spec/validators/image_validator_spec.rb checks the rule with an anonymous ActiveModel class and test doubles.
DEPLOY.md tells the owner what the check leaves to them, as requirement F-5 asks: size limits, files with no
recognizable signature, and cleanup of unattached files. An Entity whose model constant is ImageValidator would
collide with the validator, and no Analyzer check covers that name yet.
Derived numeric values
The Plan can declare sum, average, minimum, and maximum over a named Association and source Field. A derived
Field is not directly writable and must reflect changes to both source values and Association membership. Every
derived Field needs a structured consumer. A detail-only average can remain a fresh Association AVG. An average used
by a Scaffold collection, Predicate, Ordering, or Predicate-filtered Association needs a maintained queryable value.
One isolated Compiler projection records a Counter Culture-maintained sum and count plus their
quotient as a stored PostgreSQL-generated average. It explicitly records
production_wiring_complete: false; neither part is selected profile output or generated-app behavior. Decision 0024
requires the target to compare a conventional Rails or common-gem result-column path and either keep that behavior
there or record a narrow database-owned exception before generation. Counter Culture is not yet a profile pin. The
semantic Field remains one Plan subject and adds no realization choice. A compatible index can follow from actual
Predicate and Ordering use. Nullable sources, scoped membership, bulk writes, repair, and minimum or maximum still
need proof before the Compiler claims support.