From 2a8f108b0e9db868bb4bb5a99b70951b8b33a8b4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 02:56:10 +0200 Subject: [PATCH] feat(form-builder): TriggerPersonIdentityMatch becomes queued + invariant throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC-WS-6 §Q1 v1.3 (queueing) + §Q2 (invariant + IdentityMatchInvariantViolation) + §Q1 v1.3 addition 2 (broadcast). - Implements ShouldQueue (was sync). Gate as first statement: skip if apply_status !== COMPLETED (handles PARTIAL and FAILED identically per ARCH-BINDINGS §5.6). Logs at info level when skipped for triage visibility. - Failsafe-pad removed in favour of strict invariant: subject_type='person' + apply_status=COMPLETED implies subject_id IS NOT NULL. Violation throws IdentityMatchInvariantViolation, routed via Laravel queue worker to GlitchTip + form_submission_action_failures. - Status derivation preserved (string semantics 'matched'/'pending'/'none') — PersonIdentityService::detectMatches returns a Collection; status computed via user_id check + isNotEmpty(). matchCount derived from $matches->count() for the broadcast payload only (not persisted). - Person-not-found between dispatch and worker pickup terminates as 'none' rather than throwing — rare race-window where the person was deleted; banner gets a sensible final state. - Dispatches FormSubmissionIdentityMatchResolved on the submission.{id} private channel after writing the final identity_match_status. Frontend Echo subscription is a separate follow-up (out of WS-6 scope). The 4 existing failsafe-pad tests need rewriting in Phase I. Co-Authored-By: Claude Opus 4.7 --- ...TriggerPersonIdentityMatchOnFormSubmit.php | 175 ++++++++++-------- 1 file changed, 101 insertions(+), 74 deletions(-) diff --git a/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php b/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php index da006082..9065bf43 100644 --- a/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php +++ b/api/app/Listeners/FormBuilder/TriggerPersonIdentityMatchOnFormSubmit.php @@ -4,112 +4,139 @@ declare(strict_types=1); namespace App\Listeners\FormBuilder; +use App\Enums\FormBuilder\ApplyStatus; use App\Enums\FormBuilder\FormPurpose; +use App\Events\FormBuilder\FormSubmissionIdentityMatchResolved; use App\Events\FormBuilder\FormSubmissionSubmitted; +use App\Exceptions\FormBuilder\IdentityMatchInvariantViolation; use App\Models\FormBuilder\FormSubmission; use App\Models\Person; use App\Services\PersonIdentityService; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Log; /** - * ARCH §31.1 — trigger PersonIdentityService::detectMatches on - * event_registration submissions and record the outcome on the - * submission so the portal can tell the submitter what's happening. + * RFC-WS-6 §Q1 v1.3 (queued) + §Q2 (invariant) + §Q1 v1.3 addition 2 (broadcast). + * + * Runs asynchronously after ApplyBindingsOnFormSubmit commits. Identity + * matching joins the persons table scoped to organisation; in large orgs + * (10k+ Persons) this can take seconds at peak load. Keeping it sync would + * block PHP-FPM workers during public-form submission spikes — operationally + * unacceptable for enterprise SaaS. The IdentityMatchBanner first-paint + * copy stays correct because ApplyBindingsOnFormSubmit writes + * identity_match_status='pending' inside its inner transaction; this + * listener writes the final state and broadcasts on the + * `submission.{id}` private channel so the frontend can refetch. + * + * Gating-invariant per ARCH-BINDINGS §5.6: skip unless apply_status is + * COMPLETED. PARTIAL and FAILED both fall through to the early-return — + * sibling state is incoherent, identity-match against possibly-half-applied + * data is meaningless. + * + * The post-ApplyBindings invariant per §Q2: subject_type='person' AND + * subject_id IS NOT NULL, OR apply_status=FAILED. No third state exists. + * If we see subject_type='person' AND subject_id IS NULL AND + * apply_status=COMPLETED, that's a structural defect — strict throw via + * IdentityMatchInvariantViolation routes to GlitchTip + (via the queue + * exception handler) form_submission_action_failures. * * States written to form_submissions.identity_match_status: * - 'matched' — the person is already linked to a user account - * - 'pending' — one or more PersonIdentityMatch(pending) rows exist - * OR the submission is public (no subject yet; organiser - * will attach a person and matching runs later) - * - 'none' — the person exists, is unlinked, and nothing matched - * - * Failure mode per §31.1: log at error level, never rethrow so sibling - * listeners on the same event (§31.10 tag sync, §31.3 shift provisioning) - * keep running. - * - * Runs synchronously (no ShouldQueue) so identity_match_status is - * already written by the time the HTTP submit-response serialises the - * submission — the portal's IdentityMatchBanner then renders on first - * confirmation-page load instead of after a queue worker tick. When - * FORM-05 proper adds heavier value-based matching, that work will - * dispatch as a separate queued job from within this listener so the - * eager state transition stays sync and the slow resolution stays - * async. + * - 'pending' — one or more PersonIdentityMatch(pending) candidates exist + * - 'none' — no candidates and not linked */ -final class TriggerPersonIdentityMatchOnFormSubmit +final class TriggerPersonIdentityMatchOnFormSubmit implements ShouldQueue { + use InteractsWithQueue; + + // Default queue connection — redis in production, sync in tests. + public string $queue = 'default'; + public function __construct( private readonly PersonIdentityService $identityService, ) {} public function handle(FormSubmissionSubmitted $event): void { - try { - $submission = $event->submission->fresh(['schema']); - if ($submission === null) { - return; - } - - $schema = $submission->schema; - if ($schema === null) { - return; - } - - $purpose = $schema->purpose instanceof \BackedEnum - ? $schema->purpose->value - : (string) $schema->purpose; - - if ($purpose !== FormPurpose::EVENT_REGISTRATION->value) { - return; - } - - $status = $this->resolveStatus($submission); - - // Use a raw UPDATE so we don't re-fire Eloquent events / an - // observer cascade on the submission itself. - FormSubmission::query() - ->whereKey($submission->id) - ->update(['identity_match_status' => $status]); - } catch (\Throwable $e) { - Log::error('form-builder.identity-match.listener_failed', [ - 'submission_id' => $event->submission->id, - 'message' => $e->getMessage(), - ]); + // Gating-invariant first statement per ARCH-BINDINGS §5.6. + // The fresh() reload is required because the inner-txn commit + + // outer-txn failure-record write happens between dispatch and + // worker pickup; the in-memory event submission may be stale. + $submission = $event->submission->fresh(['schema']); + if (! $submission instanceof FormSubmission) { + return; } - } - private function resolveStatus(FormSubmission $submission): string - { - if ($submission->subject_type !== 'person' || $submission->subject_id === null) { - // Post-WS-6 (RFC Q2): this path should be unreachable for - // event_registration submissions because ApplyBindingsOnFormSubmit - // provisions the Person synchronously before this listener fires. - // If we reach here, either: - // - ApplyBindings failed silently (check form_submission_action_failures) - // - Schema is misconfigured (no email binding, no identity-key) - // - Different purpose where subject genuinely is null - // Failsafe: preserve the existing 'pending' state so portal banner - // still renders sensibly, and surface the misconfiguration in logs. - Log::warning('form-builder.identity-match.no_person_subject_post_apply', [ + if ($submission->apply_status !== ApplyStatus::COMPLETED) { + Log::info('form-builder.queued-listener.skipped_apply_failed', [ + 'listener' => self::class, 'submission_id' => (string) $submission->id, - 'schema_id' => (string) $submission->form_schema_id, - 'subject_type' => $submission->subject_type, + 'apply_status' => $submission->apply_status?->value, ]); - return 'pending'; + return; + } + + // Non-event_registration purposes: no-op by design. Identity-match + // is a person-resolution flow specific to public registration. + $schema = $submission->schema; + if ($schema === null) { + return; + } + $purpose = $schema->purpose instanceof \BackedEnum + ? $schema->purpose->value + : (string) $schema->purpose; + if ($purpose !== FormPurpose::EVENT_REGISTRATION->value) { + return; + } + + // Non-person subjects on event_registration: also a no-op (defensive). + if ($submission->subject_type !== 'person') { + return; + } + + // Invariant per §Q2: subject_id IS NOT NULL when apply_status=COMPLETED + // and subject_type='person'. Violation = structural defect. + if ($submission->subject_id === null) { + throw new IdentityMatchInvariantViolation(sprintf( + "subject_type='person' but subject_id=null after ApplyBindings COMPLETED. submission_id=%s", + $submission->id, + )); } $person = Person::withoutGlobalScopes()->find($submission->subject_id); if ($person === null) { - return 'none'; - } + // The person was deleted between ApplyBindings COMPLETED and this + // worker pickup. Rare but possible; treat as a no-match terminal + // state so the banner shows a sensible final copy. + FormSubmission::query() + ->whereKey($submission->id) + ->update(['identity_match_status' => 'none']); - if ($person->user_id !== null) { - return 'matched'; + return; } $matches = $this->identityService->detectMatches($person); + $status = match (true) { + $person->user_id !== null => 'matched', + $matches->isNotEmpty() => 'pending', + default => 'none', + }; - return $matches->isNotEmpty() ? 'pending' : 'none'; + FormSubmission::query() + ->whereKey($submission->id) + ->update(['identity_match_status' => $status]); + + // Per RFC §Q1 v1.3 addition 2 — broadcast on the submission's + // private channel so the frontend portal IdentityMatchBanner can + // refetch the submission resource and transition copy from + // "we're checking matches…" to the final state without a manual + // reload. matchCount is an ephemeral DTO field; not persisted. + broadcast(new FormSubmissionIdentityMatchResolved( + submissionId: (string) $submission->id, + status: $status, + matchCount: $matches->count(), + ))->toOthers(); } }