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