feat(form-builder): wire D1 building blocks into ApplyBindings + add deadline wrapper

Per RFC-WS-6 §Q1 v1.3 addition 1, 4 + §Q3 v1.3 addition 2 + ARCH-BINDINGS §5.3.

- FormBindingApplicator::withDeadline(int) returns a clone configured to
  throw FormBindingApplicatorTimeoutException if apply() exceeds the
  deadline. Soft post-call microtime check; cannot interrupt mid-query
  but catches the long tail. apply() refactored to single-return so the
  deadline check sits at one site instead of duplicated.
- ApplyBindingsOnFormSubmit::handle:
  - Initial identity_match_status='pending' write inside inner
    transaction (when subject is or becomes a person) so HTTP response
    carries the right state for the IdentityMatchBanner first-paint
    copy. Final state comes from the queued TriggerPersonIdentityMatch
    (D2 Phase C).
  - Wraps apply() with config('form_builder.apply_deadline_seconds', 5).
  - Catch block uses FormBindingExceptionClassifier::classify to write
    failure_response_code in the outer transaction alongside
    apply_status=FAILED. submission_id from the exception (when in the
    binding-applicator hierarchy) is also captured in context JSON.

Tests added in Phase I.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 02:55:11 +02:00
parent c6f4d1b5c6
commit 762fc62efa
2 changed files with 133 additions and 41 deletions

View File

@@ -7,6 +7,7 @@ namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType; use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy; use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Exceptions\FormBuilder\FormBindingApplicatorException; use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException;
use App\Exceptions\FormBuilder\FormBindingInfraException; use App\Exceptions\FormBuilder\FormBindingInfraException;
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException; use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
use App\FormBuilder\Purposes\PurposeRegistry; use App\FormBuilder\Purposes\PurposeRegistry;
@@ -28,6 +29,10 @@ use Throwable;
* - Q9: subject resolution via per-purpose PurposeSubjectResolver. * - Q9: subject resolution via per-purpose PurposeSubjectResolver.
* - Q10: optional sectionId for future section-level apply. * - Q10: optional sectionId for future section-level apply.
* - Q12: hierarchical activity log via BindingActivityLogger. * - Q12: hierarchical activity log via BindingActivityLogger.
* - v1.3 Q1 add 4: optional deadline (withDeadline()) soft post-call
* microtime check throwing FormBindingApplicatorTimeoutException.
* Cannot interrupt mid-query; intended to catch the long-tail of
* slow applies before they hang the public flow.
*/ */
// Not final + not readonly: listener tests need to override `apply()` for // Not final + not readonly: listener tests need to override `apply()` for
// throw-path coverage (Mockery can't mock final classes; PHP doesn't allow // throw-path coverage (Mockery can't mock final classes; PHP doesn't allow
@@ -35,6 +40,15 @@ use Throwable;
// individually to preserve immutability. // individually to preserve immutability.
class FormBindingApplicator class FormBindingApplicator
{ {
/**
* Per RFC-WS-6 §Q1 v1.3 addition 4 soft deadline (seconds). NULL
* means "no deadline check" (default). Set via withDeadline() so the
* value travels with a clone and the original instance stays
* deadline-free for other callers (e.g. the retry-service path,
* which currently does not bound apply() see ARCH-BINDINGS §5.3).
*/
private ?int $deadlineSeconds = null;
public function __construct( public function __construct(
private readonly PurposeRegistry $purposeRegistry, private readonly PurposeRegistry $purposeRegistry,
private readonly BindingConflictResolver $conflictResolver, private readonly BindingConflictResolver $conflictResolver,
@@ -42,11 +56,34 @@ class FormBindingApplicator
private readonly BindingActivityLogger $activityLogger, private readonly BindingActivityLogger $activityLogger,
) {} ) {}
/**
* Returns a clone configured to throw FormBindingApplicatorTimeoutException
* if apply() exceeds the given deadline.
*
* Per RFC-WS-6 §Q1 v1.3 addition 4 + ARCH-BINDINGS §5.3.
*
* Implementation note: this is a soft post-call deadline check via
* microtime. It cannot interrupt mid-query for that, configure MySQL
* connection timeouts at the database driver level. The soft deadline
* is sufficient to prevent runaway apply() calls from hanging the
* public flow indefinitely; a typical apply() takes <100ms, so a 5s
* deadline catches the long tail.
*/
public function withDeadline(int $seconds): self
{
$clone = clone $this;
$clone->deadlineSeconds = $seconds;
return $clone;
}
/** /**
* @throws FormBindingApplicatorException * @throws FormBindingApplicatorException
*/ */
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{ {
$start = microtime(true);
if (DB::transactionLevel() < 1) { if (DB::transactionLevel() < 1) {
throw new FormBindingInfraException( throw new FormBindingInfraException(
submissionId: (string) $submission->id, submissionId: (string) $submission->id,
@@ -81,38 +118,61 @@ class FormBindingApplicator
provisionedSubjectId: null, provisionedSubjectId: null,
applications: [], applications: [],
); );
$this->activityLogger->logPass($submission, $result); } else {
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
return $result; // Persist subject identity for the result + apply each binding.
} $applications = [];
foreach ($resolved as $binding) {
$resolved = $this->conflictResolver->resolve($submission, $sectionId); // Skip identity-key bindings — the resolver already used them
// for subject lookup in EventRegistration's PersonProvisioner
// Persist subject identity for the result + apply each binding. // path. Writing them again is a no-op at best, a clobber at
$applications = []; // worst.
foreach ($resolved as $binding) { if ($binding->isIdentityKey) {
// Skip identity-key bindings — the resolver already used them continue;
// for subject lookup in EventRegistration's PersonProvisioner }
// path. Writing them again is a no-op at best, a clobber at $applications[] = $this->applyOne($subject, $binding);
// worst.
if ($binding->isIdentityKey) {
continue;
} }
$applications[] = $this->applyOne($subject, $binding);
}
$result = new BindingPassResult( $result = new BindingPassResult(
formSubmissionId: (string) $submission->id, formSubmissionId: (string) $submission->id,
provisionedSubjectType: $this->morphAlias($subject), provisionedSubjectType: $this->morphAlias($subject),
provisionedSubjectId: (string) $subject->getKey(), provisionedSubjectId: (string) $subject->getKey(),
applications: $applications, applications: $applications,
); );
}
$this->activityLogger->logPass($submission, $result); $this->activityLogger->logPass($submission, $result);
$this->checkDeadline((string) $submission->id, $start);
return $result; return $result;
} }
/**
* Throws FormBindingApplicatorTimeoutException if a deadline is
* configured and the elapsed wall-clock time exceeds it.
*/
private function checkDeadline(string $submissionId, float $startMicrotime): void
{
if ($this->deadlineSeconds === null) {
return;
}
$elapsed = microtime(true) - $startMicrotime;
if ($elapsed > $this->deadlineSeconds) {
throw new FormBindingApplicatorTimeoutException(
submissionId: $submissionId,
message: sprintf(
'FormBindingApplicator exceeded deadline of %ds (elapsed: %.2fs) for submission %s',
$this->deadlineSeconds,
$elapsed,
$submissionId,
),
);
}
}
private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult
{ {
try { try {

View File

@@ -6,7 +6,9 @@ namespace App\Listeners\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus; use App\Enums\FormBuilder\ApplyStatus;
use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use App\FormBuilder\Bindings\FormBindingApplicator; use App\FormBuilder\Bindings\FormBindingApplicator;
use App\FormBuilder\Bindings\FormBindingExceptionClassifier;
use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission; use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure; use App\Models\FormBuilder\FormSubmissionActionFailure;
@@ -21,10 +23,24 @@ use Throwable;
* FormSubmissionActionFailure in a separate transaction (survives * FormSubmissionActionFailure in a separate transaction (survives
* inner rollback). * inner rollback).
* *
* v1.3 changes (per RFC v1.3.1 + ARCH-BINDINGS v1.2):
* - Q1 addition 1: writes identity_match_status='pending' inside the
* inner transaction so the HTTP response carries the right state
* for the IdentityMatchBanner first-paint copy. Final state is
* written by the queued TriggerPersonIdentityMatch.
* - Q1 addition 4: wraps applicator with config-driven deadline so
* a runaway apply() throws FormBindingApplicatorTimeoutException
* instead of hanging the public flow.
* - Q3 addition 2: outer-transaction catch uses
* FormBindingExceptionClassifier::classify to write
* failure_response_code on the parent submission. The action-failure
* row is the canonical machine-replayable artefact; the column is
* the response-shape driver.
*
* Throws are swallowed (RFC Q3) sibling listeners must keep running. * Throws are swallowed (RFC Q3) sibling listeners must keep running.
* *
* SYNCHRONOUS by design does NOT implement ShouldQueue. Identity * SYNCHRONOUS by design does NOT implement ShouldQueue. Identity-match
* match runs after this in the registered listener order. * runs queued (post-v1.3) and is gated on apply_status=COMPLETED.
*/ */
final readonly class ApplyBindingsOnFormSubmit final readonly class ApplyBindingsOnFormSubmit
{ {
@@ -37,28 +53,40 @@ final readonly class ApplyBindingsOnFormSubmit
return; return;
} }
try { $deadlineSeconds = (int) config('form_builder.apply_deadline_seconds', 5);
DB::transaction(function () use ($submission): void {
$result = $this->applicator->apply($submission);
FormSubmission::query() try {
->whereKey($submission->id) DB::transaction(function () use ($submission, $deadlineSeconds): void {
->update([ $result = $this->applicator
'apply_status' => $result->applyStatus()->value, ->withDeadline($deadlineSeconds)
'apply_completed_at' => now(), ->apply($submission);
]);
$updates = [
'apply_status' => $result->applyStatus()->value,
'apply_completed_at' => now(),
];
// Initial identity_match_status='pending' write (RFC §Q1
// v1.3 addition 1). Only meaningful when ApplyBindings
// produced a person subject; non-person purposes leave
// the column NULL per ARCH-BINDINGS §7.3 contract.
if ($result->provisionedSubjectType === 'person'
|| $submission->subject_type === 'person') {
$updates['identity_match_status'] = 'pending';
}
if ($result->provisionedSubjectType !== null && $submission->subject_type === null) { if ($result->provisionedSubjectType !== null && $submission->subject_type === null) {
// ApplyBindings just provisioned a Person; reflect it // ApplyBindings just provisioned a Person; reflect it
// on the submission so TriggerPersonIdentityMatch (next // on the submission so TriggerPersonIdentityMatch (next
// sync listener) can find it. // queued listener) can find it and the gating-invariant
FormSubmission::query() // sees a coherent (subject_type, subject_id) pair.
->whereKey($submission->id) $updates['subject_type'] = $result->provisionedSubjectType;
->update([ $updates['subject_id'] = $result->provisionedSubjectId;
'subject_type' => $result->provisionedSubjectType,
'subject_id' => $result->provisionedSubjectId,
]);
} }
FormSubmission::query()
->whereKey($submission->id)
->update($updates);
}); });
} catch (Throwable $e) { } catch (Throwable $e) {
// OUTSIDE the failed transaction — survives rollback. // OUTSIDE the failed transaction — survives rollback.
@@ -74,6 +102,9 @@ final readonly class ApplyBindingsOnFormSubmit
'exception_trace' => $e->getTraceAsString(), 'exception_trace' => $e->getTraceAsString(),
'context' => [ 'context' => [
'purpose' => $purposeValue, 'purpose' => $purposeValue,
'submission_id' => $e instanceof FormBindingApplicatorException
? $e->submissionId
: (string) $submission->id,
], ],
]); ]);
FormSubmission::query() FormSubmission::query()
@@ -81,6 +112,7 @@ final readonly class ApplyBindingsOnFormSubmit
->update([ ->update([
'apply_status' => ApplyStatus::FAILED->value, 'apply_status' => ApplyStatus::FAILED->value,
'apply_completed_at' => now(), 'apply_completed_at' => now(),
'failure_response_code' => FormBindingExceptionClassifier::classify($e),
]); ]);
}); });
Log::error('form-builder.apply.transaction_rolled_back', [ Log::error('form-builder.apply.transaction_rolled_back', [