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:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user