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

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers;
use App\FormBuilder\Bindings\BindingTypeRegistry;
use App\FormBuilder\Bindings\PersonProvisioner;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\Company;
use App\Models\CrowdList;
@@ -90,6 +91,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(PurposeRegistry::class);
$this->app->singleton(BindingTypeRegistry::class);
$this->app->singleton(PersonProvisioner::class);
// Telescope is a dev-only debugging dashboard. Three-layer
// defense keeps it out of production: composer `dont-discover`