Validations
Status: Provisional closed registry in firstdraft.foundation-plan.sketch/0.23, with a bounded implemented
analysis and Rails-emission slice.
Current answer
First Draft should expose recurring validation meaning through a closed, type-gated registry based primarily on Rails' built-in validation families. Arbitrary custom validation code remains outside the Plan for the user's agent to implement.
An ordinary rule is nested on the Field or Reference that receives its error. A tuple or cross-value rule is
nested on the Entity, names its participating values, and declares an error_target.
The Plan states the application fact. The Rails profile and Compiler derive compatible model validation, database enforcement, form behavior, localized errors, and tests.
Model feedback and database enforcement
A user who leaves a required name blank can correct the form. A missing state that AASM should have initialized points to an application bug; displaying "State can't be blank" gives that user no useful action.
Use this distinction when auditing generated code and designing new behavior:
- Model validations serve expected, recoverable failures and business rules that cannot reasonably live in SQL. A caller can be a form, API, or job; whether a human directly edits the column is not the sole criterion.
- Database constraints protect persisted facts across writers and concurrent operations. User-entered values need that protection too: a uniqueness validator supplies feedback, while a unique index handles concurrent writes.
- For an internally maintained value, a constraint exception can correctly expose broken application code. A database constraint does not by itself justify generating a matching model validation.
- Before adding or removing a validation, identify the actual write path, the expected failure, and who can correct it. An artificial invalid assignment, a linter warning, or an existing test is a prompt to investigate that behavior, not sufficient justification for extra emitted code.
Thoughtbot's guidance motivates the distinction between correctable input and internal bugs. It is a review heuristic, not a universal Rails rule: the Rails guide recommends model validations broadly. This guidance does not remove authored Validation meaning, change required Field semantics, or authorize disabling Rails or gem validations. Review each realization against its supported application behavior and preserve useful feedback and database guarantees; a deliberate behavior change needs its own decision.
The AASM initialization and validation decision is one specific application. It does not generalize to required scalar Fields, enum validation, or state storage emitted without AASM behavior.
The Plan has no independent model-validation or database-constraint switches. For authored rules, derive model feedback and applicable database enforcement by default. Absence from the initial Scaffold does not establish that feedback is unnecessary; later forms, APIs, and jobs may use the Field. Requiredness can be enforced by another ordinary validator, as in numeric validation, without a duplicate presence rule.
Why Validation is structured
Validation meaning affects several generated surfaces:
- whether a record may be persisted;
- where a form displays an error;
- which inputs and hints a generated form presents;
- whether matching database enforcement is possible;
- how uniqueness interacts with indexes and write paths;
- which localized error keys exist; and
- what the Compiler must test.
Leaving every rule to post-compilation work would make generated forms, migrations, models, and tests disagree.
Ownership
Field Validation
A Field owns a rule that primarily describes one value and should place its error on that value.
{
"subject_uuid": "019f9425-5412-7c61-99b1-9b546a7f2729",
"key": "score",
"name": "Score",
"type": "integer",
"required": true,
"validations": [
{
"subject_uuid": "019f9425-5412-7e8f-adba-e5badf96dff5",
"key": "score_between_one_and_five",
"kind": "comparison",
"comparisons": [
{
"operator": "greater_than_or_equal_to",
"right": {
"kind": "literal",
"value": 1
}
},
{
"operator": "less_than_or_equal_to",
"right": {
"kind": "literal",
"value": 5
}
}
]
}
]
}
validations is omitted when no additional rule applies. A present list is nonempty. A nested rule does not
repeat error_target; its owning Field is the target.
Reference Validation
A Reference owns a rule that primarily describes its relationship value. Unconditional requiredness remains the
Reference's required property.
{
"subject_uuid": "019f9425-5412-7220-864e-a4edf239ee2a",
"key": "parent",
"name": "Parent",
"required": false,
"on_referenced_deleted": "delete_referencing_record",
"targets": ["comment"]
}
Reference Validations currently support conditional presence and absence. Application-specific coherence
between a Reference target and another discriminator is not a general validation family. Dunbar150's Notification
source rule remains in its design-pressure document rather than becoming fixture-derived vocabulary.
Entity Validation
An Entity owns a rule that constrains several values or describes a tuple. Every Entity Validation has an explicit
error_target because nesting alone does not say where the generated error belongs.
{
"subject_uuid": "019f9425-5412-76cd-80ae-37ebea1cb161",
"key": "unique_title_and_release_date",
"kind": "uniqueness",
"targets": [
{
"field": "movie.title"
},
{
"field": "movie.released_on"
}
],
"error_target": {
"field": "movie.title"
},
"nulls": "distinct"
}
The target is a Field or Reference on the owning Entity:
| Shape | Meaning | Rails consequence |
|---|---|---|
{ "field": "movie.title" } |
Associate the error with that Field. | Usually the same-named attribute error. |
{ "reference": "bookmark.movie" } |
The error belongs to the relationship. | Profile-derived error binding. |
The error target of a uniqueness rule must be one of its targets. The tuple-membership constraint is a semantic
check because JSON Schema cannot compare an ID with another property in its containing object.
error_target describes more than where text is rendered. It names the semantic subject that owns the error. The
target profile maps that subject to a safe model error key and uses the same binding for field highlighting,
accessible error associations, form summaries, localization, and generated tests. message_target would make
that meaning sound presentation-only.
Earlier recovery reports and application censuses call the same choice “blame.” That term records the lineage of
the idea, but error_target is the current serialized name.
Choose the input a user can correct. Photogram's follow.not_self targets follow.followed; Dunbar150's counterpart
targets follow.leader, and notification.actor_is_not_recipient targets notification.actor. Entity comparison
and compound uniqueness remain Entity-owned facts, with an explicit input receiving the error.
Foundation Plan Validations no longer accept { "record": "self" } as an error_target. These three example rules
were deliberately migrated; import rejects the old shape rather than silently relocating its error. An explicit
input is enough for the admitted rules and allows ordinary Rails validators to own both validation and feedback.
Rails-native lifecycle errors may still use errors[:base]; generated form summaries
retain them.
An Entity Validation currently has one error target. When the same underlying condition should produce inline errors on several inputs, prefer separate Field or Reference Validations when that expresses the application fact honestly. Multiple error targets remain deferred until a real example requires them.
An Entity comparison Validation compares left with one or more compatible locators or paths under the named
operators. When two terminal values are Fields, they must declare compatible comparison behavior. A
case-insensitive Field and an exact Field are not a valid equality pair. PostgreSQL can otherwise resolve
citext against text as case-sensitive text equality, contradicting the first Field's meaning. Reference
operands compare compatible record identities. A relationship path inherits its terminal kind's semantics: a
Field carries its comparison behavior, a Reference contributes its referenced record identity, and a singular
Association contributes the typed identity of its one associated record. A collection Association is not an
operand. Every Association traversal must be singular after Association analysis.
An ordinary Reference terminal and its exact mechanically derived, same-key forward Association denote the same
semantic record path. Comparing those two terminals with not_equals is therefore impossible and fails analysis;
equals remains coherent. Authored aliases and Predicate-qualified Associations retain their distinct identities.
Why uniqueness remains on the Entity
Composite uniqueness is generally modeled as a model- or table-level fact. Django uses an Entity-level
UniqueConstraint. SQLAlchemy uses a table-level UniqueConstraint. Prisma
uses model-level @@unique. Ecto accepts the complete tuple and separately selects an
error_key, defaulting to the first Field.
Rails presents the application validation from the other direction:
validates :title, uniqueness: { scope: :released_on }. The validated attribute receives the error, while a
separate compound unique index enforces the persistent tuple. The Foundation Plan spans both jobs. Keeping the
tuple on the Entity and recording error_target separately preserves the complete application fact while still
giving the Rails Compiler an unambiguous attribute for feedback.
Every uniqueness rule also states how null participates in the tuple. nulls: "distinct" allows two tuples whose
same position is null to remain distinct, matching ordinary SQL unique-index behavior. "not_distinct" treats
those tuples as conflicting and requires matching validator and database enforcement. Required targets still
carry the property so the rule remains explicit and does not silently change if a target becomes optional in a
later Plan revision.
Each uniqueness target keeps its own Field comparison behavior. The Validation does not carry one comparison setting for the complete tuple. A tuple can therefore combine a case-insensitive text Field with exact Fields or References without flattening their different meanings into one option.
Rails validation census
The Rails guide is the starting census. The public Plan vocabulary may use a more semantic name, but every Rails family should have an explicit disposition.
Current structured registry
- Presence and absence. Conditional Field and Reference rules. Unconditional requiredness uses
required. - Comparison. Six named operators over compatible tagged or stored values. This replaces the
narrower
range,conditional_value, and Entitydifferentprobes. - Length.
minimum,maximum, andexact_lengthover compatible text Fields. - Format. Exactly one safe Ruby-pattern object under
matchesordoes_not_match. - Exclusion. A closed
forbidden_valueslist of tagged literals. Authored inclusion is omitted for now. - Uniqueness. An Entity-owned ordered Field/Reference tuple with an explicit error target and mandatory null policy and structural enforcement.
The names follow Rails where the meaning matches. That makes the menu easier for Rails developers to learn and reduces translation without exposing Rails callables or every framework option.
Specialized rather than general Field rules
Acceptance creates or uses a virtual form reader. It usually belongs to account registration, consent, or a Scaffold workflow rather than a stored Field Validation.
Confirmation creates a second virtual reader. Password and identifier confirmation usually belong to Account or form behavior.
Associated concerns nested writes and whether another record's errors should block this record. Revisit it with generated nested-form behavior.
Numericality is derived from numeric Field types rather than authored as another rule. The Rails profile uses Rails numericality for every emitted integer and decimal, including its raw-input handling. Integers retain integer syntax and the effective intersection of the signed four-byte storage range with straightforward unconditional authored bounds. Required numeric Fields use numericality's default nil rejection without duplicate presence validation; optional Fields add
allow_nil: true. Integer-only behavior follows from the Field type instead of anonly_integerPlan choice.Native decimal validation preserves ordinary attribute precision through persistence and rejects malformed form strings, including the old custom parser's
"1D2"spelling. Ordinary scientific"1e2"remains valid. It does not enforce PostgreSQL's extreme integral or fractional digit limits before saving, and Ruby Float/BigDecimal NaN or infinity can pass validation. Storage overflow may raise a database exception. Missing required numeric input receives:not_a_number; zero remains valid. These accepted runtime behaviors do not widen Foundation Plan literal admission or development-data checks needed to emit seedable output.Inclusion is intrinsic to
enum, State Machine, language-code, time-zone, and other closed-domain Field types. A narrower allowed subset has not appeared in a representative application.
Outside the structured registry
validates_each, validates_with, custom validation classes, and arbitrary methods are escape hatches for
ordinary source. Their desired outcome remains outside the Plan until a recurring closed pattern earns a
structured concept.
Presence semantics
required, conditional presence, and conditional absence share one type-specific definition. Absence is the
exact inverse of presence:
| Subject | Present when |
|---|---|
| Text, URL, language code, time zone, secure token | The normalized value is neither null nor blank. |
| Boolean, numeric, date-like, enum, State Machine | The value is not null. false and zero are present. |
| JSON | The value is not null. Empty objects and arrays are present opaque values. |
| Attachment or image | One attachment exists. |
| Rich text | The rich-text body is not blank. |
| Reference | A target record exists. |
This is application meaning, not a promise to call Rails' presence validator for every type. Rails treats false
and empty containers as blank, so blindly applying it would contradict Boolean and JSON meaning. The target may use
a non-null check, attachment API, rich-text API, or another closed validator while preserving the same error
binding and form behavior.
Type-gated menus
The authoring surface should offer only compatible rules. The Compiler should reject an incompatible combination even if a hand-authored JSON document bypasses that surface.
Every optional ordinary Field type admits conditional presence and absence. A Field with required: true
rejects both as redundant or contradictory. An optional State Machine already uses its Field-level when to
define availability, so it does not repeat those rules. The additional current menu is:
| Field family | Additional Validation kinds |
|---|---|
short_text, long_text |
comparison, length, format, exclusion |
integer, decimal, money, date, datetime |
comparison, exclusion |
counter, position |
comparison, exclusion |
boolean, enum |
comparison, exclusion |
state_machine |
comparison, exclusion; availability comes from the Field's when |
attachment, image, json, rich_text |
None |
language_code, secure_token, time_zone, url |
None |
url, language_code, and time_zone have type-intrinsic format or domain behavior. A user should not have to
repeat their basic validity as generic rules.
The JSON Schema encodes this matrix so a hand-authored document cannot bypass the authoring menu. Semantic validation must still check operator and operand compatibility. For example, ordering operators make sense for numbers and dates, while a Boolean comparison is limited to equality-shaped meaning.
Parameters
Each kind should expose only parameters that preserve closed meaning.
Length
The parameters are:
minimum;maximum; andexact_length.
minimum and maximum may appear together. exact_length cannot be combined with either bound.
within is Rails shorthand for minimum and maximum rather than another semantic shape.
Comparison
The six Plan comparison operators are:
greater_than;greater_than_or_equal_to;equals;less_than;less_than_or_equal_to; andnot_equals.
A nested Field comparison carries a nonempty ordered comparisons list. Each clause contains one operator and
one compatible tagged Value or typed path under right. An Entity comparison additionally names its left
typed path. Values can come from a literal, current_date, current_time, another stored target, an environment
path, or a reference-data record. A rule cannot carry a Ruby Proc or arbitrary method name. Null is not a
comparison literal; requiredness, conditional presence, and conditional absence carry that meaning.
Format
format requires exactly one of matches or does_not_match. Its value is:
{
"source": "\\A[a-z][a-z0-9_]*\\z",
"case_insensitive": true
}
source is Ruby Regexp source without slash delimiters. case_insensitive is optional and defaults to false.
Any target that lowers it must prove the admitted source valid and safe for its pinned runtime. Ruby's own
Regexp.linear_time? contract is interpreter-specific, so the current Rails target does not use
the First Draft service's Regexp engine as a proxy for target Ruby 4.0.5. Its first positive slice admits only \A
and \z around one or more printable-ASCII character-class atoms. Each atom may have ?, *, or +; class-syntax
bytes stay reserved, while ranges must ascend within a-z, A-Z, or 0-9. Every source outside that grammar,
including line anchors, \Z, backreferences, and subexpression calls, is a reviewed target gap. The target does not
interpret the broader Ruby-pattern language.
At the semantic-design level, authors use \A and \z for an exact whole-value positive allowlist. A negative
does_not_match rule may use \Z in a positive matching position to include exactly one final newline in the
pattern's matched set. Ordinary groups preserve that position; lookaround and absent expressions do not. \Z
inside either construct is not the intended negative form, and it is never a substitute for Field whitespace
normalization. No current target lowers does_not_match, so this negative authoring rule does not yet have
executable target proof.
Rails' Regexp timeout remains defense in depth. Positive and negative examples belong in
reference documentation and generated tests; the Plan does not need to repeat them for every pattern.
The Plan never carries a Ruby lambda, interpolation, or executable snippet. A recurring format should first be considered as a richer Field type.
Exclusion
exclusion requires a nonempty forbidden_values array. Every entry is a tagged literal compatible with the
Field. The
Validation inherits the Field's comparison behavior, so a case-insensitive username must exclude ADMIN when the
authored set contains admin. Null is not an exclusion value because requiredness and conditional presence carry
that meaning. Runtime-generated collections and arbitrary methods are not allowed.
Compiler-derived reserved values can be consequences rather than duplicated Plan data. The Dunbar150 username example carries only application-reserved values chosen by its author.
Conditional Expressions and Rails options
A typed when expression represents conditional application meaning.
{
"subject_uuid": "019f9425-5412-7152-9b2a-5b2f5841e6f2",
"key": "active_requires_content",
"kind": "presence",
"when": {
"kind": "is_null",
"operand": {
"target": {
"field": "message.deleted_at"
}
}
}
}
This does not expose Rails' if: or unless: callables. The Compiler lowers the closed Expression. The Plan uses
a separate named absence Validation when another condition requires absence. A not of the first Expression is
an exhaustive complement only when that Expression cannot evaluate to unknown. If unknown is possible, neither
rule applies in that state. That gap may be intentional, but the pair is not an exhaustive presence/absence
partition. There is no otherwise_forbidden switch.
Every current Validation may carry when; presence and absence require it because unconditional requiredness
belongs to required. The first uniqueness slice is unconditional. A later conditional uniqueness slice requires
matching partial structural enforcement before the target claims atomic uniqueness. A useful pre-alpha intermediate
may emit the model condition first and list the missing structural backstop as a gap.
Rails also offers allow_nil, allow_blank, on, message, and strict. They should not become universal Plan
keys automatically:
- requiredness, normalization, and Field type determine nil and blank handling; optional Fields normally derive
allow_nil: truefor non-presence validators; - create-only or update-only validity needs another representative app before entering the format;
- generated error copy should use stable I18n keys rather than embedding prose in every Plan rule; and
- strict exceptions are a Rails mechanism rather than application validity.
Realization in Rails
The Rails data-integrity page owns the realization layers, ordinary numeric validation, ordinary Reference comparison, and enum-predicate reuse.
Executable first admission slice
The Rails data-integrity page lists the Validation declarations that the current Rails target resolves.
Normalization is adjacent, not a Validation
Trimming whitespace, normalizing case, and converting blank text to null change the stored value before
validation. They belong to the Field's ordered normalizations pipeline.
Case-insensitive comparison instead preserves the stored representation. A Field can therefore trim a username while keeping its display capitalization and still make every typed comparison and uniqueness use case-insensitive. The Validation inherits those Field semantics rather than restating them.
The Field catalog owns the operation registry, exact whitespace meaning, and first Rails lowering.
Typed findings
Compiler implementation should provide machine-readable findings for at least:
- a validation kind unsupported by the Field type;
- a missing or unknown target;
- a Field or Reference uniqueness error target outside its tuple;
- an error target outside the Validation's owning Entity;
- incompatible or contradictory comparison operands;
- inconsistent length bounds or an exclusion literal incompatible with its Field;
- an unsafe format pattern;
- a presence/absence pair formed from one Expression and its
notwhen that Expression can evaluate tounknown; and - a uniqueness rule without a supported structural enforcement.
Implemented resolution and typing failures use deterministic foundation_plan.validation.* codes alongside the
shared link and Expression diagnostic families. Remaining Validation families may add codes as their semantics are
admitted. The recovered findings work (repository-only) explains why locations, explanations, and stable structured output
matter.
Current schema boundary
The current JSON Schema encodes the closed registry:
- Field:
presence,absence,comparison,length,format, andexclusion, gated by Field type; - Reference: conditional
presenceandabsence; and - Entity:
uniquenessand cross-valuecomparison.
Every kind requires its defining parameters and rejects parameters owned by another kind. The schema also makes the format-pattern object closed and requires a nonempty exclusion set.
JSON Schema cannot prove that typed paths resolve, operands have compatible types, comparison behavior matches, a
pattern belongs to a target's safe grammar, an error_target belongs to the rule's uniqueness tuple, or a uniqueness
tuple has supported structural enforcement. The current semantic passes implement path, owner and tuple resolution;
condition and comparison typing; same-Entity and tuple-membership checks; deterministic source-addressed
diagnostics; same-generation prerequisite validation; and local or upstream block propagation. The paired Rails
target also recognizes the runtime-independent source grammar for its bounded positive short_text format slice and
performs matching structural uniqueness enforcement for its bounded Entity slice. Complete family-specific literal
compatibility,
negative or conditional format lowering, structural uniqueness beyond the bounded Rails slice, plan-level condition
totality, and broader target admission remain later work.
There is no dedicated same-root or general application-coherence Validation kind. A comparison can nevertheless
traverse singular Associations and compare compatible terminal values. A terminal Association contributes the
typed identity of its one associated record; it does not implicitly select one of that record's Fields. Every
Association in through and every terminal Association must be singular after Association analysis. A collection
Association is not a comparison operand.
Split Check (repository-only) uses that existing shape to require a Line Item
Participant's Line Item and Participant to resolve to the same Check;
HOA Chargeback (repository-only) uses it for same-HOA source comparisons.
Relationship-membership assertions, target-specific rules over a multi-target Reference, and other coherence
that cannot reduce to a typed comparison remain outside the Plan until repeated demand justifies a closed shape.
Open questions
- Do create-only and update-only validations recur often enough to model?
- What stable I18n key and override design permits localized custom messages without embedding prose in every rule?
- Which compatible comparison rules receive database
CHECKenforcement in the first Rails profile? - Does a representative application need authored inclusion beyond a Field's intrinsic domain?
- When nested writes enter the Plan, does associated validity need structured vocabulary or only derived behavior?
The source recovery report (repository-only) preserves the detailed census and prior rationale.