diff --git a/dev-docs/ARCH-BINDINGS.md b/dev-docs/ARCH-BINDINGS.md index 18dabe57..34697281 100644 --- a/dev-docs/ARCH-BINDINGS.md +++ b/dev-docs/ARCH-BINDINGS.md @@ -1,6 +1,6 @@ # ARCH-BINDINGS.md — FormBindingApplicator Pipeline -**Version:** v1.0 — initial canonical reference +**Version:** v1.1 — incorporates RFC-WS-6 v1.3 refinements (architectural review 2026-05-07) **Aligned to:** `main` HEAD `4a84b9e` (post WS-6 closure, 2026-05-04) **Authoritative:** Yes. This document supersedes `RFC-WS-6.md` for ongoing maintenance — RFC-WS-6 remains as a historical record of the 2026-04-25 design session that produced this architecture. @@ -272,15 +272,16 @@ If a use-case ever surfaces for "append a string segment to a text field", model ### 5.1 Registration -`EventServiceProvider::$listen` is the single source of truth for listener registration. Two entries fire on `FormSubmissionSubmitted`: +`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 chain — order matters + // 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, - // QUEUED — order does not matter SyncTagPickerSelectionsOnSubmit::class, CreateProvisionalShiftAssignmentsFromRegistration::class, AddPersonToApplicableCrowdListsOnRegistration::class, @@ -290,29 +291,42 @@ protected $listen = [ ]; ``` -The two SYNC listeners do not implement `ShouldQueue`. The four QUEUED listeners do. Laravel's listener-array order is the ordering primitive; no `Subscriber` pattern, no `$priority` flag. +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` | Catches strict `FormBindingApplicator` throws, writes `form_submission_action_failures` row in outer transaction, swallows | -| `TriggerPersonIdentityMatchOnFormSubmit` | sync | `subject_type='person'` (incl. just-provisioned by ApplyBindings) | Calls `PersonIdentityService::detectMatches($person)`, writes `identity_match_status` | Logs at error, swallows; does not block siblings | -| `SyncTagPickerSelectionsOnSubmit` | queued | `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 | `purpose='event_registration'` AND AVAILABILITY_PICKER values present | Creates `claim_pending` ShiftAssignments for matching shifts | Logs at error, swallows | -| `AddPersonToApplicableCrowdListsOnRegistration` | queued | `purpose='event_registration'` AND new Person created | Adds Person to crowd_lists matching auto_add_criteria | Logs at error, swallows | -| `FormWebhookDispatcher` | queued | 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 | Per-purpose mailable trigger conditions | Sends purpose-specific email to submitter | Failed sends logged via existing CrewliMailable infrastructure | +| `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 the first two listeners +### 5.3 Why SYNC for ApplyBindings only -Two reasons. +`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()`. -**First**, `identity_match_status` must be in the database before the HTTP response serializes the submission resource. The portal IdentityMatchBanner (and the organizer review UI) reads this column directly from the resource payload. Queueing `TriggerPersonIdentityMatchOnFormSubmit` would mean the user sees `pending` until a queue worker processes the job, then must reload to see `matched`. A sync listener guarantees the response carries the right state. +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. -**Second**, `ApplyBindingsOnFormSubmit` runs first because `TriggerPersonIdentityMatchOnFormSubmit` needs the just-provisioned Person to detect matches against. Queueing ApplyBindings would race against the identity-match listener. +#### Sync-chain hard timeout -Both listeners cost two synchronous calls — a few hundred milliseconds total in the common case. The simplicity is worth more than the latency saving of asynchronous handoff. +`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 @@ -324,12 +338,32 @@ The two are deliberate parallel paths. Future contributors should not consolidat `tests/Feature/FormBuilder/EventServiceProviderListenerOrderTest.php` asserts that: -1. `ApplyBindingsOnFormSubmit` and `TriggerPersonIdentityMatchOnFormSubmit` are both registered -2. `ApplyBindingsOnFormSubmit` precedes `TriggerPersonIdentityMatchOnFormSubmit` in the array -3. Neither implements `ShouldQueue` -4. `SyncTagPickerSelectionsOnSubmit`, `CreateProvisionalShiftAssignmentsFromRegistration`, `AddPersonToApplicableCrowdListsOnRegistration`, `FormWebhookDispatcher` all implement `ShouldQueue` +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, the test fails before any behaviour test would. +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. --- @@ -390,6 +424,24 @@ The outer transaction is independent. It writes `form_submission_action_failures 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: @@ -428,14 +480,15 @@ If the outer (failure-record) transaction throws, the `Log::error` call still fi ### 7.1 Two independent concerns -`form_submissions` carries two status columns that the pipeline manages: +`form_submissions` carries two status columns and one failure-classification column that the pipeline manages: -| Column | Type | Default | Managed by | -|---|---|---|---| -| `apply_status` | string(20) nullable | NULL | `ApplyBindingsOnFormSubmit` (sync), retry/resolve flows | -| `identity_match_status` | string(20) nullable | NULL | `TriggerPersonIdentityMatchOnFormSubmit` (sync), `PersonIdentityService::confirmMatch` (manual) | +| Column | Type | Default | Indexed | Managed by | +|---|---|---|---|---| +| `apply_status` | string(20) nullable | NULL | yes | `ApplyBindingsOnFormSubmit` (sync), retry/resolve flows | +| `identity_match_status` | string(20) nullable | NULL | yes | `ApplyBindingsOnFormSubmit` writes initial `'pending'` for `subject_type='person'`; `TriggerPersonIdentityMatchOnFormSubmit` (queued) writes final state; `PersonIdentityService::confirmMatch` (manual) | +| `failure_response_code` | string(40) nullable | NULL | yes | `ApplyBindingsOnFormSubmit` outer-transaction failure path. Values: `schema_config_error`, `temporary_error`, `data_integrity_error`, `unknown_error` (per RFC-WS-6 §Q3 v1.3 addition 2). Serialized into API response body when `apply_status=failed` so the frontend can render contextual copy. | -They are **independent**. A submission can be `apply_status=completed` AND `identity_match_status=pending` — the bindings landed, but the organizer hasn't confirmed which existing Person this submission's just-provisioned Person should link to. Both states must be visible in the UI as separate signals, not collapsed into a single "submission status" indicator. +They are **independent**. A submission can be `apply_status=completed` AND `identity_match_status=pending` — the bindings landed, but the organizer hasn't confirmed which existing Person this submission's just-provisioned Person should link to. Both states must be visible in the UI as separate signals, not collapsed into a single "submission status" indicator. `failure_response_code` is null on success and populated only on `apply_status=failed`. ### 7.2 `ApplyStatus` enum @@ -466,9 +519,18 @@ failed ──(retry throws)──▶ failed (action_failure row gets new attempt This column predates WS-6 and is documented in ARCH-FORM-BUILDER §31.1. Pipeline-relevant points: -- `TriggerPersonIdentityMatchOnFormSubmit` writes the initial state (`pending` if matches found, `no_match` if `PersonIdentityService::detectMatches` returns empty). -- The listener can run BEFORE `ApplyBindingsOnFormSubmit` succeeds for non-event-registration purposes where Person is not the subject. In those cases the listener is a no-op (subject_type != 'person' early-return). -- For `event_registration` post-WS-6, `subject_type='person'` is always set by `ApplyBindingsOnFormSubmit` before this listener runs. RFC-WS-6 §3 Q2 documents the "no subject → pending" path that survives as a logged warning failsafe. +- `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 @@ -580,21 +642,24 @@ These wire only into providers where they apply: | Guard | Wires into | Behaviour | |---|---|---| -| `RequiresIdentityKeyBinding(entity, attribute)` | `event_registration` (`person`, `email`), `artist_advance` (`artist`, `id`), etc. | Asserts schema has at least one binding with `target_entity=entity`, `target_attribute=attribute`, `is_identity_key=true` | +| `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 `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`: -```php -new ConditionalRequirement( - predicate: fn (FormSchema $s) => $s->public_token !== null, - subGuard: new RequiresIdentityKeyBinding('person', 'email'), -); -``` - -This lets `EventRegistrationGuardProvider` enforce "if you make this schema public, you must have an email-keyed Person identity binding" without imposing the requirement on private event_registration schemas. +> **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 @@ -785,6 +850,17 @@ See SCHEMA.md §3.5.12 for the canonical column list. Pipeline-relevant columns: 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 ``` @@ -1438,7 +1514,8 @@ The pipeline does not handle: | 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 | — | Current architecture is all-or-nothing per pass: any binding throw rolls back the whole apply. Granular partial-success is a future RFC topic. | +| 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. @@ -1468,6 +1545,7 @@ When an item moves from out-of-scope to in-scope (e.g., artist_advance work begi | Version | Date | Author | Notes | |---|---|---|---| | v1.0 | 2026-05-05 | Bert Hausmans (architect), Claude Chat (drafting) | Initial canonical reference, post-WS-6. Aligned to main HEAD `4a84b9e`. | +| v1.1 | 2026-05-07 | Bert Hausmans (architect), Claude Chat (drafting) | Incorporates RFC-WS-6 v1.3 refinements (architectural review 2026-05-07). Twelve section edits, one new section: header version note; §5.1 SYNC chain reduced to one listener; §5.2 listener catalogue updated (TriggerPersonIdentityMatch sync→queued, gating-invariant cross-refs, deadline wrapper, failure_response_code mirror); §5.3 rewritten as "Why SYNC for ApplyBindings only" with deadline-wrapper note; §5.5 listener-ordering test extended for queueing + gating introspection; **§5.6 (new) Queued-listener gating invariant**; §6.2 deadline-wrapper + failure_response_code notes; §7.1 storage table extended with `failure_response_code`; §7.3 failsafe-pad replaced with explicit invariant statement; §8.5 `RequiresIdentityKeyBinding` becomes unconditional for `event_registration`; §11.1 cross-ref to `failure_response_code` companion column; §19 Partial-binding row points at `PARTIAL-BINDING-SUCCESS`, new row for `FORM-SCHEMA-DRIFT-DETECTION`. Spine unchanged (pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, sync ApplyBindings, snapshot-isolation, single-identity-key per target_entity). | ### 20.3 Update protocol diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index e3688f18..00506071 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -360,6 +360,36 @@ class extractie (2026-04-25)". --- +### FORM-SCHEMA-DRIFT-DETECTION — Detecteer stale bindings na schema-migrations + +**Aanleiding:** RFC-WS-6 v1.3 §Q3 addition 4 — schema-drift werd in v1.2 als +zijdelingse opmerking afgedaan ("DB modified out from under us"), v1.3 maakt het +expliciet als follow-up. + +**Wat:** Migration-listener die, wanneer een binding-target column wordt +renamed/dropped/type-changed, alle `form_field_bindings.target_attribute` scant op +matches. Affected `form_schemas` krijgen `needs_revalidation=true`. Schema-detail UI +toont banner; affected schemas accepteren geen nieuwe submissions tot organiser +re-publisht. + +**Vereiste:** +- Migration-listener (Laravel `events` integration of een custom `MigrationObserver`) +- Nieuwe boolean kolom op `form_schemas`: `needs_revalidation` + index +- Re-publish flow op schema-detail UI +- Tests die simuleren: column rename → schemas met binding op die column gemarkeerd + → re-publish reset + +**Trigger-conditie:** Eerste productie-incident waar een runtime-throw te herleiden +is naar een stale binding na een migration. Of: pre-emptive trigger als enterprise +customer een grootschalige column-renaming uitvoert. + +**Estimate:** 2-3 dagen. + +**Refs:** ARCH-BINDINGS.md §6.5 (binding-change safety, related but distinct), +RFC-WS-6.md §Q3 v1.3. + +--- + ### FORM-04 — `grace_days` configurable on public_token rotation **Aanleiding:** S2c §10.4 opgeleverd met een hardgecodeerd 7-daagse grace window in `PublicFormTokenResolver`. `rotatePublicToken` endpoint accepteert wel een `grace_days` request param maar schrijft die nergens naartoe; `form_schemas` heeft geen `grace_days` kolom. @@ -446,6 +476,33 @@ Prioriteit: Medium. Kan gebundeld worden met de organizer --- +### PARTIAL-BINDING-SUCCESS — Granular per-binding success-state in FormBindingApplicator + +**Aanleiding:** ARCH-BINDINGS v1.0 §19 noemde dit als "future RFC topic" — RFC-WS-6 +v1.3 verplaatst het naar expliciete BACKLOG met trigger-conditie. + +**Wat:** Granular per-binding success/failure tracking voor `FormBindingApplicator`. +Huidige v1.0: één binding throw → hele apply rolt terug → submission `apply_status=failed`. +Toekomstig: per-binding success-state, failed bindings expliciet zichtbaar in admin UI, +gebruiker krijgt "submission gedeeltelijk verwerkt — 11 van 12 velden zijn opgeslagen, +één veld vereist organiser-aandacht." + +**Vereiste designkeuzes:** +- SAGA pattern met per-binding compensation, OF +- Per-binding transactie met idempotency-keys per binding-application +- Trust-precedence resolution moet opnieuw doordacht (huidige model assumeert atomic + pass) + +**Trigger-conditie:** Eerste enterprise customer rapporteert all-or-nothing als UX +issue. Concreet signaal: support-ticket of customer-call met "gebruiker vulde 12 +velden, één crashte, alles ging verloren." + +**Estimate:** 4-6 dagen. + +**Refs:** ARCH-BINDINGS.md §19, RFC-WS-6.md §Q3 v1.3. + +--- + ### SUP-01 — Leveranciersportal + productieverzoeken **Aanleiding:** Leveranciers moeten productie-informatie kunnen indienen. diff --git a/dev-docs/RFC-WS-6.md b/dev-docs/RFC-WS-6.md index a25f97c1..8f128289 100644 --- a/dev-docs/RFC-WS-6.md +++ b/dev-docs/RFC-WS-6.md @@ -3,8 +3,8 @@ ## 1. Status - **State:** Authoritative for sessions 1, 2, 3 of WS-6 -- **Frozen:** 2026-04-25 (v1.0); refined post-session-2 cleanup as v1.1, then again as v1.2 (see §10) -- **Version:** v1.2 +- **Frozen:** 2026-04-25 (v1.0); refined post-session-2 cleanup as v1.1, then v1.2 (sessie 3a.5), then v1.3 (architectural review 2026-05-07) — see §10 +- **Version:** v1.3 - **Owner:** Bert Hausmans - **Origin:** Architectural session 2026-04-25 (Claude Chat) — 13 design decisions, 4 refinements, 3 observations - **Related:** @@ -35,29 +35,253 @@ This RFC captures every decision so sessions 2 and 3 do not drift from the archi ### Q1 — Listener ordering on `FormSubmissionSubmitted` -**Decision:** Two synchronous listeners in registration order, all other listeners queued and parallel. +**Decision (v1.3):** **One** synchronous listener (`ApplyBindingsOnFormSubmit`); all +other listeners — including `TriggerPersonIdentityMatchOnFormSubmit` — queued and +parallel. + +> **v1.2 → v1.3:** v1.2 had two synchronous listeners +> (`ApplyBindingsOnFormSubmit` → `TriggerPersonIdentityMatchOnFormSubmit`). The +> 2026-05-07 architectural review reduced the SYNC chain to one listener; identity +> matching moves to queued. Rationale below. ``` SYNC chain (registered in EventServiceProvider in this order): - 1. ApplyBindingsOnFormSubmit ← creates Person (when applicable), applies all bindings - 2. TriggerPersonIdentityMatchOnFormSubmit ← detects matches against the just-provisioned Person + 1. ApplyBindingsOnFormSubmit ← creates Person (when applicable), + applies all bindings, writes + identity_match_status='pending' + as initial state -QUEUED (no inter-ordering required): +QUEUED (no inter-ordering required, all gated on apply_status=COMPLETED): + - TriggerPersonIdentityMatchOnFormSubmit ← writes final identity_match_status - SyncTagPickerSelectionsOnSubmit - CreateProvisionalShiftAssignmentsFromRegistration + - AddPersonToApplicableCrowdListsOnRegistration - FormWebhookDispatcher → DeliverFormWebhookJob - RegistrationConfirmation mailable (and other purpose-specific mailables) ``` -Rationale: Laravel's listener-array order is the simple ordering primitive. `Subscriber` patterns and `$priority` flags add no value here. Sync vs. queued is the meaningful distinction — `identity_match_status` must be in DB before HTTP response serializes (per existing §31.1 UX choice for `TriggerPerson...`). +#### Rationale for moving identity-match to queued + +`PersonIdentityService::detectMatches` joins `person_identity_matches` against +`persons` scoped to the organisation. In organisations with 10k+ Persons (festival +with multi-year crew database, or a publisher running many events) this is not +"a few hundred milliseconds" — it is seconds, sometimes more. On a public flow at +peak (volunteer registration window opens, 100+ concurrent submissions) a synchronous +identity-match blocks PHP-FPM workers, queue depth balloons, and the public form +endpoint becomes the slow path of the system. This is operationally unacceptable for +enterprise SaaS. + +The v1.2 rationale for sync identity-match was UX-driven: "the response should carry +the right state so the IdentityMatchBanner shows correct copy without a reload." That +is a UX requirement, not an architectural one. Stripe, Plaid, and other enterprise +products do identity-resolution asynchronously and push state via real-time channels. +Soketi is already in the Crewli stack. + +#### What stays sync + +`ApplyBindingsOnFormSubmit` remains 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 response. + +#### Q1 v1.3 addition 1 — Initial `identity_match_status='pending'` written by ApplyBindings + +`ApplyBindingsOnFormSubmit::handle` writes `identity_match_status='pending'` inside +the inner transaction, immediately after subject resolution. This guarantees the HTTP +response carries `pending` (not null), matching the existing `FormSubmissionResource` +S3a contract for the `identity_match` block. The portal `IdentityMatchBanner` renders +correctly on first paint with "we're checking matches…" copy. + +#### Q1 v1.3 addition 2 — Echo broadcast on `identity_match_status` change + +`TriggerPersonIdentityMatchOnFormSubmit::handle` ends with a broadcast on the +`submission.{submission_id}` private channel after writing the final status. Payload: + +```php +broadcast(new FormSubmissionIdentityMatchResolved( + submissionId: $submission->id, + status: $submission->identity_match_status, // 'matched' | 'no_match' | 'multiple_candidates' + matchCount: $submission->identity_match_count, +))->toOthers(); +``` + +Frontend follow-up (separate ticket, not in WS-6 scope): portal IdentityMatchBanner +subscribes to this channel via Laravel Echo and refetches the submission resource +when the broadcast lands. This is a small frontend addition that lands after the +backend pipeline is stable. + +Until the frontend follow-up ships, the existing TanStack Query refetch-on-window-focus +behaviour gives users a reasonable experience: status arrives within seconds of +returning to the tab. **This is acceptable interim behaviour**, not the target. + +#### Q1 v1.3 addition 3 — Queued-listener gating invariant + +Every queued listener begins with: + +```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. Without this gate, a failed +ApplyBindings (which leaves `apply_status=failed` and `subject_id=null`) would still +trigger queued listeners that assume a valid Person exists — leading to cascading +failures whose root cause is hard to trace. + +ARCH-BINDINGS §5.6 (new section) documents this invariant. The listener-registration +test (`EventServiceProviderListenerOrderTest`) extends to assert the invariant via +listener-class introspection (each `ShouldQueue` listener has the gate as its +first statement). If a contributor adds a queued listener without the gate, the +test fails before code review. + +#### Q1 v1.3 addition 4 — Sync-chain hard timeout + +`ApplyBindingsOnFormSubmit` runs inside a 5-second deadline wrapper. Implementation: + +```php +$applicator->withDeadline(seconds: 5)->apply($submission); +``` + +Internal: a wrapper service tracks elapsed time per binding-resolution step. On +deadline-exceeded: throws `FormBindingApplicatorTimeoutException`, caught by the +outer transaction handler, written as a `form_submission_action_failures` row with +`exception_class=FormBindingApplicatorTimeoutException` and `apply_status='failed'`. + +The 5-second value is configurable via `config('form_builder.apply_deadline_seconds')` +with default 5. Documented in `config/form_builder.php` with a comment block on +when to tune it (very large schemas with many bindings, identity-key resolution on +massive person pools — neither expected in v1.0). + +This addition guarantees that no submission can hang the public flow for more than +a bounded interval. Slow paths surface as failures, not as hung connections. ### Q2 — Refactor of `TriggerPersonIdentityMatchOnFormSubmit` -**Decision:** Trim the "no subject → pending" path to a logged warning failsafe. Do NOT merge with `ApplyBindingsOnFormSubmit`. +**Decision (v1.3):** **Remove** the "no subject → pending" failsafe path. Replace with +an explicit invariant and a strict throw routed through the existing +`form_submission_action_failures` pipeline. -Post-WS-6, the listener should never see `subject_type === null` for `event_registration` submissions — `ApplyBindingsOnFormSubmit` resolves the Person via email-binding before this listener fires. The path becomes dead code, but rather than delete it we log a `Log::warning('form-builder.identity-match.no_person_subject_post_apply', [...])` and preserve the `'pending'` failsafe value. This catches misconfigured schemas (no email binding, no identity-key) and ApplyBindings silent failures with mechanical visibility. +> **v1.2 → v1.3:** v1.2 trimmed the "no subject → pending" path to a logged warning +> failsafe and kept it as defensive code. The 2026-05-07 review judged the failsafe +> architecturally dishonest (path either needed coherently or removed) and converted +> it to an explicit invariant + strict throw. Companion: `RequiresIdentityKeyBinding` +> wires unconditionally for `event_registration` (drop the `ConditionalRequirement` +> wrapper). The two listeners stay separate (testability, single-responsibility). -The two listeners stay separate for testability, single-responsibility, and zero cost (two sync calls vs. one). +#### Rationale for removing the failsafe + +A path that "should never trigger but stays as a logged warning failsafe" is +architecturally dishonest. Either the path is needed (and should have a coherent +behaviour, not a half-measure), or it is not (and should be removed). + +The v1.2 rationale was *catches misconfigured schemas and silent ApplyBindings +failures*. Both motivations dissolve under scrutiny: + +- **Misconfigured schemas**: should be caught by publish-guards. If a schema lands + in a state where post-ApplyBindings `subject_type=null` for `event_registration`, + there is a gap in the publish-guard logic. The fix is to close that gap, not to + paper over it with a runtime warning. +- **Silent ApplyBindings failures**: already write a `form_submission_action_failures` + row. A second `Log::warning` in a downstream listener creates two parallel audit + trails that can drift out of sync. In production, incident triage works on one + canonical source. + +#### The replaced behaviour + +`TriggerPersonIdentityMatchOnFormSubmit::handle`: + +```php +public function handle(FormSubmissionSubmitted $event): void +{ + $submission = $event->submission->fresh(); + + // Gate (per §Q1 addition 3) + if ($submission->apply_status !== ApplyStatus::COMPLETED) { + return; + } + + // Non-person purposes are no-ops by design + if ($submission->subject_type !== 'person') { + return; + } + + // Invariant: post-ApplyBindings for event_registration with subject_type='person' + // means subject_id is non-null. If it isn't, that's a schema-level bug + // that publish-guards failed to catch. Strict throw via the same pipeline. + if ($submission->subject_id === null) { + throw new IdentityMatchInvariantViolation( + "subject_type='person' but subject_id=null after ApplyBindings COMPLETED. " + . "submission_id={$submission->id}" + ); + } + + $person = Person::withoutGlobalScopes()->find($submission->subject_id); + $result = $this->personIdentityService->detectMatches($person); + + $submission->update([ + 'identity_match_status' => $result->status, + 'identity_match_count' => $result->matchCount, + ]); + + broadcast(new FormSubmissionIdentityMatchResolved( + submissionId: $submission->id, + status: $result->status, + matchCount: $result->matchCount, + ))->toOthers(); +} +``` + +The throw path: caught by Laravel's queue worker, written via the existing exception +handler to GlitchTip with full context, **and** written to +`form_submission_action_failures` if the listener-level handler is configured to +do so (proposed: yes, for cross-listener auditability — see §Q3 below). This gives +one canonical failure trail. + +#### The new invariant — explicit + +ARCH-BINDINGS §7.3 documents the invariant, not a failsafe-pad description: + +> 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. + +#### Companion change — `RequiresIdentityKeyBinding` always required for event_registration + +The v1.2 publish-guard provider for `event_registration` wraps +`RequiresIdentityKeyBinding('person', 'email')` in +`ConditionalRequirement(predicate: public_token !== null, …)`. This means **private** +event_registration schemas (organizer-driven, used for managed crew rosters) can +publish without an identity-key binding — and then ApplyBindings has nothing to +provision against, the failsafe-pad triggers, the architecture leaks. + +**Revised guard wiring (v1.3):** drop the predicate. `RequiresIdentityKeyBinding('person', 'email')` +wires unconditionally for `event_registration`. The semantic is: "this purpose creates +or matches a Person — that always requires an identity-key, regardless of form +visibility." Private schemas without an email-binding fail to publish with a clear +error message. + +This closes the gap the failsafe-pad was protecting against. The pad is no longer +needed because the schema can no longer reach a state where it is needed. + +#### Companion change — `FormSubmissionResource.identity_match` + +For non-person purposes (`signature_contract`, `user_profile`, `incident_report`, +`post_event_evaluation`, `supplier_intake`, `artist_advance`), the resource block: + +```json +"identity_match": null +``` + +Currently the contract leaves this implicit. v1.3 makes it explicit and adds a +contract test in `tests/Feature/Api/FormSubmissionResourceTest` that asserts the +field is `null` for all non-person purposes. This prevents a future refactor from +silently introducing ambiguity. ### Q3 — Strict-fail backend, log-and-swallow listener @@ -69,6 +293,111 @@ Listener catches the throw, writes a row to `form_submission_action_failures` (i `SyncTagPickerSelectionsOnSubmit` is NOT folded into ApplyBindings. TAG_PICKER → `user_organisation_tags` is a pivot-table-with-source-discrimination operation, semantically distinct from a binding-target-attribute write. Document the deliberate parallel paths in ARCH-BINDINGS.md so future readers don't try to consolidate them. +#### Q3 v1.3 — Failure-UX additions + +> **v1.2 → v1.3:** spine unchanged (strict service / log-and-swallow listener / +> two-transaction pattern / pre-publish guards as primary defence). Three additions +> bring failure-UX to enterprise baseline; a fourth captures schema-drift detection +> as a tracked BACKLOG item. + +##### Q3 v1.3 addition 1 — Real-time admin alert for public-form failures + +GlitchTip (operational since WS-7) runs an alert rule: + +``` +event.exception.values[0].type = "FormBindingApplicatorException" +AND tags.form_schema.public_token IS NOT NULL +AND tags.environment = "production" +→ alert: email to ops@crewli + Slack webhook +``` + +This sluit the operational loop: a public form failing for any reason (typically +a schema-config issue that slipped past publish-guards, or an infra blip) surfaces +within seconds, not "the next day when admin checks the failures UI." For enterprise +operations during an active festival registration window this is the difference +between a 5-minute incident and a 4-hour incident. + +Implementation: `LogContextEnricher` (already exists per ARCH-OBSERVABILITY) tags +GlitchTip events with `form_schema.public_token` (boolean: present or not). +ApplyBindingsOnFormSubmit's exception-report path includes +`Sentry::captureException($e, ['tags' => ['form_schema.has_public_token' => …]])`. +Alert rule configured in GlitchTip web UI; documented in `dev-docs/runbooks/observability-triage.md` +under "Form-builder binding failures". + +**Scope:** WS-6 sessie 3 (Admin UI) includes the GlitchTip configuration as a +deployment task; the tagging happens in sessie 2 (Pipeline) as part of +`ApplyBindingsOnFormSubmit`. + +##### Q3 v1.3 addition 2 — Custom exception hierarchy + `error_code` in HTTP response + +`FormBindingApplicatorException` is the abstract base. Three subclasses: + +| Subclass | Cause | HTTP code | User-facing copy class | +|---|---|---|---| +| `FormBindingSchemaConfigException` | Schema misconfiguration that publish-guards missed (e.g., column renamed without schema invalidation) | 422 | `schema_config_error` — *"This form has a configuration issue. Please contact the organiser. Reference: F-{ulid}"* | +| `FormBindingInfraException` | Database connection lost, timeout, race condition on lockForUpdate | 503 | `temporary_error` — *"Temporary issue, please try again."* with retry-after header | +| `FormBindingDataIntegrityException` | Type mismatch, foreign-key violation, attempt to write to a soft-deleted entity | 422 | `data_integrity_error` — same copy as schema_config (user-perceptible same; admin sees difference via tag) | + +Listener catches the parent class, inspects subclass, writes appropriate response: + +```php +// In ApplyBindingsOnFormSubmit::handle's catch block, after the failure-record write: +$response = match (true) { + $e instanceof FormBindingInfraException => ['error_code' => 'temporary_error', 'http_status' => 503], + $e instanceof FormBindingSchemaConfigException => ['error_code' => 'schema_config_error', 'http_status' => 422], + $e instanceof FormBindingDataIntegrityException => ['error_code' => 'data_integrity_error','http_status' => 422], + default => ['error_code' => 'unknown_error', 'http_status' => 500], +}; +$submission->update(['failure_response_code' => $response['error_code']]); +``` + +The `failure_response_code` column on `form_submissions` (new in WS-6 sessie 1) is +read by the response renderer; the controller serializes this into the API response +body when `apply_status=failed`. Frontend renders contextual copy keyed on +`error_code`. Reference ID is the `submission.id` ULID — admin uses it to find the +matching `form_submission_action_failures` row. + +##### Q3 v1.3 addition 3 — "All-or-nothing per pass" — explicit BACKLOG entry + +ARCH-BINDINGS §19 currently says: *"Granular partial-success is a future RFC topic."* +That is too open-ended for a no-compromises release. + +**Revised treatment:** Move from "future RFC topic" to explicit BACKLOG entry +`PARTIAL-BINDING-SUCCESS` with: + +- **Trigger condition:** First enterprise customer reports the all-or-nothing + behaviour as a UX issue (concretely: a registration form submission rolls back + due to one binding failing, customer complains that "the user filled in 12 + fields and lost everything") +- **Design hints:** SAGA pattern with per-binding compensation OR per-binding + transaction with idempotency-keys per binding-application (the latter requires + rethinking the trust-precedence resolution) +- **Estimated work:** 4-6 days, not in scope until trigger fires +- **Refs:** ARCH-BINDINGS §19, RFC-WS-6 §Q3 v1.3 + +ARCH-BINDINGS §19 is rewritten to point at this BACKLOG entry rather than leaving +the door half-open. + +##### Q3 v1.3 addition 4 — Schema-drift detection (separate BACKLOG entry, not v1.0 work) + +The 5% of runtime-throws that the RFC attributes to "DB modified out from under us" +deserve more architectural respect than a one-line acknowledgement. New BACKLOG entry +`FORM-SCHEMA-DRIFT-DETECTION`: + +- **Scope:** A migration-listener that, when a `binding_target_type`-affected + column is renamed/dropped/type-changed, scans `form_field_bindings.target_attribute` + for matches and marks affected `form_schemas` with `needs_revalidation=true`. + Schema-detail UI shows a banner; affected schemas can't have new submissions + accepted until an organiser re-publishes. +- **Trigger condition:** First production incident where a runtime-throw is traced + to a stale binding after a migration. +- **Estimated work:** 2-3 days. +- **Refs:** ARCH-BINDINGS §6.5 (binding-change safety, related but distinct), + RFC-WS-6 §Q3 v1.3. + +This is **not** v1.0 scope but should be on the radar so it doesn't surface as +a surprise during the first 6 months of enterprise rollout. + ### Q4 — Two-transaction atomicity pattern **Decision:** ApplyBindings + Identity status update in one inner DB transaction. Failure-record write in a separate outer transaction. Event firing AFTER commit. @@ -511,3 +840,8 @@ WS-7 sessie 1. - 5 removals (deferred to BACKLOG): `person.dietary_preferences` (FORM-BINDING-JSON-PATH); `artist.email`/`stage_name`/`tech_rider`/`hospitality_rider` plus the `artist` entity itself (ARTIST-ADV-BINDING-MODEL). - 1 new model column: `companies.kvk_number` (nullable, indexed). - `BindingTypeRegistryConsistencyTest` extended with a model-existence + column-existence assertion preventing future drift. +- 2026-05-07 — v1.3 — Architectural review (Claude Chat, post WS-7 closure). Five refinements; spine unchanged (pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, sync ApplyBindings, snapshot-isolation, single-identity-key per target_entity): + - **§Q1 — Listener-volgorde.** SYNC chain reduced to one listener (`ApplyBindingsOnFormSubmit`); `TriggerPersonIdentityMatchOnFormSubmit` moves to QUEUED. Four additions: ApplyBindings writes `identity_match_status='pending'` as initial state; queued listener ends with Echo broadcast on `submission.{id}` private channel; queued-listener gating invariant (`apply_status=COMPLETED`) as first statement of every queued listener; sync-chain hard timeout (5s deadline wrapper) → `FormBindingApplicatorTimeoutException`. + - **§Q2 — `TriggerPersonIdentityMatchOnFormSubmit` refactor.** Failsafe-pad ("no subject → pending" logged warning) replaced with explicit invariant + strict throw routed via `form_submission_action_failures`. Companion: `RequiresIdentityKeyBinding('person', 'email')` wires unconditionally for `event_registration` (drop the `ConditionalRequirement(public_token !== null)` wrapper). Companion: `FormSubmissionResource.identity_match=null` made an explicit contract for non-person purposes. + - **§Q3 — Strict-fail vs compatibility.** Spine confirmed unchanged. Three failure-UX additions: GlitchTip alert rule on `FormBindingApplicatorException` for production public flows; custom exception hierarchy (`FormBindingSchemaConfigException` / `FormBindingInfraException` / `FormBindingDataIntegrityException`) + `failure_response_code` column on `form_submissions` + `error_code` in HTTP response body; "all-or-nothing per pass" gets explicit BACKLOG entry `PARTIAL-BINDING-SUCCESS`. Fourth addition: schema-drift detection as separate BACKLOG entry `FORM-SCHEMA-DRIFT-DETECTION` (not v1.0 scope). + - Companion: `ARCH-BINDINGS.md` advances to v1.1 (twelve section edits per the v1.3 amendment companion table); `BACKLOG.md` adds `PARTIAL-BINDING-SUCCESS` and `FORM-SCHEMA-DRIFT-DETECTION` under Form Builder backlog. RFC-WS-6 v1.3, ARCH-BINDINGS v1.1, and the BACKLOG additions land in the same commit.