WS-6 v1.3-delta D1 — Foundation delta (data layer + exception hierarchy) #10

Merged
bert.hausmans merged 9 commits from feat/ws-6-v1.3-delta-d1 into main 2026-05-08 02:32:35 +02:00

WS-6 v1.3-delta D1 — Foundation delta (data layer + exception hierarchy)

Implements the data-layer prerequisites and new building blocks for the WS-6
v1.3-delta. D2 (next session) wires these into the listener chain and the
retry service.

Spine unchanged. No listener-ordering changes, no retry-flow changes, no
ApplyBindingsOnFormSubmit::handle changes. This PR only adds new classes,
the migration, the helper, and the cast/factory state — plus migrates 3
existing throw sites in FormBindingApplicator to the new subclasses.

Refs


What this PR delivers

1. failure_response_code column on form_submissions (RFC §Q3 v1.3 add 2)

Denormalised mirror of the FormBindingApplicatorException subclass classification.
Drives response-shape copy when apply_status='failed'. NULL otherwise.
Indexed; string(40) nullable.

2. Exception hierarchy refactor (RFC §Q3 v1.3 add 2 + §Q2)

FormBindingApplicatorException converted from final concrete class to
abstract base with abstract reasonCode(): string. Constructor signature
(string $submissionId, string $message, ?\Throwable $previous = null)
$submissionId exposed as public readonly so D2's outer-transaction handler
can write it structurally to form_submission_action_failures.context JSON
without regex-parsing the message.

Five new classes:

Class Extends reasonCode()
FormBindingSchemaConfigException FormBindingApplicatorException schema_config_error
FormBindingInfraException FormBindingApplicatorException temporary_error
FormBindingApplicatorTimeoutException FormBindingInfraException inherits temporary_error
FormBindingDataIntegrityException FormBindingApplicatorException data_integrity_error
IdentityMatchInvariantViolation \DomainException (NOT in hierarchy)

Rationale for IdentityMatchInvariantViolation outside the hierarchy: it's
thrown by TriggerPersonIdentityMatchOnFormSubmit (D2), not by the
binding-applicator pipeline. Different listener, different context, different
failure-record path. The classifier (see below) treats it as unknown_error.

3. Throw-site migration in FormBindingApplicator (3 sites, all in one file)

Line Old reason code New subclass
49 'no_transaction' FormBindingInfraException
59 'no_schema' FormBindingSchemaConfigException
66 'unknown_purpose' FormBindingSchemaConfigException

Mapping rationale captured in commit f94b3fb. 'no_transaction' mapped to
Infra (not DataIntegrity) because it's a developer-error wanting infra-triage
workflow rather than schema-config triage.

4. FormBindingExceptionClassifier helper (RFC §Q3 v1.3 add 2)

Static classify(\Throwable): string. D2 consumes from both
ApplyBindingsOnFormSubmit::handle's catch block and
FormFailureRetryService::recordFailure. Centralised so listener and
retry-service produce identical classifications — single behaviour-change
point.

Resolution: FormBindingApplicatorException subclass dispatch via reasonCode();
fallback 'unknown_error' for anything outside the hierarchy.

5. FormFieldBindingMergeStrategy::validForTargetType (RFC §V1, ARCH §4.2)

Implements the strategy × target-type validity matrix. Append is the only
non-trivial case: valid only for COLLECTION targets. Provides the building
block for AppendStrategyRequiresCollectionTarget publish-guard call-site
verification (D2).

6. FormSubmissionIdentityMatchResolved broadcast event (RFC §Q1 v1.3 add 2)

Class only, not yet dispatched. D2 wires the dispatch from
TriggerPersonIdentityMatchOnFormSubmit::handle plus the channel-authorization
callback in routes/channels.php. Frontend Echo subscription is a separate
follow-up out of WS-6 scope.

7. FormSubmission cast + factory state

Plain string cast (no enum — the exception subclass on
form_submission_action_failures is the canonical classification source; this
column is a denormalised mirror). Fluent factory state
withFailureResponseCode(string).


Test counts

Count
Pre-D1 baseline (per project memory) 1551
D1 added (Phase H + integration update) +32
New total 1583
Failures 0
Larastan errors 0
Full-suite duration 104s

Test files added (6, 32 tests, 64 assertions):

  • tests/Feature/FormBuilder/Schema/Ws6V13DeltaD1MigrationTest.php
  • tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php
  • tests/Unit/FormBuilder/Bindings/FormBindingExceptionClassifierTest.php
  • tests/Unit/Enums/FormBuilder/FormFieldBindingMergeStrategyValidForTargetTypeTest.php
  • tests/Unit/Events/FormBuilder/FormSubmissionIdentityMatchResolvedTest.php
  • tests/Feature/FormBuilder/FormSubmissionFailureResponseCodeTest.php

Existing test updated: FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present
asserts against the new FormBindingInfraException shape (folded into commit f94b3fb).


Migration step-counts touched (collateral)

Five backfill-test files hardcode WS-5/WS-6 migration step counts. Each
needed +1 for the new D1 migration. Fixed in two follow-up commits
(832375b + 01c5ff2) so the count-bumps don't blur the feature commits.

  • FormFieldBindingMigrationTest
  • FormFieldOptionsBackfillTest
  • ConditionalLogicBackfillTest
  • FormFieldConfigBackfillAndDropTest
  • FormFieldValidationRuleBackfillTest

Out-of-scope (D2 will do)

Deliberately untouched in this PR:

  • ApplyBindingsOnFormSubmit::handle — catch-block classifier integration, deadline-wrapper, initial pending write
  • TriggerPersonIdentityMatchOnFormSubmit — queueing, gate, invariant throw, broadcast
  • All other queued listeners — gating-invariant first-statement
  • FormSubmissionSubmittedListenerOrderTest — flip assertions when TriggerPersonIdentityMatch becomes queued
  • EventRegistrationGuardProvider — drop ConditionalRequirement(public_token) wrapper
  • FormFailureRetryService::recordFailure — classifier consumption + apply_completed_at symmetry fix
  • FormSubmissionResource.identity_match=null contract test for non-person purposes
  • GlitchTip alert rule configuration (operational task)

Commits (chronological)

  1. e32de8a — feat(form-builder): add failure_response_code column to form_submissions
  2. 832375b — test(form-builder): bump migration step counts for WS-6 v1.3-delta D1 migration
  3. f94b3fb — feat(form-builder): exception hierarchy for binding-apply pipeline
  4. b6b63a7 — feat(form-builder): validForTargetType method on FormFieldBindingMergeStrategy
  5. b7bd790 — feat(form-builder): FormSubmissionIdentityMatchResolved broadcast event
  6. 1f66fef — feat(form-builder): FormBindingExceptionClassifier helper
  7. 96062b9 — feat(form-builder): FormSubmission cast + factory state for failure_response_code
  8. 01c5ff2 — test(form-builder): bump remaining backfill-test step counts for WS-6 v1.3-delta D1 migration
  9. c29ad75 — test(form-builder): WS-6 v1.3-delta D1 tests

Review hints

  • Phase A audit findings: 0 catch sites, 0 instanceof sites, 0 tests
    asserting class shape directly — refactor risk was confirmed LOW before
    starting code work.
  • Pre-D1 throw sites in FormBindingApplicator: previously created a
    concrete FormBindingApplicatorException with runtime reason-code string.
    All 3 sites migrated; no other consumers existed.
  • Integration test that does literal 'no_transaction' source-string check:
    updated in the same commit as the throw-site migration (f94b3fb) to
    assert against the new exception class.
  • failure_response_code cast as string, not enum: deliberate.
    The four valid values (schema_config_error, temporary_error,
    data_integrity_error, unknown_error) are documented in the migration
    comment block + the factory state docstring. Promoting to a backed enum
    would introduce coupling that the current denormalised-mirror design
    intentionally avoids.

🤖 Co-Authored-By: Claude Opus 4.7

## WS-6 v1.3-delta D1 — Foundation delta (data layer + exception hierarchy) Implements the data-layer prerequisites and new building blocks for the WS-6 v1.3-delta. D2 (next session) wires these into the listener chain and the retry service. **Spine unchanged.** No listener-ordering changes, no retry-flow changes, no `ApplyBindingsOnFormSubmit::handle` changes. This PR only adds new classes, the migration, the helper, and the cast/factory state — plus migrates 3 existing throw sites in `FormBindingApplicator` to the new subclasses. ### Refs - [`dev-docs/RFC-WS-6.md`](dev-docs/RFC-WS-6.md) v1.3.1 — §Q1 v1.3 additions, §Q2 invariant, §Q3 v1.3 additions - [`dev-docs/ARCH-BINDINGS.md`](dev-docs/ARCH-BINDINGS.md) v1.2 — §5.6 (gate + PARTIAL handling), §7.1 (status columns) --- ### What this PR delivers **1. `failure_response_code` column on `form_submissions`** (RFC §Q3 v1.3 add 2) Denormalised mirror of the `FormBindingApplicatorException` subclass classification. Drives response-shape copy when `apply_status='failed'`. NULL otherwise. Indexed; `string(40) nullable`. **2. Exception hierarchy refactor** (RFC §Q3 v1.3 add 2 + §Q2) `FormBindingApplicatorException` converted from `final` concrete class to `abstract` base with `abstract reasonCode(): string`. Constructor signature `(string $submissionId, string $message, ?\Throwable $previous = null)` — `$submissionId` exposed as `public readonly` so D2's outer-transaction handler can write it structurally to `form_submission_action_failures.context` JSON without regex-parsing the message. Five new classes: | Class | Extends | `reasonCode()` | |---|---|---| | `FormBindingSchemaConfigException` | `FormBindingApplicatorException` | `schema_config_error` | | `FormBindingInfraException` | `FormBindingApplicatorException` | `temporary_error` | | `FormBindingApplicatorTimeoutException` | `FormBindingInfraException` | inherits `temporary_error` | | `FormBindingDataIntegrityException` | `FormBindingApplicatorException` | `data_integrity_error` | | `IdentityMatchInvariantViolation` | `\DomainException` (NOT in hierarchy) | — | Rationale for `IdentityMatchInvariantViolation` outside the hierarchy: it's thrown by `TriggerPersonIdentityMatchOnFormSubmit` (D2), not by the binding-applicator pipeline. Different listener, different context, different failure-record path. The classifier (see below) treats it as `unknown_error`. **3. Throw-site migration in `FormBindingApplicator`** (3 sites, all in one file) | Line | Old reason code | New subclass | |---|---|---| | 49 | `'no_transaction'` | `FormBindingInfraException` | | 59 | `'no_schema'` | `FormBindingSchemaConfigException` | | 66 | `'unknown_purpose'` | `FormBindingSchemaConfigException` | Mapping rationale captured in commit `f94b3fb`. `'no_transaction'` mapped to Infra (not DataIntegrity) because it's a developer-error wanting infra-triage workflow rather than schema-config triage. **4. `FormBindingExceptionClassifier` helper** (RFC §Q3 v1.3 add 2) Static `classify(\Throwable): string`. D2 consumes from both `ApplyBindingsOnFormSubmit::handle`'s catch block and `FormFailureRetryService::recordFailure`. Centralised so listener and retry-service produce identical classifications — single behaviour-change point. Resolution: `FormBindingApplicatorException` subclass dispatch via `reasonCode()`; fallback `'unknown_error'` for anything outside the hierarchy. **5. `FormFieldBindingMergeStrategy::validForTargetType`** (RFC §V1, ARCH §4.2) Implements the strategy × target-type validity matrix. `Append` is the only non-trivial case: valid only for `COLLECTION` targets. Provides the building block for `AppendStrategyRequiresCollectionTarget` publish-guard call-site verification (D2). **6. `FormSubmissionIdentityMatchResolved` broadcast event** (RFC §Q1 v1.3 add 2) Class only, not yet dispatched. D2 wires the dispatch from `TriggerPersonIdentityMatchOnFormSubmit::handle` plus the channel-authorization callback in `routes/channels.php`. Frontend Echo subscription is a separate follow-up out of WS-6 scope. **7. `FormSubmission` cast + factory state** Plain string cast (no enum — the exception subclass on `form_submission_action_failures` is the canonical classification source; this column is a denormalised mirror). Fluent factory state `withFailureResponseCode(string)`. --- ### Test counts | | Count | |---|---| | Pre-D1 baseline (per project memory) | 1551 | | D1 added (Phase H + integration update) | +32 | | **New total** | **1583** | | Failures | 0 | | Larastan errors | 0 | | Full-suite duration | 104s | Test files added (6, 32 tests, 64 assertions): - `tests/Feature/FormBuilder/Schema/Ws6V13DeltaD1MigrationTest.php` - `tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php` - `tests/Unit/FormBuilder/Bindings/FormBindingExceptionClassifierTest.php` - `tests/Unit/Enums/FormBuilder/FormFieldBindingMergeStrategyValidForTargetTypeTest.php` - `tests/Unit/Events/FormBuilder/FormSubmissionIdentityMatchResolvedTest.php` - `tests/Feature/FormBuilder/FormSubmissionFailureResponseCodeTest.php` Existing test updated: `FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present` asserts against the new `FormBindingInfraException` shape (folded into commit `f94b3fb`). --- ### Migration step-counts touched (collateral) Five backfill-test files hardcode WS-5/WS-6 migration step counts. Each needed `+1` for the new D1 migration. Fixed in two follow-up commits (`832375b` + `01c5ff2`) so the count-bumps don't blur the feature commits. - `FormFieldBindingMigrationTest` - `FormFieldOptionsBackfillTest` - `ConditionalLogicBackfillTest` - `FormFieldConfigBackfillAndDropTest` - `FormFieldValidationRuleBackfillTest` --- ### Out-of-scope (D2 will do) Deliberately untouched in this PR: - `ApplyBindingsOnFormSubmit::handle` — catch-block classifier integration, deadline-wrapper, initial `pending` write - `TriggerPersonIdentityMatchOnFormSubmit` — queueing, gate, invariant throw, broadcast - All other queued listeners — gating-invariant first-statement - `FormSubmissionSubmittedListenerOrderTest` — flip assertions when TriggerPersonIdentityMatch becomes queued - `EventRegistrationGuardProvider` — drop `ConditionalRequirement(public_token)` wrapper - `FormFailureRetryService::recordFailure` — classifier consumption + `apply_completed_at` symmetry fix - `FormSubmissionResource.identity_match=null` contract test for non-person purposes - GlitchTip alert rule configuration (operational task) --- ### Commits (chronological) 1. `e32de8a` — feat(form-builder): add failure_response_code column to form_submissions 2. `832375b` — test(form-builder): bump migration step counts for WS-6 v1.3-delta D1 migration 3. `f94b3fb` — feat(form-builder): exception hierarchy for binding-apply pipeline 4. `b6b63a7` — feat(form-builder): validForTargetType method on FormFieldBindingMergeStrategy 5. `b7bd790` — feat(form-builder): FormSubmissionIdentityMatchResolved broadcast event 6. `1f66fef` — feat(form-builder): FormBindingExceptionClassifier helper 7. `96062b9` — feat(form-builder): FormSubmission cast + factory state for failure_response_code 8. `01c5ff2` — test(form-builder): bump remaining backfill-test step counts for WS-6 v1.3-delta D1 migration 9. `c29ad75` — test(form-builder): WS-6 v1.3-delta D1 tests --- ### Review hints - **Phase A audit findings**: 0 catch sites, 0 instanceof sites, 0 tests asserting class shape directly — refactor risk was confirmed LOW before starting code work. - **Pre-D1 throw sites in `FormBindingApplicator`**: previously created a concrete `FormBindingApplicatorException` with runtime reason-code string. All 3 sites migrated; no other consumers existed. - **Integration test that does literal `'no_transaction'` source-string check**: updated in the same commit as the throw-site migration (`f94b3fb`) to assert against the new exception class. - **`failure_response_code` cast as `string`, not enum**: deliberate. The four valid values (`schema_config_error`, `temporary_error`, `data_integrity_error`, `unknown_error`) are documented in the migration comment block + the factory state docstring. Promoting to a backed enum would introduce coupling that the current denormalised-mirror design intentionally avoids. 🤖 Co-Authored-By: Claude Opus 4.7
bert.hausmans added 9 commits 2026-05-08 02:32:15 +02:00
Per RFC-WS-6 §Q3 v1.3 addition 2 + ARCH-BINDINGS §7.1 v1.2.

Denormalised mirror of the FormBindingApplicatorException subclass
classification, written by ApplyBindingsOnFormSubmit's outer-transaction
catch block (D2) when apply_status='failed'. Drives response-shape copy.
NULL when apply_status is not 'failed'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The forward + rollback migration tests pin --step to a fixed count to
walk the WS-5/WS-6 stack back to known pre-states. The new
2026_05_08_000001_add_failure_response_code_to_form_submissions
migration sits at the top of that stack, so both rollback step counts
need +1 to reach the same destinations.

- pre-WS-5a rollback: --step 21 -> 22 (used twice)
- pre-WS-5b rollback (from fully-forward): --step 19 -> 20 (used once)

Comments updated to enumerate the v1.3-delta D1 migration in the WS-6
group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Per RFC-WS-6 §V1 + ARCH-BINDINGS §4.2.

Implements the strategy x target-type validity matrix. Append is the
only non-trivial case: valid only for COLLECTION targets. The
AppendStrategyRequiresCollectionTarget publish-guard uses this method
(D2 wiring confirms call sites; this commit provides the building block).

Existing methods (nullWinnerBehaviour, isValidForScalarTargets) untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per RFC-WS-6 §Q1 v1.3 addition 2.

Broadcast event class only — not yet dispatched. D2 wires the dispatch
call into TriggerPersonIdentityMatchOnFormSubmit::handle (after the
final identity_match_status write), and the channel-authorization
callback into routes/channels.php.

Frontend Echo subscription is a separate frontend follow-up (out of
WS-6 v1.3-delta scope).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per RFC-WS-6 §Q3 v1.3 addition 2.

Centralises the Throwable -> failure_response_code mapping so the
listener (ApplyBindingsOnFormSubmit::handle catch block) and the
retry-service (FormFailureRetryService::recordFailure) produce
identical classifications. Single behaviour-change point.

Resolution order: FormBindingApplicatorException subclass dispatch via
reasonCode(); fallback 'unknown_error' for anything outside the hierarchy.

Wiring into the listener and the retry service lands in D2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per RFC-WS-6 §Q3 v1.3 addition 2.

- Added 'failure_response_code' to FormSubmission $fillable + 'string' cast.
  Plain string (not enum) — the exception subclass on
  form_submission_action_failures is the canonical classification source;
  this column is a denormalised mirror for response-shape rendering.
- Factory fluent state method withFailureResponseCode() with documentation
  of the four valid values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same root cause as 832375b — the new failure_response_code migration
sits at the top of the WS-5/WS-6 stack, so every test that pins --step
to walk back through that stack needs +1.

- FormFieldOptionsBackfillTest:     6 -> 7  (10 occurrences)
- ConditionalLogicBackfillTest:    10 -> 11 (4 occurrences)
- FormFieldConfigBackfillAndDropTest: 16 -> 17 (1 occurrence)
- FormFieldValidationRuleBackfillTest: 19 -> 20 (7 occurrences)

Total: 22 backfill tests now green again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
32 new tests covering D1 deliverables:

- Migration shape (3): failure_response_code column presence,
  type/length/nullability, index name. MySQL information_schema
  introspection.
- Exception hierarchy (11): abstract base, RuntimeException ancestor,
  per-subclass constructor + reasonCode (named-args asserting
  submissionId is preserved structurally), Timeout extends Infra and
  inherits temporary_error, all subclasses extend base, previous-throwable
  chaining works, IdentityMatchInvariantViolation is NOT in the
  binding-applicator hierarchy and IS a DomainException.
- FormBindingExceptionClassifier matrix (6): each subclass maps to its
  reason code; Timeout dispatches to inherited 'temporary_error';
  arbitrary RuntimeException -> 'unknown_error'; IdentityMatchInvariantViolation
  -> 'unknown_error' (intentional fallback per docstring).
- FormFieldBindingMergeStrategy::validForTargetType (4 tests covering
  the full 4 strategies x 3 target types matrix).
- FormSubmissionIdentityMatchResolved (4): ShouldBroadcast contract,
  private channel naming ('private-submission.{id}'), broadcast-as
  string, payload assignment.
- FormSubmission failure_response_code cast (4): persists as plain
  string, NULL by default, factory state composes with apply_status,
  round-trips for all four canonical codes.

Baseline regenerated to absorb new tautological-assertion entries (48
lines) — these are class-hierarchy regression guards that Larastan
correctly flags as statically known. The pattern is established in the
codebase per existing baseline entries for similar tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bert.hausmans merged commit c6f4d1b5c6 into main 2026-05-08 02:32:35 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bert.hausmans/crewli#10