docs(rfc-ws-6): v1.3 amendment — listener queueing, invariant cleanup, failure-UX

Five refinements from the 2026-05-07 architectural review:

- Q1: TriggerPersonIdentityMatchOnFormSubmit moves to queued; sync-chain reduced to ApplyBindings only; queued-listener gating invariant; sync-chain deadline wrapper.

- Q2: Failsafe pad in TriggerPersonIdentityMatch removed in favour of strict invariant + throw; RequiresIdentityKeyBinding unconditional for event_registration; FormSubmissionResource.identity_match=null contract for non-person purposes.

- Q3: Three failure-UX additions (GlitchTip alert, custom exception hierarchy + error_code, BACKLOG entries for partial-success and schema-drift).

Spine unchanged: pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, single identity-key per target_entity.

Refs: dev-docs/RFC-WS-6.md (now v1.3), dev-docs/ARCH-BINDINGS.md (now v1.1), dev-docs/BACKLOG.md (PARTIAL-BINDING-SUCCESS, FORM-SCHEMA-DRIFT-DETECTION added)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:52:19 +02:00
parent 1af7b9506d
commit 845b6e6a0e
3 changed files with 521 additions and 52 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.