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:
@@ -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.3 (§10.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.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),
|
||||
> 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 | 0–100, 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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user