Files
crewli/dev-docs/ARCH-BINDINGS.md
bert.hausmans 85f4777e0c docs(ws-6): RFC-WS-6 v1.1 addenda + ARCH-BINDINGS §6.4 alignment
Promote RFC-WS-6 to v1.1 with two §3 addenda capturing the post-session-2
cleanup decisions; align ARCH-BINDINGS.md §6.4 (Person provisioning)
with the v1.1 text. No architectural reversals — corrections + one
schema addition.

§3 Q8 v1.1 addendum — Person provisioning is scoped by `event_id`:
- Q8 v1.0 said `Person::firstOrCreate(['email', 'organisation_id'], ...)`.
  That is incorrect against the actual model: `Person::$organisationScopeColumn`
  is `event_id`. The provisioner looks up and creates by `(email, event_id)`.
- Same email registering across two events in the same org → two distinct
  Person rows. Cross-event identity reconciliation remains the job of
  `PersonIdentityService` (out of scope WS-6).
- Failsafe: `PersonProvisioningException('no_event', ...)` when
  `submission.event_id` is null on event_registration; publish guard
  `SchemaHasLinkedEvent` blocks at config time.

§3 Q9 v1.1 addendum — `form_schemas.default_crowd_type_id` replaces
`CrowdType::oldest()`:
- Session 2's PersonProvisioner used a silent oldest()-in-org heuristic
  for the new Person's `crowd_type_id` (NOT NULL). Fragile, undocumented,
  cross-org broken.
- v1.1 adds `form_schemas.default_crowd_type_id` (nullable ULID) as the
  explicit, versioned schema attribute. `RequiresDefaultCrowdType` publish
  guard wires into `EventRegistrationGuards`. Runtime failsafe in
  `PersonProvisioner::resolveCrowdTypeId()` throws
  `PersonProvisioningException('no_default_crowd_type', ...)` when null.
- Schema-level FK omitted intentionally (SQLite cascade-delete on
  ALTER TABLE ADD FOREIGN KEY observed in WS-5b/c backfill tests).
  Application-level integrity (publish guard + runtime failsafe +
  Eloquent `belongsTo`) is sufficient because writes always go through
  `FormSchemaService::publish()`.
- Snapshot impact: none. Provisioning reads from live FormSchema by
  FK; audit replay uses whatever the schema's current
  `default_crowd_type_id` is at retry time.

ARCH-BINDINGS.md §6.4:
- Now references "RFC Q8 + Q9, v1.1" in the heading.
- Default-crowd-type bullet replaces "first active CrowdType in the org"
  (the session-2 oldest() heuristic) with the schema attribute lookup.
- Multi-tenancy paragraph clarified for cross-event scoping.

Cross-references touched up:
- `PersonProvisioner::resolveCrowdTypeId()` docblock: §3 Q8 → §3 Q9.
- `RequiresDefaultCrowdType` class docblock: §3 Q8 → §3 Q9.
- `SCHEMA.md` v2.7 changelog and `default_crowd_type_id` column note:
  §3 Q8 → §3 Q9.

Document history entry added in §10 documenting v1.1 + the snapshot
dual-key cleanup and route-model-binding fix landed in earlier commits
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:09:41 +02:00

486 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ARCH-BINDINGS.md — Form Builder Binding Pipeline
## Status
- v0.1 (skeleton) — 2026-04-25
- 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<string>`
- `attributesFor(entity): list<string>`
- `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<ResolvedBinding>`.
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: <section id>)` 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
> Populated in session 3 — see RFC-WS-6.md §4 V3.
## 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