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

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