feat(form-builder): exception hierarchy for binding-apply pipeline

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 01:58:11 +02:00
parent 832375b086
commit f94b3fb329
8 changed files with 200 additions and 21 deletions

View File

@@ -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;
}