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:
@@ -5,19 +5,48 @@ declare(strict_types=1);
|
|||||||
namespace App\Exceptions\FormBuilder;
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-WS-6 §3 (Q3) — catastrophic applicator failure that bubbles to
|
* Base for all FormBindingApplicator-pipeline exceptions.
|
||||||
* the caller. Per-binding failures are captured in BindingPassResult,
|
*
|
||||||
* not thrown.
|
* 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 function __construct(
|
||||||
public readonly string $reasonCode,
|
|
||||||
public readonly string $submissionId,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by the deadline-wrapper around FormBindingApplicator when the
|
||||||
|
* inner transaction takes longer than config('form_builder.apply_deadline_seconds')
|
||||||
|
* (default 5).
|
||||||
|
*
|
||||||
|
* Extends FormBindingInfraException because from the user's perspective a
|
||||||
|
* timeout is identical to any other temporary infra issue: retry should
|
||||||
|
* help. The response-shape classification 'temporary_error' is inherited.
|
||||||
|
*
|
||||||
|
* Per RFC-WS-6 §Q1 v1.3 addition 4.
|
||||||
|
*
|
||||||
|
* The deadline-wrapper itself lands in D2 (modification to
|
||||||
|
* ApplyBindingsOnFormSubmit's apply call site). This class exists in D1
|
||||||
|
* so D2's wiring is straightforward.
|
||||||
|
*/
|
||||||
|
final class FormBindingApplicatorTimeoutException extends FormBindingInfraException
|
||||||
|
{
|
||||||
|
// No reasonCode override — inherits 'temporary_error' from FormBindingInfraException.
|
||||||
|
// No constructor override — inherits (submissionId, message, previous) from the base.
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data shape violation during binding apply.
|
||||||
|
*
|
||||||
|
* Examples: type mismatch between form_value and target attribute;
|
||||||
|
* foreign-key violation when the target references a soft-deleted entity;
|
||||||
|
* attempt to write to a column that has a unique constraint already
|
||||||
|
* violated by another row outside this submission.
|
||||||
|
*
|
||||||
|
* User-perceptible the same as schema_config (organiser fix needed); admin
|
||||||
|
* sees the difference via the exception class on the action-failures row.
|
||||||
|
*
|
||||||
|
* Maps to HTTP 422, failure_response_code='data_integrity_error'.
|
||||||
|
*
|
||||||
|
* Per RFC-WS-6 §Q3 v1.3 addition 2.
|
||||||
|
*/
|
||||||
|
final class FormBindingDataIntegrityException extends FormBindingApplicatorException
|
||||||
|
{
|
||||||
|
public function reasonCode(): string
|
||||||
|
{
|
||||||
|
return 'data_integrity_error';
|
||||||
|
}
|
||||||
|
}
|
||||||
28
api/app/Exceptions/FormBuilder/FormBindingInfraException.php
Normal file
28
api/app/Exceptions/FormBuilder/FormBindingInfraException.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure issue during binding apply.
|
||||||
|
*
|
||||||
|
* Examples: database connection lost, lock-for-update wait exceeded,
|
||||||
|
* race condition surfaced as a constraint violation that retry would
|
||||||
|
* resolve, applicator invoked outside a DB transaction (developer-error
|
||||||
|
* surfacing as infra-triage workflow).
|
||||||
|
*
|
||||||
|
* Maps to HTTP 503, failure_response_code='temporary_error'.
|
||||||
|
* User-facing copy: "Temporary issue, please try again." with retry-after.
|
||||||
|
*
|
||||||
|
* NOT final — FormBindingApplicatorTimeoutException extends this class.
|
||||||
|
*
|
||||||
|
* Per RFC-WS-6 §Q3 v1.3 addition 2.
|
||||||
|
*/
|
||||||
|
class FormBindingInfraException extends FormBindingApplicatorException
|
||||||
|
{
|
||||||
|
public function reasonCode(): string
|
||||||
|
{
|
||||||
|
return 'temporary_error';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema misconfiguration that publish-guards missed.
|
||||||
|
*
|
||||||
|
* Examples: column renamed without schema invalidation; binding target
|
||||||
|
* attribute that no longer exists on the entity; merge_strategy that's
|
||||||
|
* structurally invalid for the target type but passed publish for some
|
||||||
|
* reason; unregistered purpose value; null schema relation.
|
||||||
|
*
|
||||||
|
* Maps to HTTP 422, failure_response_code='schema_config_error'.
|
||||||
|
* User-facing copy: "This form has a configuration issue. Please contact
|
||||||
|
* the organiser. Reference: F-{ulid}"
|
||||||
|
*
|
||||||
|
* Per RFC-WS-6 §Q3 v1.3 addition 2.
|
||||||
|
*/
|
||||||
|
final class FormBindingSchemaConfigException extends FormBindingApplicatorException
|
||||||
|
{
|
||||||
|
public function reasonCode(): string
|
||||||
|
{
|
||||||
|
return 'schema_config_error';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by TriggerPersonIdentityMatchOnFormSubmit when the post-ApplyBindings
|
||||||
|
* invariant breaks.
|
||||||
|
*
|
||||||
|
* Per RFC-WS-6 §Q2 + ARCH-BINDINGS §7.3:
|
||||||
|
* Post ApplyBindingsOnFormSubmit::handle for event_registration purpose:
|
||||||
|
* subject_type='person' AND subject_id IS NOT NULL,
|
||||||
|
* OR apply_status=ApplyStatus::FAILED.
|
||||||
|
* No third state exists. Violation is a structural defect.
|
||||||
|
*
|
||||||
|
* This is NOT a FormBindingApplicatorException — the listener that throws
|
||||||
|
* this does not run inside the FormBindingApplicator pipeline. It indicates
|
||||||
|
* the pipeline succeeded according to apply_status but produced incoherent
|
||||||
|
* subject state, which means a publish-guard gap or a race with manual
|
||||||
|
* data manipulation.
|
||||||
|
*
|
||||||
|
* Routed via Laravel queue worker → GlitchTip + form_submission_action_failures
|
||||||
|
* row (the catch + outer-transaction handler in TriggerPersonIdentityMatchOnFormSubmit
|
||||||
|
* mirrors ApplyBindingsOnFormSubmit's pattern). Wiring lands in D2.
|
||||||
|
*/
|
||||||
|
final class IdentityMatchInvariantViolation extends DomainException
|
||||||
|
{
|
||||||
|
// Plain DomainException subclass. No reasonCode — when this fires,
|
||||||
|
// 'unknown_error' (the classifier's default fallback) is the right
|
||||||
|
// response-shape because users cannot meaningfully act on it; admins
|
||||||
|
// triage via GlitchTip. D2's invariant-throw path writes its own
|
||||||
|
// context JSON to form_submission_action_failures.
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ 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\FormBindingInfraException;
|
||||||
|
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
|
||||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||||
use App\Models\FormBuilder\FormSubmission;
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -46,27 +48,25 @@ class FormBindingApplicator
|
|||||||
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
|
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
|
||||||
{
|
{
|
||||||
if (DB::transactionLevel() < 1) {
|
if (DB::transactionLevel() < 1) {
|
||||||
throw new FormBindingApplicatorException(
|
throw new FormBindingInfraException(
|
||||||
'no_transaction',
|
submissionId: (string) $submission->id,
|
||||||
(string) $submission->id,
|
message: 'FormBindingApplicator must be invoked inside DB::transaction',
|
||||||
'FormBindingApplicator must be invoked inside DB::transaction',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var \App\Models\FormBuilder\FormSchema|null $schema */
|
/** @var \App\Models\FormBuilder\FormSchema|null $schema */
|
||||||
$schema = $submission->schema;
|
$schema = $submission->schema;
|
||||||
if ($schema === null) {
|
if ($schema === null) {
|
||||||
throw new FormBindingApplicatorException(
|
throw new FormBindingSchemaConfigException(
|
||||||
'no_schema',
|
submissionId: (string) $submission->id,
|
||||||
(string) $submission->id,
|
message: "schema null for submission {$submission->id}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$purposeValue = $schema->purpose->value;
|
$purposeValue = $schema->purpose->value;
|
||||||
if (! $this->purposeRegistry->has($purposeValue)) {
|
if (! $this->purposeRegistry->has($purposeValue)) {
|
||||||
throw new FormBindingApplicatorException(
|
throw new FormBindingSchemaConfigException(
|
||||||
'unknown_purpose',
|
submissionId: (string) $submission->id,
|
||||||
(string) $submission->id,
|
message: "purpose '{$purposeValue}' not registered",
|
||||||
"purpose '{$purposeValue}' not registered",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ class FormBindingApplicator
|
|||||||
// Per-strategy matrix. RFC §3 Q7.
|
// Per-strategy matrix. RFC §3 Q7.
|
||||||
if ($newValue === null) {
|
if ($newValue === null) {
|
||||||
$behaviour = $strategy->nullWinnerBehaviour();
|
$behaviour = $strategy->nullWinnerBehaviour();
|
||||||
|
|
||||||
return match ($behaviour) {
|
return match ($behaviour) {
|
||||||
'write' => null,
|
'write' => null,
|
||||||
'noop' => self::NO_OP,
|
'noop' => self::NO_OP,
|
||||||
|
|||||||
@@ -65,11 +65,15 @@ final class FormBindingApplicatorIntegrationTest extends TestCase
|
|||||||
// RefreshDatabase wraps every PHPUnit test in a transaction; the
|
// RefreshDatabase wraps every PHPUnit test in a transaction; the
|
||||||
// guard is exercised via the listener path (ApplyBindingsOnFormSubmit)
|
// guard is exercised via the listener path (ApplyBindingsOnFormSubmit)
|
||||||
// which opens its own transaction explicitly. Verify the guard
|
// 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);
|
$reflection = new \ReflectionClass(FormBindingApplicator::class);
|
||||||
$source = file_get_contents($reflection->getFileName());
|
$source = file_get_contents($reflection->getFileName());
|
||||||
$this->assertStringContainsString('no_transaction', $source);
|
$this->assertStringContainsString('FormBindingInfraException', $source);
|
||||||
$this->assertStringContainsString('DB::transactionLevel()', $source);
|
$this->assertStringContainsString('DB::transactionLevel()', $source);
|
||||||
|
$this->assertStringContainsString('must be invoked inside DB::transaction', $source);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applicator(): FormBindingApplicator
|
private function applicator(): FormBindingApplicator
|
||||||
@@ -151,7 +155,7 @@ final class FormBindingApplicatorIntegrationTest extends TestCase
|
|||||||
|
|
||||||
private function writeValue(string $submissionId, string $fieldId, mixed $value): void
|
private function writeValue(string $submissionId, string $fieldId, mixed $value): void
|
||||||
{
|
{
|
||||||
$row = new FormValue();
|
$row = new FormValue;
|
||||||
$row->form_submission_id = $submissionId;
|
$row->form_submission_id = $submissionId;
|
||||||
$row->form_field_id = $fieldId;
|
$row->form_field_id = $fieldId;
|
||||||
$row->setAttribute('value', $value);
|
$row->setAttribute('value', $value);
|
||||||
|
|||||||
Reference in New Issue
Block a user