Supporting reference · Working design and evidence, not a promise of complete support.

On this page

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:

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:

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

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

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

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:

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:

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:

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

  1. Do create-only and update-only validations recur often enough to model?
  2. What stable I18n key and override design permits localized custom messages without embedding prose in every rule?
  3. Which compatible comparison rules receive database CHECK enforcement in the first Rails profile?
  4. Does a representative application need authored inclusion beyond a Field's intrinsic domain?
  5. 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.

References marked “repository-only” name implementation or internal material outside this public guide. They are intentionally not links. Publishing a design does not prove its implementation.