docs(form-builder): WS-5b partial sign-off — SCHEMA v2.3 + ARCH v1.5 §17.4 + addendum Q3
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user