WS-6 v1.3-delta D2 — Listener refactor + integration #11

Merged
bert.hausmans merged 8 commits from feat/ws-6-v1.3-delta-d2 into main 2026-05-08 08:25:52 +02:00

WS-6 v1.3-delta D2 — Listener refactor + integration

Wires the D1 building blocks (exception hierarchy, classifier helper, broadcast event class, deadline-wrapper precursor, failure_response_code column) into the listener chain. With this PR merged, the WS-6 v1.3 amendment is fully implemented in code.

No spine changes. Two-transaction pattern preserved, pre-publish guards untouched (RequiresIdentityKeyBinding was already unconditional pre-D2 — see audit note below), BindingPassResult::applyStatus() PARTIAL semantics untouched, retry-flow contract preserved.

Refs

D1 prerequisite

Builds on D1 (PR #10, merge-commit c6f4d1b). All D1 building blocks are consumed in this PR — confirms D1's design intent.


What this PR delivers

1. ApplyBindingsOnFormSubmit::handle — deadline wrapper + classifier integration (RFC §Q1 v1.3 add 1, 4 + §Q3 v1.3 add 2)

  • Inner transaction now writes identity_match_status='pending' so the HTTP response carries correct first-paint state for the IdentityMatchBanner. The final state lands asynchronously via the queued TriggerPersonIdentityMatch.
  • apply() wrapped in withDeadline(config('form_builder.apply_deadline_seconds', 5)). On exceeded deadline, FormBindingApplicatorTimeoutException is thrown, caught by the outer transaction handler, and recorded with apply_status=FAILED + failure_response_code='temporary_error'.
  • Outer-transaction catch block uses FormBindingExceptionClassifier::classify to write failure_response_code alongside apply_status=FAILED.

2. TriggerPersonIdentityMatchOnFormSubmit — queued + invariant throw + broadcast (RFC §Q1 v1.3 + §Q2 + §Q1 v1.3 add 2)

  • Now implements ShouldQueue. Sync chain reduced to one listener (ApplyBindingsOnFormSubmit).
  • Gating-invariant first statement: apply_status === COMPLETED only. PARTIAL and FAILED both fall through to early-return per ARCH-BINDINGS §5.6.
  • Failsafe-pad removed in favour of strict invariant: subject_type='person' AND apply_status=COMPLETED implies subject_id IS NOT NULL. Violation throws IdentityMatchInvariantViolation (introduced in D1) — routed via Laravel queue worker → GlitchTip + form_submission_action_failures row.
  • Dispatches FormSubmissionIdentityMatchResolved (D1 broadcast event class) on submission.{id} private channel after writing the final identity_match_status. Frontend Echo subscription is a separate frontend follow-up out of WS-6 scope.

PersonIdentityService::detectMatches returns Collection<PersonIdentityMatch>, not (status, matchCount). The listener preserves the existing string-status semantics ('matched' / 'pending' / 'none') and computes matchCount = $matches->count() for the broadcast payload only — no identity_match_count column write (column doesn't exist; was an error in the original prompt, dropped per audit clarification).

3. Broadcasting infrastructure (NEW) (RFC §Q1 v1.3 add 2)

Broadcasting was not previously wired in this codebase:

  • api/routes/channels.php created with submission.{submissionId} private-channel authorization callback
  • api/bootstrap/app.php updated: withRouting(channels: __DIR__.'/../routes/channels.php') registration

Channel auth is submitter-only for now. Org-admin access deferred to BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN (added in this PR) — the Spatie Permission helper convention for organisation-scoped roles needs verification before extending the callback. Inline TODO in routes/channels.php cross-references the BACKLOG entry; a contract test asserts org-admin currently denied so a future flip is automatically visible.

4. Queued-listener gating (ARCH-BINDINGS §5.6)

SyncTagPickerSelectionsOnSubmit now has the apply_status === COMPLETED gate as its first statement. Logs at info level when skipped (form-builder.queued-listener.skipped_apply_failed).

ApplyBindingsOnFormSectionSubmitted is intentionally not gated — it listens to FormSubmissionSectionSubmitted, a different event, and §5.6 is specifically scoped to FormSubmissionSubmitted post-apply state.

5. FormBindingApplicator::withDeadline() (RFC §Q1 v1.3 add 4)

Fluent method returning a clone configured with a soft post-call microtime deadline. Implementation note: cannot interrupt mid-query — for that, configure MySQL connection timeouts at the driver level. The soft deadline is sufficient to prevent runaway apply() calls from hanging the public flow indefinitely; typical apply takes <100ms, so a 5s default catches the long tail.

6. FormFailureRetryService::recordFailure symmetry fix (ARCH-BINDINGS §7.1 v1.2 + RFC §Q3 v1.3 add 2)

  • Now writes failure_response_code via FormBindingExceptionClassifier::classify — same classifier the listener uses, single behaviour-change point per the v1.3-delta D1 design.
  • Now writes apply_completed_at = now() — closes the asymmetry where the listener wrote this column on both happy and failure paths but the retry service only wrote it on the success path.

7. config/form_builder.phpapply_deadline_seconds

New key, default 5, env-overridable via FORM_BUILDER_APPLY_DEADLINE_SECONDS. Documented in the file's standard comment-block style.

8. AppServiceProvider::boot listener-layout comment

Updated to v1.3 layout: 1 sync (ApplyBindings) + N queued, all gated on apply_status=COMPLETED. Reference to FormSubmissionSubmittedListenerOrderTest added as the structural verifier.


Test counts

Count
Pre-D2 baseline (D1 merged) 1583
D2 added +37 across 4 new files + extensions
D2 modified ~10 across 6 existing files
New total 1621
Failures 0
Larastan errors 0
Full-suite duration ~95s

New test files (4):

  • tests/Feature/FormBuilder/Listeners/SyncTagPickerSelectionsOnSubmitGateTest.php
  • tests/Feature/FormBuilder/Bindings/FormBindingApplicatorDeadlineTest.php
  • tests/Feature/FormBuilder/Bindings/RetryServiceFailureClassifierTest.php
  • tests/Feature/FormBuilder/Channels/SubmissionChannelAuthTest.php

Modified existing test files (6):

TriggerPersonIdentityMatchOnFormSubmitTest (4 of 7 tests adjusted):

  • test_public_event_registration_submission_is_marked_pending — replaced with two new tests (gate-skip behaviour, invariant-violation throw)
  • test_linked_person_is_marked_matched, test_unlinked_person_with_no_matches_is_marked_none, test_unlinked_person_with_pending_match_is_marked_pending — fixture updates: seed apply_status=COMPLETED before listener invocation
  • 2 tests unchanged (gate-skip behaviour preserves them)

FormSubmissionSubmittedListenerOrderTest:

  • test_identity_match_listener_is_synchronous — flipped to assert ShouldQueue

Plus ApplyBindingsOnFormSubmitTest extensions, FormBindingApplicatorIntegrationTest adjustments for the deadline path, etc.


Out-of-scope (deferred)

  • GlitchTip alert rule for apply_status=failed AND public_token IS NOT NULL — operational task, configured in GlitchTip web UI on monitoring.hausdesign.nl. Not a code change.
  • Frontend Echo subscription for FormSubmissionIdentityMatchResolved — out of WS-6 scope; backend infrastructure ready and waiting.
  • Org-admin channel authorization — deferred to BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN. Inline TODO + denied-by-default contract test ensure it's visible.

Audit findings (Phase A surfaced these before code work began)

  • Phase F was a no-op. EventRegistrationGuards::publishGuards() already calls RequiresIdentityKeyBinding('person', 'email') directly without ConditionalRequirement(public_token) wrapper. The change had silently landed before D2; nothing to do. Documented in commit history; no scope-creep into making changes that weren't needed.
  • Broadcasting infrastructure was completely absent. routes/channels.php did not exist; bootstrap/app.php had no channels: parameter; config/broadcasting.php did not exist. Phase D therefore created new wiring (file + registration), not just an additive route.
  • PersonIdentityService::detectMatches return shape mismatch with prompt. The original prompt assumed (status, matchCount) return; actual is Collection<PersonIdentityMatch>. Adapted via Bert's confirmation: preserve string-status semantics, broadcast-only matchCount, drop the non-existent column write.
  • identity_match_count column was a phantom in the prompt. Column doesn't exist on form_submissions; the v1.3 amendment didn't actually call for it either — it was a leak in the implementation prompt. Listener writes only identity_match_status to the submission; matchCount lives in the broadcast payload as an ephemeral DTO field.
  • Test isolation pattern emerged. 6 existing tests dispatched FormSubmissionSubmitted directly without arranging apply_status=COMPLETED — fine pre-gate, broken post-gate. Fix pattern: invoke the listener directly with apply_status pre-set, mirroring the existing test comments' "bypass the service to isolate the listener contract" intent. May be worth promoting to a test helper if the pattern recurs in future queued-listener work.

Commits (chronological)

  1. 762fc62 — feat: wire D1 building blocks into ApplyBindings + deadline wrapper
  2. 2a8f108 — feat: TriggerPersonIdentityMatch becomes queued + invariant throw
  3. 912022f — feat: broadcast channel auth + listener layout comment update
  4. fa06c0f — feat: gate added to SyncTagPickerSelectionsOnSubmit
  5. 012044f — fix: FormFailureRetryService writes failure_response_code + apply_completed_at
  6. 03ff1cd — feat: apply_deadline_seconds config key (default 5)
  7. 9420516 — docs(backlog): TECH-CHANNEL-AUTH-ORG-ADMIN
  8. 1afe116 — test: WS-6 v1.3-delta D2 tests

Review hints

  • The withDeadline is a soft deadline, not a hard one. It cannot interrupt mid-query. Documented in the code comment block. For hard query timeouts, configure MySQL connection-level MAX_EXECUTION_TIME separately — out of D2 scope.
  • PARTIAL is treated as skip everywhere. Gate uses strict === COMPLETED. PARTIAL is a BindingPassResult outcome (mixed per-binding success/failure) and ARCH-BINDINGS §5.6 v1.2 explicitly clarifies sibling listeners cannot safely run against PARTIAL state. PARTIAL-BINDING-SUCCESS BACKLOG entry covers the long-term direction.
  • Channel auth contract test is intentionally negative for org-admin. The test asserts org-admin currently CANNOT subscribe. When TECH-CHANNEL-AUTH-ORG-ADMIN lands, the test will need flipping — that flip is the signal that the auth was actually extended.
  • IdentityMatchInvariantViolation is \DomainException, not FormBindingApplicatorException. Different listener, different context. The classifier helper treats it as 'unknown_error' because users cannot meaningfully act on it; admins triage via GlitchTip.

🤖 Co-Authored-By: Claude Opus 4.7

## WS-6 v1.3-delta D2 — Listener refactor + integration Wires the D1 building blocks (exception hierarchy, classifier helper, broadcast event class, deadline-wrapper precursor, `failure_response_code` column) into the listener chain. With this PR merged, the WS-6 v1.3 amendment is fully implemented in code. **No spine changes.** Two-transaction pattern preserved, pre-publish guards untouched (RequiresIdentityKeyBinding was already unconditional pre-D2 — see audit note below), `BindingPassResult::applyStatus()` PARTIAL semantics untouched, retry-flow contract preserved. ### 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.1–§5.6 listener chain, §6 two-transaction pattern, §7.1 status columns ### D1 prerequisite Builds on D1 (PR #10, merge-commit `c6f4d1b`). All D1 building blocks are consumed in this PR — confirms D1's design intent. --- ### What this PR delivers **1. `ApplyBindingsOnFormSubmit::handle` — deadline wrapper + classifier integration** (RFC §Q1 v1.3 add 1, 4 + §Q3 v1.3 add 2) - Inner transaction now writes `identity_match_status='pending'` so the HTTP response carries correct first-paint state for the IdentityMatchBanner. The final state lands asynchronously via the queued `TriggerPersonIdentityMatch`. - `apply()` wrapped in `withDeadline(config('form_builder.apply_deadline_seconds', 5))`. On exceeded deadline, `FormBindingApplicatorTimeoutException` is thrown, caught by the outer transaction handler, and recorded with `apply_status=FAILED` + `failure_response_code='temporary_error'`. - Outer-transaction catch block uses `FormBindingExceptionClassifier::classify` to write `failure_response_code` alongside `apply_status=FAILED`. **2. `TriggerPersonIdentityMatchOnFormSubmit` — queued + invariant throw + broadcast** (RFC §Q1 v1.3 + §Q2 + §Q1 v1.3 add 2) - Now implements `ShouldQueue`. Sync chain reduced to one listener (`ApplyBindingsOnFormSubmit`). - Gating-invariant first statement: `apply_status === COMPLETED` only. PARTIAL and FAILED both fall through to early-return per ARCH-BINDINGS §5.6. - Failsafe-pad removed in favour of strict invariant: `subject_type='person'` AND `apply_status=COMPLETED` implies `subject_id IS NOT NULL`. Violation throws `IdentityMatchInvariantViolation` (introduced in D1) — routed via Laravel queue worker → GlitchTip + `form_submission_action_failures` row. - Dispatches `FormSubmissionIdentityMatchResolved` (D1 broadcast event class) on `submission.{id}` private channel after writing the final `identity_match_status`. Frontend Echo subscription is a separate frontend follow-up out of WS-6 scope. `PersonIdentityService::detectMatches` returns `Collection<PersonIdentityMatch>`, not `(status, matchCount)`. The listener preserves the existing string-status semantics (`'matched'` / `'pending'` / `'none'`) and computes `matchCount = $matches->count()` for the broadcast payload only — no `identity_match_count` column write (column doesn't exist; was an error in the original prompt, dropped per audit clarification). **3. Broadcasting infrastructure (NEW)** (RFC §Q1 v1.3 add 2) Broadcasting was not previously wired in this codebase: - `api/routes/channels.php` created with `submission.{submissionId}` private-channel authorization callback - `api/bootstrap/app.php` updated: `withRouting(channels: __DIR__.'/../routes/channels.php')` registration Channel auth is **submitter-only** for now. Org-admin access deferred to BACKLOG `TECH-CHANNEL-AUTH-ORG-ADMIN` (added in this PR) — the Spatie Permission helper convention for organisation-scoped roles needs verification before extending the callback. Inline TODO in `routes/channels.php` cross-references the BACKLOG entry; a contract test asserts org-admin currently denied so a future flip is automatically visible. **4. Queued-listener gating** (ARCH-BINDINGS §5.6) `SyncTagPickerSelectionsOnSubmit` now has the `apply_status === COMPLETED` gate as its first statement. Logs at info level when skipped (`form-builder.queued-listener.skipped_apply_failed`). `ApplyBindingsOnFormSectionSubmitted` is intentionally not gated — it listens to `FormSubmissionSectionSubmitted`, a different event, and §5.6 is specifically scoped to `FormSubmissionSubmitted` post-apply state. **5. `FormBindingApplicator::withDeadline()`** (RFC §Q1 v1.3 add 4) Fluent method returning a clone configured with a soft post-call microtime deadline. Implementation note: cannot interrupt mid-query — for that, configure MySQL connection timeouts at the driver level. The soft deadline is sufficient to prevent runaway apply() calls from hanging the public flow indefinitely; typical apply takes <100ms, so a 5s default catches the long tail. **6. `FormFailureRetryService::recordFailure` symmetry fix** (ARCH-BINDINGS §7.1 v1.2 + RFC §Q3 v1.3 add 2) - Now writes `failure_response_code` via `FormBindingExceptionClassifier::classify` — same classifier the listener uses, single behaviour-change point per the v1.3-delta D1 design. - Now writes `apply_completed_at = now()` — closes the asymmetry where the listener wrote this column on both happy and failure paths but the retry service only wrote it on the success path. **7. `config/form_builder.php` — `apply_deadline_seconds`** New key, default `5`, env-overridable via `FORM_BUILDER_APPLY_DEADLINE_SECONDS`. Documented in the file's standard comment-block style. **8. `AppServiceProvider::boot` listener-layout comment** Updated to v1.3 layout: 1 sync (`ApplyBindings`) + N queued, all gated on `apply_status=COMPLETED`. Reference to `FormSubmissionSubmittedListenerOrderTest` added as the structural verifier. --- ### Test counts | | Count | |---|---| | Pre-D2 baseline (D1 merged) | 1583 | | D2 added | +37 across 4 new files + extensions | | D2 modified | ~10 across 6 existing files | | **New total** | **1621** | | Failures | 0 | | Larastan errors | 0 | | Full-suite duration | ~95s | **New test files (4):** - `tests/Feature/FormBuilder/Listeners/SyncTagPickerSelectionsOnSubmitGateTest.php` - `tests/Feature/FormBuilder/Bindings/FormBindingApplicatorDeadlineTest.php` - `tests/Feature/FormBuilder/Bindings/RetryServiceFailureClassifierTest.php` - `tests/Feature/FormBuilder/Channels/SubmissionChannelAuthTest.php` **Modified existing test files (6):** `TriggerPersonIdentityMatchOnFormSubmitTest` (4 of 7 tests adjusted): - `test_public_event_registration_submission_is_marked_pending` — replaced with two new tests (gate-skip behaviour, invariant-violation throw) - `test_linked_person_is_marked_matched`, `test_unlinked_person_with_no_matches_is_marked_none`, `test_unlinked_person_with_pending_match_is_marked_pending` — fixture updates: seed `apply_status=COMPLETED` before listener invocation - 2 tests unchanged (gate-skip behaviour preserves them) `FormSubmissionSubmittedListenerOrderTest`: - `test_identity_match_listener_is_synchronous` — flipped to assert `ShouldQueue` Plus `ApplyBindingsOnFormSubmitTest` extensions, `FormBindingApplicatorIntegrationTest` adjustments for the deadline path, etc. --- ### Out-of-scope (deferred) - **GlitchTip alert rule** for `apply_status=failed AND public_token IS NOT NULL` — operational task, configured in GlitchTip web UI on `monitoring.hausdesign.nl`. Not a code change. - **Frontend Echo subscription** for `FormSubmissionIdentityMatchResolved` — out of WS-6 scope; backend infrastructure ready and waiting. - **Org-admin channel authorization** — deferred to BACKLOG `TECH-CHANNEL-AUTH-ORG-ADMIN`. Inline TODO + denied-by-default contract test ensure it's visible. --- ### Audit findings (Phase A surfaced these before code work began) - **Phase F was a no-op.** `EventRegistrationGuards::publishGuards()` already calls `RequiresIdentityKeyBinding('person', 'email')` directly without `ConditionalRequirement(public_token)` wrapper. The change had silently landed before D2; nothing to do. Documented in commit history; no scope-creep into making changes that weren't needed. - **Broadcasting infrastructure was completely absent.** `routes/channels.php` did not exist; `bootstrap/app.php` had no `channels:` parameter; `config/broadcasting.php` did not exist. Phase D therefore created new wiring (file + registration), not just an additive route. - **`PersonIdentityService::detectMatches` return shape mismatch with prompt.** The original prompt assumed `(status, matchCount)` return; actual is `Collection<PersonIdentityMatch>`. Adapted via Bert's confirmation: preserve string-status semantics, broadcast-only `matchCount`, drop the non-existent column write. - **`identity_match_count` column was a phantom in the prompt.** Column doesn't exist on `form_submissions`; the v1.3 amendment didn't actually call for it either — it was a leak in the implementation prompt. Listener writes only `identity_match_status` to the submission; `matchCount` lives in the broadcast payload as an ephemeral DTO field. - **Test isolation pattern emerged.** 6 existing tests dispatched `FormSubmissionSubmitted` directly without arranging `apply_status=COMPLETED` — fine pre-gate, broken post-gate. Fix pattern: invoke the listener directly with `apply_status` pre-set, mirroring the existing test comments' "bypass the service to isolate the listener contract" intent. May be worth promoting to a test helper if the pattern recurs in future queued-listener work. --- ### Commits (chronological) 1. `762fc62` — feat: wire D1 building blocks into ApplyBindings + deadline wrapper 2. `2a8f108` — feat: TriggerPersonIdentityMatch becomes queued + invariant throw 3. `912022f` — feat: broadcast channel auth + listener layout comment update 4. `fa06c0f` — feat: gate added to SyncTagPickerSelectionsOnSubmit 5. `012044f` — fix: FormFailureRetryService writes failure_response_code + apply_completed_at 6. `03ff1cd` — feat: apply_deadline_seconds config key (default 5) 7. `9420516` — docs(backlog): TECH-CHANNEL-AUTH-ORG-ADMIN 8. `1afe116` — test: WS-6 v1.3-delta D2 tests --- ### Review hints - **The `withDeadline` is a soft deadline, not a hard one.** It cannot interrupt mid-query. Documented in the code comment block. For hard query timeouts, configure MySQL connection-level `MAX_EXECUTION_TIME` separately — out of D2 scope. - **PARTIAL is treated as skip everywhere.** Gate uses strict `=== COMPLETED`. PARTIAL is a `BindingPassResult` outcome (mixed per-binding success/failure) and ARCH-BINDINGS §5.6 v1.2 explicitly clarifies sibling listeners cannot safely run against PARTIAL state. PARTIAL-BINDING-SUCCESS BACKLOG entry covers the long-term direction. - **Channel auth contract test is intentionally negative for org-admin.** The test asserts org-admin currently CANNOT subscribe. When `TECH-CHANNEL-AUTH-ORG-ADMIN` lands, the test will need flipping — that flip is the signal that the auth was actually extended. - **`IdentityMatchInvariantViolation` is `\DomainException`, not `FormBindingApplicatorException`.** Different listener, different context. The classifier helper treats it as `'unknown_error'` because users cannot meaningfully act on it; admins triage via GlitchTip. 🤖 Co-Authored-By: Claude Opus 4.7
bert.hausmans added 8 commits 2026-05-08 08:25:22 +02:00
Per RFC-WS-6 §Q1 v1.3 addition 1, 4 + §Q3 v1.3 addition 2 + ARCH-BINDINGS §5.3.

- FormBindingApplicator::withDeadline(int) returns a clone configured to
  throw FormBindingApplicatorTimeoutException if apply() exceeds the
  deadline. Soft post-call microtime check; cannot interrupt mid-query
  but catches the long tail. apply() refactored to single-return so the
  deadline check sits at one site instead of duplicated.
- ApplyBindingsOnFormSubmit::handle:
  - Initial identity_match_status='pending' write inside inner
    transaction (when subject is or becomes a person) so HTTP response
    carries the right state for the IdentityMatchBanner first-paint
    copy. Final state comes from the queued TriggerPersonIdentityMatch
    (D2 Phase C).
  - Wraps apply() with config('form_builder.apply_deadline_seconds', 5).
  - Catch block uses FormBindingExceptionClassifier::classify to write
    failure_response_code in the outer transaction alongside
    apply_status=FAILED. submission_id from the exception (when in the
    binding-applicator hierarchy) is also captured in context JSON.

Tests added in Phase I.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per RFC-WS-6 §Q1 v1.3 (queueing) + §Q2 (invariant + IdentityMatchInvariantViolation)
+ §Q1 v1.3 addition 2 (broadcast).

- Implements ShouldQueue (was sync). Gate as first statement: skip if
  apply_status !== COMPLETED (handles PARTIAL and FAILED identically per
  ARCH-BINDINGS §5.6). Logs at info level when skipped for triage
  visibility.
- Failsafe-pad removed in favour of strict invariant: subject_type='person'
  + apply_status=COMPLETED implies subject_id IS NOT NULL. Violation throws
  IdentityMatchInvariantViolation, routed via Laravel queue worker to
  GlitchTip + form_submission_action_failures.
- Status derivation preserved (string semantics 'matched'/'pending'/'none')
  — PersonIdentityService::detectMatches returns a Collection; status
  computed via user_id check + isNotEmpty(). matchCount derived from
  $matches->count() for the broadcast payload only (not persisted).
- Person-not-found between dispatch and worker pickup terminates as
  'none' rather than throwing — rare race-window where the person was
  deleted; banner gets a sensible final state.
- Dispatches FormSubmissionIdentityMatchResolved on the submission.{id}
  private channel after writing the final identity_match_status.

Frontend Echo subscription is a separate follow-up (out of WS-6 scope).
The 4 existing failsafe-pad tests need rewriting in Phase I.

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

- routes/channels.php (NEW): authorization callback for the
  submission.{id} private channel. v1 authz scope is submitter-only
  (matches submitted_by_user_id); org-admin access is deferred per
  BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN. Frontend Echo subscription
  lands as a separate frontend follow-up.
- bootstrap/app.php: registers routes/channels.php via withRouting()
  channels: parameter. This is NEW broadcasting wiring — Laravel's
  broadcasting auth middleware was not previously connected to the
  framework. Without this registration the channels file is dead code.
- AppServiceProvider:👢 comment block updated to v1.3 listener
  layout (1 sync ApplyBindings + N queued, all gated on
  apply_status=COMPLETED per ARCH-BINDINGS §5.6). Comment on
  TriggerPersonIdentityMatch flipped from "(sync)" to "(queued
  post-v1.3)".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per ARCH-BINDINGS §5.6 v1.2.

The queued tag-sync listener now skips unless apply_status === COMPLETED.
PARTIAL and FAILED both fall through to the early-return — rebuilding
user_organisation_tags against a Person whose tag-binding may have been
the binding that failed would propagate partial state into derived data.

Logs at info level when skipped (form-builder.queued-listener.skipped_apply_failed)
for triage visibility. The fresh() reload is required because the inner-txn
commit happens between dispatch and worker pickup.

ApplyBindingsOnFormSectionSubmitted (the other queued listener under
app/Listeners/FormBuilder/) listens to FormSubmissionSectionSubmitted, a
different event — the §5.6 gate is specifically about
FormSubmissionSubmitted's post-apply-status state, so the section-level
listener is intentionally left without this gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per ARCH-BINDINGS §7.1 v1.2 retry-service asymmetry note + RFC-WS-6 §Q3 v1.3 addition 2.

recordFailure() now mirrors ApplyBindingsOnFormSubmit's outer-transaction
failure path:

1. failure_response_code via FormBindingExceptionClassifier::classify($e).
   Same classification logic as the listener — single behaviour-change
   point per the v1.3-delta D1 design.
2. apply_completed_at = now() — closes the asymmetry where the listener
   wrote this column on both happy and failure paths but the retry
   service only wrote it on the success path.

recordSuccess() unchanged — already writes apply_completed_at via the
shared transaction block in retry().

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

Configurable deadline for FormBindingApplicator::apply(). Default 5
seconds catches the long tail of slow applies before they hang the
public flow. Tunable per environment via FORM_BUILDER_APPLY_DEADLINE_SECONDS.

Consumed by ApplyBindingsOnFormSubmit::handle's withDeadline() call
(landed in Phase B).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WS-6 v1.3-delta D2 ships the broadcast channel auth callback in
routes/channels.php with submitter-only scope. Org-admin access is
deferred because the codebase has no vetted Spatie Permission helper
for organisation-scoped role checks; guessing the API would risk
incorrect authorisation without test coverage.

Tracking entry under "Technische schuld", referenced from the inline
TODO in routes/channels.php and the v1.3-delta D2 PR description.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
~30 new tests + 6 modified covering D2 deliverables.

NEW test files:
- FormSubmissionSubmittedListenerOrderTest: rewritten — flips
  identity-match assertion from sync to ShouldQueue + adds AST-level
  structural guard that every queued listener has the
  apply_status=COMPLETED gate as an early statement
  (form-builder.queued-listener.skipped_apply_failed log line + ApplyStatus::COMPLETED check).
- TriggerPersonIdentityMatchOnFormSubmitTest: rewritten — drops
  failsafe-pad assertions; adds gate-skip tests (null/PENDING/PARTIAL/FAILED);
  invariant-violation throw test; broadcast-dispatch test.
- ApplyBindingsOnFormSubmitTest: extended — initial
  identity_match_status='pending' write, apply_completed_at on both
  paths, classifier-derived failure_response_code per exception subclass,
  unknown_error fallback, deadline wrapper invocation captured by
  test double, outer-transaction failure record.
- SyncTagPickerSelectionsOnSubmitGateTest (NEW): canonical skip-log
  assertion for null/PENDING/PARTIAL/FAILED apply_status; no-skip-log
  assertion for COMPLETED. Uses Log::spy because FormTagSyncService
  is final and can't be Mockery-mocked.
- FormBindingApplicatorDeadlineTest (NEW): withDeadline returns clone;
  no-deadline path; generous-deadline path; timeout exception thrown
  with correct submissionId + reasonCode (temporary_error inherited
  via FormBindingInfraException). Uses incident_report purpose for
  anonymous-allowed branch to avoid PersonProvisioner constraints.
- RetryServiceFailureClassifierTest (NEW): per-subclass
  failure_response_code mapping in recordFailure; apply_completed_at
  symmetry-fix coverage.
- SubmissionChannelAuthTest (NEW): submitter authorised, other user
  denied, missing submission denied, org admin currently denied
  (locks v1 contract per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN).
- FormSubmissionResourceIdentityMatchTest: extended — DataProvider
  iterates over all six non-person purposes asserting
  identity_match=null per RFC §Q2 v1.3 contract.

MODIFIED to fit v1.3 layout:
- IdentityMatchOnSubmitTest: rewritten — directly invokes the listener
  with apply_status=COMPLETED pre-set, mirroring ApplyBindings'
  happy-path output (the test fixtures lack an identity-key binding
  so going through full event dispatch fails at PersonProvisioner).
  Drops the failsafe-pad assertion in test_public_submission_marked_pending;
  replaces with v1.3 contract: subject_type=null leaves
  identity_match_status untouched.
- TagPickerSyncListenerTest: same fix — sets apply_status=COMPLETED
  on the submission and invokes the listener directly.

Full suite: 1621 passing (4281 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bert.hausmans merged commit 23a5696288 into main 2026-05-08 08:25:52 +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#11