Files
crewli/dev-docs/ARCH-BINDINGS.md
bert.hausmans b2558791e6 docs(rfc-ws-6): v1.3.1 + ARCH-BINDINGS v1.2 — drift closure pre-D1 implementation
Three code-vs-docs drifts surfaced by the 2026-05-08 v1.3-delta audit.
None changes architecture; all three close the gap between code on main
(845b6e6) and the v1.3 amendment text.

- RFC §3 (Q1): apply_status enumerations updated to four cases (added
  PARTIAL alongside PENDING/COMPLETED/FAILED). PARTIAL is the
  BindingPassResult outcome when the pass committed with mixed
  per-binding outcomes; not a separate runtime path. Long-term direction
  remains BACKLOG PARTIAL-BINDING-SUCCESS.
- ARCH-BINDINGS §5.6: new "PARTIAL handling" subsection clarifying the
  gate treats PARTIAL identically to FAILED until partial-success work
  lands. The gate code itself was already correct (strict equality on
  COMPLETED); this closes the explanatory gap.
- ARCH-BINDINGS §7.1: status-columns table extended with apply_completed_at
  row. Intro line updated. Retry-service asymmetry noted as D2 follow-up
  (FormFailureRetryService::recordFailure currently does not write
  apply_completed_at; D2 fixes this).

RFC v1.3 -> v1.3.1; ARCH-BINDINGS v1.1 -> v1.2.

Refs: dev-docs/RFC-WS-6.md, dev-docs/ARCH-BINDINGS.md, dev-docs/BACKLOG.md (PARTIAL-BINDING-SUCCESS, unchanged)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:32:19 +02:00

1576 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ARCH-BINDINGS.md — FormBindingApplicator Pipeline
**Version:** v1.2 — incorporates RFC-WS-6 v1.3.1 drift closure (2026-05-08)
**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 0100 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.
#### PARTIAL handling
`apply_status=partial` means the inner transaction committed but at least one binding in the pass failed (per `BindingPassResult::applyStatus()`). The gate skips PARTIAL submissions identically to FAILED submissions: queued listeners cannot safely assume the subject + bindings are coherent. For example, `SyncTagPickerSelectionsOnSubmit` rebuilds `user_organisation_tags` against a Person whose tag-binding may have been the binding that failed; running the listener would propagate the partial state into derived data.
Until BACKLOG `PARTIAL-BINDING-SUCCESS` lands, the gate's positive branch is restricted to `COMPLETED` only. The PARTIAL row stays visible in the failures UI through the per-binding failure rows that `FormBindingApplicator` writes inside `BindingPassResult::failures()`, so admins can triage without losing audit data.
If a future contributor extends the gate to accept PARTIAL, the structural test asserting "skip on every non-COMPLETED state" must be updated alongside the listener changes — never one without the other.
---
## 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, one failure-classification column, and one apply-finalisation timestamp 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. |
| `apply_completed_at` | timestamp nullable | NULL | no | `ApplyBindingsOnFormSubmit::handle` (writes `now()` in both the inner-transaction success path AND the outer-transaction failure path, paired with the corresponding `apply_status` write) and `FormFailureRetryService::retry` (success path). Set whenever `apply_status` transitions to a terminal state (COMPLETED, PARTIAL, or FAILED). NULL means apply has never run on this submission. Used by the failures UI to render "Failed at …" / "Completed at …" copy without joining to `form_submission_action_failures`. |
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`.
`FormFailureRetryService::recordFailure` does NOT currently write `apply_completed_at` on the retry-failure path; this asymmetry is fixed as part of the v1.3-delta D2 work (RFC v1.3 §Q3 addition 2 implementation).
### 7.2 `ApplyStatus` enum
```php
enum ApplyStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case PARTIAL = 'partial';
case FAILED = 'failed';
}
```
`PARTIAL` is not a separate runtime path through `ApplyBindingsOnFormSubmit::handle` — it is the value `BindingPassResult::applyStatus()` returns when the pass committed but at least one individual binding failed (mixed per-binding outcomes inside a single applicator pass). Per RFC-WS-6 §Q3 v1.3 addition 3, granular partial-success handling is BACKLOG `PARTIAL-BINDING-SUCCESS`. Until that work lands, `PARTIAL` is treated identically to `FAILED` by the queued-listener gate (see §5.6).
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, all bindings)──▶ completed
pending ──(apply commits, mixed per-binding outcomes)──▶ partial
pending ──(apply throws / deadline exceeded)──▶ 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). |
| v1.2 | 2026-05-08 | Bert Hausmans (architect), Claude Code (drafting) | §5.6 gained the PARTIAL-handling subsection; §7.1 gained the `apply_completed_at` row + intro update + retry-service asymmetry note; §7.2 enum block updated to four cases (added `PARTIAL`) with explanatory paragraph + state-transition diagram updated. No structural changes. Companion: RFC-WS-6 v1.3.1. |
### 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.