Files
crewli/dev-docs/ARCH-BINDINGS.md
bert.hausmans acd7cf5ec8 docs: ARCH-BINDINGS.md polish (WS-6)
End-to-end consistency review of the document. One polish edit
landed:

- §1 Scope listed "Person, Artist, Company, User" as binding-target
  entities. Sessie 3a.5 removed `artist` from the registry entirely
  (BACKLOG ARTIST-ADV-BINDING-MODEL); §1 now states "Person,
  Company, User" with a forward-pointer to the appendix that
  documents the v1 omission rationale.

Otherwise no polish needed:
- No stale renamed symbols in code examples (the two
  `dietary_preferences` mentions remaining are deliberate appendix
  content explaining the JSON-path BACKLOG deferral).
- No TODOs / FIXMEs.
- Cross-references to RFC §X / Q-ids are consistent.
- Terminology distinguishes registry (BindingTypeRegistry config +
  lookup), applicator (FormBindingApplicator runtime), and pipeline
  (the broader subject-resolve → conflict-resolve → apply flow)
  appropriately.

Version bumped to v0.6.

Refs: WS-6 sessie 3b Task 6

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

30 KiB
Raw Blame History

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)
  • v0.6 — 2026-04-28 — §1 polish: align with v1 registry scope (sessie 3b polish review)
  • 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, Company, User — via per-field bindings configured in form_field_bindings (WS-5a). The artist entity is intentionally absent from the v1 binding-target registry; see the appendix for the rationale.

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:

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.

  • 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