diff --git a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md index 5a9d7adb..65ccadcc 100644 --- a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md +++ b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md @@ -142,6 +142,18 @@ class FormField extends Model **Scope-impact:** WS-5 wordt ~7-8 dagen in plaats van 4-6. Drie extra sub-werkstromen voor de library-mirrors passen binnen de bestaande WS-5a/b/d PR-structuur (library valt onder dezelfde PR als de corresponderende field-split). +### Uitvoering — WS-5a (2026-04-24) + +**Discriminator-keuze:** polymorphe morph (`owner_type` enum met waarden `form_field` en `form_field_library`, `owner_id` ULID). Het alternatief (twee nullable FK-kolommen `form_field_id` / `form_field_library_id`) is verworpen: + +- **MySQL 8 heeft geen partial-unique-support** waarmee je per FK-kolom exclusief uniek kunt zijn; dat maakt de paired-FK-vorm in deze engine fundamenteel onhandig. +- **Consistentie over WS-5b/c/d**: `form_field_validation_rules`, `form_field_conditional_logic` en `form_field_options` gebruiken dezelfde owner-discriminator shape per Q3. Eén idiomatisch patroon over de hele familie wint van per-tabel workarounds. +- **Morph-map aliassen** `form_field` en `form_field_library` stonden al geregistreerd in `AppServiceProvider::registerMorphMap()` voor activity-log doeleinden; WS-5a heeft ze zonder extra werk hergebruikt. + +**Scope-enforcement:** `OrganisationScope` (Q2 FK-chain resolver) kan geen morph-parent walken. WS-5a levert `FormFieldBindingScope` als sibling-scope die een UNION bouwt over beide owner-ketens (`form_field → form_schema → organisation_id` ∪ `form_field_library → organisation_id`). Zie ARCH-FORM-BUILDER §6.7. + +**Service-grens:** `FormFieldBindingService` is de enige schrijver. `FormFieldService::insertFromLibrary` kopieert rijen via `copyBindings`, niet JSON (Q3 row-copy mandaat). Snapshot-writer en API-resources lezen via `toJsonShape` zodat het externe JSON-contract ongewijzigd blijft. + --- ## Q4 — Sanctum `personal_access_tokens` @@ -254,5 +266,6 @@ WS-1 rapport Categorie D bevindingen die geen architect-beslissing vereisten en - **Architect review:** akkoord per Claude Chat sessie 2026-04-24, iteratief verscherpt over drie rondes (initial → strict-enterprise op Q1/Q3 → FK-chain correctie op Q2). - **Product owner:** akkoord per Bert Hausmans 2026-04-24. +- **WS-5a afronding:** 2026-04-24 — relationele `form_field_bindings` tabel, polymorphic owner, snapshot-parity, JSON-kolommen gedropt. Volgende stap: prompt opstellen voor WS-2 (Purpose registry) met Q6-consolidatie als integraal onderdeel van de werkstroom. diff --git a/dev-docs/ARCH-FORM-BUILDER.md b/dev-docs/ARCH-FORM-BUILDER.md index 7198b1c3..093c7a27 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -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: diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index ab9b2132..098a39b1 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -327,6 +327,15 @@ shifts claimen zonder toegang tot de Organizer app. --- +### FORM-BINDING-SNAPSHOT-MULTI — snapshot shape voor multi-binding per field + +**Aanleiding:** WS-5a legt de relationele `form_field_bindings` tabel neer met een UNIQUE op `(owner_type, owner_id, target_entity, target_attribute)`. Dat laat meerdere bindings per field toe zolang ze op verschillende kolom-paren landen. De snapshot-writer (`FormSubmissionService::buildSnapshot` via `FormFieldBindingService::toJsonShape`) embed op dit moment maar één binding per field — de eerste. `schema_snapshot.fields[*].binding` is een object, geen array. +**Wat:** Snapshot-shape besluiten voor multi-binding: ofwel `binding` → array-of-objects, ofwel een nieuwe sleutel `bindings`. Migratiepad voor bestaande snapshots (ARCH §4.6.1). Reader-compat behouden. +**Trigger:** wanneer ARCH §6.1 patroon-scenario's multi-binding op één field rechtvaardigen (bv. Pattern C naar twee target entities tegelijk). +**Prioriteit:** Laag — out-of-scope van WS-5a, geen huidige user impact. + +--- + ### FORM-04 — `grace_days` configurable on public_token rotation **Aanleiding:** S2c §10.4 opgeleverd met een hardgecodeerd 7-daagse grace window in `PublicFormTokenResolver`. `rotatePublicToken` endpoint accepteert wel een `grace_days` request param maar schrijft die nergens naartoe; `form_schemas` heeft geen `grace_days` kolom. diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 807e4294..4069d03a 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1,10 +1,15 @@ # Crewli — Core Database Schema > Source: Design Document v1.3 — Section 3.5 -> **Version: 2.1** — Updated April 2026 +> **Version: 2.2** — Updated April 2026 > > **Changelog:** > +> - 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 +> ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q3. See ARCH-FORM-BUILDER.md +> §6.7 for the relational table and §6.3 for the binding-row specification. > - v1.3: Original — 12 database review findings incorporated > - v1.4: Competitor analysis amendments (Crescat, WeezCrew, In2Event) > - v1.5: Concept Event Structure review + final decisions @@ -1965,7 +1970,6 @@ that aggregates the user's submitted, non-test `form_submissions`. | `validation_rules` | JSON nullable | | | `default_is_required` | bool | default: false | | `default_is_filterable` | bool | default: false | -| `default_binding` | JSON nullable | Pattern A / B / C (see ARCH §6) | | `translations` | JSON nullable | Per-locale overrides | | `description` | text nullable | Admin-only description of intended use | | `usage_count` | int unsigned | default: 0; incremented by `FormFieldService::insertFromLibrary` | @@ -1973,12 +1977,16 @@ 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` +**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner` **Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)` **Unique constraint:** `UNIQUE(organisation_id, slug)` **Global scope:** `OrganisationScope` **Soft delete:** no +> 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. + --- ### `form_fields` @@ -2012,7 +2020,6 @@ that aggregates the user's submitted, non-test `form_submissions`. | `is_unique` | bool | default: false — uniqueness enforced in `FormValueService` (ARCH §4.2.1) | | `is_pii` | bool | default: false — drives retention + anonymisation | | `display_width` | string(10) | default: `full`; `FormFieldDisplayWidth` enum | -| `binding` | JSON nullable | Pattern A/B/C binding to entity column (ARCH §6) | | `conditional_logic` | JSON nullable | show_when rules; cycle detection on save | | `role_restrictions` | JSON nullable | Per-field RBAC driving `FieldAccessService` | | `translations` | JSON nullable | `{ : { label, help_text, options } }` | @@ -2022,10 +2029,46 @@ 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 +**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values; `morphMany` form_field_bindings 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. + +--- + +### `form_field_bindings` + +> Relational home for field and library-field bindings to entity columns. +> Polymorphic owner — morph-map aliases `form_field` and +> `form_field_library`. Pattern B (no binding) is represented by the +> absence of a row; only Pattern A (`entity_owned`) and Pattern C +> (`mirrored`) create rows. Bindings are physical state (not historical +> intent) — they cascade on owner delete (soft or hard) via +> `FormFieldBindingsCascadeObserver`. ARCH §6.7. + +| 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`) | +| `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; higher = dominant at conflict | +| `is_identity_key` | bool | default false; used by person-matching (WS-6) | +| `created_at`, `updated_at` | timestamps | | + +**Relations:** `morphTo` owner (`form_field` or `form_field_library`) +**Indexes:** `(target_entity, target_attribute)`, `(owner_type, owner_id)` +**Unique constraint:** `UNIQUE(owner_type, owner_id, target_entity, target_attribute)` +**Global scope:** `FormFieldBindingScope` — resolves tenant via UNION over both owner chains (form_field → form_schema → organisation_id, form_field_library → organisation_id). The standard `OrganisationScope` can't walk morph parents, so this scope exists as a sibling. Escape hatch: `withoutGlobalScope(FormFieldBindingScope::class)`. +**Soft delete:** no — bindings are current state, not audit + --- ### `form_submissions`