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

@@ -150,6 +150,70 @@ final class FormFieldBindingService
return $shape;
}
/**
* Richer snapshot shape for the WS-6 binding pipeline. Captures
* applicator-relevant metadata (binding id, merge_strategy,
* trust_level, is_identity_key) on top of the legacy
* mode/entity/column triple.
*
* RFC-WS-6.md §3 (Q6): the applicator reads from snapshot, not from
* the live form_field_bindings table this shape carries everything
* needed for conflict resolution and person provisioning.
*
* @return array{
* id:string,
* mode:string,
* entity:string,
* column:string,
* sync_direction?:string,
* merge_strategy:string,
* trust_level:int,
* is_identity_key:bool,
* }
*/
public function toApplicatorShape(FormFieldBinding $binding): array
{
// FormFieldBinding casts mode/merge_strategy to enum already; access
// the value directly without redundant instanceof guards.
$shape = [
'id' => (string) $binding->id,
'mode' => $binding->mode->value,
'entity' => (string) $binding->target_entity,
'column' => (string) $binding->target_attribute,
'merge_strategy' => ($binding->merge_strategy ?? FormFieldBindingMergeStrategy::Overwrite)->value,
'trust_level' => (int) $binding->trust_level,
'is_identity_key' => (bool) $binding->is_identity_key,
];
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
$shape['sync_direction'] = (string) $binding->sync_direction;
}
return $shape;
}
/**
* Build snapshot fragments for both the legacy `binding` (singular)
* key and the new WS-6 `bindings` (plural) key in one pass over the
* collection. Single helper so the FormSubmissionService snapshot
* loop accesses `$field->bindings` only once.
*
* @param iterable<FormFieldBinding> $bindings
* @return array{binding: array<string, mixed>|null, bindings: list<array<string, mixed>>}
*/
public function snapshotShapesFor(iterable $bindings): array
{
$first = null;
$all = [];
foreach ($bindings as $binding) {
if ($first === null) {
$first = $this->toJsonShape($binding);
}
$all[] = $this->toApplicatorShape($binding);
}
return ['binding' => $first, 'bindings' => $all];
}
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
{
return $owner instanceof FormField ? 'form_field' : 'form_field_library';