StageActiveOnEvent — checks the candidate stage_id is linked to the
given event_id via stage_days. Covers performance create/update
(perf.event_id ↔ stage) and the timetable move endpoint
(target_stage_id ↔ resolved target event).
WithinEventBounds — checks a candidate datetime is inside the event's
[start_at, end_at] window. Used for performance start/end dates and
move-target dates against the relevant sub-event for festivals.
OptionExpiresInFuture — conditional rule fired only when
booking_status === 'option'. Asserts option_expires_at is set and in
the future. Implementation of RFC §10.1 transition gate at the
request layer (the service layer enforces the same invariant).
ContractRequiresFee — conditional rule fired only when
booking_status === 'contracted'. Asserts fee_amount is set and > 0.
Same dual-layer enforcement as OptionExpiresInFuture.
All four pass silently when the validated field is null or the
context is irrelevant — the FormRequest still owns the surrounding
required/nullable/exists rules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GenreService, ArtistService, ArtistEngagementService (state machine),
StageService, StageDayService, PerformanceService, LaneCascadeService
under app/Services/Artist/. Plain final classes with constructor
injection — matches FormSubmissionService convention.
ArtistEngagementService implements the RFC §10.1 booking_status state
machine: terminal Cancelled/Rejected/Declined, Option requires future
option_expires_at, Contracted requires fee_amount > 0. transitionStatus
is the focused entry point; update() routes through it whenever the
payload mutates booking_status. cancel() composes transitionStatus +
soft delete in one transaction so the existing
ArtistEngagementObserver cascade fires.
LaneCascadeService is the D18 transactional move algorithm. Locks the
dragged Performance row FOR UPDATE, validates client version against
the persisted version (D14), then either parks (stage_id=null, no
cascade) or places onto (stage, event, lane) with single-level
cascade-bump of any time-overlapping rows on the target lane. Returns
a MoveResult value object carrying the moved + cascaded performances
so the controller maps them to API resources without a second query.
StageDayService implements the §10.5 atomic matrix replace. Detects
non-cancelled performances on event_ids about to be removed; throws
StageDaysOrphanedPerformancesException unless force_orphan=true. The
orphans are not deleted — they persist with the same stage_id so they
re-appear when the day re-activates (D5/D27 retention).
ArtistService.create raises DuplicateArtistException carrying the
existing master so the controller can offer a "use existing" choice
instead of forcing the booker to abandon their dialog. ArtistEngagement
defaults buma_handled_by based on artist.agent_company.handles_buma
per RFC D26.
GenreService.delete is hard-blocked (GenreInUseException) when artists
still reference the genre via default_genre_id; the frontend rebinds
those artists first.
StageService.delete cascade-parks performances (stage_id → null, lane
preserved) and returns the parked count for the activity-log entry
the controller writes in Step 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ArtistPolicy, ArtistEngagementPolicy, StagePolicy, PerformancePolicy,
GenrePolicy. Role-based authorization mirroring PersonPolicy/ShiftPolicy
pattern: super_admin bypass, org-membership check via wherePivotIn,
event_manager fallback for event-level operations.
Each policy carries a class-level docblock mapping the RFC §9
permission strings (events.view_program, events.manage_program,
organisations.manage_artists, organisations.manage_settings) to the
roles authorised, deferring permission-based authorisation to
AUTH-PERMISSIONS-MIGRATION.
ArtistPolicy.delete additionally guards on no-active-engagements
(D27): blocks soft-delete while any engagement is not Cancelled,
Rejected, or Declined.
PerformancePolicy.move and StagePolicy.reorder reuse canManageProgram
so the move endpoint and stage-reorder share the manage_program
permission semantics.
Auto-discovered by Laravel 11 (policies live at App\Policies\* matching
top-level App\Models\* — no explicit Gate::policy registration needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the two RFC-TIMETABLE §9 roles. Authorization stays role-based per
Phase A Option B; RFC §9 permission strings map to roles in policy
class docblocks, not seeded as Spatie permissions. The eventual
cross-cutting migration to fine-grained permissions is tracked under
AUTH-PERMISSIONS-MIGRATION.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema reality (varchar(64), accommodating SHA-256 hex digest) diverges
from RFC v0.2 §5.3 ("ULID unique nullable"). Session 1 implementation is
correct; RFC needs amendment in next legitimate cycle. Tracked under
RFC-TIMETABLE-V0.2-PORTAL-TOKEN-SCHEMA-AMEND. Distinct from
RFC-TIMETABLE-V0.2-DOC-CLEANUP (which covers stale cross-references).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
advance_submissions.advance_section_id FK changed from cascadeOnDelete
to nullOnDelete; column made nullable. Aligns implementation with
RFC v0.2 §5.4 audit-immutability ("submissions remain for retention
compliance") — when ArtistEngagementObserver::deleted hard-deletes a
section, its submissions persist as orphans rather than disappearing.
Migration edited in place (branch unpushed, dev-only). Observer
docblock + test assertion updated to match. Removed pre-existing
follow-up comment that documented the deviation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Phase C test files:
- tests/Unit/Models/Artist/ArtistDomainModelsTest.php — relationships,
casts, soft-delete trait presence, slug uniqueness within/across
organisations, isParked() helper, AdvanceSection's primary scope,
PURPOSE_SUBJECT_FQCN['artist'] resolves to instantiable class.
- tests/Feature/Artist/ArtistEngagementObserverTest.php — auto-fill
organisation_id from artist, cross-tenant guard throws, soft-delete
cascades to performances + hard-deletes advance_sections.
- tests/Feature/Artist/PerformanceObserverTest.php — version starts
at 0, increments by 1 per UPDATE, no bump on no-op save.
- tests/Feature/Artist/ArtistDomainScopeLeakageTest.php — 5 scoped
models (Artist/Genre/Engagement direct + Stage/Performance FK-chain)
isolate cross-org queries.
- tests/Feature/Artist/ArtistTimetableDevSeederTest.php — fixture-count
smoke (4 stages, 12 stage_days, 6 artists, 12 engagements,
13 performances incl. 1 parked).
Cross-cutting fixes that Phase C surfaced:
- AppServiceProvider: morph-map block 2 extended with the 8 new
artist-domain models (artist_engagement, artist_contact, genre,
stage, stage_day, performance, advance_section, advance_submission).
Block 1 'artist' alias was already wired via PurposeRegistry.
- 5 form-builder backfill tests bumped --step rollback counts by +10
to account for the 10 new May 8 migrations sitting at HEAD between
the test's calibration point and current head.
- phpstan-baseline.neon regenerated (1631 entries) — all errors are
same patterns existing baselined code already exhibits
(Factory generic typing, Model property docblock gaps). Tracked
systematically under TECH-LARASTAN-* in BACKLOG.
Tests: 1646 passing (was 1624 pre-Session-1 → +22 net, no losses).
Larastan: 0 errors over baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC-TIMETABLE v0.2 §5.3 moved portal_token from artists to
artist_engagements (one master artist may have multiple per-event
portal links). PortalTokenController and PortalTokenMiddleware
queried the now-removed artists.portal_token column.
Update both lookups to query artist_engagements.portal_token, joining
to artists for the master name. Response shape unchanged: data.id =
engagement id, data.name = artist name, data.booking_status = engagement
status. Middleware sets portal_context='artist' (unchanged); the
attached portal_person object now carries the engagement row.
PortalTokenSecurityTest seeds artist_engagement rows via a private
helper that writes both an Artist (master) and an artist_engagements
row with the hashed token; test assertions adjusted to check the new
shape (no more milestone fields exposed since they don't exist on
the engagement).
Out of scope refactor disclaimer: this is a forced schema-migration
follow-up, not a Session 2-style controller refactor — the controller
queries the new table with minimal change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PortalTokenController stores hash('sha256', \$plainToken) — a 64-char
hex digest. RFC v0.2 §5.3's "ULID unique nullable" annotation is loose;
in practice the column holds a hash, not a ULID. char(26) silently
truncates under MySQL strict mode (1406 Data too long) — surfaced
when PortalTokenSecurityTest exercised the auth path against the new
schema. Widen to varchar(64) to fit the hash.
Schema dump regenerated against crewli_test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ARCH-09 (Artist Eloquent model + migration) closed under
"Opgeloste items (mei 2026)" with summary of what landed in
RFC-TIMETABLE v0.2 Session 1. Removed from Phase 3 status table
and from "Nieuwe backlog items".
Two new tech-debt entries:
- ART-OBSERVER-ADVANCE-AGGREGATE: AdvanceSection lifecycle observer
to recompute artist_engagements.advancing_*_count, deferred to
Session 3 when section-level submit lands.
- RFC-TIMETABLE-V0.2-DOC-CLEANUP: capture stale ARCH-PLANNED-MODULES.md
cross-references in the Approved RFC v0.2 §1 + §15 for next amendment.
Approved RFCs are not patched ad-hoc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§3.2.5: clarify that advance_sections are engagement-scoped (not
artist-scoped). One master artist with two engagements advances each
trajectory independently. Drop the prose section enumeration that
predated the AdvanceSectionType enum and conflated section names
with section types — section type is the enum, name is a free string,
default seeds land in Session 3 with ArtistAdvanceDefault.
§17.3: footnote on the artist_advance row documenting engagement
context resolution — ArtistResolver::fromPortalToken looks up
artist_engagements.portal_token, returns the master Artist as subject,
populates form_submissions.event_id from the engagement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the pre-RFC-v0.2 design (event-scoped artists, milestone bool
flags, artist_riders, itinerary_items) with the master+engagement
split per RFC-TIMETABLE v0.2 §5.3:
- genres (org-scoped vocab, D24)
- artists (master, org-scoped, slug-unique)
- companies.handles_buma column note
- artist_contacts (master-scoped)
- stages, stage_days (event/sub-event pivot)
- artist_engagements (per-event booking — D9, D10)
- performances (engagement-scoped, nullable stage_id, D13/D14)
- advance_sections (engagement-scoped — was artist_id)
- advance_submissions (audit-immutable per RFC §5.4)
- 7 enums under App\Enums\Artist\ documented in their own subsection
artist_riders and itinerary_items removed — RFC v0.2 §5.3 does not
create them; rider data lives in advance-section submissions, and
itineraries are deferred to a future RFC.
TOC anchor unchanged (slug `#357-artists--advancing` still resolves).
ARCH-PLANNED-MODULES.md was assumed to exist by the RFC's pre-amble
and the original session prompt, but does not — §3.5.7 was already in
SCHEMA.md, so the work is an in-place rewrite. Closes ARCH-09.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The string-literal workaround was added before the Artist model existed
(ARCH-09 prerequisite). With the model now landed (RFC-TIMETABLE v0.2
Session 1), resolve to Artist::class directly so morph-map registration
matches the rest of the registry. MorphMapAlignmentTest still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight factories with named states (Genre, Artist, ArtistContact, Stage,
ArtistEngagement, Performance, AdvanceSection, AdvanceSubmission).
ArtistTimetableDevSeeder hooked into DevSeeder::seedEchtFeesten after
the form-builder showcase. Produces:
- 4 stages (Mainstage, Havana, Stairway, Socialite) with prototype-style
hex colours
- 4 stages × 3 sub-events = 12 stage_days rows
- 4 genres (Hardstyle, Techno, Indie, Live band)
- 6 master artists, each with one tour-manager ArtistContact
- 12 engagements with status mix (1 Draft, 2 Requested, 3 Option,
2 Confirmed, 3 Contracted, 1 Cancelled). Two artists have two
engagements each (different sub-events) — exercises D17 multi-
engagement-per-artist.
- 13 performances, including one parked (stage_id=null = wachtrij)
and one B2B pair within 3 minutes on Mainstage Saturday to seed
the Session 4 frontend B2B detector.
Also fix LogOptions method name across 8 models: dontSubmitEmptyLogs()
→ dontLogEmptyChanges() (Spatie's actual API; surfaced when DevSeeder
ran).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ArtistEngagementObserver:
- creating: auto-fills organisation_id from parent Artist (RFC v0.2 D10
denormalisation), asserts artist.organisation_id == event.organisation_id;
cross-tenant linkage throws CrossTenantEngagementException (extends
DomainException, included in this commit).
- saving: no-op marker reserved for Session 2 state-machine validation.
- deleted: cascades soft-delete to Performance children, hard-deletes
AdvanceSection children. AdvanceSubmission rows are immutable per
RFC §5.4 and remain attached.
PerformanceObserver:
- saving: increments version by 1 on UPDATE only (D14 optimistic lock).
MoveTimetablePerformanceRequest in Session 2 uses this for concurrent-
edit detection.
Both observers registered in AppServiceProvider::boot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anticipatory migrations from 2026-04-08 encoded the old §3.5.7 design
(artists.event_id, advance_sections.artist_id). RFC v0.2 §5.3 replaces
both tables with the engagement model. No model/factory/test/seeder
references exist. Removing before Step 1 ensures the new migrations
match RFC §5.3 verbatim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture inventory, data model, component architecture, interaction
patterns, pure logic algorithms (with verbatim excerpts), design tokens,
and 20 RFC v0.2 observations from the standalone React prototype at
resources/Crewli - Artist Timetable Management/.
Read-only audit; no prototype files modified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mark TECH-CHANNEL-AUTH-ORG-ADMIN as resolved with PR reference,
date, and one-paragraph summary of what was delivered.
Three edits:
1. Open entry block removed from "Technische schuld" section.
2. Closure bullet appended under "Opgeloste items (mei 2026)" — full
summary of the three-path auth (submitter / super_admin / org_admin),
pattern source (FormSubmissionActionFailurePolicy::canAccess port),
the audit-surfaced super_admin bypass bonus, test deltas, and
sibling FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION pointer.
3. Stale forward-reference inside FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION
updated: "submitter-only voor nu" → "submitter / super_admin /
org_admin van submission's organisatie — TECH-CHANNEL-AUTH-ORG-ADMIN
closed mei 2026". Closes the same no-compromises gap as the FORM-05
stub-status touch-up (PR #12).
Sibling BACKLOG entry FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION stays
open — that's the frontend portal IdentityMatchBanner work that pairs
with this channel auth extension.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN.
Four new tests + one deleted; existing three preserved.
NEW:
- test_super_admin_can_subscribe (positive, app-wide bypass via Spatie
HasRoles assignRole('super_admin'))
- test_organisation_admin_of_submission_org_can_subscribe (positive,
pivot-table org_admin → submission's organisation)
- test_organisation_admin_of_different_org_cannot_subscribe (CRITICAL
cross-tenant guard — admin of org B cannot subscribe to a submission
in org A)
- test_regular_organisation_member_cannot_subscribe (org_member role
on the pivot is NOT enough; only org_admin passes)
DELETED:
- test_org_admin_is_currently_denied_per_backlog_entry (the "should
flip" denied-by-default test from PR #11; superseded by the four
positive/negative tests above)
PRESERVED:
- test_submitter_is_authorised
- test_other_authenticated_user_is_denied (User with no organisation
membership → falls through every auth branch)
- test_subscription_is_denied_when_submission_does_not_exist
Test-fixture refinement: makeSubmission() now accepts an explicit
$submitter so positive role-based tests can use a separate User as
submitter, ensuring the submitter short-circuit doesn't accidentally
authorise role-based test subjects.
Test results: 7 passed in this file; 1624 in full suite (was 1621).
0 Larastan errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN.
WS-6 v1.3-delta D2 (PR #1123a5696) introduced submission.{id} private
channel with submitter-only authorization, deferring org-admin auth
to a follow-up after the Spatie Permission helper convention was
audited. This commit closes that follow-up.
Authorization now permits (cheap-first short-circuit):
1. Submitter (submitted_by_user_id === user.id) — unchanged
2. super_admin (Spatie HasRoles app-wide bypass) — audit-surfaced bonus,
matches every analogous policy in the codebase
3. Organisation admins of the submission's organisation — new
Pattern: direct port of FormSubmissionActionFailurePolicy::canAccess.
Spatie teams is disabled in config/permission.php, so org-scoping
lives in the user_organisation pivot table's `role` column with
wherePivot('role', 'org_admin') — codebase canonical (used in 17+
policy sites). withoutGlobalScopes() preserved on both FormSubmission
and Organisation lookups so channel auth is a structural gate, not a
tenant-scoped query.
Inline TODO removed; the BACKLOG entry transitions to resolved in a
follow-up commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three edits closing concessies surfaced in chat review of the closure
docs-PR:
1. FORM-05 'Resterend werk' sub-paragraph: surgical replacement of
resolveStatus references (method removed in D2, PR #1123a5696).
Updated to describe post-D2 reality: gate + invariant +
handle()-internal status derivation. Ticket stays open (the
detectMatchesByValues extension is unbuilt).
2. FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION (NEW): tracks the frontend
follow-up where the portal IdentityMatchBanner subscribes to the
submission.{id} channel for live banner updates. Previously
documented in PR #11 body and RFC §Q1 v1.3 add 2 commentary but
without an actionable BACKLOG ticket.
3. HARD-DEADLINE-QUERY-TIMEOUT (NEW): tracks the upgrade from soft
post-call microtime deadline to a hard deadline that can interrupt
hanging MySQL queries (connection-level timeouts, MAX_EXECUTION_TIME
hints, or pcntl_alarm). Previously documented as 'soft deadline
limitation' inline in code comments without an actionable BACKLOG
ticket.
No spec changes; no code changes. Closes the chat-identified gaps so
WS-6 v1.3-delta closure has zero un-anchored mental TODOs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
§1 Status: add Implementation status line citing D1 (PR #10c6f4d1b)
and D2 (PR #1123a5696), both 2026-05-08.
§10 Document history: append v1.3-delta closure entry summarising what
D1 and D2 each delivered + what remains as separate operational task
(GlitchTip alert rule configuration in the web UI) and frontend
follow-up (Echo subscription).
No spec changes — purely lifecycle marker update.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per RFC-WS-6 §Q3 v1.3 + ARCH-BINDINGS §11.
Nieuwe runbook-sectie §7 (na §6 Audit trail) die de triage-flow
documenteert wanneer GlitchTip een FormBindingApplicatorException
event opbrengt:
- §7.1 failure_response_code classificatie (schema_config_error /
temporary_error / data_integrity_error / unknown_error) drijft het
initiële triage-pad
- §7.2 form_schema.has_public_token tag onderscheidt klant-zichtbare
failures (alert-waardig) van organizer-driven failures (admin-UI only)
- §7.3 retry/dismiss decision-matrix met form-failures:retry artisan
command + DismissalReasonType enum cases
- §7.4 severe-failure escalatie criteria (>10/uur op één schema = P1)
- §7.5 cross-references naar RFC, ARCH-BINDINGS, en erasure-runbook
Companion van de operationele GlitchTip alert-rule (apart geconfigureerd
in de GlitchTip web UI op monitoring.hausdesign.nl).
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>
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>
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>
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 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 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 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 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>
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>
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>
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>
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 §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 §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 §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>
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>