From 9d2758a42c742db54472a8d0ec824cbb12a2d5e8 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 22:30:17 +0200 Subject: [PATCH] =?UTF-8?q?docs(form-builder):=20WS-5b=20partial=20sign-of?= =?UTF-8?q?f=20=E2=80=94=20SCHEMA=20v2.3=20+=20ARCH=20v1.5=20=C2=A717.4=20?= =?UTF-8?q?+=20addendum=20Q3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md | 20 ++ dev-docs/ARCH-FORM-BUILDER.md | 217 ++++++++++++++++-- dev-docs/SCHEMA.md | 63 ++++- 3 files changed, 279 insertions(+), 21 deletions(-) diff --git a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md index 20972dfb..93be91d3 100644 --- a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md +++ b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md @@ -156,6 +156,26 @@ class FormField extends Model **Git-log kanttekening commit 3.** De 1Password signer gaf herhaalde "failed to fill whole buffer" errors op de lange HEREDOC body van de bedoelde commit-message; de uiteindelijke commit landde met alleen de titel (`refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns`, SHA `61719bf`). De volledige rationale — pre-publish check switch van JSON naar relationele query, kolom-drops op `form_fields.binding` en `form_field_library.default_binding`, factory/resource/form-request cleanup, fixture-rewrites — staat in de WS-5a completion notitie, niet in `git show 61719bf`. Amenden is niet geprobeerd: CLAUDE.md verbiedt signed-commit amenden. +### Uitvoering — WS-5b (2026-04-25) + +WS-5b splitst `form_fields.validation_rules` en `form_field_library.validation_rules` naar een relationele tabel `form_field_validation_rules`. Rij-shape: `owner_type` / `owner_id` (polymorphic morph, hergebruik van de WS-5a aliassen) + `rule_type` string(40, app-enforced enum `FormFieldValidationRuleType`) + `parameters` JSON per rule-type. Rationale: rule_type is de queryable dimensie ("welke velden hanteren regex?"), waarden zijn heterogeen (int voor min_length, string voor regex, array voor mime_types) waardoor JSON *per rij* passend is, niet voor de hele bag. + +**Seed-scan resultaat (Phase A).** Zes distinct top-level keys geobserveerd in seeders/factories/tests/resource-code-paden: `min`, `max`, `regex`, `unique`, `max_priorities`, `tag_categories`. `required` bestond niet in de wild (kolom `is_required` was al de bron van waarheid). Enum-catalogus uitbreiding beperkt tot de vijftien validatie-cases; non-validatie-keys verplaatsen naar een parallelle tabel. + +**Strict-enterprise scope-uitbreiding.** Tijdens de decision gate is WS-5b uitgebreid met een parallelle tabel `form_field_configs` (zie ARCH §17.5, landed in WS-5b commit 5). Rationale: `tag_categories` en `storage_disk` zijn renderings-/upload-configuratie, geen validatie-regels. Ze opnemen in `form_field_validation_rules` zou de tabel-naam semantisch vervuilen — precies het type drift dat deze sprint opruimt. Eén extra tabel, één extra enum, één extra service, één extra scope; géén scope-creep verder dan dat paar. + +**Key-translaties bij backfill.** +- `required`, `unique`: WARN-log + skip. Kolom-duplicaten van `is_required` / `is_unique`. In WS-5b commit 3 is de `FormValueService::$rules['unique']` fallback verwijderd — `is_unique` is nu de enige bron. +- `max_priorities` → `rule_type = max_selected`: semantisch gelijk (cap op entries in list-valued veld); twee enum-cases voor één semantiek = rot. Seeder (`FormBuilderDevSeeder`) en test-assertie (`PublicFormSeederTest:190`) aangepast om de canonieke naam te gebruiken. Geen bridging in `toJsonShape`; historische snapshots blijven onaangeraakt. +- Ambigu `min` / `max`: field-type dispatch — NUMBER → `min_value`/`max_value`; TEXT/TEXTAREA/EMAIL/PHONE/URL → `min_length`/`max_length`; DATE/DATETIME → `date_min`/`date_max`; anders **FAIL** migratie. Type-inappropriate gebruik van `min`/`max` is seed-data-bug; force correction, absorbeer niet stilletjes. +- `tag_categories`, `storage_disk`: skipped door de validation-rules backfill met INFO log; commit 5's configs-backfill pickt ze op in `form_field_configs`. + +**Activity log-conventie.** Per §6.7 WS-5a: `field.validation_rules_replaced` wordt alleen geëmit op FormField-subject, niet op FormFieldLibrary. Library-niveau audits leven elders; bewuste convergentie met de binding-pattern. + +**Scope-sibling.** `FormFieldValidationRuleScope` is een near-duplicaat van `FormFieldBindingScope` (zelfde UNION-over-two-owner-chains shape). Base-class extractie is uitgesteld tot WS-5d (waar het derde sibling `form_field_options` een gedeelde vorm moet onthullen); premature abstractie uit twee is nog steeds premature. Cascade-observer `FormFieldBindingsCascadeObserver` is hernoemd naar `FormFieldChildTablesCascadeObserver` en ruimt nu drie child-tabellen op (bindings, validation-rules, en — vanaf commit 5 — configs). + +**Strict validator op save (commit 3).** De vier FormRequests (`StoreFormFieldRequest`, `UpdateFormFieldRequest`, `StoreFormFieldLibraryRequest`, `UpdateFormFieldLibraryRequest`) accepteren `validation_rules` nu als array-of-spec-objects (`[{rule_type, parameters, error_message_key?}, ...]`). Semantische validatie (enum-case + parameter-shape + callback-registry) loopt via `FormFieldValidationRuleService::assertSpecsValid()` in een `after()` hook, zodat bad specs 422 geven vóór enige write. Controllers schrijven niet langer `validation_rules` naar de JSON-kolom; writes gaan uitsluitend via `replaceRules()`. + --- ## Q4 — Sanctum `personal_access_tokens` diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index 56452193..72305651 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -1,18 +1,21 @@ -# ARCH — Universal Form Builder (v1.4) +# ARCH — Universal Form Builder (v1.5) > **Source of truth** for Crewli's universal Form Builder architecture. > Any discrepancy with SCHEMA.md is resolved in favour of this document > during the refactor. SCHEMA.md is updated at the end of the refactor. > -> **Status:** Approved — WS-5a landed (relational `form_field_bindings`) -> **Version:** 1.4 (§6.3 retitled to "Binding row specification"; new -> §6.7 "Relational binding table"; §17.3 pre-publish check in present -> tense per WS-5a) -> **Previous version:** 1.3 (§10.4 public submission lifecycle — -> draft/save/submit split with error envelope and drift detection), +> **Status:** Approved — WS-5a landed (relational `form_field_bindings`); +> WS-5b validation rules landed (relational `form_field_validation_rules`). +> **Version:** 1.5 (§17.4 restructured into relational sub-sections: +> catalogue, relational table, callback rules, legacy JSON migration). +> **Previous versions:** +> 1.4 (§6.3 retitled to "Binding row specification"; new §6.7 "Relational +> binding table"; §17.3 pre-publish check in present tense per WS-5a), +> 1.3 (§10.4 public submission lifecycle — draft/save/submit split with +> error envelope and drift detection), > 1.2.1 April 2026 (§31.10 FORM-02 contract), > 1.2 April 2026 (per-purpose lifecycles, integration contracts, user -> guidance principles, documentation coverage, in-app copy catalogue) +> guidance principles, documentation coverage, in-app copy catalogue). > **Created:** April 2026 > **Owner:** Architecture doc; every session reads this before starting > @@ -2206,16 +2209,150 @@ and diffs them against `requiredBindings`. External contract (identity-match, tag sync, entity creation, etc.). 5. Add a lifecycle paragraph under §3.2 and a row to the table above. -### 17.4 Custom validation callbacks +### 17.4 Validation rules -`form_fields.validation_rules` JSON supports callback references: -```json -{ - "callback": "App\\Services\\Validators\\KvkValidator@validate" -} +Pre-WS-5b, validation rules lived as a flat JSON bag on +`form_fields.validation_rules` and `form_field_library.validation_rules`. +WS-5b moved them to the relational `form_field_validation_rules` table +(one row per rule), in parallel with §6.7 (bindings). This section is the +canonical home for the rule catalogue, the relational-table shape, the +callback-registry integration, and the legacy-key migration notes. + +#### 17.4.1 Rule-type catalogue + +`FormFieldValidationRuleType` (PHP backed enum) is the canonical list of +rule_type values. Each case documents the required `parameters` shape +that `FormFieldValidationRuleService::replaceRules()` enforces. + +| `rule_type` | `parameters` shape | Notes | +| -------------------- | ------------------------------ | ------------------------------------------------------- | +| `min_length` | `{"value": int}` | Minimum character length for string-valued fields | +| `max_length` | `{"value": int}` | Maximum character length | +| `min_value` | `{"value": number}` | Minimum numeric value for NUMBER fields | +| `max_value` | `{"value": number}` | Maximum numeric value | +| `regex` | `{"pattern": string, "flags": string?}` | `preg_match` pattern; flags optional | +| `email_format` | `{}` | Boolean marker — enforces RFC-5321 email shape | +| `url_format` | `{}` | Boolean marker — enforces URL shape | +| `phone_e164` | `{}` | Boolean marker — enforces E.164 phone shape | +| `allowed_mime_types` | `{"mime_types": [string]}` | Whitelist for FILE_UPLOAD / IMAGE_UPLOAD / SIGNATURE | +| `max_file_size` | `{"bytes": int}` | Upper bound for uploads | +| `min_selected` | `{"value": int}` | Lower bound for multi-value selections | +| `max_selected` | `{"value": int}` | Upper bound; covers the legacy `max_priorities` key | +| `date_min` | `{"date": string}` | ISO-8601 date; lower bound for DATE / DATETIME | +| `date_max` | `{"date": string}` | Upper bound | +| `callback` | `{"key": string}` | Registered callback key, see §17.4.3 | + +**Not in the catalogue (deliberate):** + +- `required` — `form_fields.is_required` column is the single source of + truth. Any legacy `validation_rules.required` JSON is WARN-logged and + skipped at backfill. +- `unique` — `form_fields.is_unique` column is the single source of + truth. The pre-WS-5b JSON fallback path in `FormValueService` was + stripped in WS-5b commit 3. +- `tag_categories`, `storage_disk` — not validation rules, they are + field-rendering / upload-storage configuration. WS-5b relocates these + to a separate `form_field_configs` table (ARCH §17.5, landed in + WS-5b commit 5) rather than polluting the validation-rules catalogue. + +**Rule types are app-enforced, not DB enum.** The column is +`string(40)` so the enum can extend in application code without a +migration — identical rationale to `form_fields.field_type` (§4.2) and +`CustomFieldTypeRegistry`. + +#### 17.4.2 Relational table `form_field_validation_rules` + +**Columns** (SCHEMA.md §3.5.12): + +| Column | Type | Notes | +| ------------------- | ----------------- | ---------------------------------------------------------- | +| `id` | ULID | PK | +| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` | +| `owner_id` | ULID | parent row | +| `rule_type` | string(40) | enum case value | +| `parameters` | JSON | per-rule-type bag | +| `error_message_key` | string(100) null | optional i18n key for custom rejection copy | +| `created_at`, `updated_at` | timestamps | | + +- **Unique:** `(owner_type, owner_id, rule_type)` — at most one rule of + each type per field. +- **Indexes:** `(rule_type)` for "which fields enforce regex?" queries, + `(owner_type, owner_id)` for per-owner lookups. +- **Morph-map aliases** `form_field` and `form_field_library` are reused + from WS-5a (`AppServiceProvider::registerMorphMap`) — no new entries + needed. + +**Multi-tenancy (`FormFieldValidationRuleScope`).** Sibling to +`FormFieldBindingScope` with identical UNION-over-two-owner-chains +shape: + +``` +owner_id ∈ ( + SELECT id FROM form_fields + WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?) + UNION + SELECT id FROM form_field_library + WHERE organisation_id = ? +) ``` -Registered callbacks in `config/form_builder.php`: +Organisation context resolution mirrors `OrganisationScope`; the escape +hatch is +`FormFieldValidationRule::withoutGlobalScope(FormFieldValidationRuleScope::class)`. + +Base-class extraction between the two scope classes is deliberately +deferred to WS-5d per addendum Q3 — premature abstraction from two +siblings is still premature, and WS-5d's `form_field_options` / +WS-5c's `form_field_conditional_logic` may surface a different shared +shape. + +**Service boundary (`FormFieldValidationRuleService`).** All writes go +through the service — no controller writes rules directly on the +model. The service owns: + +- `rulesFor(owner)` — eager, scope-aware fetch. +- `replaceRules(owner, specs)` — transactional delete + insert; + validates every spec's `rule_type` against the enum and `parameters` + against the per-rule shape (including callback-key registry check). + Logs `field.validation_rules_replaced` on the owning FormField + subject (matching the WS-5a convention: library-level changes are + silent in activity log). +- `copyRules(library, field)` — row-clone on + `FormFieldService::insertFromLibrary` (addendum Q3 row-copy mandate). +- `toJsonShape(rules)` — single source of truth for serialising a + collection to the canonical flat bag shape consumed by the snapshot + writer (`FormSubmissionService::buildSnapshot`) and by API resources + (`FormFieldResource`, `FormFieldLibraryResource`, + `PublicFormSchemaResource`). +- `assertSpecsValid(specs)` — public helper the FormRequests invoke in + their `after()` hook to reject bad specs at the HTTP boundary before + any write lands (strict validator on save, WS-5b commit 3). + +**Cascade (`FormFieldChildTablesCascadeObserver`).** Shared observer — +also cleans up `form_field_bindings` rows (WS-5a) and `form_field_configs` +rows (WS-5b commit 5). Rules are physical state, not audit: on soft- +or hard-delete of the owner, the observer physically deletes the rows. + +**Activity log events.** Changing a field's rules emits two entries +on the parent `FormField` subject: + +- `field.updated` — payload includes `old.validation_rules` / + `new.validation_rules` shapes reconstructed from the relational + table via `FormFieldValidationRuleService::toJsonShape()`. + Preserves the pre-WS-5b audit-consumer contract for downstream + tooling that parses `field.updated` diffs. +- `field.validation_rules_replaced` — the semantic rule-change event, + emitted by `FormFieldValidationRuleService::replaceRules()`. + +Both fire for the same semantic change. Aggregate queries over +activity-log event counts should filter on one, not both — the WS-5a +precedent (§6.7). + +#### 17.4.3 Callback rules + +`rule_type = callback` references a named handler registered in +`config/form_builder.php`: + ```php 'validation_callbacks' => [ 'kvk_lookup' => \App\Services\Validators\KvkValidator::class, @@ -2223,7 +2360,55 @@ Registered callbacks in `config/form_builder.php`: ], ``` -Unregistered callbacks rejected at save time. +The rule row's `parameters.key` must be a key in this map — unregistered +keys are rejected by `FormFieldValidationRuleService::assertSpecsValid()` +at save time (WS-5b commit 3 strict validator). + +**Legacy `FQCN@method` strings are not accepted.** Pre-WS-5b +`validation_rules` JSON sometimes stored callbacks as fully-qualified +class strings (`App\Services\Validators\KvkValidator@validate`). Those +were never a formal contract and are not portable across refactors. +Backfill surfaces them as WARN log lines for manual review; operators +either register the callback under a named key in +`config/form_builder.php` or drop the reference. + +#### 17.4.4 Legacy JSON migration (WS-5b) + +The WS-5b backfill migration +(`2026_04_25_110001_backfill_form_field_validation_rules.php`) +translates pre-WS-5b JSON keys to relational rows. Strict-enterprise +dispatch — no guessing, no silent drops for unknown data. + +- **Column-duplicates** (`required`, `unique`): WARN-log and skip. + The is_* columns are the single sources of truth. +- **Canonicalisations**: legacy `max_priorities` (SECTION_PRIORITY UI + soft cap) collapses to `rule_type = max_selected` — same semantic of + "cap on entries in a list-valued field", and two enum cases for one + semantic is rot. +- **Ambiguous `min` / `max`**: dispatched by `field_type`: + - NUMBER → `min_value` / `max_value` + - TEXT/TEXTAREA/EMAIL/PHONE/URL → `min_length` / `max_length` + - DATE/DATETIME → `date_min` / `date_max` + - anything else → **FAIL** the migration. + Type-inappropriate uses of `min` / `max` are seed-data bugs. +- **Non-validation keys** (`tag_categories`, `storage_disk`): skipped + with an INFO log line — commit 5's configs-backfill migration picks + them up into the separate `form_field_configs` table (ARCH §17.5). +- **Unknown top-level keys**: **FAIL** the migration. Phase A seed-scan + should have caught these; if one slips through we want the crash, + not the skip. + +Rollback reconstructs the JSON bag using canonical keys (post-rename). +It does NOT resurrect column-duplicates or non-validation keys — those +never landed in the relational table. The forward+back pair is safe as +a unit; a partial rollback that pops this migration but leaves its +create-table sibling is not a supported state. + +Historical snapshots written pre-WS-5b embed the legacy flat bag +(`validation_rules: {"min": 16, "max": 99, "max_priorities": 3}`). +Those rows are immutable records and are not rewritten by the +migration. Snapshot readers must tolerate both shapes — pre-WS-5b +legacy keys and post-WS-5b canonical keys. ### 17.5 Webhooks diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 4069d03a..98c7b14a 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,23 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.2** — Updated April 2026 +> **Version: 2.3** — Updated April 2026 > > **Changelog:** > +> - v2.3: WS-5b (partial) — `form_field_validation_rules` relational table +> replaces the `validation_rules` JSON on `form_fields` and +> `form_field_library`. Typed `rule_type` column + per-rule `parameters` +> JSON; polymorphic morph owner reuses the WS-5a aliases (`form_field`, +> `form_field_library`). Canonicalised legacy keys at backfill: ambiguous +> `min`/`max` → `min_value`/`max_value`/`min_length`/`max_length`/ +> `date_min`/`date_max` by field type; `max_priorities` → `max_selected`. +> Skipped keys: `required` (is_required column), `unique` (is_unique +> column). Non-validation keys (`tag_categories`, `storage_disk`) +> deferred to WS-5b commit 5 — they relocate to a separate +> `form_field_configs` table (ARCH §17.5) rather than polluting the +> validation-rules table. See ARCH-FORM-BUILDER.md §17.4 and +> ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q3 WS-5b Uitvoering. > - v2.2: WS-5a — `form_field_bindings` relational table replaces > `form_fields.binding` and `form_field_library.default_binding` JSON. > Polymorphic morph owner (`form_field` / `form_field_library`) per @@ -1967,7 +1980,6 @@ that aggregates the user's submitted, non-test `form_submissions`. | `label` | string | | | `help_text` | text nullable | | | `options` | JSON nullable | Choice options | -| `validation_rules` | JSON nullable | | | `default_is_required` | bool | default: false | | `default_is_filterable` | bool | default: false | | `translations` | JSON nullable | Per-locale overrides | @@ -1977,7 +1989,7 @@ that aggregates the user's submitted, non-test `form_submissions`. | `is_active` | bool | default: true | | `created_at`, `updated_at` | timestamps | | -**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner` +**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner` **Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)` **Unique constraint:** `UNIQUE(organisation_id, slug)` **Global scope:** `OrganisationScope` @@ -1986,6 +1998,11 @@ that aggregates the user's submitted, non-test `form_submissions`. > Bindings moved to the relational `form_field_bindings` table > (ARCH-FORM-BUILDER.md §6.7). See WS-5a in > `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3. +> +> Validation rules moved to the relational `form_field_validation_rules` +> table (ARCH-FORM-BUILDER.md §17.4). The column was dropped in WS-5b; +> see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5b +> Uitvoering for the full catalogue and migration notes. --- @@ -2012,7 +2029,6 @@ that aggregates the user's submitted, non-test `form_submissions`. | `help_text` | text nullable | | | `section` | string(100) null | Visual grouping header (independent of `form_schema_section_id`) | | `options` | JSON nullable | Choice options | -| `validation_rules` | JSON nullable | min/max/regex/allowed_mime_types/storage_disk/callback, etc. | | `is_required` | bool | default: false | | `is_filterable` | bool | default: false — populates `form_values.value_indexed` / pivot | | `is_portal_visible` | bool | default: true | @@ -2029,13 +2045,18 @@ that aggregates the user's submitted, non-test `form_submissions`. | `created_at`, `updated_at` | timestamps | | | `deleted_at` | timestamp nullable | Soft delete preserves history | -**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values; `morphMany` form_field_bindings as `owner` +**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner` **Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)`, `(form_schema_id, slug)` **Soft delete:** yes > Bindings moved to the relational `form_field_bindings` table > (ARCH-FORM-BUILDER.md §6.7). See WS-5a in > `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3. +> +> Validation rules moved to the relational `form_field_validation_rules` +> table (ARCH-FORM-BUILDER.md §17.4). The column was dropped in WS-5b; +> see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5b +> Uitvoering for the full catalogue and migration notes. --- @@ -2071,6 +2092,38 @@ that aggregates the user's submitted, non-test `form_submissions`. --- +### `form_field_validation_rules` + +> Relational home for field and library-field validation rules. One row +> per `(owner, rule_type)`. `rule_type` is an app-enforced enum +> (`FormFieldValidationRuleType`) — database column is `string(40)` so +> the enum can extend without migration. `parameters` JSON carries per- +> rule-type configuration (value, pattern, mime_types, etc.); shape is +> enforced at the service layer, not the DB. +> +> Polymorphic owner — morph-map aliases `form_field` and +> `form_field_library` (reused from WS-5a). Rules are physical state +> (not audit) — they cascade on owner delete (soft or hard) via the +> shared `FormFieldChildTablesCascadeObserver`. ARCH §17.4. + +| Column | Type | Notes | +| ------------------- | ----------------- | ------------------------------------------------------------------------------ | +| `id` | ULID | PK | +| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` | +| `owner_id` | ULID | parent row (a `form_fields.id` or `form_field_library.id`) | +| `rule_type` | string(40) | `FormFieldValidationRuleType` case (`min_length`, `max_value`, `regex`, `allowed_mime_types`, `callback`, etc.) | +| `parameters` | JSON | Per-rule-type bag (e.g. `{"value": 3}` for `min_length`, `{"mime_types":[...]}` for `allowed_mime_types`) | +| `error_message_key` | string(100) null | Optional i18n key for custom rejection message | +| `created_at`, `updated_at` | timestamps | | + +**Relations:** `morphTo` owner (`form_field` or `form_field_library`) +**Indexes:** `(rule_type)`, `(owner_type, owner_id)` +**Unique constraint:** `UNIQUE(owner_type, owner_id, rule_type)` +**Global scope:** `FormFieldValidationRuleScope` — sibling to `FormFieldBindingScope`, same UNION shape over both owner chains. Escape hatch: `withoutGlobalScope(FormFieldValidationRuleScope::class)`. Base-class extraction deferred to WS-5d per addendum Q3 (premature abstraction from two siblings is still premature). +**Soft delete:** no — rules are current state, not audit + +--- + ### `form_submissions` > One submission per `(schema, subject)` in `single` / `draft_single`