Files
crewli/dev-docs/ARCH-BINDINGS.md
bert.hausmans 8a4682ab35 docs: BACKLOG + ARCH-BINDINGS appendix + RFC v1.2 for registry alignment (WS-6)
Two new BACKLOG entries capture the deliberate v1 deferrals:

  - ARTIST-ADV-BINDING-MODEL — design how artist_advance form data
    relates to Artist + AdvanceSection entities (when, if ever, an
    Eloquent Artist class is needed, and whether bindings are even
    the right abstraction for OUTPUT-shaped advance forms).

  - FORM-BINDING-JSON-PATH — extend binding registry to support
    JSON-path attributes (custom_fields.dietary_preferences etc).
    For v1 the recommendation is TAG_PICKER + tag_categories config.

ARCH-BINDINGS.md gets an appendix explaining the v1 scope decisions
explicitly: why 'artist' has no registry entries (model class
absent + advance forms are OUTPUT-shaped, not provisioning-shaped),
why JSON-path attributes are out of scope (v1 is column-level only),
and how BindingTypeRegistryConsistencyTest prevents future drift.

RFC-WS-6.md → v1.2 with a §3 Q9 addendum tracking the registry
alignment + the 3 renames, 5 removals, and 1 new column landed in
this branch.

Refs: WS-6 sessie 3a binding-target drift audit, sessie 3a.5 cleanup

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

655 lines
30 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
- v0.4 — 2026-04-28 — § 8.2 IDOR class tests (sessie 3a backend hardening)
- v0.5 — 2026-04-28 — Appendix on v1 registry scope (sessie 3a.5 model alignment)
- 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
#### 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`
## Appendix — v1 binding registry scope
The `App\FormBuilder\Bindings\BindingTypeRegistry` covers the four
binding-target entities active in WS-6 v1: `person`, `company`,
`user`, and an intentionally-empty `artist` (omitted from registry).
### Why `artist` has no registry entries in v1
The `artists` table exists (since 2026-04-08) and `subject_type='artist'`
is a valid `form_submissions` value. But:
1. No Eloquent `Artist` model class exists yet. Polymorphic subject
relations work via the morph map (string → table) but cannot be
Eloquent-loaded without a class.
2. The `artist_advance` purpose is OUTPUT-shaped: the advance form
**gathers** information FROM the artist (rider, hospitality,
technical needs) — it does not provision Artist attributes the way
`event_registration` provisions Person attributes.
3. Bindings as a concept may not be the correct abstraction for advance
forms. v2 work tracked via BACKLOG `ARTIST-ADV-BINDING-MODEL`.
In v1: `artist_advance` schemas may exist with `required_bindings: []`.
PublishGuards enforce only the cross-cutting invariants (no identity
key conflicts, no append-on-scalar, etc). The applicator runs (per
RFC §3 Q4 two-transaction pattern) but resolves bindings to an empty
list, completing in COMPLETED state with zero applications. The
Person/Company/User-level effects of WS-6 (apply_status, action_failures,
retry/dismiss workflows) all apply uniformly.
### Why JSON-path attributes are not in v1
`persons.custom_fields` is a JSON column; semantically a Person can
have `dietary_preferences` (etc.) inside that column. But binding
targets in v1 are **column-level** scalars, lists, and relations —
not JSON-path. Adding JSON-path support is tracked via BACKLOG
`FORM-BINDING-JSON-PATH`.
For v1 the recommendation: model `dietary_preferences` (and similar
`custom_fields` properties) as a `TAG_PICKER` form_field with a
`tag_categories` config. The TAG_PICKER → user_organisation_tags sync
(per ARCH-FORM-BUILDER §31.10) handles this without requiring
binding-target column mapping.
### Drift prevention
`BindingTypeRegistryConsistencyTest::test_every_registry_entity_maps_to_an_eloquent_model_with_the_attribute`
asserts that every registry entry corresponds to a real Eloquent model
class plus a real column on that model's table. Future drift (renamed
column without registry update, or registry entry without column)
becomes a test failure, not a runtime surprise.
## 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