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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user