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

@@ -7,6 +7,8 @@ namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
use App\Exceptions\FormBuilder\FormBindingInfraException;
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Database\Eloquent\Model;
@@ -46,27 +48,25 @@ class FormBindingApplicator
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
if (DB::transactionLevel() < 1) {
throw new FormBindingApplicatorException(
'no_transaction',
(string) $submission->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,