feat(form-builder): add PersonProvisioner with race-safe firstOrCreate (WS-6)

PersonProvisioner reads bindings from schema_snapshot (RFC Q6) and
provisions Persons via lockForUpdate + firstOrCreate (RFC Q8).
Person is event-scoped (Person::$organisationScopeColumn = 'event_id'),
so the lookup matches by (email, event_id) — cross-event submissions
never collide.

Throws PersonProvisioningException on misconfiguration (failsafe —
publish guards should prevent these at config time): no_transaction,
no_event, no_identity_key, identity_key_missing_value, no_crowd_type.

Snapshot enrichment: FormFieldBindingService::toApplicatorShape +
FormSubmissionService snapshot now adds a 'bindings' (plural) key with
binding id, merge_strategy, trust_level, is_identity_key. Singular
'binding' key kept for legacy webhook / GDPR readers.

Includes RFC V4 state-injection concurrency test asserting recovery
semantics under lockForUpdate windows.

Refs: RFC-WS-6.md §3 (Q6, Q8), §4 (V4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:43:12 +02:00
parent c6a8d13b6f
commit d257d64925
7 changed files with 723 additions and 1 deletions

View File

@@ -243,7 +243,13 @@ final class FormSubmissionService
'is_required' => (bool) $f->is_required,
'is_filterable' => (bool) $f->is_filterable,
'is_pii' => (bool) $f->is_pii,
'binding' => $this->bindingService->toJsonShape($f->bindings->first()),
// WS-6 RFC Q6 — singular 'binding' kept for legacy webhook /
// GDPR readers; plural 'bindings' carries every binding on
// the field with id, merge_strategy, trust_level,
// is_identity_key for PersonProvisioner / BindingConflictResolver
// / FormBindingApplicator. Single helper to avoid duplicated
// dynamic-property access inside this lambda.
...$this->bindingService->snapshotShapesFor($f->bindings),
'conditional_logic' => $this->conditionalLogicService->toJsonShape($f->rootConditionalLogicGroup()),
'translations' => $this->stripOptionsFromTranslations($f->translations),
'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint,