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>
486 lines
22 KiB
Markdown
486 lines
22 KiB
Markdown
# 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
|