WS-6 v1.3-delta D1 — Foundation delta (data layer + exception hierarchy) #10
Reference in New Issue
Block a user
Delete Branch "feat/ws-6-v1.3-delta-d1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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::handlechanges. This PR only adds new classes,the migration, the helper, and the cast/factory state — plus migrates 3
existing throw sites in
FormBindingApplicatorto the new subclasses.Refs
dev-docs/RFC-WS-6.mdv1.3.1 — §Q1 v1.3 additions, §Q2 invariant, §Q3 v1.3 additionsdev-docs/ARCH-BINDINGS.mdv1.2 — §5.6 (gate + PARTIAL handling), §7.1 (status columns)What this PR delivers
1.
failure_response_codecolumn onform_submissions(RFC §Q3 v1.3 add 2)Denormalised mirror of the
FormBindingApplicatorExceptionsubclass 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)
FormBindingApplicatorExceptionconverted fromfinalconcrete class toabstractbase withabstract reasonCode(): string. Constructor signature(string $submissionId, string $message, ?\Throwable $previous = null)—$submissionIdexposed aspublic readonlyso D2's outer-transaction handlercan write it structurally to
form_submission_action_failures.contextJSONwithout regex-parsing the message.
Five new classes:
reasonCode()FormBindingSchemaConfigExceptionFormBindingApplicatorExceptionschema_config_errorFormBindingInfraExceptionFormBindingApplicatorExceptiontemporary_errorFormBindingApplicatorTimeoutExceptionFormBindingInfraExceptiontemporary_errorFormBindingDataIntegrityExceptionFormBindingApplicatorExceptiondata_integrity_errorIdentityMatchInvariantViolation\DomainException(NOT in hierarchy)Rationale for
IdentityMatchInvariantViolationoutside the hierarchy: it'sthrown by
TriggerPersonIdentityMatchOnFormSubmit(D2), not by thebinding-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)'no_transaction'FormBindingInfraException'no_schema'FormBindingSchemaConfigException'unknown_purpose'FormBindingSchemaConfigExceptionMapping rationale captured in commit
f94b3fb.'no_transaction'mapped toInfra (not DataIntegrity) because it's a developer-error wanting infra-triage
workflow rather than schema-config triage.
4.
FormBindingExceptionClassifierhelper (RFC §Q3 v1.3 add 2)Static
classify(\Throwable): string. D2 consumes from bothApplyBindingsOnFormSubmit::handle's catch block andFormFailureRetryService::recordFailure. Centralised so listener andretry-service produce identical classifications — single behaviour-change
point.
Resolution:
FormBindingApplicatorExceptionsubclass dispatch viareasonCode();fallback
'unknown_error'for anything outside the hierarchy.5.
FormFieldBindingMergeStrategy::validForTargetType(RFC §V1, ARCH §4.2)Implements the strategy × target-type validity matrix.
Appendis the onlynon-trivial case: valid only for
COLLECTIONtargets. Provides the buildingblock for
AppendStrategyRequiresCollectionTargetpublish-guard call-siteverification (D2).
6.
FormSubmissionIdentityMatchResolvedbroadcast event (RFC §Q1 v1.3 add 2)Class only, not yet dispatched. D2 wires the dispatch from
TriggerPersonIdentityMatchOnFormSubmit::handleplus the channel-authorizationcallback in
routes/channels.php. Frontend Echo subscription is a separatefollow-up out of WS-6 scope.
7.
FormSubmissioncast + factory statePlain string cast (no enum — the exception subclass on
form_submission_action_failuresis the canonical classification source; thiscolumn is a denormalised mirror). Fluent factory state
withFailureResponseCode(string).Test counts
Test files added (6, 32 tests, 64 assertions):
tests/Feature/FormBuilder/Schema/Ws6V13DeltaD1MigrationTest.phptests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.phptests/Unit/FormBuilder/Bindings/FormBindingExceptionClassifierTest.phptests/Unit/Enums/FormBuilder/FormFieldBindingMergeStrategyValidForTargetTypeTest.phptests/Unit/Events/FormBuilder/FormSubmissionIdentityMatchResolvedTest.phptests/Feature/FormBuilder/FormSubmissionFailureResponseCodeTest.phpExisting test updated:
FormBindingApplicatorIntegrationTest::test_no_transaction_guard_presentasserts against the new
FormBindingInfraExceptionshape (folded into commitf94b3fb).Migration step-counts touched (collateral)
Five backfill-test files hardcode WS-5/WS-6 migration step counts. Each
needed
+1for the new D1 migration. Fixed in two follow-up commits(
832375b+01c5ff2) so the count-bumps don't blur the feature commits.FormFieldBindingMigrationTestFormFieldOptionsBackfillTestConditionalLogicBackfillTestFormFieldConfigBackfillAndDropTestFormFieldValidationRuleBackfillTestOut-of-scope (D2 will do)
Deliberately untouched in this PR:
ApplyBindingsOnFormSubmit::handle— catch-block classifier integration, deadline-wrapper, initialpendingwriteTriggerPersonIdentityMatchOnFormSubmit— queueing, gate, invariant throw, broadcastFormSubmissionSubmittedListenerOrderTest— flip assertions when TriggerPersonIdentityMatch becomes queuedEventRegistrationGuardProvider— dropConditionalRequirement(public_token)wrapperFormFailureRetryService::recordFailure— classifier consumption +apply_completed_atsymmetry fixFormSubmissionResource.identity_match=nullcontract test for non-person purposesCommits (chronological)
e32de8a— feat(form-builder): add failure_response_code column to form_submissions832375b— test(form-builder): bump migration step counts for WS-6 v1.3-delta D1 migrationf94b3fb— feat(form-builder): exception hierarchy for binding-apply pipelineb6b63a7— feat(form-builder): validForTargetType method on FormFieldBindingMergeStrategyb7bd790— feat(form-builder): FormSubmissionIdentityMatchResolved broadcast event1f66fef— feat(form-builder): FormBindingExceptionClassifier helper96062b9— feat(form-builder): FormSubmission cast + factory state for failure_response_code01c5ff2— test(form-builder): bump remaining backfill-test step counts for WS-6 v1.3-delta D1 migrationc29ad75— test(form-builder): WS-6 v1.3-delta D1 testsReview hints
asserting class shape directly — refactor risk was confirmed LOW before
starting code work.
FormBindingApplicator: previously created aconcrete
FormBindingApplicatorExceptionwith runtime reason-code string.All 3 sites migrated; no other consumers existed.
'no_transaction'source-string check:updated in the same commit as the throw-site migration (
f94b3fb) toassert against the new exception class.
failure_response_codecast asstring, not enum: deliberate.The four valid values (
schema_config_error,temporary_error,data_integrity_error,unknown_error) are documented in the migrationcomment 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
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>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>