From 9e181092fcfc4ac705fd65603cda723ea3b89ac5 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 00:09:12 +0200 Subject: [PATCH] =?UTF-8?q?docs(form-builder):=20WS-5c=20sign-off=20?= =?UTF-8?q?=E2=80=94=20SCHEMA=20v2.5=20+=20ARCH=20v1.7=20=C2=A78=20+=20add?= =?UTF-8?q?endum=20Q3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md | 25 +++ dev-docs/ARCH-FORM-BUILDER.md | 174 ++++++++++++++++-- dev-docs/SCHEMA.md | 75 +++++++- 3 files changed, 260 insertions(+), 14 deletions(-) diff --git a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md index b193c963..58ff3fe2 100644 --- a/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md +++ b/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md @@ -186,6 +186,30 @@ WS-5b splitst `form_fields.validation_rules` en `form_field_library.validation_r **Afronding WS-5b.** 5 commits, baseline tests 1047 → volledig groen na commit 5. WS-5b is hiermee compleet; scope-sibling extractie en WS-5c (`conditional_logic`) / WS-5d (`options`) zijn separate work packages. +### Uitvoering — WS-5c (2026-04-26) + +WS-5c splitst `form_fields.conditional_logic` JSON naar een relationele boom over twee tabellen: `form_field_conditional_logic_groups` (AND/OR nodes met `parent_group_id` voor nesting) + `form_field_conditional_logic_conditions` (leaves met `field_slug` / `comparison_operator` / `value` JSON). Tree-structuur met interleaved mixed children (groups en conditions als siblings onder één ouder), sort-order per positie binnen parent. + +**Geen library-mirror — Q3 expliciet.** `form_field_library` draagt geen conditional_logic en komt niet in scope. Eenvoudige FK `form_field_id` op de groups-tabel; geen polymorphic morph, geen `owner_type`/`owner_id` kolommen. `FormFieldService::insertFromLibrary` propageert geen conditional_logic — library-entries dragen die state niet. + +**Twee-tabel tree-structuur.** Alternatief (single table met nullable `value`/`operator` per rij-rol) zou semantische vervuiling hetzelfde type rot oproepen dat WS-5b's configs-split weg haalde. Groepen en condities zijn semantisch anders — één tabel per concept, interleaving via sort_order bij read-time in `toJsonShape`. Geen `sort_order` drift tussen siblings. + +**OrganisationScope cap verhoging van 3 naar 5.** De conditions-scope-chain (`condition → group → field → schema → organisation_id`) is 4 hops; de group-chain is 3. De oude cap van 3 zou bij conditions throwen via `TenantScopeResolutionException`. Raising naar 5 dekt de ketens en geeft headroom voor toekomstige diepere trees zonder `form_field_id` op conditions te denormaliseren (drift-risico). Infrastructurele wijziging — niet beperkt tot WS-5c; volgende WS's mogen diepere chains declareren als dat architectuur-winst oplevert. + +**Cycle detection behoud contract.** `FormFieldService::assertNoConditionalCycle` werd vervangen door `FormFieldConditionalLogicService::assertNoCycles`. Zelfde algoritme (DFS over sibling-adjacency), andere implementatie-site: leest de relationele tree, niet meer JSON. Tree-interne cycles zijn structureel onmogelijk via `parent_group_id` adjacency-list. + +**Activity log dual-events.** `field.updated` (met gereconstrueerde `old.conditional_logic` / `new.conditional_logic` via `toJsonShape`) + `field.conditional_logic_replaced` (semantisch). FormField subject-only, conform §6.7 WS-5a en §17.4.2 WS-5b. Library-niveau is silent — er IS geen library conditional_logic om te loggen. + +**Snapshot + API resource shape ongewijzigd.** Externe JSON-contract blijft byte-gelijk aan pre-WS-5c via `toJsonShape`. Geen frontend breaking change — de portal's `evaluateConditionalLogic` composable blijft dezelfde `{show_when: {...}}` struct consumeren. + +**Strict validator op save (commit 3).** `StoreFormFieldRequest` / `UpdateFormFieldRequest` accepteren `conditional_logic` nu als array-shape, niet JSON string. `after()` hook roept `FormFieldConditionalLogicService::assertSpecsValid` aan en rejects unknown operators, root conditions, empty groups, en malformed children als 422 vóór enige write. + +**Drie scope-siblings + twee FK-chain strategy users.** `FormFieldBindingScope` + `FormFieldValidationRuleScope` + `FormFieldConfigScope` zijn nog steeds de drie morph-based UNION-scope near-duplicaten (base-class extractie blijft uitgesteld tot WS-5d). WS-5c voegt twee FK-chain declaratieve strategy models toe (`FormFieldConditionalLogicGroup`, `FormFieldConditionalLogicCondition`) — die tellen niet voor de morph-sibling extractie-discipline. WS-5d's `form_field_options` wordt het vierde polymorphic-morph sibling en beslist het "abstract of niet". + +**JSON-kolom gedropt.** `form_fields.conditional_logic` gedropt in `2026_04_26_100003`. Geen library-equivalent om te droppen. Rollback-path: "roll back WS-5c commits 1–3 samen" — de backfill `down()` reconstrueert JSON terug naar de (via commit 3 rollback teruggekomen) kolom. + +**Afronding WS-5c.** 4 commits, baseline tests 1104 → 1148 volledig groen na commit 3 (drop-column). Breaking change acceptance: geen bridging compatibility layer — de portal blijft onaangeraakt omdat het externe JSON-contract identiek is. WS-5d (`options`) is het laatste WS-5-werkpakket. + --- ## Q4 — Sanctum `personal_access_tokens` @@ -300,5 +324,6 @@ WS-1 rapport Categorie D bevindingen die geen architect-beslissing vereisten en - **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. - **WS-5b afronding:** 2026-04-25 — relationele `form_field_validation_rules` + parallel `form_field_configs` tabel; `validation_rules` JSON-kolommen gedropt; frontend-contract migratie naar canonieke key-namen landed in commit 5. +- **WS-5c afronding:** 2026-04-26 — relationele `form_field_conditional_logic_groups` + `form_field_conditional_logic_conditions` tree-tabellen; simple FK op FormField (geen library-mirror per Q3); `conditional_logic` JSON-kolom gedropt; `OrganisationScope` FK-chain cap verhoogd van 3 naar 5 hops; snapshot + API resource JSON-contract byte-identiek via `toJsonShape`. 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 ac3d2039..a259701b 100644 --- a/dev-docs/ARCH-FORM-BUILDER.md +++ b/dev-docs/ARCH-FORM-BUILDER.md @@ -1,4 +1,4 @@ -# ARCH — Universal Form Builder (v1.6) +# ARCH — Universal Form Builder (v1.7) > **Source of truth** for Crewli's universal Form Builder architecture. > Any discrepancy with SCHEMA.md is resolved in favour of this document @@ -7,11 +7,17 @@ > **Status:** Approved — WS-5a landed (relational `form_field_bindings`); > WS-5b landed in full (relational `form_field_validation_rules` and > parallel `form_field_configs`; pre-WS-5b `validation_rules` JSON -> columns dropped). -> **Version:** 1.6 (new §17.5 "Field configuration (non-validation)" for -> the `form_field_configs` split; §17.4.4 updated with the -> non-validation-key relocation note). +> columns dropped); WS-5c landed (relational +> `form_field_conditional_logic_groups` + `form_field_conditional_logic_conditions`; +> pre-WS-5c `conditional_logic` JSON column dropped; no library mirror +> per addendum Q3). +> **Version:** 1.7 (§8 restructured into tree-structure, relational-tables, +> service-boundary, operator-catalogue, cycle-detection, activity-log and +> legacy-migration sub-sections; contract unchanged). > **Previous:** +> 1.6 (new §17.5 "Field configuration (non-validation)" for +> the `form_field_configs` split; §17.4.4 updated with the +> non-validation-key relocation note), > 1.5 (§17.4 restructured into relational sub-sections: catalogue, > relational table, callback rules, legacy JSON migration). > **Previous versions:** @@ -1425,7 +1431,12 @@ velden die je echt als filter gebruikt." ## 8. Conditional logic -`form_fields.conditional_logic` JSON: +### 8.1 Tree structure + +Per-field visibility rules are a boolean tree: mixed condition leaves +and sub-groups under a parent `all` (AND) / `any` (OR) group. The +external JSON contract (snapshot writer + API resources) renders the +tree under a `show_when` wrapper: ```json { @@ -1437,13 +1448,154 @@ velden die je echt als filter gebruikt." } ``` -Operators: equals, not_equals, contains, not_contains, in, not_in, -greater_than, less_than, empty, not_empty. +Groups nest arbitrarily. Leaves reference sibling fields by +`field_slug` within the same schema. Seed-scan (2026-04-26, Phase A) +confirmed nesting depth ≤ 2 in the wild; the architecture tolerates +deeper nesting within the scope-cap ceiling. -Groups: `all` (AND), `any` (OR), nestable. +### 8.2 Relational tables (WS-5c) -References: by `field_slug` within same schema. Cyclic dependencies -rejected at save time via depth-first traversal check. +Pre-WS-5c the tree lived in a `form_fields.conditional_logic` JSON +column. WS-5c split it into two semantic-pure tables: + +- `form_field_conditional_logic_groups` — tree nodes (AND/OR), adjacency- + list nesting via `parent_group_id`. Columns: `id` ULID PK, + `form_field_id` FK, `parent_group_id` nullable FK self, `operator` + (FormFieldConditionalLogicGroupOperator enum), `sort_order`, + timestamps. Indexes: `(form_field_id)`, + `(parent_group_id, sort_order)`. +- `form_field_conditional_logic_conditions` — leaves. Columns: `id` ULID + PK, `group_id` FK, `field_slug` string(100), `comparison_operator` + (FormFieldConditionalLogicConditionOperator enum), `value` JSON + nullable, `sort_order`, timestamps. Indexes: + `(group_id, sort_order)`, `(field_slug)`. + +**No polymorphic morph.** Per addendum Q3, only `FormField` is in scope +for conditional_logic — the library doesn't carry conditional_logic and +is not mirrored. Simple `form_field_id` FK, not `owner_type`/`owner_id`. + +**Multi-tenancy.** Both tables use the Q2 declarative FK-chain resolver +via `tenantScopeStrategy()`: + +- Group chain (3 hops): `group → field → schema → organisation_id` +- Condition chain (4 hops): `condition → group → field → schema → + organisation_id` + +The condition chain is 4 hops and requires the OrganisationScope cap to +be ≥ 4. WS-5c raised the global cap from 3 to 5 to accommodate the +chain (and to give headroom for future deeper trees). + +**Cascade.** DB-level `ON DELETE CASCADE` on `form_field_id` and +`parent_group_id` handles hard deletes. The shared +`FormFieldChildTablesCascadeObserver` physically deletes groups on +FormField soft- or hard-delete; conditions cascade via `group_id`. +Bindings, validation rules, configs and now conditional-logic groups +are all current state (not audit) — they never carry soft-delete +semantics of their own. + +### 8.3 Service boundary + +`FormFieldConditionalLogicService` is the only writer. No controller +writes groups or conditions directly on a model. + +- `logicFor(field)` — depth-limited eager-load of the full tree. Bounded + to 5 levels to match the scope-cap ceiling. +- `replaceLogic(field, tree)` — transactional: structure validation, + operator enum enforcement, `field_slug` existence check (against + sibling fields in the same schema), cycle detection, then delete- + and-insert. Emits `field.conditional_logic_replaced` on the FormField + subject. +- `toJsonShape(root)` — single source of truth for serialising a tree + back into the ARCH §8.1 `{show_when: {...}}` shape. Consumed by + `FormSubmissionService::buildSnapshot` and by `FormFieldResource`, + `PublicFormSchemaResource`. Deterministic interleave of sub-groups + and conditions by `(sort_order, id)`. +- `assertSpecsValid(tree)` — public guard called by the + Store/Update FormRequests' `after()` hook. Rejects bad specs at the + HTTP boundary before any write. +- `assertNoCycles(field, tree)` — see §8.5. + +`FormFieldService::insertFromLibrary` does **not** propagate +conditional logic — the library carries none (addendum Q3). + +### 8.4 Operator catalogues + +**Group operators** (`FormFieldConditionalLogicGroupOperator` DB-backed enum): + +| Value | Semantic | +| ----- | --------- | +| `all` | AND | +| `any` | OR | + +**Comparison operators** (`FormFieldConditionalLogicConditionOperator` +DB-backed enum — catalogue confirmed by Phase A seed-scan against the +frontend evaluator in `packages/form-schema/src/composables/useConditionalLogic.ts`): + +| Value | Reads `value`? | Notes | +| -------------- | -------------- | ------------------------------------------------------- | +| `equals` | yes | | +| `not_equals` | yes | | +| `contains` | yes | substring (strings) / membership (arrays) | +| `not_contains` | yes | | +| `in` | yes (array) | | +| `not_in` | yes (array) | | +| `greater_than` | yes (numeric) | | +| `less_than` | yes (numeric) | | +| `empty` | no | `value` column stored as NULL; service enforces | +| `not_empty` | no | `value` column stored as NULL; service enforces | + +### 8.5 Cycle detection + +Cross-field cycle detection (contract preserved from pre-WS-5c +`FormFieldService::assertNoConditionalCycle`, implementation moved to +`FormFieldConditionalLogicService::assertNoCycles`). + +Algorithm: build a slug → list-of-dependent-slugs adjacency over every +sibling field in the schema (reads the relational tree — post-WS-5c +source of truth) plus the proposed tree for the subject field. DFS +from the subject's slug; a back-edge raises `CyclicDependencyException`. +Controller maps the exception to 422. + +Tree-internal cycles are structurally impossible via the adjacency-list +nesting (parent_group_id is a single ancestor). + +### 8.6 Activity log + +Matches the WS-5a/b pattern. Two entries emit on a logic change: + +- `field.updated` — payload includes `old.conditional_logic` / + `new.conditional_logic` shapes reconstructed from the relational + tree via `toJsonShape`. Preserves the pre-WS-5c audit-consumer + contract. +- `field.conditional_logic_replaced` — the semantic event, emitted + inside `replaceLogic()`. + +FormField subject only. No library mirror — matches §6.7 WS-5a and +§17.4.2 WS-5b on the "library-level changes silent in activity log" +convention. + +### 8.7 Legacy JSON migration (WS-5c) + +The WS-5c backfill migration +(`2026_04_26_100002_backfill_form_field_conditional_logic.php`) +translates pre-WS-5c `form_fields.conditional_logic` JSON into rows. +Strict dispatch — no guessing, no silent drops: + +- Top-level keys other than `show_when`: FAIL the migration. Phase A + seed-scan (2026-04-26) confirmed only `show_when` exists in the wild. +- Comparison operators outside the 10-case catalogue: FAIL. +- Group with no children / non-array child / missing `all`/`any`: FAIL. + +Pre-WS-5c data is assumed acyclic — the JSON-era save-time cycle check +enforced that. The backfill does NOT re-run cycle detection across the +whole schema. Post-backfill the service enforces going forward. + +Rollback reconstructs the canonical JSON shape from the relational +tree (stable `(sort_order, id)` ordering) and writes it back to +`form_fields.conditional_logic` (still present pre-drop migration), +then clears the relational tables. The forward+back pair is safe as a +unit; a partial rollback that pops just this migration but leaves its +create-table siblings is not a supported state. --- diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 413c4a4e..1929ed7b 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -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 | `{ : { 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`