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>
22 KiB
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'sBindingConflictResolver. One winning binding per(target_entity, target_attribute)group. CarriesvalueIsExplicitto distinguish "user explicitly cleared" from "field skipped by conditional logic" (RFC §3 Q7).BindingApplicationResult— result of applying one resolved binding. Sealed viasucceeded()/failed()named constructors so consumers cannot synthesise impossible states.BindingPassResult— aggregate result of one applicator pass.applyStatus()maps toApplyStatusenum per RFC §3 Q4 rules: empty applications →COMPLETED, all OK →COMPLETED, all failed →FAILED, mixed →PARTIAL.BindingTargetMeta— single config row fromconfig/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_resolvedis 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 onBindingTargetMeta.FormFieldBindingMergeStrategy(existing, WS-5a) — extended in WS-6 withnullWinnerBehaviour()andisValidForScalarTargets().
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): BindingTargetMetaisKnown(entity, attribute): boolisIdentityKeyEligible(entity, attribute): boolentities(): list<string>attributesFor(entity): list<string>validateAppendStrategy(entity, attribute, strategy)— throwsInvalidBindingTargetExceptionwhenstrategy=Appendis 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:
assertRequiredBindingsPresent()(existing) — every required binding path on the purpose'srequired_bindingslist is bound. ThrowsPurposeRequirementsNotMetException.assertPublishGuardsSatisfied()(new) — every guard returned by the purpose'sPurposeGuardProviderevaluates to passed. ThrowsPublishGuardViolationExceptioncarrying ALL violations sorted lexicographically bycode().
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:
- Assert
DB::transactionLevel() > 0— caller MUST own the transaction (RFC Q4). Catastrophic violations throwFormBindingApplicatorException. - Resolve subject via
PurposeSubjectResolver(Q9). - If subject is null (incident_report anonymous path), return a
COMPLETED
BindingPassResultwith no applications. - Resolve bindings via
BindingConflictResolverfiltered bysectionId(Q10) and the candidate-set rule (Q7). - 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.
- For each non-identity binding compute the new value via the merge
matrix (below), call
$subject->setAttribute()+save(). Per-binding failures are captured inBindingApplicationResult::failed()inside the result and do NOT throw — partial passes are expected. - Return
BindingPassResultwithapplications,successCount,failureCount, derivedapplyStatus(). - 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):
- Reads bindings from
schema_snapshot.fields[*].bindings. - Finds the unique
is_identity_key=truebinding fortarget_entity='person'(single-key invariant — composite identity tracked in BACKLOGFORM-BINDING-COMPOSITE-IDENTITY). - Reads the form_value for that field (raises if missing/null — publish guards prevent this at config time).
Person::query()->where('email', $value)->where('event_id', $eventId) ->lockForUpdate()->first()→ returns existing if found.- Otherwise builds attributes from the OTHER (non-identity-key)
bindings filtered to
Person::$fillable, resolvescrowd_type_idfrom$submission->schema->default_crowd_type_id(RFC Q9 v1.1 addendum — replaces the silentCrowdType::oldest()heuristic), and callsPerson::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'scrowd_type_id.RequiresDefaultCrowdTypepublish guard blocks publish when null on anevent_registrationschema.PersonProvisioner::resolveCrowdTypeId()throwsPersonProvisioningException('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()belongsTorelation 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: setsresolved_at = now(). On repeat failure: appends a NEW row preserving the audit trail (withcontext.retry_ofpointing 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_notemandatory only when reason isOTHER.manually_resolvedis 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):
ApplyBindingsOnFormSubmit— provisions subject + applies bindings (Q4 two-transaction, swallows exceptions per Q3).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. ImplementsShouldQueue. 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 decisionsARCH-FORM-BUILDER.md§17, §31ARCH-CONSOLIDATION-2026-04.md§6.1, §6.2SCHEMA.md§3.5.12