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,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.
---