# ARCH-BINDINGS.md — Form Builder Binding Pipeline ## Status - v0.1 (skeleton) — 2026-04-25 - v0.4 — 2026-04-28 — § 8.2 IDOR class tests (sessie 3a backend hardening) - Owner: Bert - Authoritative for the binding pipeline architecture, complementing ARCH-FORM-BUILDER.md §17 and §31. - Decisions originate from RFC-WS-6.md. ## 1. Scope The binding pipeline turns a submitted form (`form_submissions`) into durable writes against domain entities — Person, Artist, Company, User — via per-field bindings configured in `form_field_bindings` (WS-5a). This document describes the end-to-end flow from `FormSubmissionSubmitted` event to the post-pipeline state where the domain has been updated, identity-match has been triggered, queued listeners (tag sync, shifts, webhooks, mail) have fired, and any binding-level failures have been recorded as `form_submission_action_failures` rows. The library is a **template-source, not a live link.** When a `form_field` is created from a `FormFieldLibrary` entry with bindings, the library's bindings are **copied** into `form_field_bindings` rows with `owner_type = 'form_field'`. Library updates do not propagate to existing field instances (RFC §3 Q11). Future re-sync admin action tracked in BACKLOG: `FORM-LIBRARY-RESYNC`. ## 2. Schema ### 2.1 form_field_bindings (WS-5a) Polymorphic owner (`owner_type` ∈ {`form_field`, `form_field_library`}, `owner_id` ULID), `target_entity` × `target_attribute` columns, `merge_strategy` (Overwrite/Append/Replace/FirstWriteWins), `trust_level` (0-100, default 50), `is_identity_key` bool. UNIQUE on `(owner_type, owner_id, target_entity, target_attribute)`. See `SCHEMA.md §3.5.12`. ### 2.2 form_submissions.apply_status / apply_completed_at (WS-6) Two new columns added in `2026_04_25_140000_extend_form_submissions_with_apply_status`: - `apply_status` — string(20) nullable, no default. Values: `pending|completed|partial|failed`. Drives admin "open work" filtering. NULL legacy rows are excluded from open-work views by design (RFC O1). - `apply_completed_at` — timestamp nullable, set when applicator finishes (success or fail). Indexed on `(form_schema_id, apply_status)` and `(organisation_id, apply_status)` for dashboard queries. ### 2.3 form_submission_action_failures (WS-6) Audit table for binding-pipeline failures (RFC §3 Q5). One row per listener invocation that failed, with retry / resolve / dismiss state fields. No `organisation_id` column — tenant scope flows via `form_submission_id → form_submissions.organisation_id`. Cascade-delete through the parent submission. Resolve and Dismiss are mutually exclusive workflows (RFC V2): a failure has at most one of `resolved_at` or `dismissed_at`. Full DDL in `SCHEMA.md §3.5.12`. ## 3. Value objects and enums `App\FormBuilder\Bindings\` (final readonly classes): - `ResolvedBinding` — output of session 2's `BindingConflictResolver`. One winning binding per `(target_entity, target_attribute)` group. Carries `valueIsExplicit` to distinguish "user explicitly cleared" from "field skipped by conditional logic" (RFC §3 Q7). - `BindingApplicationResult` — result of applying one resolved binding. Sealed via `succeeded()` / `failed()` named constructors so consumers cannot synthesise impossible states. - `BindingPassResult` — aggregate result of one applicator pass. `applyStatus()` maps to `ApplyStatus` enum per RFC §3 Q4 rules: empty applications → `COMPLETED`, all OK → `COMPLETED`, all failed → `FAILED`, mixed → `PARTIAL`. - `BindingTargetMeta` — single config row from `config/form_builder/binding_targets.php`. Enums in `App\Enums\FormBuilder\`: - `ApplyStatus` (RFC §3 Q4) — `PENDING|COMPLETED|PARTIAL|FAILED`. Helpers: `isTerminal()`, `isOpen()`, `label()`. - `DismissalReasonType` (RFC §4 V2) — six cases. `manually_resolved` is intentionally absent: Resolve and Dismiss are different workflows. - `BindingTargetType` (RFC §4 V1) — `SCALAR|COLLECTION|RELATION`. Storage shape only; PHP type and identity-key eligibility live on `BindingTargetMeta`. - `FormFieldBindingMergeStrategy` (existing, WS-5a) — extended in WS-6 with `nullWinnerBehaviour()` and `isValidForScalarTargets()`. ## 4. BindingTypeRegistry `App\FormBuilder\Bindings\BindingTypeRegistry` is the single source of truth for the storage shape of binding-target attributes. Config-driven (`config/form_builder/binding_targets.php`), singleton-bound in `AppServiceProvider`. Public surface: - `resolve(entity, attribute): BindingTargetMeta` - `isKnown(entity, attribute): bool` - `isIdentityKeyEligible(entity, attribute): bool` - `entities(): list` - `attributesFor(entity): list` - `validateAppendStrategy(entity, attribute, strategy)` — throws `InvalidBindingTargetException` when `strategy=Append` is paired with a non-COLLECTION target. The registry is **not** name-suffix matching (e.g. "ends with `_tags`"). Convention-not-contract is rejected because it silently misclassifies attributes that don't follow the convention or accidentally match it. ## 5. PublishGuard framework Per-purpose schema validation. `FormSchemaService::publish()` calls, in order: 1. `assertRequiredBindingsPresent()` (existing) — every required binding path on the purpose's `required_bindings` list is bound. Throws `PurposeRequirementsNotMetException`. 2. `assertPublishGuardsSatisfied()` (new) — every guard returned by the purpose's `PurposeGuardProvider` evaluates to passed. Throws `PublishGuardViolationException` carrying ALL violations sorted lexicographically by `code()`. ### Guard catalog | Class | code() | Universal? | |---|---|---| | `RequiresIdentityKeyBinding(entity, attribute)` | `requires_identity_key_binding:{entity}:{attribute}` | no | | `MaxOneIdentityKeyPerTargetEntity` | `max_one_identity_key_per_target_entity` | yes | | `RequiresFieldType(type, minCount)` | `requires_field_type:{type}` | no | | `SchemaHasLinkedEvent` | `schema_has_linked_event` | sub-guard | | `TagCategoriesConfiguredOnAllPickers` | `tag_categories_configured_on_all_pickers` | sub-guard | | `IdentityKeyBindingsOnlyInFirstSection` | `identity_key_bindings_only_in_first_section` | yes | | `AppendStrategyRequiresCollectionTarget` | `append_strategy_requires_collection_target` | yes | | `NoAmbiguousTrustLevels` | `no_ambiguous_trust_levels` | yes | | `ConditionalRequirement(predicate, sub, code)` | `conditional:{caller-supplied}` | composer | ### Per-purpose providers | Purpose | Provider | Purpose-specific guards | |---|---|---| | `event_registration` | `EventRegistrationGuards` | `RequiresIdentityKeyBinding(person, email)`, `RequiresFieldType(EMAIL,1)`, `Conditional(AVAILABILITY_PICKER → SchemaHasLinkedEvent)`, `Conditional(TAG_PICKER → TagCategoriesConfiguredOnAllPickers)` | | `artist_advance` | `ArtistAdvanceGuards` | (universal only — artist resolved via portal token) | | `supplier_intake` | `SupplierIntakeGuards` | (universal only — company via production_request) | | `post_event_evaluation` | `PostEventEvaluationGuards` | (universal only — person via auth) | | `incident_report` | `IncidentReportGuards` | (universal only — anonymous-allowed) | | `signature_contract` | `SignatureContractGuards` | (universal only — user via auth) | | `user_profile` | `UserProfileGuards` | (universal only — user via auth) | Universal guards (`MaxOneIdentityKeyPerTargetEntity`, `AppendStrategyRequiresCollectionTarget`, `NoAmbiguousTrustLevels`, `IdentityKeyBindingsOnlyInFirstSection`) wire into every purpose. The section guard is a cheap no-op when `section_level_submit=false`, but remains active at publish time for schemas that flip it on later. i18n message keys live in `api/lang/nl/form_builder_publish_guards.php`. Dutch only for v1. ## 6. Apply pipeline ### 6.1 Snapshot vs. live (RFC Q6) The `FormBindingApplicator` reads bindings from `form_submissions.schema_snapshot.fields[*].bindings` (plural), not from the live `form_field_bindings` table. WS-6 expanded the snapshot's binding shape to carry every applicator-relevant field — `{id, mode, entity, column, merge_strategy, trust_level, is_identity_key, sync_direction?}` — via `FormFieldBindingService::toApplicatorShape()`. The legacy singular `binding` key is preserved for webhook / GDPR readers; the new plural `bindings` key is what the pipeline consumes. This guarantees that a retry executed days after the original submission applies bindings as they were configured at submit time, not as they may have been edited since. Reproducibility for audit. The `PersonProvisioner` and `BindingConflictResolver` both read the snapshot exclusively; the live table is only consulted by the publish guards (config time) and BindingTypeRegistry (target shape). Tests include a `snapshot_is_truth_ignores_post_submit_binding_edits` assertion that mutates the live table after submission and verifies the provisioner ignores the change. ### 6.2 Conflict resolution (RFC Q7) `BindingConflictResolver::resolve(submission, sectionId?)` is pure logic, no DB writes. It walks `schema_snapshot.fields[*].bindings`, filters by section when `sectionId` is non-null (Q10 future), and groups candidates by `(target_entity, target_attribute)`. Within each group it sorts `trust_level DESC` then `form_field.sort_order ASC`, picks the first as winner, and returns a `list`. Candidate-set rule: a binding is a candidate **iff** the source form_field has a row in `form_values` for this submission. Absence excludes; null value is included with `valueIsExplicit = true`. The write-path invariant test (Task 10) asserts the necessary precondition: every visible field has a `form_values` row after submit, every absent field has none. Without this invariant Q7 collapses — "explicit clear" becomes indistinguishable from "skipped by conditional logic". ### 6.3 Apply algorithm and merge-strategy null matrix (RFC V1, Q7) `FormBindingApplicator::apply($submission, ?$sectionId)` is the orchestrator: 1. Assert `DB::transactionLevel() > 0` — caller MUST own the transaction (RFC Q4). Catastrophic violations throw `FormBindingApplicatorException`. 2. Resolve subject via `PurposeSubjectResolver` (Q9). 3. If subject is null (incident_report anonymous path), return a COMPLETED `BindingPassResult` with no applications. 4. Resolve bindings via `BindingConflictResolver` filtered by `sectionId` (Q10) and the candidate-set rule (Q7). 5. Skip identity-key bindings during apply — the subject resolver already used them for lookup; re-writing is at-best a no-op, at-worst a clobber. 6. For each non-identity binding compute the new value via the merge matrix (below), call `$subject->setAttribute()` + `save()`. Per-binding failures are captured in `BindingApplicationResult::failed()` inside the result and do NOT throw — partial passes are expected. 7. Return `BindingPassResult` with `applications`, `successCount`, `failureCount`, derived `applyStatus()`. 8. Pass result to `BindingActivityLogger::logPass()` for the hierarchical activity-log (§6.7). Merge strategy × null winner matrix (Q7 + V1): | Strategy | Winner non-null | Winner null | |---|---|---| | `overwrite` | write | write null | | `append` (collection only — V1) | merge with set semantics | no-op | | `replace` | write only when target null | no-op | | `first_write_wins` | write only when target null | write null when target null | `Append` on a scalar target is a defensive runtime check via `BindingTypeRegistry::validateAppendStrategy()` — publish guards catch this at config time, but the runtime check protects against live-table edits between publish and apply. ### 6.4 Person provisioning (RFC Q8 + Q9, v1.1) `PersonProvisioner::provisionFromSubmission()` (called from `EventRegistrationSubjectResolver`): 1. Reads bindings from `schema_snapshot.fields[*].bindings`. 2. Finds the unique `is_identity_key=true` binding for `target_entity='person'` (single-key invariant — composite identity tracked in BACKLOG `FORM-BINDING-COMPOSITE-IDENTITY`). 3. Reads the form_value for that field (raises if missing/null — publish guards prevent this at config time). 4. `Person::query()->where('email', $value)->where('event_id', $eventId) ->lockForUpdate()->first()` → returns existing if found. 5. Otherwise builds attributes from the OTHER (non-identity-key) bindings filtered to `Person::$fillable`, resolves `crowd_type_id` from `$submission->schema->default_crowd_type_id` (RFC Q9 v1.1 addendum — replaces the silent `CrowdType::oldest()` heuristic), and calls `Person::firstOrCreate(['email' => ..., 'event_id' => ...], $attrs)`. `firstOrCreate` semantics resolve the "transaction A's lockForUpdate window vs. transaction B's insert" race — the unique-constraint surfaces and re-reads the existing row. Tested by `PersonProvisionerConcurrencyTest` with state-injection under a real DB transaction (RFC V4 — wall-clock load testing is deferred to BACKLOG `LOAD-TEST-FOUNDATION`). Multi-tenancy: Person's `organisationScopeColumn` is `event_id` (not `organisation_id` directly). The provisioner scopes by `event_id` only — cross-event submissions never collide. Same email registering across two events in the same org → two distinct Person rows; identity reconciliation is `PersonIdentityService`'s job (out of scope for WS-6, RFC §6 / RFC Q8 v1.1 addendum). Default crowd type: - `form_schemas.default_crowd_type_id` (nullable ULID) is the single source of truth for the freshly-provisioned Person's `crowd_type_id`. - `RequiresDefaultCrowdType` publish guard blocks publish when null on an `event_registration` schema. - `PersonProvisioner::resolveCrowdTypeId()` throws `PersonProvisioningException('no_default_crowd_type', ...)` when null at apply time (failsafe for live-table edits between publish and apply). - No DB-level FK — application-level integrity only (SQLite cascade problem, see RFC Q9 v1.1 addendum). The Eloquent `FormSchema::defaultCrowdType()` `belongsTo` relation handles read-side correctness. ### 6.5 Per-purpose subject resolution (RFC Q9) Each purpose declares a `subject_resolver_class` in `config/form_builder/purposes.php` implementing `App\FormBuilder\Purposes\PurposeSubjectResolver`. The interface is parallel to `PurposeGuardProvider` from session 1 — `PurposeDefinition` remains a frozen value object; both interfaces hang off the registry. | Purpose | Resolver | Mechanism | |---|---|---| | `event_registration` | `EventRegistrationSubjectResolver` | `PersonProvisioner` (may create) | | `artist_advance` | `ArtistAdvanceSubjectResolver` | Portal token; subject preset on submission | | `supplier_intake` | `SupplierIntakeSubjectResolver` | Production-request → Company subject | | `post_event_evaluation` | `PostEventEvaluationSubjectResolver` | Auth user → linked Person | | `incident_report` | `IncidentReportSubjectResolver` | Anonymous-allowed; may return null | | `signature_contract` | `SignatureContractSubjectResolver` | Auth user | | `user_profile` | `UserProfileSubjectResolver` | Auth user | Concrete resolvers narrow the return type via PHP covariance (`Person`, `Company`, `User`) so callers don't need to assert. Only `IncidentReportSubjectResolver` may return null; the others throw `PurposeSubjectResolutionException` with a typed `reasonCode`. ### 6.6 Section-level apply stub (RFC Q10) `ApplyBindingsOnFormSectionSubmitted` is a queued listener registered on `FormSubmissionSectionSubmitted`. Its `handle()` early-returns when `config('form_builder.section_apply_enabled')` is false (default). When enabled it forwards to `FormBindingApplicator::apply($submission, sectionId:
)` inside a `DB::transaction`. The publish-time guard `IdentityKeyBindingsOnlyInFirstSection` is active **regardless** of the runtime flag — schema structure is gated at publish, runtime behaviour is gated by the flag. This means section-aware schemas can't ship structurally-unsafe configurations even before the runtime path activates. Removal trigger documented in `config/form_builder.php`: when ARTIST_ADVANCE feature work begins, set `FORM_BUILDER_SECTION_APPLY=true`, write section-scoped tests, remove the early-return guard. Tracking: `ARTIST-ADV-SECTION-APPLY` in BACKLOG.md. ### 6.7 Activity log granularity (RFC Q12) `BindingActivityLogger::logPass()` writes one parent activity (`form_submission.bindings_pass_completed` with `{binding_count, succeeded, failed, apply_status, person_provisioned, subject_type, subject_id}`) plus one child activity per binding (`form_submission.binding_applied` with `{parent_activity_id, binding_id, target_entity, target_attribute, success, old_value, new_value, source_submission_id}`). Failed bindings get `error_class` / `error_message` in their child activity in addition to a `FormSubmissionActionFailure` 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. Sessions 3's UI renders pass-level visible with per-binding expand-on-demand. ## 7. Failures and retry ### 7.1 Two-transaction pattern (RFC Q4) `ApplyBindingsOnFormSubmit::handle()` uses two distinct DB transactions: ``` try { DB::transaction(function () { // Inner: applicator + apply_status update + (when ApplyBindings // provisioned a Person) subject_type/subject_id sync to submission. }); } catch (Throwable $e) { // OUTSIDE the failed transaction — survives inner rollback. DB::transaction(function () { FormSubmissionActionFailure::create([...]); FormSubmission::query()->whereKey(...) ->update(['apply_status' => FAILED, 'apply_completed_at' => now()]); }); Log::error('form-builder.apply.transaction_rolled_back', [...]); } ``` The inner transaction owns the apply pass. On exception it rolls back atomically (any provisioned Person, any partial writes) — but the outer catch then opens a SECOND transaction and writes the failure record, which survives because it's not part of the inner rollback. The second transaction's failure path is Sentry-only with an explicit error log line for filterability. The listener does not rethrow (RFC Q3) so sibling listeners (TriggerPersonIdentityMatch, queued tag-sync, queued webhooks, queued mailables) keep running. ### 7.2 Retry, resolve, dismiss flows (RFC V2) Three admin actions on a `FormSubmissionActionFailure` row: - **Retry** — replay the applicator. Idempotent. Increments `retry_count`. On success: sets `resolved_at = now()`. On repeat failure: appends a NEW row preserving the audit trail (with `context.retry_of` pointing to the original). - **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` (DismissalReasonType enum), `dismissed_reason_note` mandatory only when reason is `OTHER`. `manually_resolved` is intentionally absent from the enum — Resolve and Dismiss are different workflows. Three artisan commands mirror the API endpoints with the same FK-chain isolation (`form-failures:retry|resolve|dismiss`). Bulk-retry by organisation is supported only via the API endpoint and the `--org=` artisan flag; no in-place mutation, history is preserved. ## 8. Multi-tenancy and security ### 8.1 FK-chain tenant resolution (RFC V3) `form_submission_action_failures` has no `organisation_id` column by design. Tenant scope flows via `failure.submission.organisation_id`. The `FormSubmissionActionFailurePolicy` resolves the chain at access time with `withoutGlobalScopes()` so cross-tenant access reaches the policy (which then translates denied → 404, never 403, to prevent resource-existence enumeration). The controller's `resolveFailure()` helper performs the same `withoutGlobalScopes` lookup. Soft-delete on the parent submission is checked explicitly (`$submission->deleted_at !== null`) since `withoutGlobalScopes` bypasses the SoftDeletes scope too. The policy is registered explicitly in `AppServiceProvider::boot()` because Laravel's auto-discovery doesn't reliably resolve `App\Models\FormBuilder\FormSubmissionActionFailure` to `App\Policies\FormBuilder\FormSubmissionActionFailurePolicy`. ### 8.2 IDOR class tests #### Threat model An org_admin from organisation A attempts to access organisation B's failure resources via crafted URLs: ``` GET /api/v1/organisations/{orgB}/form-failures/{failure-from-orgA-id} POST /api/v1/organisations/{orgA}/form-failures/{failure-from-orgA-id}/dismiss ``` Even if the policy correctly denies the action, the response status code itself is information leakage: - **403 Forbidden** confirms the resource exists; only the caller's authorisation is missing. An attacker can enumerate which IDs exist on other tenants by sweeping the namespace and recording 403 vs 404. - **404 Not Found** makes existence indistinguishable from absence — the attacker can't distinguish a real-but-forbidden resource from a random non-existent ID. RFC §4 V3 mandates 404 for this endpoint family. Confirm-by-existence (403) is replaced with deny-by-invisibility (404). #### Two-axis policy enforcement Two distinct denial axes, each with its own correct status code: - **Role-class** — the `super_admin` platform endpoints (`/api/v1/admin/form-failures/...`) are gated by Laravel's `role:super_admin` middleware. An authenticated org_admin who hits these endpoints gets **403** because the role gate fails. The endpoint exists; the user is just forbidden ("you're not allowed in this room"). Enumeration via this axis is moot — the URL set is fixed and documented; failing role check on a known endpoint reveals nothing. - **Ownership-class** — the org-scoped endpoints (`/api/v1/organisations/{org}/form-failures/...`) are gated by `FormSubmissionActionFailurePolicy`'s FK-chain resolution. A denied policy translates to **404** in the controller helpers (`authorizeOrNotFound` / `authorizeViewAnyInOrgOrNotFound`). Cross- tenant access becomes "this room doesn't exist for you" rather than "this room exists but you can't enter." The distinction is the prompt: in role-class, the endpoint URL itself is the universe under test; in ownership-class, individual resource IDs are the universe — and that universe must remain unobservable to unauthorised callers. #### Implementation `FormSubmissionActionFailurePolicy` is the single tenant gate. Two abilities for the IDOR-class enforcement: - `view` / `retry` / `resolve` / `dismiss(User, FormSubmissionActionFailure)` — calls `canAccess()` which loads the parent submission with `withoutGlobalScopes()`, returns false on absent or soft-deleted parent (sessie 2 deviation #7), and otherwise checks that the user is `super_admin` OR an `org_admin` on the failure's organisation (resolved via `submission.organisation_id`). - `viewAnyInOrganisation(User, Organisation)` — sessie 3a addition. The bare `viewAny(User)` permits any org_admin in any org, which was a real IDOR gap on the `orgIndex` endpoint: orgB's admin hitting `/organisations/{orgA}/form-failures` would receive orgA's failure list because `viewAny` passed and the query's `whereHas` filtered to orgA. `viewAnyInOrganisation` requires the user to have the `org_admin` role on the URL's specific organisation; denied → 404. The controller's two-helper pattern keeps the 404-translation explicit: ```php private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void; private function authorizeViewAnyInOrgOrNotFound(Organisation $organisation): void; ``` `->withoutScopedBindings()` on the org-scoped routes prevents Laravel's implicit-binding scoped-relation lookup (Organisation has no `formSubmissionActionFailures` relation; the policy is the gate). #### Test coverage `Tests\Feature\FormBuilder\Api\Security\FormSubmissionActionFailureRouteSecurityTest` exercises the contract end-to-end (24 tests, all passing on the schema-dump fast path): - 5 org-scoped endpoints (index/show/retry/resolve/dismiss) × cross- tenant scenarios → 404 for every endpoint - 5 platform endpoints × role-class scenarios → 401 unauthenticated, 403 for org_admin without super_admin role, 200/204 for super_admin - Edge cases: - **Soft-deleted parent submission** — failure exists but its `form_submission_id` points to a row with `deleted_at IS NOT NULL`. Policy treats parent-gone as resource-gone → 404. - **Invalid ULID format** in the URL → Laravel's route binding fails cleanly, returns 404 (not 500). - **Non-existent ID** → 404 regardless of role. - **Authenticated but no role on org** → 404 (IDOR-class: a non-org user enumerating IDs on a real org's URL must not be able to distinguish real vs fabricated IDs). - **Unauthenticated** → 401 on every endpoint. The 403-vs-404 distinction is documented in the test class docblock and exercised explicitly by the platform-endpoint tests (`test_platform_*_org_admin_returns_403`) — those tests would fail if a future refactor accidentally translated role-class denials to 404 "to be consistent," because that would actually weaken the role-gate's clarity for legitimate UX (an org_admin should know they're forbidden, not be misled into thinking the platform endpoint doesn't exist). #### Frontend implications Frontend admin UI in WS-6 sessie 3b applies the same authorisation model client-side: org-scoped views are rendered only for authenticated users with the appropriate role on that organisation, and platform admin views only for `super_admin`. Backend remains the source of truth — the frontend's role check is a UX optimisation (avoid showing links the user can't follow), not a security boundary. Direct API hits without going through the SPA must still hit the backend gates documented above. ## 9. Listener chain `FormSubmissionSubmitted` listeners are registered explicitly in `AppServiceProvider::boot()` (RFC Q1) — Laravel auto-discovery's filesystem-traversal order is fragile cross-platform. Sync chain (registration order is execution order): 1. `ApplyBindingsOnFormSubmit` — provisions subject + applies bindings (Q4 two-transaction, swallows exceptions per Q3). 2. `TriggerPersonIdentityMatchOnFormSubmit` — runs identity-match detection against the freshly-provisioned Person. Per RFC Q2 the "no person subject → pending" path is now a logged-warning failsafe; it should never fire for event_registration submissions post-WS-6 because ApplyBindings runs first. Queued (parallel, post-sync): - `SyncTagPickerSelectionsOnSubmit` — TAG_PICKER → user_organisation_tags rebuild. Implements `ShouldQueue`. Deliberately NOT folded into ApplyBindings: TAG_PICKER → pivot-table-with-source-discrimination is semantically distinct from a binding-target-attribute write (RFC Q3). - (future) FormWebhookDispatcher, RegistrationConfirmation mailable. `FormSubmissionSectionSubmitted` listener: `ApplyBindingsOnFormSectionSubmitted` (queued, feature-flagged, currently a no-op). `FormSubmissionSubmitted` itself is dispatched **after** the `FormSubmissionService::submit()` transaction commits (RFC O2). Pre-commit dispatch let queued listeners enqueue with state that might never persist on rollback — fixed in WS-6. ## 10. Out of scope (v1) - **Composite identity-key resolution** (single-key only) — BACKLOG: `FORM-BINDING-COMPOSITE-IDENTITY` - **Library-binding runtime cascade** — BACKLOG: `FORM-LIBRARY-RESYNC` - **Append on scalar targets** — collection-only by design (RFC V1) - **Active section-level apply** — stub only, activated when ARTIST_ADVANCE feature work begins (BACKLOG: `ARTIST-ADV-SECTION-APPLY`) - **Daily failure digest mailable** — depends on notification framework - **Wall-clock concurrent load testing** — BACKLOG: `LOAD-TEST-FOUNDATION` ## 11. Related docs - `RFC-WS-6.md` — design decisions - `ARCH-FORM-BUILDER.md` §17, §31 - `ARCH-CONSOLIDATION-2026-04.md` §6.1, §6.2 - `SCHEMA.md` §3.5.12