feat(form-builder): TriggerPersonIdentityMatch becomes queued + invariant throw

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 02:56:10 +02:00
parent 762fc62efa
commit 2a8f108b0e

View File

@@ -4,112 +4,139 @@ declare(strict_types=1);
namespace App\Listeners\FormBuilder; namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormPurpose; use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionIdentityMatchResolved;
use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\IdentityMatchInvariantViolation;
use App\Models\FormBuilder\FormSubmission; use App\Models\FormBuilder\FormSubmission;
use App\Models\Person; use App\Models\Person;
use App\Services\PersonIdentityService; use App\Services\PersonIdentityService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
* ARCH §31.1 trigger PersonIdentityService::detectMatches on * RFC-WS-6 §Q1 v1.3 (queued) + §Q2 (invariant) + §Q1 v1.3 addition 2 (broadcast).
* event_registration submissions and record the outcome on the *
* submission so the portal can tell the submitter what's happening. * 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: * States written to form_submissions.identity_match_status:
* - 'matched' the person is already linked to a user account * - 'matched' the person is already linked to a user account
* - 'pending' one or more PersonIdentityMatch(pending) rows exist * - 'pending' one or more PersonIdentityMatch(pending) candidates exist
* OR the submission is public (no subject yet; organiser * - 'none' no candidates and not linked
* 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.
*/ */
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( public function __construct(
private readonly PersonIdentityService $identityService, private readonly PersonIdentityService $identityService,
) {} ) {}
public function handle(FormSubmissionSubmitted $event): void public function handle(FormSubmissionSubmitted $event): void
{ {
try { // Gating-invariant first statement per ARCH-BINDINGS §5.6.
$submission = $event->submission->fresh(['schema']); // The fresh() reload is required because the inner-txn commit +
if ($submission === null) { // outer-txn failure-record write happens between dispatch and
return; // worker pickup; the in-memory event submission may be stale.
} $submission = $event->submission->fresh(['schema']);
if (! $submission instanceof FormSubmission) {
$schema = $submission->schema; return;
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(),
]);
} }
}
private function resolveStatus(FormSubmission $submission): string if ($submission->apply_status !== ApplyStatus::COMPLETED) {
{ Log::info('form-builder.queued-listener.skipped_apply_failed', [
if ($submission->subject_type !== 'person' || $submission->subject_id === null) { 'listener' => self::class,
// 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', [
'submission_id' => (string) $submission->id, 'submission_id' => (string) $submission->id,
'schema_id' => (string) $submission->form_schema_id, 'apply_status' => $submission->apply_status?->value,
'subject_type' => $submission->subject_type,
]); ]);
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); $person = Person::withoutGlobalScopes()->find($submission->subject_id);
if ($person === null) { 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;
return 'matched';
} }
$matches = $this->identityService->detectMatches($person); $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();
} }
} }