docs(form-builder): WS-5b partial sign-off — SCHEMA v2.3 + ARCH v1.5 §17.4 + addendum Q3

This commit is contained in:
2026-04-24 22:30:17 +02:00
parent 64ec4bcc5c
commit 9d2758a42c
3 changed files with 279 additions and 21 deletions

View File

@@ -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`

View File

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

View File

@@ -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`