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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user