docs(form-builder): WS-5c sign-off — SCHEMA v2.5 + ARCH v1.7 §8 + addendum Q3

SCHEMA v2.5:
- form_fields: conditional_logic row removed; cross-reference note
  added pointing at the two new tables and the addendum Q3 WS-5c
  Uitvoering (no library mirror).
- New sections: form_field_conditional_logic_groups (tree nodes,
  adjacency-list via parent_group_id) and
  form_field_conditional_logic_conditions (leaves; value JSON
  nullable for empty/not_empty). Both tables use the Q2 declarative
  FK-chain resolver via tenantScopeStrategy() — group chain 3 hops,
  condition chain 4 hops (fits the WS-5c-raised cap of 5).

ARCH v1.7 §8 restructured into sub-sections mirroring the §17.4 /
§17.5 pattern:
- 8.1 Tree structure (read-side contract)
- 8.2 Relational tables (column specs, cascade, scope)
- 8.3 Service boundary (logicFor/replaceLogic/toJsonShape/
  assertSpecsValid/assertNoCycles)
- 8.4 Operator catalogues (group + comparison)
- 8.5 Cycle detection (contract preserved, implementation moved)
- 8.6 Activity log (dual-events: field.updated +
  field.conditional_logic_replaced; FormField subject only)
- 8.7 Legacy JSON migration (strict dispatch, rollback reversible)

Addendum Q3 extended with "Uitvoering — WS-5c (2026-04-26)":
- No-library-mirror decision reaffirmed (simple FK, no morph)
- Two-table tree-structure rationale (groups + conditions semantic
  purity over single-table mixed-nullables)
- OrganisationScope cap raise 3 → 5, rationale: legitimate 4-hop
  conditions chain + headroom for future deeper trees without
  denormalising form_field_id onto conditions
- Cycle detection migrated to service, contract unchanged
- Snapshot + resource JSON contract byte-identical via toJsonShape
- Strict validator on save at FormRequest boundary
- Scope-sibling discipline: WS-5c adds two FK-chain models (not
  morph); base-class extraction still parked for WS-5d

Sign-off table: WS-5c afronding 2026-04-26 added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:09:12 +02:00
parent 079d10975b
commit 9e181092fc
3 changed files with 260 additions and 14 deletions

View File

@@ -1,10 +1,24 @@
# Crewli — Core Database Schema
> Source: Design Document v1.3 — Section 3.5
> **Version: 2.4** — Updated April 2026
> **Version: 2.5** — Updated April 2026
>
> **Changelog:**
>
> - v2.5: WS-5c — `form_fields.conditional_logic` JSON column **dropped**;
> replaced by a two-table relational tree:
> `form_field_conditional_logic_groups` (AND/OR nodes with optional
> parent_group_id for nesting) + `form_field_conditional_logic_conditions`
> (leaves: field_slug + comparison_operator + value JSON). Simple FK to
> `form_fields` — per addendum Q3 the library is explicitly **out of
> scope** for conditional_logic (no library mirror, no polymorphic morph
> on these tables). Snapshot + API resource JSON shape preserved
> byte-for-byte via `FormFieldConditionalLogicService::toJsonShape`.
> `OrganisationScope` FK-chain cap raised from 3 to 5 hops to
> accommodate the 4-hop conditions chain (condition → group → field →
> schema → organisation_id column) without denormalising `form_field_id`
> onto conditions. See ARCH-FORM-BUILDER.md §8 and
> ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q3 WS-5c Uitvoering.
> - v2.4: WS-5b completion — `form_field_configs` relational table lands
> alongside `form_field_validation_rules` (both from WS-5b). Holds
> non-validation per-field configuration (`tag_categories`,
@@ -2052,7 +2066,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 |
| `conditional_logic` | JSON nullable | show_when rules; cycle detection on save |
| `role_restrictions` | JSON nullable | Per-field RBAC driving `FieldAccessService` |
| `translations` | JSON nullable | `{ <locale>: { label, help_text, options } }` |
| `value_storage_hint` | string(10) | default: `json`. `FormValueStorageHint` enum — guides typed-column population |
@@ -2061,7 +2074,7 @@ 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; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`
**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values, conditionalLogicGroups; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`
**Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)`, `(form_schema_id, slug)`
**Soft delete:** yes
@@ -2073,6 +2086,13 @@ that aggregates the user's submitted, non-test `form_submissions`.
> table (ARCH-FORM-BUILDER.md §17.4). The column was dropped in WS-5b;
> see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5b
> Uitvoering for the full catalogue and migration notes.
>
> Conditional logic moved to the relational
> `form_field_conditional_logic_groups` + `form_field_conditional_logic_conditions`
> tables (ARCH-FORM-BUILDER.md §8). The column was dropped in WS-5c; see
> `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5c
> Uitvoering. No library mirror — addendum Q3 excludes library from
> conditional_logic scope.
---
@@ -2166,6 +2186,55 @@ that aggregates the user's submitted, non-test `form_submissions`.
---
### `form_field_conditional_logic_groups`
> Tree nodes (root + branches) of the relational conditional-logic
> structure that replaced `form_fields.conditional_logic` JSON in WS-5c.
> Per addendum Q3, only FormField is in scope — no polymorphic morph,
> just a simple FK to `form_fields`. Nesting is adjacency-list via
> `parent_group_id`; leaves live in `form_field_conditional_logic_conditions`.
> ARCH-FORM-BUILDER.md §8.
| Column | Type | Notes |
| ------------------ | --------------------- | ------------------------------------------------------------------------------ |
| `id` | ULID | PK |
| `form_field_id` | ULID FK | → form_fields, cascade delete |
| `parent_group_id` | ULID FK nullable | → form_field_conditional_logic_groups, cascade delete; null at the tree root |
| `operator` | string(10) | `FormFieldConditionalLogicGroupOperator` enum: `all` (AND) \| `any` (OR) |
| `sort_order` | int unsigned | default: 0; ordering within parent_group_id |
| `created_at`, `updated_at` | timestamps | |
**Relations:** `belongsTo` form_field, parentGroup; `hasMany` childGroups, conditions
**Indexes:** `(form_field_id)`, `(parent_group_id, sort_order)`
**Global scope:** `OrganisationScope` via `tenantScopeStrategy()` — FK-chain `group → field → schema → organisation_id` (3 hops, inside the cap). Escape hatch: `withoutGlobalScope(OrganisationScope::class)`.
**Soft delete:** no — conditional logic is current state, not audit
---
### `form_field_conditional_logic_conditions`
> Leaves of the relational conditional-logic tree. Each row holds one
> `(field_slug, comparison_operator, value)` comparison attached to a
> parent group. `value` is JSON nullable — scalar for most operators,
> array for `in`/`not_in`, null for `empty`/`not_empty`. ARCH-FORM-BUILDER.md §8.
| Column | Type | Notes |
| ---------------------- | --------------------- | ------------------------------------------------------------------------------ |
| `id` | ULID | PK |
| `group_id` | ULID FK | → form_field_conditional_logic_groups, cascade delete |
| `field_slug` | string(100) | references `form_fields.slug` within the same schema (not a FK — the service layer resolves it and raises on unknown slugs) |
| `comparison_operator` | string(20) | `FormFieldConditionalLogicConditionOperator` enum: equals, not_equals, contains, not_contains, in, not_in, greater_than, less_than, empty, not_empty |
| `value` | JSON nullable | per-operator; null for `empty`/`not_empty` |
| `sort_order` | int unsigned | default: 0; ordering within the parent group alongside sub-groups |
| `created_at`, `updated_at` | timestamps | |
**Relations:** `belongsTo` group
**Indexes:** `(group_id, sort_order)`, `(field_slug)`
**Global scope:** `OrganisationScope` via `tenantScopeStrategy()` — FK-chain `condition → group → field → schema → organisation_id` (4 hops; fits within the WS-5c-raised cap of 5). Escape hatch: `withoutGlobalScope(OrganisationScope::class)`.
**Soft delete:** no — conditional logic is current state, not audit
---
### `form_submissions`
> One submission per `(schema, subject)` in `single` / `draft_single`