Files
crewli/dev-docs/RFC-WS-6.md
bert.hausmans 5ac6b4168d docs(rfc-ws-6): mark v1.3.1 as fully implemented
§1 Status: add Implementation status line citing D1 (PR #10 c6f4d1b)
and D2 (PR #11 23a5696), both 2026-05-08.

§10 Document history: append v1.3-delta closure entry summarising what
D1 and D2 each delivered + what remains as separate operational task
(GlitchTip alert rule configuration in the web UI) and frontend
follow-up (Echo subscription).

No spec changes — purely lifecycle marker update.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:57:50 +02:00

864 lines
54 KiB
Markdown

# RFC-WS-6 — FormBindingApplicator Pipeline Architecture
## 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 v1.2 (sessie 3a.5), then v1.3 (architectural review 2026-05-07); v1.3.1 (2026-05-08) — code-vs-docs drift closure pre-D1 implementation — see §10
- **Implementation status:** v1.3.1 fully implemented in main as of 2026-05-08 (D1: PR #10 `c6f4d1b`, D2: PR #11 `23a5696`)
- **Version:** v1.3.1
- **Owner:** Bert Hausmans
- **Origin:** Architectural session 2026-04-25 (Claude Chat) — 13 design decisions, 4 refinements, 3 observations
- **Related:**
- `ARCH-FORM-BUILDER.md` §17 (form_field_bindings) and §31 (integration contracts)
- `ARCH-CONSOLIDATION-2026-04.md` §6.1 (WS-5a binding table), §6.2 (WS-6 charter)
- `ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` Q1 (ULID exception retired), Q2 (denormalized organisation_id)
- `SCHEMA.md` §3.5.12 (form_submissions, form_field_bindings)
- `BACKLOG.md` items: FORM-BINDING-SNAPSHOT-MULTI, FORM-05, FORM-BUILDER-LIBRARY-AUDIT-LOG
## 2. Why this RFC exists
Charter §6.2 estimated WS-6 at 4-5 days. The architectural review on 2026-04-25 identified scope previously absent from the charter:
- Two-transaction failure-write pattern
- Separate `apply_status` column distinct from `identity_match_status`
- `form_submission_action_failures` table with retry/resolve/dismiss flows
- Dual-route admin UI (platform + organisation)
- Per-purpose `PublishGuard` framework with ~9 concrete guards
- Append-strategy collection-only restriction
- Section-level apply stub structure (feature-flagged)
- IDOR-class FK-chain policy
Revised estimate: **7-8.5 days across three Claude Code sessions**. The overrun is accepted — eerlijke scope, eerlijke inschatting. WS-6 lands as enterprise-grade fundament, not an MVP.
This RFC captures every decision so sessions 2 and 3 do not drift from the architecture established in session 1's planning phase.
## 3. Decisions
### Q1 — Listener ordering on `FormSubmissionSubmitted`
**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, writes
identity_match_status='pending'
as initial state
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 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.
#### Q1 v1.3.1 clarification — `apply_status` four-case enumeration
`apply_status` has four states:
- `pending` — apply has started but the inner transaction has not committed
- `completed` — every binding in the pass succeeded; the inner transaction committed
- `partial` — at least one binding in the pass failed AND at least one succeeded; the inner transaction committed (per `BindingPassResult::applyStatus()`); see BACKLOG `PARTIAL-BINDING-SUCCESS` for the long-term direction
- `failed` — every binding failed OR the deadline-wrapper threw; the inner transaction rolled back; an entry exists in `form_submission_action_failures`
NULL means apply has not yet run on this submission.
`PARTIAL` is not a separate runtime path through `ApplyBindingsOnFormSubmit::handle` — it is the value `BindingPassResult::applyStatus()` returns when the pass committed but at least one individual binding failed. Per RFC v1.3 §Q3 addition 3, granular partial-success handling is BACKLOG `PARTIAL-BINDING-SUCCESS`. Until that work lands, `PARTIAL` is treated identically to `FAILED` by the queued-listener gate (see ARCH-BINDINGS §5.6).
### Q2 — Refactor of `TriggerPersonIdentityMatchOnFormSubmit`
**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.
> **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).
#### 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
**Decision:** `FormBindingApplicator` (service) throws strictly on invalid state; `ApplyBindingsOnFormSubmit` (listener) catches, records via `form_submission_action_failures`, swallows.
Service-layer throws on invalid `MergeStrategy`, missing required binding spec keys, deleted target columns, type-shape mismatches. Pre-publish guards (`PurposeGuardProvider::publishGuards()`) catch ~95% of these at config time. Runtime throws are reserved for "DB modified out from under us" rare cases.
Listener catches the throw, writes a row to `form_submission_action_failures` (in a separate transaction — see Q4), logs at error, swallows so sibling listeners (tag sync, shifts, webhooks, mail) keep running.
`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.
```php
try {
DB::transaction(function () use ($submission) {
$subject = $this->provisioner->provision($submission);
$resolved = $this->resolveBindings($submission);
$this->applyAll($subject, $resolved);
$this->writeApplyStatus($submission, ApplyStatus::COMPLETED);
$this->triggerIdentityMatch($submission);
});
} catch (\Throwable $e) {
DB::transaction(function () use ($submission, $e) {
FormSubmissionActionFailure::create([
'form_submission_id' => $submission->id,
'listener_class' => self::class,
'failed_at' => now(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'context' => [...],
]);
FormSubmission::query()
->whereKey($submission->id)
->update(['apply_status' => ApplyStatus::FAILED->value]);
});
Log::error('form-builder.apply.transaction_rolled_back', [
'submission_id' => $submission->id,
'exception' => $e::class,
]);
}
```
Person provision uses `SELECT ... FOR UPDATE` on email lookup. Race conditions resolve to `firstOrCreate` semantics.
The inner transaction rollback survives the failure-record write because the failure row is in a different transaction. The second transaction is small (one insert + one update) — its failure path is Sentry-only, with explicit error log line for filterability.
`FormSubmissionSubmitted` event fires AFTER the inner transaction commits. This is non-negotiable: queued listeners (tag sync, shifts, webhooks, mail) must never see pre-commit state. Implementation uses explicit ordering in `FormSubmissionService::submit()` — service fires the event, not the Eloquent observer (per O2).
### Q5 — `form_submission_action_failures` complete in WS-6 (incl. admin UI)
**Decision:** Full table + write path + retry artisan command + dual-route admin UI (super_admin platform-wide + org_admin scoped) + retry/resolve/dismiss flows. All in WS-6 across sessions 2 (backend) and 3 (UI).
Schema:
```
form_submission_action_failures:
id ulid pk
form_submission_id ulid fk → form_submissions, cascade delete
listener_class string(255)
binding_id ulid fk nullable → form_field_bindings, null on delete
failed_at timestamp
exception_class string(255)
exception_message text
context json
retry_count unsignedTinyInteger default 0
resolved_at timestamp nullable
resolved_by_user_id ulid fk nullable → users
resolved_note text nullable
dismissed_at timestamp nullable
dismissed_by_user_id ulid fk nullable → users
dismissed_reason_type string(40) nullable -- DismissalReasonType enum
dismissed_reason_note string(500) nullable -- required when type = 'other'
created_at, updated_at
```
No soft delete. Retention follows the parent submission via cascade-delete.
**No `organisation_id` column** on this table — tenant scope flows via `form_submission_id → form_submissions.organisation_id`. See V3 below for policy implications.
Three actions in the admin UI:
- **Retry** — re-runs the applicator for this submission. Idempotent. Increments `retry_count`. On success: sets `resolved_at = now()`. On repeat failure: another row appended (history preserved).
- **Mark as resolved** — manual close, optional `resolved_note`. Used when an admin fixed the data via a different path.
- **Dismiss** — final close, requires `dismissed_reason_type` (enum), `dismissed_reason_note` required only when type = `OTHER`. Used when retry is impossible (schema deleted, target deleted).
Daily digest mailable is deferred to the notification framework (out of scope WS-6 — depends on infrastructure not yet in place).
### Q6 — Snapshot is truth, not live schema
**Decision:** `FormBindingApplicator` reads bindings from `form_submissions.schema_snapshot.fields[*].bindings`, not from the live `form_field_bindings` table.
Webhook payload, GDPR export, and audit replay all use `schema_snapshot`. Bindings follow the same pattern. The live table is consulted only for pre-publish validation by `FormSchemaService`.
This guarantees that a retry-from-failures-table executed a week after the original submission applies the bindings as they were configured at submit time, not as they may have been edited since. Reproducibility for audit.
### Q7 — Conflict resolution: candidate set + trust precedence
**Decision:** Candidate set = bindings whose source form_field has a row in `form_values` for this submission, regardless of whether the value is null. Sort by `trust_level DESC`, tie-break by `form_field.sort_order ASC`. Empty winner writes null when `merge_strategy` allows it.
The candidate distinction matters: a row in `form_values` with `value = null` is an **explicit clear** by the user (multi-step form, edit-resubmit). Absence of a row means the field was not submitted (conditional logic skipped it, validation rejected it, etc.). The binary "row exists" is the gate; value content is decided afterwards.
Per-strategy null-winner matrix:
| `merge_strategy` | Winner value = null | Behaviour |
|---|---|---|
| `overwrite` | null | Write null to target (intent: clear) |
| `append` | null | No-op (nothing to append; target unchanged) |
| `replace` | null | No-op when target is already non-null; no-op when target is null |
| `first_write_wins` | null | Write null when target is null (claim the slot); skip when target has any value |
`append` is restricted to collection-typed targets per V1 — the matrix entry above describes the no-op case for completeness.
**Write-path invariant test required (session 2):** for every form_field that should be visible after conditional-logic evaluation at submit time, a `form_values` row exists after submit, even with null value. Otherwise "explicit null" is indistinguishable from "skipped by logic" and Q7 collapses.
### Q8 — Single identity-key per target_entity
**Decision:** At most one binding per `(target_entity)` may have `is_identity_key = true`, enforced by the `MaxOneIdentityKeyPerTargetEntity` publish guard. `PersonProvisioner::provisionFromBindings` uses `Person::firstOrCreate(['email' => $value, 'organisation_id' => $orgId], $otherBindings)`. Race-safe via DB-driver `firstOrCreate` semantics.
Composite identity (email OR (first_name + last_name + DOB)) is **out of scope for v1.0**. Backlog item `FORM-BINDING-COMPOSITE-IDENTITY` tracks the future work. Identity-matching against existing user accounts (via `person_identity_matches`) is a separate flow, not in scope here — that's `PersonIdentityService` running after provision.
#### Q8 v1.1 addendum — Person provisioning is scoped by `event_id`, not `organisation_id`
The v1.0 text above describes the firstOrCreate predicate as
`(email, organisation_id)`. That is incorrect for the actual Person
model: `Person::$organisationScopeColumn = 'event_id'` (single source
of truth for tenant scoping on this model — `organisation_id` is the
denormalised parent column, `event_id` is the scope discriminator).
`PersonProvisioner::provisionFromSubmission()` therefore looks up and
creates by `(email, event_id)`:
```php
Person::query()
->withoutGlobalScopes()
->where('email', $emailValue)
->where('event_id', $submission->event_id)
->lockForUpdate()
->first();
// ...
Person::query()
->withoutGlobalScopes()
->firstOrCreate(
['email' => $emailValue, 'event_id' => $submission->event_id],
$attributes,
);
```
Practical consequence: the same email registering for two different
events in the same organisation creates two distinct `Person` rows.
Cross-event identity reconciliation is the job of
`PersonIdentityService` / `person_identity_matches` (existing flow,
out of scope for WS-6 — see RFC §6).
`PersonProvisioner` raises `PersonProvisioningException('no_event', ...)`
when `submission.event_id` is null on an `event_registration`
submission. Schemas reaching apply without `event_id` is a structural
defect; the publish guard `SchemaHasLinkedEvent` prevents it at config
time, the runtime throw is the failsafe.
### Q9 — Apply uniform across all 7 purposes; provisioning per `PurposeDefinition`
**Decision:** `FormBindingApplicator::apply()` is purpose-agnostic. Subject resolution lives on `PurposeDefinition::resolveOrProvisionSubject(FormSubmission, PersonProvisioner): ?Model`.
```
event_registration → PersonProvisioner::provisionByEmailBinding (may create)
artist_advance → ArtistResolver::fromPortalToken (existing context, throws if absent)
supplier_intake → CompanyResolver::fromProductionRequest
post_event_evaluation → PersonResolver::fromAuth
incident_report → PersonResolver::fromAuth OR null (anonymous-allowed)
signature_contract → UserResolver::fromAuth
user_profile → UserResolver::fromAuth
```
Test coverage: 4 cases per purpose minimum (happy-path, missing required binding, conflict-resolution, anonymous-when-applicable) → 28 + ~20 purpose-agnostic = ~48 pipeline tests in session 2.
#### Q9 v1.1 addendum — `form_schemas.default_crowd_type_id` replaces `CrowdType::oldest()`
`Person.crowd_type_id` is `NOT NULL` on the migration; `PersonProvisioner`
must supply a value at create time. Session 2 used a silent
`CrowdType::oldest()->where('organisation_id', ...)` heuristic — fragile
(depends on insertion order, breaks across organisations, no auditable
intent) and undocumented in the published schema.
**Decision (v1.1):** add `form_schemas.default_crowd_type_id` (nullable
ULID column) to make the target CrowdType an explicit, versioned schema
attribute. `PersonProvisioner::resolveCrowdTypeId()` reads
`$submission->schema->default_crowd_type_id` and throws
`PersonProvisioningException('no_default_crowd_type', ...)` when null.
A new universal-on-event_registration publish guard
`RequiresDefaultCrowdType` blocks publish when the column is null on a
schema with `purpose = event_registration`. The runtime throw remains
as a failsafe for live-table edits between publish and apply.
**Schema-level FK omitted intentionally.** The migration adds the
column without a database-level foreign key. SQLite's table-rebuild on
`ALTER TABLE ADD FOREIGN KEY` cascade-deletes existing form_fields rows
when an unrelated FK on the rebuilt table happens to overlap — observed
in WS-5b/c backfill tests. Application-level integrity (publish guard +
runtime failsafe + Eloquent `belongsTo` for read-side correctness) is
sufficient: writes always go through `FormSchemaService::publish()`,
which runs the guard, and the runtime throw blocks any apply that
slipped past.
Snapshot impact: none. The published schema_snapshot does not embed
`default_crowd_type_id` directly; provisioning reads from the live
`FormSchema` row by FK from `FormSubmission::form_schema_id`. Audit
replay (RFC Q6) of an old snapshot uses whatever the schema's current
`default_crowd_type_id` is at retry time — admins are expected to
either update the column before retrying or dismiss with reason
`SCHEMA_DELETED` / `OTHER`.
`FormBuilderDevSeeder` resolves a CrowdType via VOLUNTEER → first-active →
create-as-needed fallback chain when seeding event_registration
schemas, so dev environments don't fail the publish guard out of the
box.
#### Q9 v1.2 addendum — Registry alignment with model columns
Sessie 3a surfaced that several entries in the `BindingTypeRegistry`
config did not match actual Eloquent model column names
(`person.phone_number` vs `phone`, `company.email` vs `contact_email`,
`company.phone_number` vs `contact_phone`) and that an `Artist` Eloquent
model class is absent.
Sessie 3a.5 corrected this:
- **Renames** (registry → matches model column): `person.phone_number`
`person.phone`, `company.email``company.contact_email`,
`company.phone_number``company.contact_phone`.
- **New column**: `companies.kvk_number` (nullable, indexed) added so
the registry's B2B identity-key candidate is now legitimately
bindable.
- **Removed entries** (intentional v1 deferrals): `person.dietary_preferences`
(custom_fields JSON path; BACKLOG `FORM-BINDING-JSON-PATH`),
`artist.email` / `artist.stage_name` / `artist.tech_rider` /
`artist.hospitality_rider` (column absent + Artist model absent),
and the `artist` entity removed entirely from the registry (BACKLOG
`ARTIST-ADV-BINDING-MODEL`).
- **Drift-prevention test**: `BindingTypeRegistryConsistencyTest`
extended with a model-existence + column-existence assertion. Future
drift surfaces as a test failure, not a runtime surprise.
`artist_advance` schemas may still exist in v1 with empty
`required_bindings`; the applicator runs and resolves to an empty
binding list, COMPLETED state. See ARCH-BINDINGS.md appendix for the
rationale.
### Q10 — Section-level submit: stub now, activate later
**Decision:** Build the listener-class structure now; gate the runtime via `config('form_builder.section_apply_enabled', false)`; activate when ARTIST_ADVANCE feature work lands.
```php
class ApplyBindingsOnFormSectionSubmitted implements ShouldQueue
{
public function handle(FormSubmissionSectionSubmitted $event): void
{
if (!config('form_builder.section_apply_enabled', false)) {
return;
}
$this->applicator->apply($event->submission, sectionId: $event->sectionId);
}
}
```
`FormBindingApplicator::apply()` accepts an optional `sectionId` parameter. Null = all bindings. Set = only bindings whose source form_field's section_id matches. Listener registered in `EventServiceProvider` from session 2.
**Publish guards land NOW for section-aware schemas, regardless of feature flag.** A schema with `section_level_submit = true` and an `is_identity_key` binding in section 3 is structurally unsafe; it must not pass publish even if the runtime listener is gated off. The `IdentityKeyBindingsOnlyInFirstSection` guard wires into every PurposeGuardProvider as a universal guard from session 1 (no-op for non-section schemas).
Feature flag has explicit removal trigger documented in `config/form_builder/section_apply.php` comment block: "Enable when ARTIST_ADVANCE feature work begins. At enablement: write section-scoped tests, activate stub listener registration, remove this flag and the early-return guard. Tracking: BACKLOG.md → ARTIST-ADV-SECTION-APPLY."
A minimal wiring test asserts the stub listener is registered and forwards correctly to the applicator signature — no business logic, just keeping the path alive.
### Q11 — Library-binding propagation: copy at instantiation, no runtime cascade
**Decision:** When a `form_field` is created from a `FormFieldLibrary` entry with bindings, the library-bindings are **copied** to `form_field_bindings` rows with `owner_type = 'form_field'`. Subsequent updates to the library-bindings do NOT propagate to existing field instances. Future "library re-sync" admin action lives on the BACKLOG.
This matches the WS-5d decision for `form_field_options` and is consistent with template-source semantics. Predictable for versioning. Documented explicitly in ARCH-BINDINGS.md: "the library is a template-source, not a live link."
### Q12 — Activity log: hierarchical pass + per-binding entries
**Decision:** One `form_submission.bindings_pass_completed` activity per applicator invocation, plus one `form_submission.binding_applied` activity per binding (linked via `properties.parent_activity_id`).
```
activity 1: form_submission.bindings_pass_completed
subject: form_submission Z
properties: {
binding_count: 12,
succeeded: 11,
failed: 1,
person_provisioned: true
}
activity 2..13: form_submission.binding_applied
subject: person X (or whichever target entity)
properties: {
parent_activity_id: <activity 1 id>,
target_entity: 'person',
target_attribute: 'email',
old_value: null,
new_value: 'jan@example.nl',
trust_level: 80,
merge_strategy: 'overwrite',
source_form_field_id: ...,
source_submission_id: Z
}
```
Spatie/laravel-activitylog supports arbitrary properties; `parent_activity_id` is just a properties field. UI renders this hierarchically as a collapse: pass-level visible, per-binding expandable. Failed bindings get their own activity entry too (`properties.error_class`/`error_message`), in addition to their `form_submission_action_failures` row.
Two sources of truth for failures (activity_log + action_failures) is intentional: activity_log is the human-readable timeline, action_failures is the machine-replayable workflow.
### Q13 — Pre-publish validation: `PurposeGuardProvider` framework
**Decision:** Pre-publish rules live on a parallel `PurposeGuardProvider` interface (NOT on `PurposeDefinition`, which stays an immutable value object). Each purpose's concrete provider returns a `list<PublishGuard>`. `FormSchemaService::publish()` walks the list, runs every guard, collects all violations, and throws a `PublishGuardViolationException` with the full list (not first-fail).
```php
interface PurposeGuardProvider
{
/** @return list<PublishGuard> */
public function publishGuards(): array;
}
```
Nine concrete guards in `App\FormBuilder\Publishing\`:
1. `RequiresIdentityKeyBinding(entity, attribute)` — flag-check (binding existence enforced separately by `assertRequiredBindingsPresent`)
2. `MaxOneIdentityKeyPerTargetEntity` — universal
3. `RequiresFieldType(type, minCount)`
4. `SchemaHasLinkedEvent`
5. `TagCategoriesConfiguredOnAllPickers`
6. `IdentityKeyBindingsOnlyInFirstSection` — universal (no-op for non-section schemas)
7. `AppendStrategyRequiresCollectionTarget` — universal (paired with V1)
8. `NoAmbiguousTrustLevels` — universal
9. `ConditionalRequirement(predicate, subGuard)` — higher-order composer
Universal guards (2, 6, 7, 8) wire into every purpose. Purpose-specific guards per the Task 8 table.
The `PurposeGuardProvider` is wired into `purposes.php` via a new `guards_class` key per purpose. `PurposeRegistry::guardProviderFor(string $slug)` instantiates and caches.
Open-closed: a new purpose adds its own `PurposeDefinition` config entry plus a new `PurposeGuardProvider` class; `FormSchemaService::publish()` does not change.
## 4. Refinements
### V1 — Append strategy is collection-only
**Decision:** `merge_strategy = APPEND` is valid only when the target attribute resolves to `BindingTargetType::COLLECTION` per `BindingTypeRegistry`. Pre-publish guard `AppendStrategyRequiresCollectionTarget` enforces this.
Rationale: append on scalars (string concatenation, comma-separated lists) requires a fingerprint mechanism to detect duplicate-append on retry. Embedding fingerprints in domain data is an architectural smell. Collection types with SET semantics (JSON array with deduplication, pivot relations) make retry naturally idempotent. This restriction eliminates the entire problem class.
The `BindingTypeRegistry` is the single source of truth for target shape — NOT name-suffix matching (e.g. attributes ending in `_tags` or `_skills`). Name-suffix matching is convention-not-contract and would silently misclassify any attribute that doesn't follow the convention or accidentally matches it (e.g. `user_tags_count` would falsely qualify as collection).
### V2 — `DismissalReasonType` enum + resolve action separated
**Decision:** Six enum values (`schema_deleted`, `target_entity_deleted`, `binding_removed`, `duplicate_submission`, `data_quality_issue`, `other`). Free-text `dismissed_reason_note` required only for `other`. **Resolve is a separate action** with its own `resolved_note` (no enum), distinct from dismiss.
Three admin actions on a failure:
- **Retry** — replay the applicator
- **Mark as resolved** — manual close (succeeded via another path)
- **Dismiss** — final close (will not be replayed); requires reason enum
Dismiss-without-enum was rejected because "diversen-syndroom" makes failure analytics impossible after six months. `manually_resolved` as a dismissal reason was rejected because resolve and dismiss are semantically different workflows.
### V3 — IDOR-class FK-chain policy
**Decision:** `FormSubmissionActionFailurePolicy` resolves tenant via `failure.submission.organisation_id`, NOT via route binding. Cross-tenant access returns `false` from the policy; controllers translate to 404 (not 403, which would confirm resource existence and enable enumeration).
Security tests in this class are explicitly named as IDOR-class so they are findable in audit. Pattern follows ARCH §22.9 `FormResourceSecurityTest`.
### V4 — Concurrent-test via PHPUnit state-injection
**Decision:** PHPUnit-based concurrent test with state-injection (insert conflicting Person row inside `lockForUpdate` window, assert `firstOrCreate` recovery). NOT an Artisan command + manual TEST_SCENARIO.md step.
A handmatige test is geen test. State-injection in PHPUnit tests the recovery path under the actual conflict condition — which is what matters. Real wall-clock load testing belongs in a separate "Load-test foundation" workstream against staging (BACKLOG: LOAD-TEST-FOUNDATION).
## 5. Observations
### O1 — `apply_status` default for legacy rows = NULL
Existing seed-data and pre-WS-6 staging submissions have no apply_status. Migration adds the column WITHOUT a default. Admin UI's "open work" filter explicitly excludes NULL rows so legacy submissions don't appear as "pending applicator work that will never run." Document in migration file comment block.
### O2 — Event firing AFTER `DB::afterCommit()` (or explicit post-commit dispatch)
`FormSubmissionSubmitted` must fire after the inner transaction commits, not before. Implementation: `FormSubmissionService::submit()` runs the transaction, then explicitly calls `event(new FormSubmissionSubmitted($submission->refresh()))`. Do NOT rely on Eloquent observers firing the event from within the transaction — that pre-commit fire would let queued listeners enqueue with state that may never commit.
### O3 — RFC-WS-6.md as session-handover anchor
This document. Sessions 2 and 3 reference RFC sections by number rather than re-establishing context. Drift between chat decisions and code implementation is prevented by committing the RFC before any session-1 code lands.
## 6. Out of scope (explicit non-goals for v1)
- **Composite identity-key resolution** (multi-attribute matching) → BACKLOG: `FORM-BINDING-COMPOSITE-IDENTITY`
- **Cross-event submission deduplication** (one Person, multiple events with separate registrations) → handled by existing identity-match flow, not a binding concern
- **Library-binding runtime cascade** (updates propagate to instantiated fields) → BACKLOG: `FORM-LIBRARY-RESYNC`
- **Append strategy on scalar targets** → architecturally rejected (V1)
- **Active section-level apply** → stub structure only; activated when ARTIST_ADVANCE feature work begins (BACKLOG: `ARTIST-ADV-SECTION-APPLY`)
- **Daily failure digest mailable** → depends on notification framework not yet built
- **Wall-clock concurrent load testing** → BACKLOG: `LOAD-TEST-FOUNDATION` (separate workstream)
## 7. Sessions split
| Session | Scope | Estimate |
|---|---|---|
| **1 — Foundation** | Schema migrations (apply_status, action_failures), enums (ApplyStatus, DismissalReasonType, BindingTargetType), MergeStrategy enum methods, value objects (ResolvedBinding, BindingApplicationResult, BindingPassResult), BindingTypeRegistry + config, Models, FormSubmissionActionFailurePolicy, PublishGuard framework + 9 concrete guards, PurposeGuardProvider interface + 7 concrete providers, FormSchemaService publish integration, ARCH-BINDINGS.md skeleton, this RFC | 2-2.5 days |
| **2 — Pipeline** | FormBindingApplicator service, PersonProvisioner, multi-purpose subject resolvers, listener chain (sync ApplyBindings + sync TriggerPersonIdentityMatch + queued siblings), two-transaction pattern, ApplyBindings stub for section-submit, retry/resolve/dismiss artisan commands + controllers + Form Requests + Resources, all backend tests (~100-120 new), ARCH-BINDINGS.md sections 6-9 filled | 3-4 days |
| **3 — Admin UI** | Vuexy admin UI on `/platform/form-failures` (super_admin) and `/orgs/{org}/form-failures` (org_admin), retry + resolve + dismiss flows, IDOR-class API security tests, ARCH-BINDINGS.md final, ARCH-OBSERVABILITY.md initial draft | 1.5-2 days |
**Total: 7-8.5 days.** Charter §6.2 had 4-5 days; the overrun is explicit and accepted (see §2 of this RFC).
## 8. Test coverage targets
| Session | New tests | Cumulative backend |
|---|---|---|
| Pre-WS-6 baseline | — | 1208 |
| Session 1 | ~90-110 | ~1300-1320 |
| Session 2 | ~110 | ~1410-1430 |
| Session 3 | ~35 | ~1445-1465 |
## 9. Open follow-ups (post-WS-6)
| BACKLOG item | Trigger |
|---|---|
| `FORM-BINDING-COMPOSITE-IDENTITY` | When a purpose requires multi-attribute identity resolution |
| `FORM-LIBRARY-RESYNC` | When organisations report friction updating library-derived fields |
| `ARTIST-ADV-SECTION-APPLY` | When ARTIST_ADVANCE feature work begins (post-S5) |
| `LOAD-TEST-FOUNDATION` | Pre-release hardening, separate workstream |
| `FORM-BINDING-SNAPSHOT-MULTI` | When patterns require multi-binding-per-field snapshot shape |
| Daily failure digest | When notification framework lands |
| Observability | Sentry SDK, structured logs, metrics, alerts — see `ARCH-OBSERVABILITY.md` skeleton (sessie 3b). WS-7 sessie 1 fills it in. |
Observability strategy for the WS-6 binding pipeline (Sentry
`$dontReport` decisions, log levels, metric names) is documented in
`ARCH-OBSERVABILITY.md`. The skeleton landed in WS-6 sessie 3b with
§3 (`$dontReport`) concrete; the remaining sections are filled in
WS-7 sessie 1.
## 10. Document history
- 2026-04-25 — v1.0 — Initial RFC, frozen at start of WS-6 session 1.
- 2026-04-28 — v1.1 — Post-session-2 cleanup addenda (no architectural reversals; corrections + one schema addition):
- **§3 Q8 addendum** — Person provisioning scopes by `(email, event_id)`, not `(email, organisation_id)`. Aligns with `Person::$organisationScopeColumn = 'event_id'`.
- **§3 Q9 addendum** — `form_schemas.default_crowd_type_id` (nullable ULID, no DB-level FK) replaces the silent `CrowdType::oldest()` heuristic. New `RequiresDefaultCrowdType` publish guard wired into `EventRegistrationGuards`. Runtime failsafe in `PersonProvisioner::resolveCrowdTypeId()`.
- Snapshot dual-key cleanup (separate from RFC §3): legacy `binding` (singular) snapshot key dropped; `bindings` (plural list) is the single source of truth in `schema_snapshot.fields[*]`. ARCH-BINDINGS.md §6.4 / §6.1 already specified the plural list — code converged.
- Route model binding (separate from RFC §3): controller-level workaround `$request->route('formSubmissionActionFailure')` replaced with explicit `Route::bind()` in `AppServiceProvider::boot()` plus `->withoutScopedBindings()` on org-scoped routes. Type-hinted parameters restored. RFC V3 (FK-chain tenant policy) unchanged.
- 2026-04-28 — v1.2 — Registry alignment with model columns (sessie 3a.5):
- 3 renames (registry → model): `person.phone_number``phone`, `company.email``contact_email`, `company.phone_number``contact_phone`.
- 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.
- 2026-05-08 — v1.3.1 — Pre-D1-implementation drift closure. (1) Updated apply_status enumerations throughout §3 to include the PARTIAL case (which exists in code per `BindingPassResult::applyStatus()` and was not anticipated by the v1.3 amendment author). (2) ARCH-BINDINGS §5.6 received a PARTIAL-handling clarification: gate treats PARTIAL identically to FAILED, deferring granular partial-success to BACKLOG `PARTIAL-BINDING-SUCCESS`. (3) ARCH-BINDINGS §7.1 status-columns table extended with `apply_completed_at` row + cross-reference to the D2 retry-service symmetry fix. No spine changes; no behaviour changes; documentation truth-in-naming. Companion: ARCH-BINDINGS.md advances to v1.2.
- 2026-05-08 — **v1.3-delta closure** — RFC v1.3.1 fully implemented in main. **D1** (PR #10 `c6f4d1b`, 2026-05-08) delivered the data-layer prerequisites: `failure_response_code` column on `form_submissions`, abstract `FormBindingApplicatorException` hierarchy with 4 reason-coded subclasses (`FormBindingSchemaConfigException`, `FormBindingInfraException`, `FormBindingApplicatorTimeoutException`, `FormBindingDataIntegrityException`), `IdentityMatchInvariantViolation` sibling DomainException, `FormBindingExceptionClassifier` helper, `FormSubmissionIdentityMatchResolved` broadcast event class, `FormFieldBindingMergeStrategy::validForTargetType` matrix method, plus cast + factory state. **D2** (PR #11 `23a5696`, 2026-05-08) wired the building blocks into the listener chain: `ApplyBindingsOnFormSubmit` writes initial `identity_match_status='pending'`, uses the deadline wrapper, and consumes the classifier in its outer-transaction catch block; `TriggerPersonIdentityMatchOnFormSubmit` becomes queued with the gating-invariant first statement, the strict invariant throw, and broadcast dispatch; `routes/channels.php` introduces broadcasting infrastructure (NEW wiring) with submitter-only authorization (org-admin extension tracked as BACKLOG `TECH-CHANNEL-AUTH-ORG-ADMIN`); queued listeners gain the `apply_status=COMPLETED` first-statement gate; `FormFailureRetryService::recordFailure` consumes the classifier and writes `apply_completed_at` for symmetry with the listener; `apply_deadline_seconds` config key (default 5) added; six existing tests adapted to the v1.3 layout. Test counts: pre-D1 baseline 1551 → post-D2 1621 (+70). 0 Larastan errors. The remaining v1.3 add (Q3 v1.3 add 1 — GlitchTip alert rule on `apply_status=failed AND form_schema.has_public_token=true`) is an operational task, configured in the GlitchTip web UI on `monitoring.hausdesign.nl` outside the code lifecycle; runbook procedure documented in `dev-docs/runbooks/observability-triage.md` §7. Frontend Echo subscription for `FormSubmissionIdentityMatchResolved` is a separate frontend follow-up, out of WS-6 scope.