# 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 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` 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 */ 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 $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 */ 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 ", "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: , 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.