From f94b3fb32982225acfb44d160fb53aff4f861376 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 01:58:11 +0200 Subject: [PATCH] feat(form-builder): exception hierarchy for binding-apply pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC-WS-6 §Q3 v1.3 addition 2 (binding hierarchy) + §Q2 (invariant exception). - Refactored FormBindingApplicatorException from concrete final to abstract base. Constructor (submissionId, message, previous?) preserves submissionId as a public readonly property so D2's outer-transaction handler can write it structurally to form_submission_action_failures.context JSON without regex-parsing the message. Replaced public-readonly reasonCode property with abstract reasonCode(): string method. - Added 3 reason-coded subclasses: - FormBindingSchemaConfigException -> 'schema_config_error' (422) - FormBindingInfraException -> 'temporary_error' (503, NOT final because Timeout extends it) - FormBindingDataIntegrityException -> 'data_integrity_error' (422) - Added FormBindingApplicatorTimeoutException extending FormBindingInfraException (timeout = temporary infra issue from user perspective; reasonCode inherited). - Added IdentityMatchInvariantViolation as a sibling DomainException — NOT in the FormBindingApplicatorException hierarchy because it's thrown outside the binding-applicator pipeline. - Migrated 3 existing throw sites in FormBindingApplicator::apply(): - 'no_transaction' -> FormBindingInfraException (developer-error wants infra-triage workflow: GlitchTip alert + retry-after) - 'no_schema' -> FormBindingSchemaConfigException - 'unknown_purpose' -> FormBindingSchemaConfigException - Updated FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present to assert against the new throw shape (FormBindingInfraException + new message string) while preserving the test's intent (guard exists in source). Wiring (deadline wrapper, classifier integration in listener catch + retry-service recordFailure) lands in D2. Co-Authored-By: Claude Opus 4.7 --- .../FormBindingApplicatorException.php | 43 ++++++++++++++++--- .../FormBindingApplicatorTimeoutException.php | 26 +++++++++++ .../FormBindingDataIntegrityException.php | 28 ++++++++++++ .../FormBuilder/FormBindingInfraException.php | 28 ++++++++++++ .../FormBindingSchemaConfigException.php | 27 ++++++++++++ .../IdentityMatchInvariantViolation.php | 36 ++++++++++++++++ .../Bindings/FormBindingApplicator.php | 23 +++++----- .../FormBindingApplicatorIntegrationTest.php | 10 +++-- 8 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php create mode 100644 api/app/Exceptions/FormBuilder/FormBindingDataIntegrityException.php create mode 100644 api/app/Exceptions/FormBuilder/FormBindingInfraException.php create mode 100644 api/app/Exceptions/FormBuilder/FormBindingSchemaConfigException.php create mode 100644 api/app/Exceptions/FormBuilder/IdentityMatchInvariantViolation.php diff --git a/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php b/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php index 8cfd9f42..ebf1f3fb 100644 --- a/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php +++ b/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php @@ -5,19 +5,48 @@ declare(strict_types=1); namespace App\Exceptions\FormBuilder; use RuntimeException; +use Throwable; /** - * RFC-WS-6 §3 (Q3) — catastrophic applicator failure that bubbles to - * the caller. Per-binding failures are captured in BindingPassResult, - * not thrown. + * Base for all FormBindingApplicator-pipeline exceptions. + * + * Subclasses provide a `reasonCode()` that maps to: + * - failure_response_code on form_submissions (response-shape driver) + * - HTTP status code (422 / 503 / 500) + * - user-facing copy class (rendered by frontend) + * + * Per RFC-WS-6 §Q3 v1.3 addition 2. + * + * Concrete subclasses: + * - FormBindingSchemaConfigException — schema misconfiguration (422, schema_config_error) + * - FormBindingInfraException — infra issue, retryable (503, temporary_error) + * - FormBindingApplicatorTimeoutException — deadline-wrapper exceeded (extends Infra) + * - FormBindingDataIntegrityException — data shape violation (422, data_integrity_error) + * + * The classifier (FormBindingExceptionClassifier) maps unknown Throwables + * to 'unknown_error' — that is the fallback for anything not in this + * hierarchy. + * + * `submissionId` is preserved as a public readonly property so D2's + * outer-transaction handler can structurally read it when writing the + * `form_submission_action_failures.context` JSON, instead of regex-parsing + * the message string. */ -final class FormBindingApplicatorException extends RuntimeException +abstract class FormBindingApplicatorException extends RuntimeException { public function __construct( - public readonly string $reasonCode, public readonly string $submissionId, - ?string $message = null, + string $message, + ?Throwable $previous = null, ) { - parent::__construct($message ?? "FormBindingApplicator failed: {$reasonCode} (submission {$submissionId})"); + parent::__construct($message, 0, $previous); } + + /** + * Response-shape classification token. One of: + * - 'schema_config_error' + * - 'temporary_error' + * - 'data_integrity_error' + */ + abstract public function reasonCode(): string; } diff --git a/api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php b/api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php new file mode 100644 index 00000000..79847178 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php @@ -0,0 +1,26 @@ +id, - 'FormBindingApplicator must be invoked inside DB::transaction', + throw new FormBindingInfraException( + submissionId: (string) $submission->id, + message: 'FormBindingApplicator must be invoked inside DB::transaction', ); } /** @var \App\Models\FormBuilder\FormSchema|null $schema */ $schema = $submission->schema; if ($schema === null) { - throw new FormBindingApplicatorException( - 'no_schema', - (string) $submission->id, + throw new FormBindingSchemaConfigException( + submissionId: (string) $submission->id, + message: "schema null for submission {$submission->id}", ); } $purposeValue = $schema->purpose->value; if (! $this->purposeRegistry->has($purposeValue)) { - throw new FormBindingApplicatorException( - 'unknown_purpose', - (string) $submission->id, - "purpose '{$purposeValue}' not registered", + throw new FormBindingSchemaConfigException( + submissionId: (string) $submission->id, + message: "purpose '{$purposeValue}' not registered", ); } @@ -168,6 +168,7 @@ class FormBindingApplicator // Per-strategy matrix. RFC §3 Q7. if ($newValue === null) { $behaviour = $strategy->nullWinnerBehaviour(); + return match ($behaviour) { 'write' => null, 'noop' => self::NO_OP, diff --git a/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php index cb7aa846..3e70b83a 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php @@ -65,11 +65,15 @@ final class FormBindingApplicatorIntegrationTest extends TestCase // RefreshDatabase wraps every PHPUnit test in a transaction; the // guard is exercised via the listener path (ApplyBindingsOnFormSubmit) // which opens its own transaction explicitly. Verify the guard - // exists in the source. + // exists in the source by checking for the throw of + // FormBindingInfraException (per RFC-WS-6 §Q3 v1.3 addition 2 — the + // 'no_transaction' developer-error maps onto temporary_error so the + // GlitchTip alert + retry-after workflow fires). $reflection = new \ReflectionClass(FormBindingApplicator::class); $source = file_get_contents($reflection->getFileName()); - $this->assertStringContainsString('no_transaction', $source); + $this->assertStringContainsString('FormBindingInfraException', $source); $this->assertStringContainsString('DB::transactionLevel()', $source); + $this->assertStringContainsString('must be invoked inside DB::transaction', $source); } private function applicator(): FormBindingApplicator @@ -151,7 +155,7 @@ final class FormBindingApplicatorIntegrationTest extends TestCase private function writeValue(string $submissionId, string $fieldId, mixed $value): void { - $row = new FormValue(); + $row = new FormValue; $row->form_submission_id = $submissionId; $row->form_field_id = $fieldId; $row->setAttribute('value', $value);