From 762fc62efa3eedc5582214ac47850ff572900e94 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 02:55:11 +0200 Subject: [PATCH] feat(form-builder): wire D1 building blocks into ApplyBindings + add deadline wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Bindings/FormBindingApplicator.php | 106 ++++++++++++++---- .../FormBuilder/ApplyBindingsOnFormSubmit.php | 68 ++++++++--- 2 files changed, 133 insertions(+), 41 deletions(-) diff --git a/api/app/FormBuilder/Bindings/FormBindingApplicator.php b/api/app/FormBuilder/Bindings/FormBindingApplicator.php index 8501cd10..ddeafd6d 100644 --- a/api/app/FormBuilder/Bindings/FormBindingApplicator.php +++ b/api/app/FormBuilder/Bindings/FormBindingApplicator.php @@ -7,6 +7,7 @@ namespace App\FormBuilder\Bindings; use App\Enums\FormBuilder\BindingTargetType; use App\Enums\FormBuilder\FormFieldBindingMergeStrategy; use App\Exceptions\FormBuilder\FormBindingApplicatorException; +use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException; use App\Exceptions\FormBuilder\FormBindingInfraException; use App\Exceptions\FormBuilder\FormBindingSchemaConfigException; use App\FormBuilder\Purposes\PurposeRegistry; @@ -28,6 +29,10 @@ use Throwable; * - Q9: subject resolution via per-purpose PurposeSubjectResolver. * - Q10: optional sectionId for future section-level apply. * - 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 // throw-path coverage (Mockery can't mock final classes; PHP doesn't allow @@ -35,6 +40,15 @@ use Throwable; // individually to preserve immutability. 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( private readonly PurposeRegistry $purposeRegistry, private readonly BindingConflictResolver $conflictResolver, @@ -42,11 +56,34 @@ class FormBindingApplicator 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 */ public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult { + $start = microtime(true); + if (DB::transactionLevel() < 1) { throw new FormBindingInfraException( submissionId: (string) $submission->id, @@ -81,38 +118,61 @@ class FormBindingApplicator provisionedSubjectId: null, applications: [], ); - $this->activityLogger->logPass($submission, $result); + } else { + $resolved = $this->conflictResolver->resolve($submission, $sectionId); - return $result; - } - - $resolved = $this->conflictResolver->resolve($submission, $sectionId); - - // Persist subject identity for the result + apply each binding. - $applications = []; - foreach ($resolved as $binding) { - // Skip identity-key bindings — the resolver already used them - // for subject lookup in EventRegistration's PersonProvisioner - // path. Writing them again is a no-op at best, a clobber at - // worst. - if ($binding->isIdentityKey) { - continue; + // Persist subject identity for the result + apply each binding. + $applications = []; + foreach ($resolved as $binding) { + // Skip identity-key bindings — the resolver already used them + // for subject lookup in EventRegistration's PersonProvisioner + // path. Writing them again is a no-op at best, a clobber at + // worst. + if ($binding->isIdentityKey) { + continue; + } + $applications[] = $this->applyOne($subject, $binding); } - $applications[] = $this->applyOne($subject, $binding); - } - $result = new BindingPassResult( - formSubmissionId: (string) $submission->id, - provisionedSubjectType: $this->morphAlias($subject), - provisionedSubjectId: (string) $subject->getKey(), - applications: $applications, - ); + $result = new BindingPassResult( + formSubmissionId: (string) $submission->id, + provisionedSubjectType: $this->morphAlias($subject), + provisionedSubjectId: (string) $subject->getKey(), + applications: $applications, + ); + } $this->activityLogger->logPass($submission, $result); + $this->checkDeadline((string) $submission->id, $start); + 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 { try { diff --git a/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php index e6a8fd12..42e10ad2 100644 --- a/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php +++ b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php @@ -6,7 +6,9 @@ namespace App\Listeners\FormBuilder; use App\Enums\FormBuilder\ApplyStatus; use App\Events\FormBuilder\FormSubmissionSubmitted; +use App\Exceptions\FormBuilder\FormBindingApplicatorException; use App\FormBuilder\Bindings\FormBindingApplicator; +use App\FormBuilder\Bindings\FormBindingExceptionClassifier; use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSubmission; use App\Models\FormBuilder\FormSubmissionActionFailure; @@ -21,10 +23,24 @@ use Throwable; * FormSubmissionActionFailure in a separate transaction (survives * 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. * - * SYNCHRONOUS by design — does NOT implement ShouldQueue. Identity - * match runs after this in the registered listener order. + * SYNCHRONOUS by design — does NOT implement ShouldQueue. Identity-match + * runs queued (post-v1.3) and is gated on apply_status=COMPLETED. */ final readonly class ApplyBindingsOnFormSubmit { @@ -37,28 +53,40 @@ final readonly class ApplyBindingsOnFormSubmit return; } - try { - DB::transaction(function () use ($submission): void { - $result = $this->applicator->apply($submission); + $deadlineSeconds = (int) config('form_builder.apply_deadline_seconds', 5); - FormSubmission::query() - ->whereKey($submission->id) - ->update([ - 'apply_status' => $result->applyStatus()->value, - 'apply_completed_at' => now(), - ]); + try { + DB::transaction(function () use ($submission, $deadlineSeconds): void { + $result = $this->applicator + ->withDeadline($deadlineSeconds) + ->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) { // ApplyBindings just provisioned a Person; reflect it // on the submission so TriggerPersonIdentityMatch (next - // sync listener) can find it. - FormSubmission::query() - ->whereKey($submission->id) - ->update([ - 'subject_type' => $result->provisionedSubjectType, - 'subject_id' => $result->provisionedSubjectId, - ]); + // queued listener) can find it and the gating-invariant + // sees a coherent (subject_type, subject_id) pair. + $updates['subject_type'] = $result->provisionedSubjectType; + $updates['subject_id'] = $result->provisionedSubjectId; } + + FormSubmission::query() + ->whereKey($submission->id) + ->update($updates); }); } catch (Throwable $e) { // OUTSIDE the failed transaction — survives rollback. @@ -74,6 +102,9 @@ final readonly class ApplyBindingsOnFormSubmit 'exception_trace' => $e->getTraceAsString(), 'context' => [ 'purpose' => $purposeValue, + 'submission_id' => $e instanceof FormBindingApplicatorException + ? $e->submissionId + : (string) $submission->id, ], ]); FormSubmission::query() @@ -81,6 +112,7 @@ final readonly class ApplyBindingsOnFormSubmit ->update([ 'apply_status' => ApplyStatus::FAILED->value, 'apply_completed_at' => now(), + 'failure_response_code' => FormBindingExceptionClassifier::classify($e), ]); }); Log::error('form-builder.apply.transaction_rolled_back', [