docs(form-builder): WS-5a — SCHEMA v2.2 §3.5.12, ARCH v1.4 §6.7, addendum Q3 sign-off

This commit is contained in:
2026-04-24 20:13:51 +02:00
parent 61719bf8bf
commit 60c3abbe26
4 changed files with 171 additions and 14 deletions

View File

@@ -1,13 +1,16 @@
# ARCH — Universal Form Builder (v1.3)
# ARCH — Universal Form Builder (v1.4)
> **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 — S2c landed (public API completion)
> **Version:** 1.310.4 public submission lifecycle — draft/save/submit
> split with error envelope and drift detection)
> **Previous version:** 1.2.1 April 2026 (§31.10 FORM-02 contract),
> **Status:** Approved — WS-5a landed (relational `form_field_bindings`)
> **Version:** 1.46.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),
> 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)
> **Created:** April 2026
@@ -1143,10 +1146,32 @@ return [
Only registered columns are valid binding targets. Form Request validates
at save time.
### 6.3 Binding JSON specification
### 6.3 Binding row specification
Bindings live in the relational `form_field_bindings` table (see §6.7).
The columns on a row are:
| Column | Type | Notes |
| ------------------ | ------------------ | ------------------------------------------------------------------------------ |
| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` |
| `owner_id` | ULID | parent row |
| `target_entity` | string(50) | e.g. `person`, `user_profile`, `company`, `organisation`, `artist` |
| `target_attribute` | string(100) | e.g. `email`, `first_name`, `emergency_contact_phone` |
| `mode` | string(20) | `FormFieldBindingMode` enum: `entity_owned` or `mirrored` |
| `sync_direction` | string(30) null | Pattern C only (e.g. `write_on_submit`); null for Pattern A |
| `merge_strategy` | string(20) | `FormFieldBindingMergeStrategy` enum; default `overwrite` |
| `trust_level` | tinyint unsigned | 0100, default 50; WS-6 consumer |
| `is_identity_key` | bool | default false; WS-6 person-matching |
Pattern B is represented by the **absence of a row**; only Pattern A and
Pattern C create rows.
Snapshot embedding (`form_submissions.schema_snapshot`, §4.6.1) continues
to embed bindings inline in the ARCH JSON shape. The snapshot writer
serialises rows via `FormFieldBindingService::toJsonShape`:
```json
// Pattern B (default)
// Pattern B (no row)
"binding": null
// Pattern A
@@ -1165,6 +1190,9 @@ at save time.
}
```
Historical snapshots (written pre-WS-5a) use the same JSON shape, so
snapshot readers keep working unchanged.
### 6.4 Read/write semantics per pattern
| Operation | Pattern A | Pattern B | Pattern C |
@@ -1200,6 +1228,67 @@ to `user_profile` columns IF `person.user_id` is set. When user_id is null
(external person), the mirror write is **skipped gracefully** and logged.
form_values row is still written in both cases.
### 6.7 Relational binding table
**Table:** `form_field_bindings` — columns defined in §6.3.
**Discriminator (WS-5a commit 1, Uitvoering per addendum Q3):** polymorphic
morph (`owner_type` / `owner_id`) with morph-map aliases `form_field` and
`form_field_library`. The paired-nullable-FK alternative was rejected —
MySQL 8 has no partial-unique support, and the remaining WS-5 sub-work-
packages (5b `form_field_validation_rules`, 5d `form_field_options`) reuse
the same owner-discriminator shape; a single idiomatic pattern across the
family beats per-table workarounds.
**Multi-tenancy (`FormFieldBindingScope`):** `OrganisationScope`'s
declarative FK-chain resolver (addendum Q2) walks direct or single-FK
parents; it cannot walk a morph parent. `FormFieldBindingScope` builds
the equivalent UNION:
```
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 = ?
)
```
Organisation context resolves the same way `OrganisationScope` does —
explicit override via constructor, then route parameter `organisation`
(and the `event` fallback). CLI, queues, and unauthenticated flows skip
the scope. Escape hatch:
`FormFieldBinding::withoutGlobalScope(FormFieldBindingScope::class)`.
**Service boundary (`FormFieldBindingService`):** all writes go through
the service — no controller fills bindings directly on the model. The
service owns:
- `bindingsFor(owner)` — eager, scope-aware fetch.
- `replaceBindings(owner, specs)` — transactional delete + insert;
validates every spec against the entity-column registry
(`config/form_binding.php`) and against `FormFieldBindingMode` /
`FormFieldBindingMergeStrategy` enums. Logs `field.bindings_replaced`
on the owning field.
- `copyBindings(library, field)` — row-clone on
`FormFieldService::insertFromLibrary` (Q3 row-copy mandate). Every
column is preserved; only `owner_type` / `owner_id` change.
- `toJsonShape(binding)` — single source of truth for serialising a row
into the ARCH §6.3 JSON shape. Consumed by the snapshot writer
(`FormSubmissionService::buildSnapshot`) and by API resources
(`FormFieldResource`, `FormFieldLibraryResource`).
**Cascade (`FormFieldBindingsCascadeObserver`):** bindings are physical
state, not audit. On soft- or hard-delete of the owner
(`FormField::delete()` or `FormFieldLibrary::delete()`), the observer
physically deletes the owner's bindings. No soft-delete on the binding
table itself.
**TODO (out of WS-5a scope, `FORM-BINDING-SNAPSHOT-MULTI`):** the
snapshot writer embeds at most one binding per field. Multi-binding on a
single field (per §6.1 future scenarios) needs a snapshot shape decision.
---
## 7. Filter architecture
@@ -2082,8 +2171,11 @@ in `Relation::morphMap()`.
`PurposeRequirementsNotMetException` (structured; `purposeSlug` +
`missingBindings[]`) if any binding path in the schema's
`PurposeDefinition::requiredBindings` is not present on at least one
`form_fields.binding` JSON of the schema. In WS-5a this check switches
to the relational `form_field_bindings` table.
field of the schema. The check queries the relational
`form_field_bindings` table directly (§6.7) — it assembles the set of
`{target_entity}.{target_attribute}` pairs across the schema's fields
and diffs them against `requiredBindings`. External contract
(`purposeSlug` + `missingBindings[]`) unchanged.
**Adding a new purpose.** In scope only via an architect-level decision: