Five refinements from the 2026-05-07 architectural review: - Q1: TriggerPersonIdentityMatchOnFormSubmit moves to queued; sync-chain reduced to ApplyBindings only; queued-listener gating invariant; sync-chain deadline wrapper. - Q2: Failsafe pad in TriggerPersonIdentityMatch removed in favour of strict invariant + throw; RequiresIdentityKeyBinding unconditional for event_registration; FormSubmissionResource.identity_match=null contract for non-person purposes. - Q3: Three failure-UX additions (GlitchTip alert, custom exception hierarchy + error_code, BACKLOG entries for partial-success and schema-drift). Spine unchanged: pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, single identity-key per target_entity. Refs: dev-docs/RFC-WS-6.md (now v1.3), dev-docs/ARCH-BINDINGS.md (now v1.1), dev-docs/BACKLOG.md (PARTIAL-BINDING-SUCCESS, FORM-SCHEMA-DRIFT-DETECTION added) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1560 lines
86 KiB
Markdown
1560 lines
86 KiB
Markdown
# ARCH-BINDINGS.md — FormBindingApplicator Pipeline
|
||
|
||
**Version:** v1.1 — incorporates RFC-WS-6 v1.3 refinements (architectural review 2026-05-07)
|
||
**Aligned to:** `main` HEAD `4a84b9e` (post WS-6 closure, 2026-05-04)
|
||
**Authoritative:** Yes. This document supersedes `RFC-WS-6.md` for ongoing maintenance — RFC-WS-6 remains as a historical record of the 2026-04-25 design session that produced this architecture.
|
||
|
||
---
|
||
|
||
## 1. Status & how to use this doc
|
||
|
||
This document is the canonical reference for the FormBindingApplicator pipeline: the system that turns a submitted form into mutations on domain entities (persons, users, artists, companies, etc.). The pipeline lives behind the `FormSubmissionSubmitted` event and runs synchronously where ordering matters and queued where it does not.
|
||
|
||
ARCH-BINDINGS is the **second-tier** form-builder reference. ARCH-FORM-BUILDER is the umbrella doc covering the full form-builder domain (schemas, fields, sections, submissions, values, library, webhooks, integrations). ARCH-BINDINGS narrows in on the pipeline that connects submissions to domain entities.
|
||
|
||
### Read this for…
|
||
|
||
| Goal | Read |
|
||
|---|---|
|
||
| Modify the apply pipeline (add a strategy, change a listener) | §2, §5, §6, §17 |
|
||
| Build the Form Builder UI bindings editor | §3, §4, §8, §9 |
|
||
| Wire observability for binding events | §7, §11, §14 |
|
||
| Add a new FormPurpose | §3, §9 |
|
||
| Diagnose a production binding failure | §11, §12, §14 |
|
||
| Understand why a guard rejected a publish | §8 |
|
||
| Implement library-bindings UI | §15 |
|
||
| Audit GDPR compliance of a submission | §14, §16 |
|
||
|
||
### Cross-document relations
|
||
|
||
- **ARCH-FORM-BUILDER.md** — sibling, authoritative for `form_field_bindings` table shape (§17), per-purpose lifecycles (§3.2), integration contracts to non-binding listeners (§31), purpose registry mechanics (§17.3)
|
||
- **SCHEMA.md §3.5.12** — authoritative for `form_submissions`, `form_field_bindings`, `form_submission_action_failures` table shape
|
||
- **RFC-WS-6.md** — historical design source; cited in this doc as `RFC-WS-6 §X` for the "why" behind any decision
|
||
- **ARCH-CONSOLIDATION-2026-04.md** §6.2 — WS-6 charter and post-WS-6 deliverables list
|
||
- **ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md** Q1, Q2 — ULID exception retired, denormalized `organisation_id`
|
||
- **ARCH-OBSERVABILITY.md** (WS-8b, forthcoming) — how pipeline events surface in GlitchTip and Telescope, PII-scrubbing contract for failure exports
|
||
- **BACKLOG.md** — deferred items referenced throughout this doc
|
||
|
||
### Stability commitment
|
||
|
||
The contracts in §3 (`BindingTypeRegistry`), §4 (merge strategies), §5 (listener chain), §6 (two-transaction pattern), §7 (status columns), §8 (PublishGuard interface), §9 (PurposeGuardProvider) are stable. Changes require an RFC and a coordinated update of this doc + tests + ARCH-FORM-BUILDER cross-refs.
|
||
|
||
The contracts in §10 (section-level apply), §11 (`form_submission_action_failures`), §12 (retry/resolve/dismiss flows) are stable but feature-flagged or admin-only — additive changes are expected.
|
||
|
||
---
|
||
|
||
## 2. The pipeline at a glance
|
||
|
||
### 2.1 Diagram
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ FormSubmissionService::submit($submission) │
|
||
│ │
|
||
│ DB::transaction (inner): │
|
||
│ 1. validate │
|
||
│ 2. write form_values │
|
||
│ 3. update submission.status │
|
||
│ 4. write submission.schema_snapshot (canonicalized) │
|
||
│ commit │
|
||
│ │
|
||
│ event(new FormSubmissionSubmitted($submission->refresh())) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌───────────────────────┴────────────────────────┐
|
||
│ │
|
||
▼ SYNC chain (registration order) ▼ QUEUED listeners
|
||
(parallel, no ordering)
|
||
┌─────────────────────────────────┐
|
||
│ ApplyBindingsOnFormSubmit │ ┌──────────────────────────────┐
|
||
│ ├─ resolveOrProvisionSubject │ │ SyncTagPickerSelectionsOnSubmit
|
||
│ ├─ DB::transaction (inner) │ ├──────────────────────────────┤
|
||
│ │ ├─ resolve bindings │ │ CreateProvisionalShift…OnReg.│
|
||
│ │ ├─ apply per binding │ ├──────────────────────────────┤
|
||
│ │ ├─ writeApplyStatus(OK) │ │ FormWebhookDispatcher │
|
||
│ │ └─ triggerIdentityMatch │ │ → DeliverFormWebhookJob │
|
||
│ │ (status update only) │ ├──────────────────────────────┤
|
||
│ ├─ catch → DB::transaction │ │ AddPersonToApplicableCrowd… │
|
||
│ │ (outer, separate) │ ├──────────────────────────────┤
|
||
│ │ ├─ FormSubmissionAction… │ │ RegistrationConfirmation │
|
||
│ │ │ Failure::create │ │ (purpose-specific mailable)│
|
||
│ │ └─ writeApplyStatus(FAIL) │ └──────────────────────────────┘
|
||
│ └─ swallow exception │
|
||
└─────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ TriggerPersonIdentityMatch… │
|
||
│ (sync, runs second) │
|
||
│ ├─ if subject_type=null: │
|
||
│ │ log warning, set 'pending' │
|
||
│ └─ else: │
|
||
│ PersonIdentityService:: │
|
||
│ detectMatches($person) │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 What the pipeline is — and is not
|
||
|
||
The pipeline **is**:
|
||
- A mechanism for translating form values into domain-entity attribute mutations under purpose-specific rules
|
||
- A pre-publish validation framework that prevents structurally unsafe schemas from going live
|
||
- An audit infrastructure (apply_status, action_failures, activity log)
|
||
- A failure-recovery surface (retry / resolve / dismiss flows)
|
||
|
||
The pipeline **is not**:
|
||
- A workflow engine — there are no per-step states, approvals, or human-in-the-loop branches
|
||
- A scheduler — all work fires from `FormSubmissionSubmitted` in real time (sync) or near-real-time (queued)
|
||
- A data-transformation layer — values are written as-resolved, not transformed mid-flight
|
||
- A notification system — mailables are siblings of the pipeline, not part of it
|
||
|
||
### 2.3 Glossary
|
||
|
||
| Term | Meaning |
|
||
|---|---|
|
||
| **binding** | A row in `form_field_bindings` connecting a `form_field` to a `target_entity.target_attribute` with a `merge_strategy`, `trust_level`, and optional `is_identity_key` flag |
|
||
| **target_entity** | The Eloquent model class (morph alias) that receives the mutation: `person`, `user`, `artist`, `company`, etc. |
|
||
| **target_attribute** | The attribute path on the target_entity, e.g. `email`, `profile.t_shirt_size`, `tags` (collection) |
|
||
| **trust_level** | Integer 0–100 expressing how authoritative this binding is when multiple bindings target the same attribute |
|
||
| **merge_strategy** | One of `overwrite`, `append`, `replace`, `first_write_wins` — see §4 |
|
||
| **is_identity_key** | Boolean flag marking a binding as the lookup key for `firstOrCreate` semantics; max one per `target_entity` per schema |
|
||
| **apply_status** | Column on `form_submissions` tracking pipeline-completion state: `null` (not applicable), `pending`, `completed`, `failed` |
|
||
| **identity_match_status** | Existing column on `form_submissions` tracking person-identity-match progress: `pending`, `matched`, `no_match`, `auto_linked` |
|
||
| **PublishGuard** | A pre-publish check that returns `PublishGuardResult` (passed or one or more violation messages); see §8 |
|
||
| **PurposeGuardProvider** | A service interface returning the `list<PublishGuard>` for a given purpose; see §9 |
|
||
| **action failure** | A row in `form_submission_action_failures` recording a binding-pipeline exception that survived the inner-transaction rollback |
|
||
| **DismissalReasonType** | Enum of 6 reasons an action failure can be dismissed without retry |
|
||
|
||
---
|
||
|
||
## 3. Configuration: BindingTypeRegistry & config files
|
||
|
||
### 3.1 `config/form_builder/bindings.php`
|
||
|
||
The `BindingTypeRegistry` is the **single source of truth** for what shape a target attribute has at runtime. The file maps every supported `target_entity.target_attribute` pair to a `BindingTargetType`:
|
||
|
||
```php
|
||
return [
|
||
'targets' => [
|
||
'person' => [
|
||
'email' => BindingTargetType::SCALAR,
|
||
'first_name' => BindingTargetType::SCALAR,
|
||
'last_name' => BindingTargetType::SCALAR,
|
||
'date_of_birth' => BindingTargetType::SCALAR,
|
||
'phone' => BindingTargetType::SCALAR,
|
||
'tags' => BindingTargetType::COLLECTION,
|
||
'crowd_type_id' => BindingTargetType::RELATION,
|
||
// ...
|
||
],
|
||
'user' => [
|
||
'email' => BindingTargetType::SCALAR,
|
||
'profile.t_shirt_size' => BindingTargetType::SCALAR,
|
||
'profile.dietary_preferences' => BindingTargetType::COLLECTION,
|
||
// ...
|
||
],
|
||
// ...
|
||
],
|
||
];
|
||
```
|
||
|
||
`BindingTargetType` (PHP backed enum) has three cases:
|
||
|
||
| Case | Semantics | Example |
|
||
|---|---|---|
|
||
| `SCALAR` | Single value; `merge_strategy` choice meaningful for conflict resolution | `person.email`, `user.first_name` |
|
||
| `COLLECTION` | Set of values with deduplication; `append` becomes idempotent | `person.tags`, `user.profile.languages` |
|
||
| `RELATION` | Foreign-key reference; `merge_strategy` of `overwrite` is the only sane choice | `person.crowd_type_id` |
|
||
|
||
**Convention-not-contract is forbidden.** The registry is queried explicitly. Name-suffix matching (e.g. attributes ending in `_tags` are collection) was rejected per RFC-WS-6 §4 V1 because it would silently misclassify edge cases (`user_tags_count` would falsely qualify).
|
||
|
||
### 3.2 `config/form_builder/purposes.php`
|
||
|
||
Each purpose entry has a `guards_class` key alongside the existing `subject_type`, `requiredBindings`, `submission_mode`, and `public_token_supported`:
|
||
|
||
```php
|
||
'event_registration' => [
|
||
'subject_type' => 'person',
|
||
'submission_mode' => SubmissionMode::DRAFT_SINGLE,
|
||
'public_token_supported' => true,
|
||
'requiredBindings' => [
|
||
['target_entity' => 'person', 'target_attribute' => 'email', 'is_identity_key' => true],
|
||
],
|
||
'guards_class' => EventRegistrationGuardProvider::class,
|
||
],
|
||
```
|
||
|
||
`PurposeRegistry::guardProviderFor(string $slug): PurposeGuardProvider` instantiates and caches the class. See §9.
|
||
|
||
### 3.3 `config/form_builder/section_apply.php`
|
||
|
||
Section-level binding apply is feature-flagged. The config file consists of one boolean and a comment block documenting the removal trigger:
|
||
|
||
```php
|
||
return [
|
||
/*
|
||
* Section-level binding apply is gated until ARTIST_ADVANCE feature
|
||
* work begins (post-S5).
|
||
*
|
||
* To enable:
|
||
* 1. Set 'enabled' => true (or via FORM_BUILDER_SECTION_APPLY env var)
|
||
* 2. Activate ApplyBindingsOnFormSectionSubmitted listener registration
|
||
* in EventServiceProvider
|
||
* 3. Write section-scoped tests against FormBindingApplicator::apply($s, sectionId: $id)
|
||
* 4. Remove the early-return guard from the listener
|
||
* 5. Remove this entire feature-flag config — section-level apply
|
||
* becomes the default for purposes with section_level_submit=true.
|
||
*
|
||
* Tracking: BACKLOG.md → ARTIST-ADV-SECTION-APPLY
|
||
*/
|
||
'enabled' => env('FORM_BUILDER_SECTION_APPLY', false),
|
||
];
|
||
```
|
||
|
||
### 3.4 Adding a new target attribute
|
||
|
||
To extend the registry with a new bindable attribute (e.g. `person.country_code`):
|
||
|
||
1. Add the entry under `targets.person.country_code` in `config/form_builder/bindings.php` with the correct `BindingTargetType`
|
||
2. Add a fixture row in `database/factories/FormFieldBindingFactory.php` if test coverage uses the new attribute
|
||
3. Add a single positive test asserting `BindingTypeRegistry::resolve('person', 'country_code')->equals(BindingTargetType::SCALAR)` (or whichever)
|
||
4. **Optional:** add a `PublishGuard` if the new attribute imposes constraints on schema authors (e.g. `RequiresFieldType('text', minCount: 1)` for a country-code field that must appear in the schema)
|
||
|
||
No migration is needed — the registry is config, and the `form_field_bindings` table already has the columns.
|
||
|
||
---
|
||
|
||
## 4. Merge strategies & target-type contract
|
||
|
||
### 4.1 The four strategies
|
||
|
||
`MergeStrategy` (PHP backed enum):
|
||
|
||
| Strategy | Semantics |
|
||
|---|---|
|
||
| `overwrite` | Winner value (after trust precedence) replaces the target attribute unconditionally |
|
||
| `append` | Winner value is added to the target collection; duplicates deduplicated by collection semantics. **Restricted to `BindingTargetType::COLLECTION` per V1.** |
|
||
| `replace` | Winner value replaces the target only if the target is currently null. Non-null targets are preserved. |
|
||
| `first_write_wins` | Winner value writes only if the target is null AND the winner value is non-null. |
|
||
|
||
### 4.2 Strategy × target-type validity matrix
|
||
|
||
| | SCALAR | COLLECTION | RELATION |
|
||
|---|---|---|---|
|
||
| `overwrite` | ✅ valid | ⚠ valid but unusual (overwrites entire collection) | ✅ valid |
|
||
| `append` | ❌ rejected at publish-time | ✅ valid (idempotent on retry) | ❌ rejected at publish-time |
|
||
| `replace` | ✅ valid | ✅ valid (only writes if collection is null/empty) | ✅ valid |
|
||
| `first_write_wins` | ✅ valid | ✅ valid | ✅ valid |
|
||
|
||
The `AppendStrategyRequiresCollectionTarget` PublishGuard (§8) enforces the two ❌ cells at publish time. Runtime apply assumes publish has succeeded.
|
||
|
||
### 4.3 Null-winner matrix (Q7 conflict resolution)
|
||
|
||
After trust precedence, the winning binding may have a null value. Behaviour per strategy:
|
||
|
||
| `merge_strategy` | Winner value = null | Behaviour |
|
||
|---|---|---|
|
||
| `overwrite` | null | Writes null to target — explicit clear |
|
||
| `append` | null | No-op — nothing to append, target unchanged |
|
||
| `replace` | null | No-op when target is non-null; no-op when target is null (no value to write) |
|
||
| `first_write_wins` | null | Writes null when target is null (claims the slot); skipped when target has any value |
|
||
|
||
The candidate-set definition that supports this matrix lives in §17. The short version: a `form_values` row with `value=null` is an explicit clear by the user (multi-step edit-resubmit), distinct from absence of a row (skipped by conditional logic).
|
||
|
||
### 4.4 Why `append` is collection-only
|
||
|
||
Appending to scalars (string concatenation, comma-separated lists) requires a fingerprint mechanism to detect duplicate-append on retry. Embedding fingerprints in domain data is an architectural smell. Collection types with set semantics (deduplicated JSON arrays, pivot relations) make retry naturally idempotent. Restricting `append` to collections eliminates the entire problem class.
|
||
|
||
If a use-case ever surfaces for "append a string segment to a text field", model it as a collection with later projection to a single string at read-time, not as an `append` on a scalar.
|
||
|
||
---
|
||
|
||
## 5. Listener chain — sync, queued, ordering
|
||
|
||
### 5.1 Registration
|
||
|
||
`EventServiceProvider::$listen` is the single source of truth for listener registration. One synchronous listener fires on `FormSubmissionSubmitted`; all others are queued and run in parallel:
|
||
|
||
```php
|
||
protected $listen = [
|
||
FormSubmissionSubmitted::class => [
|
||
// SYNC — only ApplyBindings, because subject_id must be on the
|
||
// submission before the HTTP response serializes
|
||
ApplyBindingsOnFormSubmit::class,
|
||
// QUEUED — order does not matter; all gated on apply_status=COMPLETED
|
||
TriggerPersonIdentityMatchOnFormSubmit::class,
|
||
SyncTagPickerSelectionsOnSubmit::class,
|
||
CreateProvisionalShiftAssignmentsFromRegistration::class,
|
||
AddPersonToApplicableCrowdListsOnRegistration::class,
|
||
FormWebhookDispatcher::class,
|
||
// Plus purpose-specific mailables (RegistrationConfirmation etc.)
|
||
],
|
||
];
|
||
```
|
||
|
||
The single SYNC listener does not implement `ShouldQueue`. All others do. Laravel's listener-array order is the ordering primitive; no `Subscriber` pattern, no `$priority` flag.
|
||
|
||
> **v1.0 → v1.1:** v1.0 had two synchronous listeners (`ApplyBindingsOnFormSubmit` →
|
||
> `TriggerPersonIdentityMatchOnFormSubmit`). RFC-WS-6 v1.3 (2026-05-07 review) reduced
|
||
> the SYNC chain to one: identity-match joins against `persons` scoped to organisation
|
||
> and runs in seconds at scale; blocking PHP-FPM workers on it during peak public
|
||
> registration is operationally unacceptable. See §5.6 for the gating invariant that
|
||
> queued listeners must include.
|
||
|
||
### 5.2 Listener catalogue
|
||
|
||
| Listener | Sync/Queued | Trigger condition | What it does | Failure mode |
|
||
|---|---|---|---|---|
|
||
| `ApplyBindingsOnFormSubmit` | sync | Any submission | Provisions or resolves subject; applies all bindings; writes `apply_status`; writes initial `identity_match_status='pending'` for `subject_type='person'`. Runs inside a 5s deadline wrapper — exceeded deadline throws `FormBindingApplicatorTimeoutException` routed via outer transaction. | Catches strict `FormBindingApplicator` throws, writes `form_submission_action_failures` row in outer transaction (with `failure_response_code` mirrored onto `form_submissions`), swallows |
|
||
| `TriggerPersonIdentityMatchOnFormSubmit` | queued | Gated on `apply_status=COMPLETED` (per §5.6); `subject_type='person'`. Throws `IdentityMatchInvariantViolation` if `subject_id IS NULL` despite COMPLETED state — that condition is a structural defect, not a failsafe (RFC-WS-6 §Q2 v1.3). | Calls `PersonIdentityService::detectMatches($person)`, writes final `identity_match_status` + `identity_match_count`, broadcasts `FormSubmissionIdentityMatchResolved` on the `submission.{id}` private channel | Throw routed via Laravel queue worker → GlitchTip + `form_submission_action_failures` row; does not block siblings |
|
||
| `SyncTagPickerSelectionsOnSubmit` | queued | Gated on `apply_status=COMPLETED` (per §5.6); `purpose='event_registration'` AND TAG_PICKER values present | Rebuilds `user_organisation_tags` (source=self_reported) for the linked Person | Logs at error, swallows; idempotent on retry |
|
||
| `CreateProvisionalShiftAssignmentsFromRegistration` | queued | Gated on `apply_status=COMPLETED` (per §5.6); `purpose='event_registration'` AND AVAILABILITY_PICKER values present | Creates `claim_pending` ShiftAssignments for matching shifts | Logs at error, swallows |
|
||
| `AddPersonToApplicableCrowdListsOnRegistration` | queued | Gated on `apply_status=COMPLETED` (per §5.6); `purpose='event_registration'` AND new Person created | Adds Person to crowd_lists matching auto_add_criteria | Logs at error, swallows |
|
||
| `FormWebhookDispatcher` | queued | Gated on `apply_status=COMPLETED` (per §5.6); any submission with active `form_schema_webhooks` | Dispatches `DeliverFormWebhookJob` per active webhook | Per-webhook job has its own retry chain (§17.5 in ARCH-FORM-BUILDER) |
|
||
| `RegistrationConfirmation` (and siblings) | queued | Gated on `apply_status=COMPLETED` (per §5.6); per-purpose mailable trigger conditions | Sends purpose-specific email to submitter | Failed sends logged via existing CrewliMailable infrastructure |
|
||
|
||
### 5.3 Why SYNC for ApplyBindings only
|
||
|
||
`ApplyBindingsOnFormSubmit` is synchronous because `subject_id` must be on the submission before the HTTP response serializes — the submission resource needs a non-null `subject` reference, and the frontend immediately routes on the resolved Person. Provisioning is the only operation that must complete before the response leaves `FormSubmissionService::submit()`.
|
||
|
||
The portal IdentityMatchBanner reads `identity_match_status` from the resource payload. To keep first-paint copy correct ("we're checking matches…"), `ApplyBindingsOnFormSubmit::handle` writes `identity_match_status='pending'` inside the inner transaction, immediately after subject resolution. This satisfies the `FormSubmissionResource` S3a contract for the `identity_match` block. The final state (`matched` / `no_match` / `multiple_candidates`) is written by the queued `TriggerPersonIdentityMatchOnFormSubmit`, and propagated to the portal via an Echo broadcast on the `submission.{id}` private channel; until the frontend follow-up subscribes to that channel, TanStack Query refetch-on-window-focus is the interim path.
|
||
|
||
#### Sync-chain hard timeout
|
||
|
||
`ApplyBindingsOnFormSubmit` runs inside a 5-second deadline wrapper (`$applicator->withDeadline(seconds: 5)`). The deadline is configurable via `config('form_builder.apply_deadline_seconds')` with default 5. On exceeded deadline, the wrapper throws `FormBindingApplicatorTimeoutException`, caught by the outer transaction handler and written as a `form_submission_action_failures` row with `apply_status='failed'`. This guarantees no submission can hang the public flow for more than a bounded interval — slow paths surface as failures, not as hung connections.
|
||
|
||
> **v1.0 → v1.1:** v1.0 had two SYNC listeners with rationale "ordering primitive +
|
||
> response carries identity-match state." RFC-WS-6 v1.3 reduced SYNC to one
|
||
> (ApplyBindings) and added the deadline wrapper. The "response carries
|
||
> identity-match state" requirement is satisfied by writing `pending` inside the
|
||
> inner transaction; the final state lands asynchronously via Echo broadcast.
|
||
|
||
### 5.4 Why `SyncTagPickerSelectionsOnSubmit` is not folded into ApplyBindings
|
||
|
||
TAG_PICKER → `user_organisation_tags` is semantically different from a binding-target-attribute write. Tag sync rebuilds a pivot table with source-discrimination (`source=self_reported` is rebuildable; `source=organiser_assigned` is preserved). Bindings write attribute mutations. Conflating them would introduce a special-case path inside `FormBindingApplicator::applyAll` that handles "this isn't really a target attribute, it's a tag-pivot rebuild" — exactly the kind of branch that turns simple code into legacy code over time.
|
||
|
||
The two are deliberate parallel paths. Future contributors should not consolidate them. See ARCH-FORM-BUILDER §31.10 for the tag-sync listener contract.
|
||
|
||
### 5.5 Listener ordering test
|
||
|
||
`tests/Feature/FormBuilder/EventServiceProviderListenerOrderTest.php` asserts that:
|
||
|
||
1. `ApplyBindingsOnFormSubmit` is registered as the first listener for `FormSubmissionSubmitted` and does NOT implement `ShouldQueue`
|
||
2. `TriggerPersonIdentityMatchOnFormSubmit` is registered AFTER `ApplyBindingsOnFormSubmit` AND implements `ShouldQueue`
|
||
3. `SyncTagPickerSelectionsOnSubmit`, `CreateProvisionalShiftAssignmentsFromRegistration`, `AddPersonToApplicableCrowdListsOnRegistration`, `FormWebhookDispatcher` all implement `ShouldQueue`
|
||
4. Every `ShouldQueue` listener for this event includes the §5.6 gating invariant as the first executable statement of `handle()` — verified via `ReflectionMethod` introspection of the AST first node, asserting it is the canonical early-return on `apply_status !== ApplyStatus::COMPLETED`
|
||
|
||
This is a structural test — if a future change reorders listeners, queues a previously sync listener wrong, or omits the gating invariant on a new queued listener, the test fails before any behaviour test would.
|
||
|
||
### 5.6 Queued-listener gating invariant
|
||
|
||
Every queued listener for `FormSubmissionSubmitted` begins with this gate as the first executable statement of `handle()`:
|
||
|
||
```php
|
||
if ($event->submission->fresh()->apply_status !== ApplyStatus::COMPLETED) {
|
||
Log::info('form-builder.queued-listener.skipped_apply_failed', [
|
||
'listener' => static::class,
|
||
'submission_id' => $event->submission->id,
|
||
]);
|
||
return;
|
||
}
|
||
```
|
||
|
||
**This is a hard invariant**, not a recommendation. ApplyBindings runs synchronously before any queued listener; if its inner transaction throws, the outer-transaction failure-record path leaves the submission with `apply_status=failed` and `subject_id=null`. Without this gate, queued listeners would still trigger and assume a valid Person exists — leading to cascading failures whose root cause is hard to trace.
|
||
|
||
The gate is enforced by the §5.5 listener-ordering test: a contributor adding a queued listener without the gate sees a structural test failure before code review. Adding a queued listener that *intentionally* needs to run on FAILED submissions is a structural-defect signal — that listener belongs on a different event (or its trigger condition belongs in the gate's positive branch, not as an exception to the gate).
|
||
|
||
The gate uses `fresh()` rather than the in-memory event submission because the inner-transaction commit + outer-transaction failure-record write happens between event dispatch and queued-listener execution. The DB has the canonical `apply_status` value; the in-memory model does not.
|
||
|
||
---
|
||
|
||
## 6. Atomicity — two-transaction failure-write pattern
|
||
|
||
### 6.1 The pattern
|
||
|
||
```php
|
||
// Inside ApplyBindingsOnFormSubmit::handle($event)
|
||
$submission = $event->submission;
|
||
|
||
try {
|
||
DB::transaction(function () use ($submission) {
|
||
$subject = $this->purposeDef
|
||
->resolveOrProvisionSubject($submission, $this->personProvisioner);
|
||
|
||
$resolved = $this->applicator->resolveBindings($submission);
|
||
$this->applicator->applyAll($subject, $resolved);
|
||
|
||
$submission->update(['apply_status' => ApplyStatus::COMPLETED->value]);
|
||
|
||
// identity-match status update, NOT the matching itself
|
||
// (PersonIdentityService runs in the next sync listener)
|
||
$this->markIdentityMatchPending($submission);
|
||
});
|
||
} catch (\Throwable $e) {
|
||
DB::transaction(function () use ($submission, $e) {
|
||
FormSubmissionActionFailure::create([
|
||
'form_submission_id' => $submission->id,
|
||
'listener_class' => self::class,
|
||
'failed_at' => now(),
|
||
'exception_class' => $e::class,
|
||
'exception_message' => $e->getMessage(),
|
||
'context' => [
|
||
'apply_phase' => $this->applicator->lastPhase() ?? 'unknown',
|
||
'subject_type' => $submission->subject_type,
|
||
'subject_id' => $submission->subject_id,
|
||
],
|
||
]);
|
||
|
||
FormSubmission::query()
|
||
->whereKey($submission->id)
|
||
->update(['apply_status' => ApplyStatus::FAILED->value]);
|
||
});
|
||
|
||
Log::error('form-builder.apply.transaction_rolled_back', [
|
||
'submission_id' => $submission->id,
|
||
'exception_class' => $e::class,
|
||
]);
|
||
}
|
||
```
|
||
|
||
### 6.2 Why two transactions
|
||
|
||
The inner transaction wraps subject provisioning, binding application, and status writes. If anything inside throws, the rollback is total — no half-applied state. But that rollback also discards any failure-record we might have tried to write.
|
||
|
||
The outer transaction is independent. It writes `form_submission_action_failures` plus the `apply_status=failed` update on the submission, in a small two-statement transaction that can succeed even when the inner transaction has rolled back the entire binding-apply attempt.
|
||
|
||
If the outer transaction itself fails (database connection lost between inner rollback and outer commit), GlitchTip captures the `Throwable` from the inner-`Log::error` call and the failure becomes invisible to the admin UI — but still captured in error tracking. This is a degenerate case; the system does not pretend to handle infrastructure-level failures gracefully beyond logging.
|
||
|
||
> **v1.0 → v1.1 — deadline wrapper.** The inner transaction runs inside a 5-second
|
||
> deadline wrapper (`$applicator->withDeadline(seconds: 5)`, configurable via
|
||
> `config('form_builder.apply_deadline_seconds')`). On exceeded deadline the wrapper
|
||
> throws `FormBindingApplicatorTimeoutException`, which is caught by the outer
|
||
> transaction handler exactly like any other applicator throw. The failure-record
|
||
> row carries `exception_class=FormBindingApplicatorTimeoutException` and
|
||
> `apply_status='failed'` — the public flow never hangs longer than the deadline
|
||
> bound. See §5.3 for the rationale; RFC-WS-6 §Q1 v1.3 addition 4 for the source
|
||
> decision.
|
||
|
||
> **v1.0 → v1.1 — `failure_response_code` mirror.** Per RFC-WS-6 §Q3 v1.3 addition 2,
|
||
> the outer-transaction failure path also writes `failure_response_code` onto the
|
||
> `form_submissions` row (`schema_config_error` / `temporary_error` /
|
||
> `data_integrity_error` / `unknown_error`), driven by which subclass of
|
||
> `FormBindingApplicatorException` was thrown. The controller serializes this into
|
||
> the API response body when `apply_status=failed`; frontend renders contextual
|
||
> copy keyed on `error_code`.
|
||
|
||
### 6.3 Event-firing timing
|
||
|
||
`FormSubmissionSubmitted` fires **after** the `FormSubmissionService::submit()` transaction commits, not from inside the transaction:
|
||
|
||
```php
|
||
// Inside FormSubmissionService::submit()
|
||
$submission = DB::transaction(function () use ($payload) {
|
||
// ... build submission, write form_values, snapshot schema, etc.
|
||
return $submission;
|
||
});
|
||
|
||
event(new FormSubmissionSubmitted($submission->refresh()));
|
||
```
|
||
|
||
This is non-negotiable. Eloquent observers that fire events from inside a transaction have a known race: queued listeners enqueue with state that may never commit. By dispatching the event explicitly after `DB::transaction()` returns, all listeners see committed state.
|
||
|
||
Do not move event firing into a model observer. Do not use `DB::afterCommit()` — explicit ordering inside `FormSubmissionService` is clearer and unambiguous.
|
||
|
||
### 6.4 Person provisioning races
|
||
|
||
`PersonProvisioner::provisionByEmailBinding` uses `Person::firstOrCreate()` with `lockForUpdate()` semantics on the email lookup. The MySQL InnoDB driver guarantees a serializable-equivalent read for `SELECT ... FOR UPDATE` inside a transaction. Two concurrent submissions with the same email resolve to the same Person, with the first to commit creating the row and the second seeing it on its lookup.
|
||
|
||
A unique index on `(organisation_id, email)` on the `persons` table backs the guarantee — if both transactions reach the insert path simultaneously (rare under `FOR UPDATE`), the second fails with a duplicate-key error which `firstOrCreate` catches and retries with the now-existing row.
|
||
|
||
### 6.5 Outer transaction failure-mode
|
||
|
||
If the outer (failure-record) transaction throws, the `Log::error` call still fires before the listener swallows the exception. The submission ends with `apply_status=null` (the original value) which is misleading — the apply did fail, the record just didn't land. GlitchTip captures the `Throwable`. This is acceptable because:
|
||
|
||
- The outer transaction is two statements (one INSERT, one UPDATE). Failure is rare.
|
||
- The `Log::error` line is filterable in GlitchTip with `form-builder.apply.transaction_rolled_back` as the message tag.
|
||
- ARCH-OBSERVABILITY.md (§ TBD) defines an alert on this log line at threshold "more than 5 in 24h", which is conservative enough to not page-flap but tight enough to surface infrastructure issues.
|
||
|
||
---
|
||
|
||
## 7. Status columns — apply_status and identity_match_status
|
||
|
||
### 7.1 Two independent concerns
|
||
|
||
`form_submissions` carries two status columns and one failure-classification column that the pipeline manages:
|
||
|
||
| Column | Type | Default | Indexed | Managed by |
|
||
|---|---|---|---|---|
|
||
| `apply_status` | string(20) nullable | NULL | yes | `ApplyBindingsOnFormSubmit` (sync), retry/resolve flows |
|
||
| `identity_match_status` | string(20) nullable | NULL | yes | `ApplyBindingsOnFormSubmit` writes initial `'pending'` for `subject_type='person'`; `TriggerPersonIdentityMatchOnFormSubmit` (queued) writes final state; `PersonIdentityService::confirmMatch` (manual) |
|
||
| `failure_response_code` | string(40) nullable | NULL | yes | `ApplyBindingsOnFormSubmit` outer-transaction failure path. Values: `schema_config_error`, `temporary_error`, `data_integrity_error`, `unknown_error` (per RFC-WS-6 §Q3 v1.3 addition 2). Serialized into API response body when `apply_status=failed` so the frontend can render contextual copy. |
|
||
|
||
They are **independent**. A submission can be `apply_status=completed` AND `identity_match_status=pending` — the bindings landed, but the organizer hasn't confirmed which existing Person this submission's just-provisioned Person should link to. Both states must be visible in the UI as separate signals, not collapsed into a single "submission status" indicator. `failure_response_code` is null on success and populated only on `apply_status=failed`.
|
||
|
||
### 7.2 `ApplyStatus` enum
|
||
|
||
```php
|
||
enum ApplyStatus: string
|
||
{
|
||
case PENDING = 'pending';
|
||
case COMPLETED = 'completed';
|
||
case FAILED = 'failed';
|
||
}
|
||
```
|
||
|
||
The column itself defaults to `NULL` rather than `'pending'`. This is deliberate: NULL means **this schema has no bindings to apply** (e.g., a `feedback` submission with no entity-bound fields). Using `pending` as the default would force every unrelated submission into a pipeline-state it has no business in.
|
||
|
||
State transitions:
|
||
|
||
```
|
||
NULL ──(submission of binding-bearing schema)──▶ pending
|
||
pending ──(apply succeeds)──▶ completed
|
||
pending ──(apply throws)──▶ failed
|
||
failed ──(retry succeeds)──▶ completed
|
||
failed ──(retry throws)──▶ failed (action_failure row gets new attempts entry)
|
||
```
|
||
|
||
`pending` is a transient state during the inner transaction; if the response serializes the submission resource with `apply_status=pending`, something is wrong (the inner transaction should have completed before the response leaves `FormSubmissionService::submit`). A monitoring rule (ARCH-OBSERVABILITY) flags submissions stuck in `pending` for more than 60 seconds.
|
||
|
||
### 7.3 `identity_match_status`
|
||
|
||
This column predates WS-6 and is documented in ARCH-FORM-BUILDER §31.1. Pipeline-relevant points:
|
||
|
||
- `ApplyBindingsOnFormSubmit` writes the initial `'pending'` state inside the inner transaction immediately after subject resolution, for any submission with `subject_type='person'`. This satisfies the `FormSubmissionResource` S3a contract — the HTTP response carries `'pending'`, never `null`, for person-typed submissions.
|
||
- `TriggerPersonIdentityMatchOnFormSubmit` (queued, gated on `apply_status=COMPLETED` per §5.6) writes the final state (`matched` / `no_match` / `multiple_candidates`) plus `identity_match_count`, then broadcasts `FormSubmissionIdentityMatchResolved` on the `submission.{id}` private channel.
|
||
- For non-person purposes (`signature_contract`, `user_profile`, `incident_report`, `post_event_evaluation`, `supplier_intake`, `artist_advance`), the column stays NULL. `FormSubmissionResource.identity_match` is `null` for these — RFC-WS-6 §Q2 v1.3 makes this contract explicit, with a contract test in `tests/Feature/Api/FormSubmissionResourceTest`.
|
||
|
||
#### Invariant (RFC-WS-6 §Q2 v1.3)
|
||
|
||
> Post `ApplyBindingsOnFormSubmit::handle` for `event_registration` purpose:
|
||
> `subject_type='person'` AND `subject_id IS NOT NULL`, OR
|
||
> `apply_status=ApplyStatus::FAILED`.
|
||
> No third state exists. Violation is a structural defect.
|
||
|
||
`TriggerPersonIdentityMatchOnFormSubmit` enforces the invariant via a strict throw — `IdentityMatchInvariantViolation` — when it observes `subject_type='person'` AND `subject_id IS NULL` AND `apply_status=COMPLETED`. The throw routes through Laravel's queue-worker exception handler to GlitchTip and writes a `form_submission_action_failures` row, exactly like any other applicator failure. There is no "no subject → pending" failsafe path. The `RequiresIdentityKeyBinding('person', 'email')` publish guard wires unconditionally for `event_registration` (see §8.5), preventing the only schema configuration that could have produced this state from ever reaching publish.
|
||
|
||
### 7.4 UI surface
|
||
|
||
The platform admin "Form failures" page (`/platform/form-failures`) and organisation admin equivalent (`/orgs/{org}/form-failures`) filter by `apply_status=failed`.
|
||
|
||
The submission-detail review UI (organizer side) shows both columns as separate badges. PR-FORM-BUILDER-UI defines the badge styling.
|
||
|
||
---
|
||
|
||
## 8. Pre-publish validation — PublishGuard framework
|
||
|
||
### 8.1 Why pre-publish, not runtime
|
||
|
||
Most binding-pipeline failures are configuration errors: the schema author wrote a binding to `person.shoe_size` (typo for `t_shirt_size`), set `merge_strategy=append` on a SCALAR target, or built a section-aware schema with an `is_identity_key` binding outside section 1. These errors are caught at publish-time by the PublishGuard framework rather than at submit-time.
|
||
|
||
Publish-time guards have three advantages over runtime errors:
|
||
|
||
1. The schema author sees the error immediately, not days later when the first submission lands
|
||
2. The error message can be specific and actionable ("Field 'Schoenmaat' has merge_strategy=append but person.shoe_size is SCALAR. Use overwrite or first_write_wins.")
|
||
3. Production submissions never see the error class — apply-time exceptions are reserved for "DB modified out from under us" rare cases
|
||
|
||
### 8.2 The interface
|
||
|
||
```php
|
||
interface PublishGuard
|
||
{
|
||
public function check(FormSchema $schema): PublishGuardResult;
|
||
}
|
||
|
||
final readonly class PublishGuardResult
|
||
{
|
||
public function __construct(
|
||
public bool $passed,
|
||
/** @var list<PublishGuardViolation> */
|
||
public array $violations = [],
|
||
) {}
|
||
|
||
public static function passed(): self
|
||
{
|
||
return new self(true);
|
||
}
|
||
|
||
public static function failed(string $message, ?string $fieldSlug = null, array $context = []): self
|
||
{
|
||
return new self(false, [new PublishGuardViolation($message, $fieldSlug, $context)]);
|
||
}
|
||
}
|
||
|
||
final readonly class PublishGuardViolation
|
||
{
|
||
public function __construct(
|
||
public string $message,
|
||
public ?string $fieldSlug = null,
|
||
public array $context = [],
|
||
) {}
|
||
}
|
||
```
|
||
|
||
Returning a typed object (not a bool) lets the framework collect multiple violations from a single guard. A `MaxOneIdentityKeyPerTargetEntity` check can return three violations in one `PublishGuardResult` if the schema has three offending fields.
|
||
|
||
### 8.3 The collection contract
|
||
|
||
`FormSchemaService::publish(FormSchema $schema)`:
|
||
|
||
```php
|
||
public function publish(FormSchema $schema): void
|
||
{
|
||
$provider = $this->purposeRegistry->guardProviderFor($schema->purpose);
|
||
$guards = $provider->publishGuards();
|
||
|
||
/** @var list<PublishGuardViolation> $violations */
|
||
$violations = [];
|
||
foreach ($guards as $guard) {
|
||
$result = $guard->check($schema);
|
||
if (! $result->passed) {
|
||
array_push($violations, ...$result->violations);
|
||
}
|
||
}
|
||
|
||
if (count($violations) > 0) {
|
||
throw new PublishGuardViolationException($violations);
|
||
}
|
||
|
||
// ... continue with publish (existing logic)
|
||
}
|
||
```
|
||
|
||
The framework collects **all** violations before throwing. First-fail would be hostile: a schema with five publish-blocking issues should surface all five in one round-trip, not require five publish attempts.
|
||
|
||
### 8.4 The four universal guards
|
||
|
||
These four wire into every `PurposeGuardProvider`, regardless of purpose:
|
||
|
||
#### `MaxOneIdentityKeyPerTargetEntity`
|
||
At most one binding per `(target_entity)` may have `is_identity_key=true`. Multiple identity keys on the same entity create ambiguity in `PersonProvisioner::provisionFromBindings` — which value defines "this is the same entity"?
|
||
|
||
#### `IdentityKeyBindingsOnlyInFirstSection`
|
||
For schemas with `section_level_submit=true`, all `is_identity_key=true` bindings must live on fields in the first section. Otherwise sections 2+ might submit before section 1 has provisioned the entity, causing apply to run with no subject. No-op for non-section schemas.
|
||
|
||
#### `AppendStrategyRequiresCollectionTarget`
|
||
A binding with `merge_strategy=append` must target a `BindingTargetType::COLLECTION` per the `BindingTypeRegistry`. Per V1, append-on-scalar is architecturally rejected.
|
||
|
||
#### `NoAmbiguousTrustLevels`
|
||
Two bindings targeting the same `(target_entity, target_attribute)` must have distinct `trust_level` values — or distinct `form_field.sort_order` values as the documented tie-break. This guard catches "two bindings, both at trust 50, both at sort_order 0" which would resolve in arbitrary order across MySQL versions.
|
||
|
||
### 8.5 Purpose-specific guards (catalogue)
|
||
|
||
These wire only into providers where they apply:
|
||
|
||
| Guard | Wires into | Behaviour |
|
||
|---|---|---|
|
||
| `RequiresIdentityKeyBinding(entity, attribute)` | `event_registration` (`person`, `email`) — **unconditional, both public and private schemas**; `artist_advance` (`artist`, `id`); etc. | Asserts schema has at least one binding with `target_entity=entity`, `target_attribute=attribute`, `is_identity_key=true` |
|
||
| `RequiresFieldType(type, minCount)` | `event_registration` (`AVAILABILITY_PICKER`, 0 or more), `signature_contract` (`SIGNATURE`, 1) | Asserts at least `minCount` fields of the given type exist |
|
||
| `SchemaHasLinkedEvent` | `event_registration`, `artist_advance` | Asserts `form_schemas.linked_event_id` is non-null |
|
||
| `TagCategoriesConfiguredOnAllPickers` | `event_registration`, `user_profile` | Asserts every TAG_PICKER field has at least one `tag_category` configured |
|
||
|
||
The `ConditionalRequirement(predicate, subGuard)` higher-order composer wraps a guard with a predicate that only runs the inner guard when the predicate matches the schema. The composer is retained for purposes that genuinely need conditional wiring; it is **not** used for `RequiresIdentityKeyBinding` on `event_registration`:
|
||
|
||
> **v1.0 → v1.1 — `RequiresIdentityKeyBinding` becomes unconditional for
|
||
> `event_registration`.** v1.0 wrapped this guard in
|
||
> `ConditionalRequirement(predicate: $s->public_token !== null, …)` — meaning
|
||
> private (organizer-driven) event_registration schemas could publish without an
|
||
> email-keyed Person identity binding. Post-publish, ApplyBindings would have
|
||
> nothing to provision against, and the `TriggerPersonIdentityMatch` failsafe-pad
|
||
> would absorb the consequences. RFC-WS-6 §Q2 v1.3 closes this gap: every
|
||
> `event_registration` schema requires the binding regardless of visibility, because
|
||
> the purpose itself creates or matches a Person — that requirement is intrinsic to
|
||
> the purpose, not to the schema's visibility. The failsafe-pad in
|
||
> `TriggerPersonIdentityMatch` is removed in the same change (§7.3).
|
||
|
||
### 8.6 Adding a new guard
|
||
|
||
1. Add the class under `app/FormBuilder/Publishing/{GuardName}.php` implementing `PublishGuard`
|
||
2. Wire it into the appropriate `PurposeGuardProvider::publishGuards()` return (or add it to a universal-guards trait if it applies to all purposes)
|
||
3. Write one positive test (passing case) and one negative test (failing case with expected violation message format) under `tests/Unit/FormBuilder/Publishing/`
|
||
4. If the guard introduces new error UX, add a copy-catalogue entry under ARCH-FORM-BUILDER §30 with the violation message template
|
||
|
||
The framework is open-closed: a new guard never modifies `FormSchemaService::publish()`.
|
||
|
||
---
|
||
|
||
## 9. Purpose-specific behaviour — PurposeGuardProvider & subject resolution
|
||
|
||
### 9.1 `PurposeGuardProvider` interface
|
||
|
||
```php
|
||
interface PurposeGuardProvider
|
||
{
|
||
/** @return list<PublishGuard> */
|
||
public function publishGuards(): array;
|
||
}
|
||
```
|
||
|
||
Each concrete provider returns the guard list for its purpose. The four universal guards are typically composed via a trait or shared base class:
|
||
|
||
```php
|
||
final class EventRegistrationGuardProvider implements PurposeGuardProvider
|
||
{
|
||
use HasUniversalPublishGuards;
|
||
|
||
public function publishGuards(): array
|
||
{
|
||
return [
|
||
...$this->universalGuards(),
|
||
new RequiresIdentityKeyBinding('person', 'email'),
|
||
new SchemaHasLinkedEvent(),
|
||
new TagCategoriesConfiguredOnAllPickers(),
|
||
new ConditionalRequirement(
|
||
fn (FormSchema $s) => $s->public_token !== null,
|
||
new RequiresFieldType('email', minCount: 1)
|
||
),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9.2 Subject resolution per purpose
|
||
|
||
`PurposeDefinition::resolveOrProvisionSubject(FormSubmission, PersonProvisioner): ?Model` is the entry point for every purpose-specific resolver. The `?Model` return type acknowledges purposes (incident_report, public_complaint) where anonymous submission is allowed.
|
||
|
||
Subject resolution table:
|
||
|
||
| Purpose | Resolver | Behaviour |
|
||
|---|---|---|
|
||
| `event_registration` | `PersonProvisioner::provisionByEmailBinding` | May create a new Person if no match found by `(organisation_id, email)`. Returns existing Person otherwise. |
|
||
| `artist_advance` | `ArtistResolver::fromPortalToken` | Resolves Artist from `form_submissions.portal_token` context. Throws `MissingArtistContextException` if absent. |
|
||
| `supplier_intake` | `CompanyResolver::fromProductionRequest` | Resolves Company from the `production_request` row whose token is in the submission context. Throws if production_request absent. |
|
||
| `post_event_evaluation` | `PersonResolver::fromAuth` | Resolves Person from authenticated user's linked Person. Throws if user has no linked Person. |
|
||
| `incident_report` | `PersonResolver::fromAuthOrNull` | Returns null for anonymous submissions. |
|
||
| `signature_contract` | `UserResolver::fromAuth` | Resolves User from auth context. |
|
||
| `user_profile` | `UserResolver::fromAuth` | Same as signature_contract. |
|
||
|
||
### 9.3 Adding a new purpose
|
||
|
||
1. Add the slug-keyed entry to `config/form_builder/purposes.php` with `subject_type`, `submission_mode`, `public_token_supported`, `requiredBindings`, `guards_class`
|
||
2. If the new purpose has a new `subject_type` not already present, register the FQCN in `AppServiceProvider::PURPOSE_SUBJECT_FQCN`. `MorphMapAlignmentTest` enforces this step.
|
||
3. Add the new `PurposeGuardProvider` class under `app/FormBuilder/Purposes/Guards/`
|
||
4. Add the new resolver class under `app/FormBuilder/Purposes/Resolvers/` if reuse of existing resolvers is not possible
|
||
5. Wire any purpose-specific listeners in `EventServiceProvider::$listen` (identity-match, tag sync, mailables, etc.)
|
||
6. Add a lifecycle paragraph to ARCH-FORM-BUILDER §3.2 and a row to the purpose catalogue table in §3.1
|
||
7. Add at least 4 pipeline tests (happy-path, missing required binding, conflict-resolution, anonymous-when-applicable) under `tests/Feature/FormBuilder/Pipeline/Purposes/`
|
||
|
||
### 9.4 Open-closed property
|
||
|
||
Adding a purpose modifies:
|
||
|
||
- `config/form_builder/purposes.php` (one new array entry)
|
||
- New PurposeGuardProvider class
|
||
- Optionally new resolver class
|
||
- Optionally new listeners
|
||
- Documentation
|
||
|
||
It does **not** modify:
|
||
|
||
- `FormSchemaService::publish()` — generic, walks the guard list
|
||
- `FormBindingApplicator::apply()` — purpose-agnostic, delegates to PurposeDefinition
|
||
- Existing `PurposeGuardProvider` classes
|
||
- Existing resolvers
|
||
|
||
This is the open-closed contract that the framework enforces. Reviewers should reject any PR that adds a purpose by branching inside `publish()` or `apply()` instead of adding a new provider.
|
||
|
||
---
|
||
|
||
## 10. Section-level apply — stub structure, feature flag, removal trigger
|
||
|
||
### 10.1 Why stubbed
|
||
|
||
Section-level submit is a feature primarily relevant for `artist_advance` and `supplier_intake` schemas with `section_level_submit=true`. These purposes let the submitter complete and submit one section at a time over weeks (typical for artist advancing), with each section-submit triggering its own pipeline pass.
|
||
|
||
Building section-aware apply is straightforward (the `FormBindingApplicator::apply()` method already accepts an optional `sectionId` parameter). However, the queued listener that consumes `FormSubmissionSectionSubmitted` events is gated until artist-advance feature work begins post-S5. Building it now and gating it costs little; activating it without artist-advance UX support would expose a half-built workflow.
|
||
|
||
### 10.2 The listener
|
||
|
||
```php
|
||
class ApplyBindingsOnFormSectionSubmitted implements ShouldQueue
|
||
{
|
||
public function __construct(
|
||
private FormBindingApplicator $applicator,
|
||
) {}
|
||
|
||
public function handle(FormSubmissionSectionSubmitted $event): void
|
||
{
|
||
if (! config('form_builder.section_apply.enabled', false)) {
|
||
return;
|
||
}
|
||
$this->applicator->apply(
|
||
$event->submission,
|
||
sectionId: $event->sectionId
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
The listener is registered in `EventServiceProvider::$listen` from session 2 onward. The early-return guard is the activation gate.
|
||
|
||
### 10.3 The applicator signature
|
||
|
||
```php
|
||
public function apply(
|
||
FormSubmission $submission,
|
||
?string $sectionId = null,
|
||
): BindingApplicationResult
|
||
```
|
||
|
||
Null `sectionId` (default) applies all bindings whose source `form_field.section_id` is null OR matches the schema. Set `sectionId` filters bindings to only those whose source `form_field.section_id` equals the parameter.
|
||
|
||
### 10.4 Publish guards land regardless of feature flag
|
||
|
||
The `IdentityKeyBindingsOnlyInFirstSection` universal guard runs for every schema with `section_level_submit=true`, even with the feature flag disabled. A schema is structurally unsafe regardless of whether the runtime is gated; publish must reject it.
|
||
|
||
### 10.5 Removal trigger
|
||
|
||
`config/form_builder/section_apply.php` documents the removal trigger inline (see §3.3 above). When artist-advance feature work begins:
|
||
|
||
1. Set `'enabled' => true`
|
||
2. Write section-scoped tests against `FormBindingApplicator::apply($s, sectionId: $id)`
|
||
3. Remove the early-return guard from the listener
|
||
4. Remove the entire feature-flag config — section-level apply becomes the default for purposes with `section_level_submit=true`
|
||
|
||
BACKLOG item: `ARTIST-ADV-SECTION-APPLY`.
|
||
|
||
### 10.6 Wiring test
|
||
|
||
`tests/Feature/FormBuilder/Pipeline/SectionLevelApplyStubTest.php` asserts:
|
||
|
||
1. `ApplyBindingsOnFormSectionSubmitted` is registered as a `FormSubmissionSectionSubmitted` listener
|
||
2. With the feature flag disabled, dispatching the event does not invoke `FormBindingApplicator::apply()`
|
||
3. With the feature flag enabled (test-only override), dispatching the event invokes `apply()` with the correct `sectionId`
|
||
|
||
No business-logic test — those land when the flag goes live.
|
||
|
||
---
|
||
|
||
## 11. Failure management — `form_submission_action_failures` lifecycle
|
||
|
||
### 11.1 Table shape
|
||
|
||
See SCHEMA.md §3.5.12 for the canonical column list. Pipeline-relevant columns:
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | ULID | PK |
|
||
| `form_submission_id` | ULID FK | → `form_submissions`, cascade delete |
|
||
| `listener_class` | string | The listener that caught the throw, e.g. `ApplyBindingsOnFormSubmit` |
|
||
| `failed_at` | timestamp | When the inner transaction rolled back |
|
||
| `exception_class` | string | FQCN of the throw |
|
||
| `exception_message` | text | Pruned to ~2000 chars |
|
||
| `context` | JSON | `apply_phase`, `subject_type`, `subject_id`, etc. |
|
||
| `apply_status` | string(20) | Mirrors the submission's status at failure time. `pending` while a retry is in flight; `failed` after exhaustion. |
|
||
| `resolved_at` | timestamp nullable | Set by Resolve action |
|
||
| `resolved_by_user_id` | ULID FK nullable | → `users` |
|
||
| `resolved_note` | text nullable | Free-text, optional |
|
||
| `dismissed_at` | timestamp nullable | Set by Dismiss action |
|
||
| `dismissed_by_user_id` | ULID FK nullable | → `users` |
|
||
| `dismissed_reason` | string(40) nullable | `DismissalReasonType` enum value |
|
||
| `dismissed_reason_note` | text nullable | Required only when `dismissed_reason='other'` |
|
||
|
||
No `organisation_id` column. Tenant scope flows via `form_submission_id → form_submissions.organisation_id`. Enforced by `FormSubmissionActionFailurePolicy` (§13).
|
||
|
||
> **v1.0 → v1.1 — `failure_response_code` companion column on `form_submissions`.**
|
||
> Per RFC-WS-6 §Q3 v1.3 addition 2, when ApplyBindings catches a
|
||
> `FormBindingApplicatorException` subclass, the outer-transaction handler also
|
||
> mirrors a classification onto `form_submissions.failure_response_code`
|
||
> (`schema_config_error` / `temporary_error` / `data_integrity_error` /
|
||
> `unknown_error`). The action-failures row is the machine-replayable workflow
|
||
> artefact; the `form_submissions.failure_response_code` field is the response-shape
|
||
> driver — read by the controller to render contextual user-facing copy when the
|
||
> submission is queried with `apply_status=failed`. Both rows reference the same
|
||
> `submission.id` ULID. See §7.1 for the column listing on `form_submissions`.
|
||
|
||
### 11.2 Lifecycle
|
||
|
||
```
|
||
┌──────────────────────────────┐
|
||
│ ApplyBindingsOnFormSubmit │
|
||
│ catches throw, writes row │
|
||
└──────────────┬───────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────┐
|
||
│ state = failed │
|
||
└──────────────────┘
|
||
│ │ │
|
||
┌───────────┘ │ └────────────┐
|
||
▼ ▼ ▼
|
||
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
|
||
│ resolved │ │ dismissed │ │ retried │
|
||
│ (manual fix) │ │ (DismissalType │ │ (re-run apply │
|
||
│ resolved_at + │ │ + note if │ │ with snapshot)│
|
||
│ resolved_note │ │ 'other') │ └───────┬────────┘
|
||
└────────────────┘ └────────────────┘ │
|
||
▼
|
||
┌──────────────────┐
|
||
│ retry succeeded │
|
||
│ OR │
|
||
│ retry failed │
|
||
│ (state cycles) │
|
||
└──────────────────┘
|
||
```
|
||
|
||
`resolved` and `dismissed` are terminal states. `retried` is transient — the row exits to either `resolved` (apply succeeded) or back to `failed` (apply threw again, with new `exception_class`/`exception_message` updates and incremented internal attempts counter).
|
||
|
||
### 11.3 `DismissalReasonType` enum
|
||
|
||
```php
|
||
enum DismissalReasonType: string
|
||
{
|
||
case SCHEMA_DELETED = 'schema_deleted';
|
||
case TARGET_ENTITY_DELETED = 'target_entity_deleted';
|
||
case BINDING_REMOVED = 'binding_removed';
|
||
case DUPLICATE_SUBMISSION = 'duplicate_submission';
|
||
case DATA_QUALITY_ISSUE = 'data_quality_issue';
|
||
case OTHER = 'other';
|
||
}
|
||
```
|
||
|
||
Six cases. Per RFC-WS-6 §4 V2, `OTHER` is the only case that requires `dismissed_reason_note`. The other five are self-explanatory categories that drive aggregate reporting (e.g., "60% of dismissals are `duplicate_submission` — let's investigate the duplicate-detection code").
|
||
|
||
### 11.4 `resolved_note` vs `dismissed_reason_note`
|
||
|
||
- `resolved_note`: free-text, **always optional**. The user fixed the underlying issue (renamed the binding's target attribute, restored the deleted target entity, fixed bad data) and Resolve closes the failure-record without retry. The note is for future reference if the same class of failure recurs.
|
||
|
||
- `dismissed_reason_note`: free-text, **required only when `dismissed_reason='other'`**. The five other DismissalReasonType cases speak for themselves.
|
||
|
||
The two are deliberately separate columns so reports can aggregate "% resolved with notes" vs "% dismissed-as-other with notes" cleanly.
|
||
|
||
### 11.5 No soft delete
|
||
|
||
The table is append-only at the row level; rows never delete. Resolved and dismissed rows are filtered out of the active failure-management UI but retained for audit. GDPR purge on Person deletion cascades through `form_submission_id` (form_submissions has its own anonymisation flow per ARCH-FORM-BUILDER §31.2).
|
||
|
||
---
|
||
|
||
## 12. Retry / Resolve / Dismiss flows
|
||
|
||
### 12.1 Retry
|
||
|
||
#### Artisan
|
||
|
||
```bash
|
||
php artisan form-builder:retry-failures {failure-id?} {--dry-run}
|
||
```
|
||
|
||
- Without `failure-id`: walks all `state=failed` rows, dispatches retry per row, reports counts.
|
||
- With `failure-id`: retries the single row.
|
||
- `--dry-run`: walks but does not dispatch — useful for "how many would retry" counts.
|
||
|
||
#### HTTP
|
||
|
||
```
|
||
POST /api/v1/orgs/{org}/form-failures/{failure}/retry
|
||
POST /api/v1/platform/form-failures/{failure}/retry
|
||
```
|
||
|
||
Both routes use the same controller logic; only the policy gates differ (org-scoped vs platform-wide).
|
||
|
||
#### Behaviour
|
||
|
||
```
|
||
1. Load the failure row + its parent FormSubmission
|
||
2. Acquire SELECT ... FOR UPDATE on the failure row inside a small DB transaction
|
||
3. If state != 'failed': abort 409 Conflict with current state
|
||
4. Mark state = 'pending' (visible to the UI as "retry in flight")
|
||
5. Dispatch ApplyBindingsOnFormSubmit::dispatchSync(new FormSubmissionSubmitted($submission))
|
||
6. Catch the same exception classes as the original handler:
|
||
- On success: mark state = 'resolved', set resolved_at/resolved_by_user_id, leave resolved_note null (or copy from request payload if provided)
|
||
- On throw: update exception_class / exception_message, mark state = 'failed' again, increment internal attempts counter (in context JSON for now; no dedicated column unless we observe operational need)
|
||
```
|
||
|
||
Retry uses the submission's frozen `schema_snapshot` for binding configuration, not the live `form_field_bindings`. See §16.
|
||
|
||
#### Idempotency
|
||
|
||
Retrying an already-resolved or already-dismissed failure returns 200 with the current state. No-op. Internal attempts counter does not increment.
|
||
|
||
### 12.2 Resolve
|
||
|
||
#### HTTP
|
||
|
||
```
|
||
POST /api/v1/orgs/{org}/form-failures/{failure}/resolve
|
||
POST /api/v1/platform/form-failures/{failure}/resolve
|
||
|
||
Request body:
|
||
{
|
||
"note": "Renamed the t_shirt_size binding to match the latest schema." // optional
|
||
}
|
||
```
|
||
|
||
Marks the failure `resolved` without retry. Used when the operator has manually applied the fix outside the pipeline (e.g., fixed the binding configuration, manually updated the target entity attribute) and the failure-record no longer represents an open issue.
|
||
|
||
If the operator wants the pipeline to re-apply, they Retry instead.
|
||
|
||
### 12.3 Dismiss
|
||
|
||
#### HTTP
|
||
|
||
```
|
||
POST /api/v1/orgs/{org}/form-failures/{failure}/dismiss
|
||
POST /api/v1/platform/form-failures/{failure}/dismiss
|
||
|
||
Request body:
|
||
{
|
||
"reason": "duplicate_submission",
|
||
"note": null // required iff reason='other'
|
||
}
|
||
```
|
||
|
||
Form Request `DismissFormFailureRequest`:
|
||
|
||
```php
|
||
public function rules(): array
|
||
{
|
||
return [
|
||
'reason' => ['required', Rule::enum(DismissalReasonType::class)],
|
||
'note' => [
|
||
'nullable',
|
||
'string',
|
||
'max:5000',
|
||
Rule::requiredIf(fn () => $this->input('reason') === DismissalReasonType::OTHER->value),
|
||
],
|
||
];
|
||
}
|
||
```
|
||
|
||
### 12.4 Resource shape
|
||
|
||
`FormSubmissionActionFailureResource`:
|
||
|
||
```json
|
||
{
|
||
"id": "01HX...",
|
||
"form_submission_id": "01HX...",
|
||
"submission_summary": {
|
||
"schema_name": "Volunteer Registration 2026",
|
||
"schema_purpose": "event_registration",
|
||
"submitter_label": "Jan Janssen <jan@example.nl>",
|
||
"submitted_at": "2026-04-30T14:22:01Z"
|
||
},
|
||
"listener_class": "ApplyBindingsOnFormSubmit",
|
||
"failed_at": "2026-04-30T14:22:02Z",
|
||
"exception_class": "App\\FormBuilder\\Exceptions\\InvalidBindingTargetException",
|
||
"exception_message": "Target attribute 'shoe_size' not registered on entity 'person'",
|
||
"context": {
|
||
"apply_phase": "binding_resolution",
|
||
"subject_type": "person",
|
||
"subject_id": null
|
||
},
|
||
"state": "failed",
|
||
"resolved_at": null,
|
||
"resolved_by_user": null,
|
||
"resolved_note": null,
|
||
"dismissed_at": null,
|
||
"dismissed_by_user": null,
|
||
"dismissed_reason": null,
|
||
"dismissed_reason_note": null,
|
||
"abilities": {
|
||
"can_retry": true,
|
||
"can_resolve": true,
|
||
"can_dismiss": true
|
||
}
|
||
}
|
||
```
|
||
|
||
The `abilities` block reflects policy-evaluated permissions for the requesting user in the requesting context. UI uses these to enable/disable action buttons rather than hard-coding role checks.
|
||
|
||
---
|
||
|
||
## 13. Authorization — IDOR-class FK-chain pattern
|
||
|
||
### 13.1 The threat
|
||
|
||
`form_submission_action_failures` has no `organisation_id` column. An IDOR-class threat scenario:
|
||
|
||
1. Org A admin authenticates, gets a list of their org's failures
|
||
2. Org B admin can hit `GET /api/v1/orgs/{org-a}/form-failures/{failure-id}/retry` with a failure ID from org A's list
|
||
3. If authorization is naive, the failure retries even though org B has no business with it
|
||
|
||
Naive authorization would be: scope route to `{org}`, fetch failure by ID, retry. The `{org}` route segment is unverified against the failure's actual organisation.
|
||
|
||
### 13.2 The pattern
|
||
|
||
`FormSubmissionActionFailurePolicy` resolves the failure's organisation via FK-chain explicitly:
|
||
|
||
```php
|
||
final class FormSubmissionActionFailurePolicy
|
||
{
|
||
public function viewAny(User $user, ?Organisation $organisation = null): bool
|
||
{
|
||
if ($organisation === null) {
|
||
// Platform route
|
||
return $user->hasRole('super_admin');
|
||
}
|
||
// Org-scoped route
|
||
return $user->isMemberOf($organisation)
|
||
&& $user->can('manage_form_failures', $organisation);
|
||
}
|
||
|
||
public function view(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool
|
||
{
|
||
if (! $this->viewAny($user, $organisation)) {
|
||
return false;
|
||
}
|
||
|
||
$failureOrgId = $failure->submission->form_schema->organisation_id;
|
||
|
||
if ($organisation === null) {
|
||
return true; // super_admin can view any
|
||
}
|
||
|
||
return $failureOrgId === $organisation->id;
|
||
}
|
||
|
||
public function retry(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool
|
||
{
|
||
return $this->view($user, $failure, $organisation)
|
||
&& $failure->state === 'failed';
|
||
}
|
||
|
||
public function resolve(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool
|
||
{
|
||
return $this->view($user, $failure, $organisation)
|
||
&& in_array($failure->state, ['failed', 'pending'], true);
|
||
}
|
||
|
||
public function dismiss(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool
|
||
{
|
||
return $this->view($user, $failure, $organisation)
|
||
&& $failure->state === 'failed';
|
||
}
|
||
}
|
||
```
|
||
|
||
The `$failure->submission->form_schema->organisation_id` walk is eager-loaded by the controller (`FormSubmissionActionFailure::with(['submission.form_schema'])`) so the policy method does not trigger N+1.
|
||
|
||
### 13.3 Why not `OrganisationScope`
|
||
|
||
`OrganisationScope` is FK-chain-resolver-aware (per WS-4 Q2 addendum). It could be configured to walk `form_submission_action_failure → form_submission → form_schema → organisation_id`. But:
|
||
|
||
1. The chain is three hops; every query against the table would carry three implicit JOINs
|
||
2. Platform-wide super_admin queries would need to bypass the scope, adding `withoutGlobalScope` boilerplate at the platform layer
|
||
3. Policy-level checks already need to walk the chain for ability evaluation; doing it once in the policy avoids duplication
|
||
|
||
The IDOR-class FK-chain pattern is the documented preference for tables where:
|
||
- Tenant-scoping is essential
|
||
- The table has no direct `organisation_id` column (because the data is scoped through a parent)
|
||
- Platform-wide views exist and need clean bypass
|
||
|
||
### 13.4 Tests
|
||
|
||
`tests/Feature/Api/FormFailureIdorClassTest.php` covers, per policy method:
|
||
|
||
- Happy path (org A admin acting on org A failure → 200)
|
||
- IDOR attempt (org A admin acting on org B failure → 403)
|
||
- Platform happy path (super_admin acting on any org's failure → 200)
|
||
- Wrong-state attempts (acting on already-resolved failure → 422 / 409 depending on action)
|
||
|
||
Five policy methods × four cases = twenty tests. The test class is the canonical regression suite for the pattern; reviewers should add a row when any change touches `FormSubmissionActionFailurePolicy`.
|
||
|
||
---
|
||
|
||
## 14. Activity log — hierarchical pass + per-binding entries
|
||
|
||
### 14.1 Two activity levels
|
||
|
||
The pipeline writes activity-log entries at two granularities:
|
||
|
||
**Pass level** — one entry per `FormBindingApplicator::apply()` invocation:
|
||
|
||
```
|
||
log_name: form-builder
|
||
description: form_submission.bindings_pass_completed
|
||
subject: FormSubmission Z
|
||
causer: User who submitted (or null for public)
|
||
properties: {
|
||
binding_count: 12,
|
||
succeeded: 11,
|
||
failed: 1,
|
||
person_provisioned: true
|
||
}
|
||
```
|
||
|
||
**Binding level** — one entry per binding (success or failure):
|
||
|
||
```
|
||
log_name: form-builder
|
||
description: form_submission.binding_applied
|
||
subject: Person X (or whichever target_entity)
|
||
causer: same as pass-level entry
|
||
properties: {
|
||
parent_activity_id: <pass entry id>,
|
||
target_entity: 'person',
|
||
target_attribute: 'email',
|
||
old_value: null,
|
||
new_value: 'jan@example.nl',
|
||
trust_level: 80,
|
||
merge_strategy: 'overwrite',
|
||
source_form_field_id: 01HX...,
|
||
source_submission_id: Z
|
||
}
|
||
```
|
||
|
||
For failed bindings, additional properties:
|
||
|
||
```
|
||
properties: {
|
||
...,
|
||
error_class: 'TypeError',
|
||
error_message: 'Expected string, got array'
|
||
}
|
||
```
|
||
|
||
### 14.2 `parent_activity_id` is a properties field
|
||
|
||
`spatie/laravel-activitylog` does not natively support hierarchical activity. The hierarchical structure is encoded as a properties-key reference: each binding-level entry stores the pass-level entry's ID under `properties.parent_activity_id`.
|
||
|
||
This avoids schema changes to the `activity_log` table while supporting hierarchical UI rendering. The trade-off is that reverse traversal ("show me all bindings under this pass") requires a JSON query (`properties->>'$.parent_activity_id' = ?`) which MySQL 8 indexes well via virtual columns if needed (no virtual columns yet — query volume is low and read-pattern is admin-UI not user-facing).
|
||
|
||
### 14.3 Two sources of truth
|
||
|
||
Failed bindings appear in both:
|
||
|
||
- `activity_log` — human-readable timeline
|
||
- `form_submission_action_failures` — machine-replayable workflow row
|
||
|
||
This duplication is deliberate. `activity_log` is for narrative reading: "what happened to this submission". `action_failures` is for state-machine action: "what failed, what state is it in, how do we move it to resolved or dismissed".
|
||
|
||
A failed binding generates one `activity_log` entry (binding-level with error properties) AND one `form_submission_action_failures` row (only if the entire pass throws — partial-binding failures are TODO for future work; current architecture is all-or-nothing per pass).
|
||
|
||
### 14.4 UI rendering
|
||
|
||
The submission-detail review UI renders activity hierarchically:
|
||
|
||
```
|
||
[2026-04-30 14:22:02] Bindings applied (11 succeeded, 1 failed) ▼ expand
|
||
├ ✓ person.email = "jan@example.nl" (trust 80, overwrite, was null)
|
||
├ ✓ person.first_name = "Jan" (trust 60, overwrite, was null)
|
||
├ ✓ person.last_name = "Janssen" (trust 60, overwrite, was null)
|
||
├ ...
|
||
└ ✗ person.shoe_size = "42" (TypeError) [view failure]
|
||
[2026-04-30 14:22:01] Submission submitted by jan@example.nl
|
||
```
|
||
|
||
The `[view failure]` link navigates to `/orgs/{org}/form-failures/{id}` with the failure-record details.
|
||
|
||
RFC-FORM-BUILDER-UI specifies the styling and interaction details for this view.
|
||
|
||
---
|
||
|
||
## 15. Library bindings — copy-at-instantiation semantics
|
||
|
||
### 15.1 Template-source semantics
|
||
|
||
`FormFieldLibrary` entries can carry bindings (rows in `form_field_bindings` with `owner_type='form_field_library'`). When a `form_field` is instantiated from a library entry, those bindings are **copied** as new rows with `owner_type='form_field'` and the new field's ULID as `owner_id`.
|
||
|
||
```php
|
||
// Inside FormFieldService::insertFromLibrary
|
||
$field = FormField::create([...]);
|
||
|
||
$libraryBindings = FormFieldBinding::query()
|
||
->where('owner_type', 'form_field_library')
|
||
->where('owner_id', $libraryEntry->id)
|
||
->get();
|
||
|
||
foreach ($libraryBindings as $libraryBinding) {
|
||
$this->bindingService->copyBindingFor($field, $libraryBinding);
|
||
}
|
||
```
|
||
|
||
`copyBindingFor` writes a new row referencing the new field's ULID with the same `target_entity`, `target_attribute`, `merge_strategy`, `trust_level`, `is_identity_key`. The library binding is unchanged.
|
||
|
||
### 15.2 No runtime cascade
|
||
|
||
Subsequent edits to a library binding do **not** propagate to existing field instances. If an organisation edits the library entry's `default_binding` (now the relational binding row) to change `merge_strategy=overwrite` to `merge_strategy=replace`, only future field instantiations from that library entry receive the new strategy. Existing fields remain on the old strategy.
|
||
|
||
This matches the WS-5d decision for `form_field_options` (option lists also copy-at-instantiation, no runtime cascade) and the general template-source pattern documented in ARCH-FORM-BUILDER §17.
|
||
|
||
### 15.3 Why no cascade
|
||
|
||
Three reasons:
|
||
|
||
1. **Predictability for versioning.** A schema published in April uses bindings as they were configured in April. A library edit in May should not silently change the behaviour of April's published schemas.
|
||
|
||
2. **Audit reproducibility.** A retry of an April submission must apply April's bindings (this is also enforced at the snapshot layer; see §16). Cascade would create a window where the snapshot disagrees with the live state.
|
||
|
||
3. **Operational safety.** A library admin should be able to fix a typo in a library binding without worrying about which field instances across all organisations will mutate.
|
||
|
||
The trade-off is that library updates require a manual "re-sync" admin action to propagate to existing instances. That action is BACKLOG `FORM-LIBRARY-RESYNC` — implemented when organisations report friction.
|
||
|
||
### 15.4 Library audit log
|
||
|
||
Library binding changes log to `activity_log` under the library entry's subject. Tracking: BACKLOG `FORM-BUILDER-LIBRARY-AUDIT-LOG`. Currently, library edits are scoped to library admins (super_admin or library-manager role); the audit-log entries fire automatically via the existing `LogsActivity` trait on `FormFieldLibrary` and `FormFieldBinding`.
|
||
|
||
---
|
||
|
||
## 16. Reproducibility — schema_snapshot and binding contract freezing
|
||
|
||
### 16.1 The principle
|
||
|
||
A retry of an April submission must apply the bindings as they were configured at submission time, not as they may have been edited since. This is reproducibility-for-audit.
|
||
|
||
The mechanism is `form_submissions.schema_snapshot`: a JSON column populated at submission time with the canonicalized schema state. Binding configuration is part of that snapshot.
|
||
|
||
### 16.2 Snapshot shape (binding-relevant subset)
|
||
|
||
See ARCH-FORM-BUILDER §4.6.1 for the full snapshot structure. Binding-relevant fields per snapshot field:
|
||
|
||
```json
|
||
{
|
||
"fields": [
|
||
{
|
||
"id": "01HX...",
|
||
"slug": "email",
|
||
"field_type": "email",
|
||
"label": "Emailadres",
|
||
"section_slug": null,
|
||
"binding": {
|
||
"target_entity": "person",
|
||
"target_attribute": "email",
|
||
"merge_strategy": "overwrite",
|
||
"trust_level": 80,
|
||
"is_identity_key": true
|
||
},
|
||
"validation_rules": [...],
|
||
"options": null,
|
||
"conditional_logic": null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Note that the snapshot uses an inlined `binding` object per field, not a reference to `form_field_bindings.id`. The canonical contract is "what binding-config this field had at submission time", not "which binding-row backed this field".
|
||
|
||
### 16.3 Canonicalization
|
||
|
||
JSON content stored in `form_submissions.schema_snapshot` is canonicalized on write via `App\Support\Json\JsonCanonicalizer::canonicalize()`. The transformation:
|
||
|
||
- Recursive `ksort` on associative arrays (alphabetical by key)
|
||
- Numeric-indexed lists preserve order
|
||
- All other content unchanged
|
||
|
||
This guarantees byte-stability across re-emits of the same logical content. Critical for:
|
||
|
||
- **Audit-replay diffs** — without canonicalization, MySQL JSON-column round-trip can reorder keys non-deterministically, making "did the snapshot change" hard to answer
|
||
- **Webhook payload signing** — HMAC over a JSON string requires byte-stable input
|
||
- **Activity log diff regression tests** — `field.updated` activity entries land via `FormField::logFieldChange` which canonicalizes before `withProperties()`
|
||
|
||
The `SchemaSnapshotByteStableAcrossReemitsTest` test class is the end-to-end contract.
|
||
|
||
### 16.4 Retry uses the snapshot
|
||
|
||
`form-builder:retry-failures` does not re-query `form_field_bindings`. It loads the submission's `schema_snapshot`, extracts the per-field `binding` objects, and feeds them into `FormBindingApplicator::applyAll($subject, $resolvedFromSnapshot)`.
|
||
|
||
Live `form_field_bindings` is only consulted at:
|
||
|
||
- Pre-publish validation (§8) — checking the current state of the schema against guards
|
||
- Snapshot writing (§4.6.1 in ARCH-FORM-BUILDER) — emitting the canonical snapshot at submission time
|
||
- Library copying (§15) — instantiating fields from library entries
|
||
|
||
Apply-time **never** queries `form_field_bindings` directly. Reproducibility is the invariant.
|
||
|
||
### 16.5 Configuration-only fields excluded
|
||
|
||
`form_schemas.settings`, `form_schemas.translations`, and similar opaque-config columns are NOT canonicalized — key order has no semantic meaning there. Bindings, fields, sections, and the schema-level metadata that webhooks emit ARE canonicalized.
|
||
|
||
---
|
||
|
||
## 17. Conflict resolution & trust precedence
|
||
|
||
### 17.1 The problem
|
||
|
||
Two fields can bind to the same `(target_entity, target_attribute)`. Example: a registration schema with both a "Name" field bound to `person.first_name` (trust 80) and a "Display name" field bound to `person.first_name` (trust 50). What happens at apply time?
|
||
|
||
### 17.2 Candidate set
|
||
|
||
The candidate set for a given target_attribute is:
|
||
|
||
> Bindings whose source `form_field` has a row in `form_values` for this submission, regardless of whether the value is null.
|
||
|
||
The "row exists" gate is binary. Once a binding qualifies, its value is decided by §17.3 below.
|
||
|
||
The distinction matters because of multi-step / edit-resubmit flows:
|
||
|
||
- **No row in form_values for this field** → the field was skipped by conditional logic, or the user never reached it. The binding is not a candidate.
|
||
- **Row in form_values with `value=null`** → the user explicitly cleared the field (e.g., they set a value, came back to the section, deleted it, and resubmitted). The binding IS a candidate; it may end up writing null to the target depending on the strategy.
|
||
|
||
### 17.3 Sort order
|
||
|
||
Candidates are sorted:
|
||
|
||
1. `trust_level DESC` — higher trust wins
|
||
2. `form_field.sort_order ASC` — earlier-in-form wins on ties
|
||
|
||
The winner is the first row after sorting.
|
||
|
||
### 17.4 Per-strategy resolution
|
||
|
||
Once the winner is identified, the merge_strategy applies to determine what (if anything) writes to the target attribute. See §4.3 for the null-winner matrix.
|
||
|
||
For non-null winner values, the strategy applies straightforwardly:
|
||
|
||
- `overwrite` — winner value replaces target unconditionally
|
||
- `append` — winner value added to collection (deduplicated)
|
||
- `replace` — winner value replaces target if target is null
|
||
- `first_write_wins` — winner value writes if target is null
|
||
|
||
### 17.5 Write-path invariant test
|
||
|
||
For every form_field that should be visible after conditional-logic evaluation at submit time, a `form_values` row must exist after submit, even with null value. This is the invariant that distinguishes "explicit null" from "skipped by conditional logic".
|
||
|
||
`tests/Feature/FormBuilder/FormSubmissionWritePathInvariantTest.php` asserts this for the seeded fixtures. The test fails if `FormValueService::writeValues` silently drops null values on visible fields.
|
||
|
||
### 17.6 Why not "first binding wins"
|
||
|
||
The trust-level + sort-order ordering is more expressive than "first binding wins" because:
|
||
|
||
- Schema authors can express authority gradients (data from a trusted upstream system at trust 90, user-self-reported at trust 50)
|
||
- Renaming or reordering fields in the builder UI does not silently change resolution semantics — the `trust_level` is explicit
|
||
- The tie-break on `sort_order` is documented and stable
|
||
|
||
The cost is that schema authors must understand `trust_level`. The Form Builder UI surfaces this in the binding editor with a tooltip explaining the semantics; defaults are 50 (medium trust).
|
||
|
||
---
|
||
|
||
## 18. Test contract & coverage
|
||
|
||
### 18.1 Categories
|
||
|
||
Pipeline tests fall into five categories. Each category has a coverage target and a canonical test-class location.
|
||
|
||
#### Category 1 — Pipeline-agnostic (~20 tests)
|
||
|
||
Located: `tests/Feature/FormBuilder/Pipeline/`
|
||
|
||
Coverage:
|
||
|
||
- Listener registration order test (§5.5)
|
||
- Two-transaction failure-write pattern (inner rollback + outer success; outer failure with Sentry capture)
|
||
- Post-commit event firing (no listener sees pre-commit state)
|
||
- Schema snapshot byte-stability across re-emits
|
||
- ApplyStatus state transitions (NULL → pending → completed; NULL → pending → failed; failed → pending → completed via retry; failed → resolved; failed → dismissed)
|
||
- IdentityMatchStatus update interplay with apply (apply must complete before identity-match writes status)
|
||
- Section-level apply stub (registered, gated, signature forwarding)
|
||
|
||
#### Category 2 — Per-purpose pipeline (~28 tests)
|
||
|
||
Located: `tests/Feature/FormBuilder/Pipeline/Purposes/{Purpose}/`
|
||
|
||
Coverage: 4 cases × 7 purposes = 28 tests.
|
||
|
||
Per purpose (`event_registration`, `artist_advance`, `supplier_intake`, `post_event_evaluation`, `incident_report`, `signature_contract`, `user_profile`):
|
||
|
||
- Happy path — submission with all required bindings resolves, applies, persists
|
||
- Missing required binding — submission rejected at submit time (via existing form-validation), or apply fails gracefully if validation slipped
|
||
- Conflict resolution — two bindings on same target attribute, trust precedence wins
|
||
- Anonymous-when-applicable — for purposes where `subject_type` can be null, anonymous submission goes through the stub-resolver path
|
||
|
||
#### Category 3 — PublishGuard (~18 tests)
|
||
|
||
Located: `tests/Unit/FormBuilder/Publishing/{Guard}Test.php`
|
||
|
||
Coverage: 1 passing case + 1 failing case per guard × 9 guards = 18 tests.
|
||
|
||
Plus a smoke test asserting the four universal guards wire into every `PurposeGuardProvider` (catches "I added a new purpose provider but forgot the universal guards").
|
||
|
||
#### Category 4 — Failure management (~24 tests)
|
||
|
||
Located: `tests/Feature/FormBuilder/FailureManagement/` and `tests/Feature/Api/FormFailure*Test.php`
|
||
|
||
Coverage:
|
||
|
||
- Retry happy path (failed → resolved)
|
||
- Retry that throws again (failed → failed with new exception)
|
||
- Retry of resolved/dismissed (no-op, idempotent)
|
||
- Resolve happy path
|
||
- Resolve with optional note
|
||
- Dismiss with each `DismissalReasonType` (6 cases)
|
||
- Dismiss with `OTHER` requiring `dismissed_reason_note`
|
||
- IDOR-class tests (§13.4) — 5 policy methods × 4 cases = 20 tests
|
||
|
||
#### Category 5 — Activity log (~10 tests)
|
||
|
||
Located: `tests/Feature/FormBuilder/ActivityLog/`
|
||
|
||
Coverage:
|
||
|
||
- Pass-level entry written per applicator invocation
|
||
- Per-binding entries written, each with `parent_activity_id`
|
||
- Failed binding writes an entry with error properties
|
||
- Hierarchical query: given a pass entry ID, retrieve all child binding entries
|
||
- Activity log entries reference correct subject types (Person for person bindings, User for user bindings, etc.)
|
||
|
||
### 18.2 Coverage baseline
|
||
|
||
Pre-WS-6: 1208 backend tests.
|
||
|
||
WS-6 added ~278 tests (sessions 1, 2, 3 combined).
|
||
|
||
Post-WS-6 (main HEAD `4a84b9e`): ~1486 backend tests.
|
||
|
||
Future binding-pipeline changes should add at least one test per new decision-point and one test per new guard. Reviewers should reject PRs that add new branches in `FormBindingApplicator::apply()` or `FormSchemaService::publish()` without corresponding test additions.
|
||
|
||
### 18.3 Test data
|
||
|
||
`FormBuilderDevSeeder` provides fixtures for one event_registration schema per dev-org with 5 fields, 1 draft submission, 1 submitted submission. The submitted submission triggers the §31.10 tag-sync listener inline via `queue.connection=sync` flip in seeder context.
|
||
|
||
For pipeline tests, factories live under `database/factories/FormFieldBindingFactory.php`, `FormSubmissionFactory.php`, `FormSubmissionActionFailureFactory.php`. They support fluent state methods like `->withApplyStatus(ApplyStatus::FAILED)` and `->forPurpose('event_registration')`.
|
||
|
||
---
|
||
|
||
## 19. Out of scope — explicit non-goals + BACKLOG references
|
||
|
||
The pipeline does not handle:
|
||
|
||
| Non-goal | BACKLOG item | Rationale |
|
||
|---|---|---|
|
||
| Composite identity-key resolution (e.g. `email OR (first_name + last_name + DOB)`) | `FORM-BINDING-COMPOSITE-IDENTITY` | V1 enforces single identity-key per target_entity. Multi-attribute matching requires query-language design beyond `firstOrCreate`. |
|
||
| Cross-event submission deduplication (one Person, multiple events with separate registrations) | — | Handled by existing `PersonIdentityService` flow, not a binding concern. |
|
||
| Library-binding runtime cascade (updates propagate to instantiated fields) | `FORM-LIBRARY-RESYNC` | Copy-at-instantiation semantics chosen for predictability + audit reproducibility. |
|
||
| Append strategy on scalar targets | — | Architecturally rejected (V1). Eliminates duplicate-append-on-retry problem class. |
|
||
| Active section-level apply | `ARTIST-ADV-SECTION-APPLY` | Stubbed structure with feature flag; activated when artist_advance feature work begins. |
|
||
| Daily failure digest mailable | `FORM-FAILURE-DAILY-DIGEST` | Depends on notification framework (post-accreditation engine). |
|
||
| Wall-clock concurrent load testing | `LOAD-TEST-FOUNDATION` | Separate workstream; pre-release hardening. |
|
||
| ARTIST_ADV-specific binding-target registry entries | `ARTIST-ADV-BINDING-MODEL` | V1 omits artist binding-target registry entries entirely; landing in artist_advance feature work. |
|
||
| Partial-binding failure recovery | `PARTIAL-BINDING-SUCCESS` | Current architecture is all-or-nothing per pass: any binding throw rolls back the whole apply. v1.0 deliberate limitation; partial-success requires a SAGA pattern or per-binding-transaction redesign. Trigger: first enterprise customer reports it as a UX issue. |
|
||
| Schema-drift detection on migration | `FORM-SCHEMA-DRIFT-DETECTION` | The 5% of runtime applicator throws that the architecture attributes to "DB modified out from under us" deserve dedicated detection. Trigger: first production incident where a runtime-throw is traced to a stale binding after a migration, OR pre-emptive trigger on a large-scale column-renaming. |
|
||
|
||
When an item moves from out-of-scope to in-scope (e.g., artist_advance work begins), the corresponding BACKLOG item is the entry point. Each BACKLOG item references back to this doc and to RFC-WS-6.
|
||
|
||
---
|
||
|
||
## 20. Cross-references & document history
|
||
|
||
### 20.1 Document references
|
||
|
||
| Document | Relevance |
|
||
|---|---|
|
||
| ARCH-FORM-BUILDER.md §17 | `form_field_bindings` table shape + binding semantics in the form-builder domain |
|
||
| ARCH-FORM-BUILDER.md §17.3 | PurposeRegistry mechanics, adding a new purpose |
|
||
| ARCH-FORM-BUILDER.md §17.4 | Validation rules table (sibling to bindings, same WS-5 split pattern) |
|
||
| ARCH-FORM-BUILDER.md §31.1–§31.10 | Integration contracts to non-binding listeners (identity match, GDPR, shifts, mail, code-of-conduct, supplier intake, accreditation hooks, crowd lists, tag sync) |
|
||
| ARCH-FORM-BUILDER.md §3.2 | Per-purpose lifecycles |
|
||
| ARCH-FORM-BUILDER.md §4.6.1 | Schema snapshot structure + byte-stability addendum |
|
||
| SCHEMA.md §3.5.12 | `form_submissions`, `form_field_bindings`, `form_submission_action_failures` table shapes |
|
||
| RFC-WS-6.md (frozen 2026-04-25) | Historical design source; cited as `RFC-WS-6 §X` for "why" decisions |
|
||
| ARCH-CONSOLIDATION-2026-04.md §6.2 | WS-6 charter |
|
||
| ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1, Q2 | ULID exception retired, denormalized organisation_id pattern |
|
||
| ARCH-OBSERVABILITY.md (forthcoming WS-8b) | How pipeline events surface in GlitchTip + Telescope; PII-scrubbing contract for failure exports |
|
||
| BACKLOG.md | All deferred items referenced in §19 |
|
||
|
||
### 20.2 Document history
|
||
|
||
| Version | Date | Author | Notes |
|
||
|---|---|---|---|
|
||
| v1.0 | 2026-05-05 | Bert Hausmans (architect), Claude Chat (drafting) | Initial canonical reference, post-WS-6. Aligned to main HEAD `4a84b9e`. |
|
||
| v1.1 | 2026-05-07 | Bert Hausmans (architect), Claude Chat (drafting) | Incorporates RFC-WS-6 v1.3 refinements (architectural review 2026-05-07). Twelve section edits, one new section: header version note; §5.1 SYNC chain reduced to one listener; §5.2 listener catalogue updated (TriggerPersonIdentityMatch sync→queued, gating-invariant cross-refs, deadline wrapper, failure_response_code mirror); §5.3 rewritten as "Why SYNC for ApplyBindings only" with deadline-wrapper note; §5.5 listener-ordering test extended for queueing + gating introspection; **§5.6 (new) Queued-listener gating invariant**; §6.2 deadline-wrapper + failure_response_code notes; §7.1 storage table extended with `failure_response_code`; §7.3 failsafe-pad replaced with explicit invariant statement; §8.5 `RequiresIdentityKeyBinding` becomes unconditional for `event_registration`; §11.1 cross-ref to `failure_response_code` companion column; §19 Partial-binding row points at `PARTIAL-BINDING-SUCCESS`, new row for `FORM-SCHEMA-DRIFT-DETECTION`. Spine unchanged (pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, sync ApplyBindings, snapshot-isolation, single-identity-key per target_entity). |
|
||
|
||
### 20.3 Update protocol
|
||
|
||
This document updates when:
|
||
|
||
- A pipeline contract changes (any of §3, §4, §5, §6, §7, §8, §9 — RFC required)
|
||
- A failure-management flow changes (§11, §12, §13)
|
||
- An out-of-scope item is moved into scope (§19)
|
||
- WS-8b lands and §20.1 needs the ARCH-OBSERVABILITY reference filled in concretely
|
||
|
||
Section-level edits are auto-committed per CLAUDE.md Git Commit Policy. Major contract changes route through RFC + ARCH update + test update + ARCH-FORM-BUILDER cross-ref update as a single commit chain.
|