LaneCascadeService::move() now calls disableLogging() before every
save inside the transaction (locked performance + cascade-bumped
peers + park-path). The two explicit activity('performance')
->event('moved'|'parked') entries with cascade_count + cascaded_ids
properties are the only audit records per move, matching RFC §8's
"single parent entry summarising the cascade" requirement.
Park path additionally writes an explicit 'performance.parked'
entry per RFC §8 vocabulary instead of falling back to a generic
'updated' auto-log entry.
Two new tests verify:
- cascade move with N peers produces exactly 1 activity entry on
the moved subject and 0 on each cascade-bumped peer
- park writes exactly 1 'parked' entry
PerformanceObserver::saving (version bump) is unaffected:
disableLogging() suppresses only the activity log trait, not
Eloquent model events.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`artist:demote-expired-options` artisan command finds every
ArtistEngagement still in Option whose option_expires_at has passed,
transitions it back to Draft via the existing state-machine
(transitionStatus), and writes an `option_expired` activity entry
with the original expiry timestamp captured in properties so the
audit log distinguishes system-driven expiries from manual demotions.
Idempotency: the state-machine bails when the engagement is no longer
in Option, so a second run within the same minute is a no-op for any
given row. The auto-logged `updated` row + the explicit
`status_changed` + the `option_expired` entries are emitted only by
the run that actually performs the transition.
Scheduled in routes/console.php daily at 03:00 Europe/Amsterdam,
matching the existing nightly low-traffic window.
Notification (email project leader on demotion) is deferred to the
notification framework that lands post-Accreditation; tracked under
BACKLOG entry ART-DEMOTE-NOTIFICATION.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LogOptions on Artist, ArtistEngagement, Stage, Performance, Genre now
list the specific attributes the audit log captures (per §8 last
paragraph) instead of logFillable. Each model gets a distinct
log_name (artist / artist_engagement / stage / performance / genre)
so the activity-log filter can scope queries by domain.
tapActivity() on every model adds organisation_id (and event_id where
relevant) to the activity entry's properties. The audit-log filter in
the SPA can then query
`->where('properties->event_id', $event->id)` without joining through
multiple subject types.
Performance gets dontLogIfAttributesChangedOnly(['updated_at',
'version']) so the bookkeeping touch from PerformanceObserver doesn't
generate noise when nothing user-meaningful changed.
Custom activity events emitted by services for the cases where the
auto-log can't infer intent:
performance.moved — LaneCascadeService::move writes a single
parent entry with cascade_count and
cascaded_ids[] after the cascade-bump
commits. Per-row updates still flow
through the model trait so the audit log
shows both the summary and the diffs.
stage.day_added /
stage.day_removed — StageDayService::replaceDays writes one
entry per added/removed event_id, performed
on the parent Stage so the log groups by
stage rather than by pivot row.
stage.reordered — StageService::reorder writes one entry on
the parent Event with the full new
stage_ids[] order.
artist_engagement.
status_changed /
cancelled — ArtistEngagementService::transitionStatus
emits one of these depending on the target
status; pairs with the auto-logged `updated`
row.
The remaining artist_engagement.option_expired event lands in Step 10
when the DemoteExpiredOptions command writes a system-causer entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six thin controllers under app/Http/Controllers/Api/V1/Artist/. Zero
business logic: every mutation routes through a service from
app/Services/Artist/. Authorization via Gate::authorize matching
PersonController convention (request authorize() returns true; gates
fire in the controller).
ArtistController — org-scoped CRUD + restore. Catches
DuplicateArtistException → 409 with
duplicate_artist_id so the dialog can
offer "use existing".
GenreController — org-scoped CRUD; catches GenreInUseException
→ 409 with referencing_artists_count.
ArtistEngagementController — event-scoped CRUD; catches
InvalidStatusTransitionException → 422
with a Dutch-readable message.
StageController — event-scoped CRUD + reorder + replaceDays;
catches StageDaysOrphanedPerformancesException
→ 409 with the orphaned performance ids
and the removed event ids per RFC §10.5.
destroy returns the parked performance
count (cascade-park).
PerformanceController — event-scoped CRUD with index filters
`?day={subevent}` and `?stage_id=null`
(wachtrij). update is non-placement only.
TimetableMoveController — single __invoke for POST /timetable/move.
Catches VersionMismatchException → 409
with current_version + server_data per
RFC D14.
Routes wired into api/routes/api.php nested under the existing
organisations/{organisation}/events/{event} prefix group, matching
PersonController and ShiftController structure. The move endpoint
gets the new `idempotency.60s` middleware alias for R1. `stages/order`
and `stages/{stage}/days` registered before the apiResource so the
literal path wins over the wildcard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC v0.2 R1 — Idempotency-Key replay window for POST
/api/v1/events/{event}/timetable/move. Narrow scope by design: the
12-hour ARCH §10 default would let a cached cascade-bump response
overwrite a fresh edit; 60 seconds covers honest network retry but
expires before a meaningful conflict can emerge.
Backed by the Laravel Cache facade (Redis in non-test env). Cache key
namespace `idempotency:60s:*` distinct from FormSubmission's
DB-column idempotency. Replays carry an `Idempotency-Replayed: true`
header so observability can distinguish them.
Registered as the route-middleware alias `idempotency.60s` in
bootstrap/app.php; will be applied on the move route in Step 8.
Missing or empty Idempotency-Key returns 400 with
`{"error":"idempotency_key_required"}`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six resources under app/Http/Resources/Api/V1/Artist/ matching
FormSubmissionResource conventions (final class, @mixin model,
optional()->toIso8601String, whenLoaded relationships).
GenreResource — id, name, color, sort_order, is_active
ArtistResource — master + lifetime/upcoming engagement counts
computed lazily from the engagements relation
ArtistContactResource — paired with ArtistResource.contacts
ArtistEngagementResource — full deal block with the RFC D26 Buma/VAT
formulas computed live in `computed.*`:
buma_amount = fee × buma_pct/100
IFF Organisation handles BUMA
vat_grondslag = fee + (buma when Organisation)
vat_amount = vat_grondslag × vat_pct/100
when vat_applicable
total_cost = fee + buma + vat + Σ breakdown
Frontend (Session 5) ports the same formula.
StageResource — adds stage_days as a flat array of event_ids
(not nested Event resources, to keep payload
light)
PerformanceResource — `lane` (raw, persisted), `lane_resolved`
(computed per D19), `warnings` (overlap +
B2B at minimum; capacity-warn refined later)
LaneResolver under app/Services/Artist/ is the pure-logic helper that
PerformanceResource calls. Greedy lowest-non-conflicting lane
assignment over the (stage_id, event_id) cohort sorted by start_at
then by raw lane (so cascade-bumped rows stay where they were
visually). Frontend port lands in Session 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Created under app/Http/Requests/Api/V1/Artist/, mirroring the
existing FormRequest pattern (final class, authorize() returns true,
controller-level Gate::authorize). One request per CRUD shape plus the
two domain-specific endpoints:
artists create / update
genres create / update (with org-scoped unique)
stages create / update (with event-scoped unique)
stages/order ReorderStagesRequest — permutation check
engagements create / update — per RFC §10.3, with
ContractRequiresFee + OptionExpiresInFuture
conditional rules wired
performances create / update — per §10.2; cross-FK
engagement.event_id ↔ event_id chain
enforced via withValidator closure;
update is non-placement only (placement
edits go through /timetable/move)
timetable/move per §10.4; resolves target_event_id from
target_stage_id + target_start_at via
stage_days, then reuses StageActiveOnEvent
+ WithinEventBounds for downstream rules
stages/{stage}/days §10.5 matrix replace; each event_id must
equal stage.event_id (flat) or be sub-event
(festival)
Custom error messages in Dutch where user-facing. Cross-FK rules that
span request inputs (engagement vs event-id chain, day matrix sub-event
membership) live in withValidator after-closures so the rule cache is
stable per request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
RFC §3.6 vereist impersonation.active als always-present binary signal
op authenticated events. Originele PR-2 architectural-fixes verplaatste
impersonation-tagging naar HandleImpersonation middleware, die alleen
draait bij actieve impersonation. Resultaat: non-impersonation events
hadden GEEN tag, niet 'false' tag — wat filtering op "alle impersonation
events" in GlitchTip stilletjes onmogelijk maakte.
Fix: AuthScopeContextListener::bindForUser() zet baseline 'false';
HandleImpersonation overschrijft naar 'true' + impersonator_user_id
wanneer actief. Default-in-listener, override-in-middleware pattern.
HandleImpersonation deed de override-set al correct sinds commit
9414d09; alleen de baseline ontbrak.
Bert's live verification toonde de gap: super_admin event zonder
impersonation actief, GlitchTip event zonder impersonation.active tag.
Tests:
- test_impersonation_active_default_false_for_non_impersonation_authenticated_event
(was test_authenticated_event_does_not_set_impersonation_tags;
hernoemd + assertion gewijzigd)
- test_impersonation_active_default_false_across_every_actor_scope_branch
walks elke actor_scope branch (user/organisation/platform) en bewijst
baseline geldt uniform — vangt toekomstige refactors die per branch
vroegtijdig returnen.
Test count 1544 to 1545. Larastan + Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-discovery + explicit Event::listen() runt observability listeners
twee keer per event (verified via php artisan event:list duplicate
entries). Vandaag idempotent vanwege scope-tag overwrite semantics, maar
architecturaal onacceptabel — toekomstige additive listeners zouden
onmiddellijk breken zonder waarschuwing.
Optie A (Bert bevestigd, RFC-WS-7 OBS-8): expliciete registraties
behouden in AppServiceProvider::boot(), auto-discovery globaal uit via
->withEvents(discover: false) in bootstrap/app.php. Reden: explicit >
implicit voor observability-kritische bindings — grep-baar, IDE-
navigeerbaar, direct zichtbaar bij code review.
TagJobAttemptOnSentry registratie ook van class-string naar array-
callable vorm gebracht zodat event:list de gebonden methode toont
(consistent met AuthScopeContextListener-registraties).
Test count ongewijzigd op 1544. Larastan + Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live HTTP smoke test on the post-architectural-fixes branch surfaced
that captured Sentry events carried only route-scope tags (app,
route_name, http.method) — auth-scope tags (user_id, actor_type,
actor_scope) were absent on every request.
Root cause: Sanctum's Guard fires Laravel\Sanctum\Events\TokenAuthenticated
(vendor/laravel/sanctum/src/Guard.php:77) on bearer-token resolution,
NOT Illuminate\Auth\Events\Authenticated. The Authenticated event only
fires from SessionGuard
(vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:833),
which Crewli does not use — CookieBearerToken middleware injects the
httpOnly cookie as Authorization: Bearer, then auth:sanctum invokes
Sanctum's Guard. So the listener never ran on Crewli's HTTP path.
Offline tests in AuthScopeContextListenerTest passed because they
dispatch event(new Authenticated(...)) directly, bypassing the Guard
layer. Sanctum::actingAs() in tests has the same blind spot — it
short-circuits the Guard via guard('sanctum')->setUser() and fires
neither event.
Fix:
- New handleTokenAuthenticated(TokenAuthenticated $event) method on
AuthScopeContextListener extracts the user via $event->token->tokenable
and delegates to a private bindForUser() shared with handle().
- AppServiceProvider registers the listener for both Authenticated
(covers SessionGuard / login flow / future authenticators) and
TokenAuthenticated (covers Crewli's bearer-token Sanctum flow).
Regression coverage: AuthScopeBindingHttpFlowTest exercises the real
Sanctum Guard via $user->createToken() + Authorization: Bearer header.
Three cases:
- super_admin on a user-scope route: actor_scope=user, all auth tags
present.
- super_admin on an admin.* route: actor_scope=platform, no
organisation_id (correct platform-mode behaviour).
- org_admin on a route with {organisation} param: actor_scope=
organisation, organisation_id valid ULID.
Test count 1541 to 1544. Larastan clean. Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-2 live smoke test surfaced that super_admin platform-route
exceptions arrived without organisation_id, and the original RFC §3.6
invariant (always-present organisation_id on authenticated events)
would force misleading attribution if it tried to fill that gap.
Refined invariant: every authenticated event carries actor_scope
(organisation/platform/user/anonymous), AND when actor_scope is
organisation, organisation_id MUST be a valid ULID. Platform-mode
correctly omits organisation_id rather than fabricate one.
Resolution chain in AuthScopeContextListener:
1. {organisation} or {event} URI parameter -> actor_scope=organisation
2. portal_event request attribute -> actor_scope=organisation
3. super_admin on admin.* named route -> actor_scope=platform
(Crewli's platform-admin routes use the admin. name prefix)
4. Default authenticated -> actor_scope=user, no org tag
(User<->Organisation is many-to-many; no reliable single-org hint)
Eight new test cases in AuthScopeContextListenerTest cover each branch
and the conditional invariant, including ULID validity via
Symfony\Component\Uid\Ulid::isValid.
Test count 1531 to 1539. Larastan clean. Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sentry-context binding split into two responsibilities:
- Route-scope (app, http.method, route_name) stays in middleware on
the api group as BindSentryRouteContext — works on every request,
no auth required.
- Auth-scope (user_id, actor_type) moves to AuthScopeContextListener
on Illuminate\Auth\Events\Authenticated — works on every
authentication mechanism (Sanctum, portal-tokens, future
authenticators) without per-route middleware-attachment. Listener
also augments Log::withContext with user_id (closes OBS-2).
Architecturally fault-preventing rather than fault-detecting: new
authenticated route groups need no separate sentry.context aliasing,
so silent observability gaps are no longer possible (closes OBS-3).
Impersonation tagging is co-located with HandleImpersonation: after
the user-swap, the middleware re-tags Sentry scope with the target
user_id/actor_type and adds impersonation.active /
impersonation.impersonator_user_id / impersonation.session_id. The
Authenticated event fires for the admin (Sanctum's natural flow),
the listener tags the admin, then HandleImpersonation overwrites
post-swap.
Files renamed:
- BindSentryContext -> BindSentryRouteContext (route-scope only)
- BindSentryContextTest -> BindSentryRouteContextTest (4 cases)
Files added:
- AuthScopeContextListener
- AuthScopeContextListenerTest (6 cases)
bootstrap/app.php drops the sentry.context alias and prepends
BindSentryRouteContext to the api group. routes/api.php drops every
sentry.context middleware string from auth:sanctum groups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VOLUNTEER was reserved-but-unused. Resolver mapped non-admin
authenticated users to ORG_MEMBER because Crewli has no dedicated
volunteer Spatie role; volunteer-ness is behaviour (shift assignments),
not identity.
Dead enum cases are YAGNI violations under zero-compromise: a future
developer could use the case without realising no resolution path
leads to it, producing a silent no-op. Re-introduce alongside a real
volunteer role split when that lands (BACKLOG OBS-1).
ActorType keeps ORGANIZER_ADMIN, SUPER_ADMIN, PORTAL_TOKEN, ORG_MEMBER,
UNAUTHENTICATED. Tests at 1537, Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scrubber is fully stateless. Container-resolution per event was
overhead without value, the closure indirection polluted the config
layer with executable logic, and stack traces showed an anonymous
closure frame instead of the class name.
- SentryEventScrubber::scrub() and its private helpers all become
static methods. No instance fields, so the change is mechanical.
- config/sentry.php before_send switches from a closure that calls
app() to PHP array-callable notation [Class, method]. Symfony
OptionsResolver accepts array-callables for static methods.
- PiiScrubbingTest swaps (new SentryEventScrubber)->scrub(...) for
SentryEventScrubber::scrub(...). Semantics unchanged.
Tests 1537 unchanged. Larastan and Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-7 PR-2 commit 3. RFC §3.13.
- app/Http/Middleware/BindRequestLogContext.php: tags every Laravel log
line written during the request with request_id, organisation_id,
user_id, and route name. Sets X-Request-Id on the response so the
SPA can correlate to backend log lines via one click.
- Client-supplied X-Request-Id is honoured only if it parses as a ULID
via Str::isUlid. Junk input (empty, non-ULID) is rejected and a
fresh ULID is generated server-side.
- Registered as a global api-group middleware via the prepend list so
it runs before authentication. Unauthenticated 4xx responses still
carry the X-Request-Id header.
- Test count: 1523 to 1532. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-7 PR-2 commit 2.
- app/Http/Middleware/BindSentryContext.php: sets RFC §3.6 tags on the
active Sentry scope (app, http.method, route_name, actor_type,
user_id, organisation_id, event_id, impersonation). Multi-tenant
invariant: throws RuntimeException in local/testing when an auth
request to a tenant-scoped route lacks organisation_id; logs a
warning in production so the user flow still completes.
- app/Listeners/Observability/TagJobAttemptOnSentry.php: tags
queue.attempt on the scope from the JobProcessing event. Default
stack-trace grouping preserved per §3.11.
- ActorType: VOLUNTEER case reserved for a future role split. Current
resolver maps non-admin authenticated users to ORG_MEMBER.
- bootstrap/app.php: registers sentry.context alias. Applied inside
auth:sanctum groups in routes/api.php so it runs after auth.
- AppServiceProvider::boot registers the queue listener.
Test count: 1507 to 1523. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dual-cookie machinery (crewli_app_token + crewli_portal_token,
Origin-based resolution) was load-bearing only when the second SPA
existed. apps/portal/ was deleted in WS-3 PR-B1; the resolver code
has been carrying dead branches since then. Collapse to one cookie.
Cookie name retained as crewli_app_token — no session breakage on
deploy. crewli_portal_token is fully purged from the server-side.
CookieBearerToken middleware:
- COOKIE_NAMES array → single COOKIE_NAME constant
- resolveCookieName method (Origin/Referer parsing, host+port
matching against frontend_app_url/frontend_portal_url) → removed
- Body collapses to: skip if Authorization header present; else
read crewli_app_token cookie and inject Bearer header
SetAuthCookie trait:
- COOKIE_MAP / resolveCookieName / originMatches → removed
- makeAuthCookie / forgetAuthCookie now take only $token; the
cookie name is the trait's private constant
Five callers updated to drop the resolveCookieName($request) line
and the cookie-name argument: LoginController (3 sites),
MfaVerifyController (1 site), AuthRefreshController (1 site),
LogoutController (1 site), InvitationController (1 site — caller
list in the prompt missed this one but the same pattern applies).
frontend_portal_url config key retained (per Phase A directive Q1):
EmailChangeController, PasswordResetController, PersonController are
non-auth consumers that build per-app URL maps for outbound emails.
The map structure is now functionally redundant (production resolves
all FRONTEND_* env vars to the same host) but stays structurally
intact. Refactor tracked as TECH-FRONTEND-URL-CONSOLIDATE in the
upcoming docs commit.
HttpOnlyCookieAuthTest:
- Removed 4 dual-cookie tests (login_sets_portal_cookie_for_portal_origin,
app_cookie_does_not_authenticate_portal_requests,
portal_cookie_does_not_authenticate_app_requests,
correct_cookie_authenticates_with_matching_origin)
- Renamed login_sets_app_cookie_for_unknown_origin →
login_sets_app_cookie_regardless_of_origin; expanded to four
Origin variants (none, app, unknown, foreign) — pins the new
origin-agnostic contract
- Removed Origin headers from request calls in remaining tests
(now meaningless)
Backend test count: 1491 → 1487 (-4 deleted, dual-cookie tests
encoding the obsolete contract). Pint clean. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive enrichment to MeResource — existing fields untouched, MeTest stays green.
New fields:
- contexts.available: list<'portal'|'organizer'> derived from Person + Organisation memberships
- contexts.default: precedence super_admin > organizer > portal > fallback portal
- platform.is_super_admin: bool promoted from app_roles
- organisations[].roles: 1-element array form alongside the legacy scalar role,
forward-compatible for the multi-role pivot work tracked in TECH-PIVOT-ROLES-MULTI
UserFactory gains volunteer(), orgAdmin(), volunteerAndOrganizer(), superAdmin()
state methods — codified role categories for reuse across future workstreams.
Adds forbidden.vue placeholder (PublicLayout) for the context-failure landing in
the upcoming guard rewrite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.
Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
listener_class. orgIndex + platformIndex apply them via a single
applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
resolved_by_user_name, dismissed_by_user_name, exception_trace,
retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
data (timestamp, actor name, outcome, exception details) inside
retry_history[]. Index payloads omit the field via whenLoaded() to keep
list responses lean.
Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
endpoint contract. FormFailuresKpis is now { open, resolved_30d,
dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
filtering, adds listener_class + date-range filter inputs, and renames
the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
header, surfaces an expandable stack-trace card, names the resolved/
dismissed actor in the timeline, and replaces the "v1 placeholder"
retry-history card with a full per-attempt timeline.
ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
rules. `pnpm lint` now runs successfully (was previously broken — the
package.json script referenced a missing config). The 80 baseline
violations across the codebase are pre-existing and out of scope for
this session.
Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
before the parent table is restored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.
Changes:
- New `form_submission_action_failure_retry_attempts` table (cascade on
parent delete, nullOnDelete on user). Explicit short FK names
(`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
the API controller and the artisan command delegate to it. Service
writes a retry_attempt record per attempt; parent's retry_count
stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
("Geslaagde retry door {actor.name}" or "Geslaagde retry
(geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
parent.retry_count++. Parent's exception_class/_message stay
audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
ParentSubmissionGoneException (controller → 410) for cleaner
flow control.
12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
succeeded path, failed path, resolved/dismissed blocking,
multiple-retries chronological ordering, canBeRetried truth tables)
Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
— updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
— child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).
SCHEMA.md → v2.9. Schema dump regenerated.
Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-6 binding-target registry references company.kvk_number as a B2B
identity-key candidate. The column needed to exist on the model
before the registry could legitimately reference it. Nullable
because not every Company has a registered KvK (foreign companies,
partners, agencies); identity-key publish guards enforce presence
where required, not at schema level.
Changes:
- New migration `2026_04_28_140000_add_kvk_number_to_companies_table`
adds nullable string column + index after `type`.
- Company::$fillable expanded.
- CompanyFactory generates an 8-digit KvK by default.
- CompanyKvkNumberTest covers attribute persistence, nullability,
and information_schema-verified index existence.
- SCHEMA.md → v2.8 with the new column row + indexes line.
- Schema dump regenerated (CI fast-path).
Migration step counts in 5 backfill tests bumped +1 (the new
migration sits at the top of the migration stack):
- FormFieldBindingMigrationTest: 18→19, 16→17
- ConditionalLogicBackfillTest: 7→8
- FormFieldConfigBackfillAndDropTest: 13→14
- FormFieldOptionsBackfillTest: 3→4
- FormFieldValidationRuleBackfillTest: 16→17
Refs: WS-6 sessie 3a binding-target drift audit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC §4 V3 compliance — cross-tenant access to FormSubmissionActionFailure
endpoints returns 404, not 403, to prevent resource-existence
enumeration. The FormSubmissionActionFailurePolicy is the single tenant
gate; these tests assert the route-level integration end-to-end.
Production-code finding (in scope per "security gaps zijn altijd urgent"):
the orgIndex endpoint had a real IDOR gap. Original implementation called
`Gate::authorize('viewAny', ...)` which permits any org_admin in any org,
then filtered the result set by the URL's `{organisation}` param. orgB's
admin hitting `/organisations/{orgA}/form-failures` would get back orgA's
failures — leakage.
Fix:
- New policy method `viewAnyInOrganisation(User, Organisation)` that
requires super_admin OR org_admin on THIS specific organisation.
- Controller `orgIndex` calls `authorizeViewAnyInOrgOrNotFound()` which
translates a denied policy → 404 (matches the show/retry/resolve/dismiss
pattern).
- viewAny on the class level stays as the platformIndex gate (super_admin
+ any-org_admin enumeration is acceptable on the platform endpoint
because the role middleware already restricts to super_admin).
Test coverage (24 tests, all passing):
- 5 org-scoped endpoints × cross-tenant scenarios (all return 404)
- 5 platform endpoints × role-class scenarios (org_admin gets 403, never 404)
- Edge cases: soft-deleted parent submission, invalid ULID format,
non-existent ID, unauthenticated, authenticated-without-role on org
The 403 vs 404 distinction matters: role-gated endpoints return 403
(auth-class — "not allowed in this room"); ownership-gated endpoints
return 404 (IDOR-class — "this room doesn't exist for you").
Refs: RFC-WS-6.md §4 V3, ARCH-BINDINGS.md §8.2 (Task 3 of this session)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MySQL 8.0 JSON columns may reorder associative-array keys on
round-trip. For audit-immutable values (schema snapshots, webhook
payloads, activity log diffs), this is corrupting: re-emits produce
different byte sequences for the same logical content.
Introduced JsonCanonicalizer (recursive ksort on associative arrays;
numeric-indexed lists preserve order) and applied at every writer
site that produces byte-stable JSON:
- FormSubmissionService: canonicalize the schema_snapshot array
before storage (audit-immutable per ARCH §4.3, RFC-WS-6 v1.1).
- FormField::logFieldChange / FormSchema::logSchemaChange: canonicalize
activity-log properties before withProperties() so old/new diffs
read back byte-stable.
- BindingActivityLogger: canonicalize both the pass-level and
per-binding activity properties.
- FormWebhookDispatcher: canonicalize payload_snapshot before
storage (delivery-time HMAC re-encodes the same canonical bytes).
- DeliverFormWebhookJob: switched json_encode to
JsonCanonicalizer::encode for the HMAC-signed body, so the
signature is byte-stable across re-deliveries and reproducible by
receivers from the same logical payload.
Sites NOT canonicalized (deliberate):
- form_schemas.settings — opaque UI config; key order has no
semantic meaning, no byte-stability requirement.
- form_schemas.translations / form_fields.translations — read by
display layer; key order doesn't matter.
- form_templates.schema_snapshot — user-supplied input via store/
update; user is the source of truth, not audit-immutable in the
same way as form_submissions.schema_snapshot.
Reverted the 7 assertEquals workarounds from session 2.6:
- ConditionalLogicActivityLogPayloadTest
- ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
- FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
- FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
- FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
- FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
- FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options
Each now uses assertSame on JsonCanonicalizer::encode of both sides —
byte-stable comparison meaningful regardless of MySQL JSON storage
behavior.
New regression test SchemaSnapshotByteStableAcrossReemitsTest
exercises the contract end-to-end: complex schema with bindings,
validation rules, options, conditional logic, submitted; reads
schema_snapshot via three roads (Eloquent cast, fresh model, raw
bytes) and asserts the canonical encode is identical.
ARCH-FORM-BUILDER.md §4.6.1 gets a "Byte-stability" sub-section
explaining what's canonicalized and why.
Test count: 1388 → 1400 (+11 JsonCanonicalizer unit, +1 snapshot
regression). Larastan clean. Rector dry-run unchanged at 355.
Refs: WS-6 session 2.6 deviation #4 cleanup, RFC-WS-6 v1.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original session 2.5 migration had to omit this FK due to an
SQLite-only "rebuild on FK add" cascade-delete quirk. Now that the
test infrastructure has moved to MySQL (Task 1 of this session), the
quirk does not apply and the FK is restored to match every other FK
in this table.
Changes:
- New migration `2026_04_28_100000_restore_default_crowd_type_id_foreign_key`
adds a FOREIGN KEY (default_crowd_type_id) REFERENCES crowd_types(id)
ON DELETE SET NULL. Deleting a CrowdType nulls the column on dependent
schemas instead of cascading the schema delete.
- Original migration's comment block rewritten — the SQLite-quirk
rationale was demonstrably misleading; replaced with a forward-looking
pointer to the FK-restore migration.
- PersonProvisioner::resolveCrowdTypeId() docblock updated: the runtime
failsafe is now defense in depth alongside the DB-level FK + publish
guard, not the sole load-bearing check.
New test (`DefaultCrowdTypeForeignKeyTest`) exercises both the
ON-DELETE-SET-NULL cascade and the existence of the FK in
information_schema.REFERENTIAL_CONSTRAINTS — the second assertion would
have been impossible on SQLite, which is exactly the point.
Migration step counts in 5 backfill tests bumped +1 because the FK-
restore migration sits at the top of the migration stack:
- FormFieldBindingMigrationTest: 17→18, 15→16
- ConditionalLogicBackfillTest: 6→7
- FormFieldConfigBackfillAndDropTest: 12→13
- FormFieldOptionsBackfillTest: 2→3
- FormFieldValidationRuleBackfillTest: 15→16
All 1388 tests pass on MySQL (1386 prior + 2 new FK tests). Larastan
baseline unchanged.
Refs: RFC-WS-6.md v1.1 §3 Q9 addendum, WS-6 session 2.5 deviation #1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test infrastructure now uses the same MySQL 8.0 engine as local dev
and production. SQLite is no longer used anywhere in the project.
Eliminates the SQLite "rebuild on FK add" quirk that forced session 2.5
to omit a foreign key on form_schemas.default_crowd_type_id (Task 2 of
this session restores it).
Configuration:
- phpunit.xml: DB_CONNECTION=sqlite (:memory:) replaced with mysql
pointing at crewli_test database (127.0.0.1:3306, crewli/secret)
- Makefile: new test-db-create target creates crewli_test in the
bm_mysql Docker container; make test ensures it exists before
running suite
Latent-bug surfacing — fixes that MySQL exposed:
1. form_submissions.idempotency_key was declared `ulid()` (VARCHAR 26)
while FormRequest validates `string|max:30`. SQLite ignored the cap;
MySQL truncated and rejected. Column widened to string(30) to match
validation.
2. FormFieldValidationRuleService / FormFieldConfigService /
FormFieldBindingService::snapshotShapesFor — toJsonShape iterated
collection in DB-default order (insertion-stable on SQLite, undefined
on MySQL). Schema_snapshot bytes drifted across re-emits, breaking
audit-replay. Added `->sortBy('id')` (ULID = insertion-order
semantics, deterministic) on all three.
3. FormSubmissionObserverTest::test_denormalized_indexes_exist queried
sqlite_master directly. Replaced with the cross-engine
information_schema.STATISTICS query (the real production check is
on MySQL anyway).
4. JSON column key order non-determinism: MySQL JSON columns may
round-trip associative-array keys in a different order than they
were inserted. assertSame on JSON-derived associative arrays now
uses assertEquals (structural equality) where the test was previously
over-asserting on key order:
- ConditionalLogicActivityLogPayloadTest
- ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
- FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
- FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
- FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
- FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
- FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options
5. Backfill / migration tests (4 classes, 21 tests) ran migrate:rollback
then migrate inside RefreshDatabase's wrapping transaction. MySQL
DDL implicit-commits the surrounding transaction, leaving Laravel
unable to ROLLBACK TO SAVEPOINT at end-of-test (1305 SAVEPOINT
does not exist). Replaced RefreshDatabase with a per-test
migrate:fresh in setUp + RefreshDatabaseState::\$migrated = false to
force the next RefreshDatabase test to re-migrate cleanly:
- FormFieldBindingMigrationTest
- ConditionalLogicBackfillTest
- FormFieldOptionsBackfillTest
- FormFieldValidationRuleBackfillTest
All 1386 tests now pass on MySQL. Larastan baseline unchanged.
Refs: WS-6 session 2.5 deviation #1 cleanup, RFC-WS-6.md v1.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote RFC-WS-6 to v1.1 with two §3 addenda capturing the post-session-2
cleanup decisions; align ARCH-BINDINGS.md §6.4 (Person provisioning)
with the v1.1 text. No architectural reversals — corrections + one
schema addition.
§3 Q8 v1.1 addendum — Person provisioning is scoped by `event_id`:
- Q8 v1.0 said `Person::firstOrCreate(['email', 'organisation_id'], ...)`.
That is incorrect against the actual model: `Person::$organisationScopeColumn`
is `event_id`. The provisioner looks up and creates by `(email, event_id)`.
- Same email registering across two events in the same org → two distinct
Person rows. Cross-event identity reconciliation remains the job of
`PersonIdentityService` (out of scope WS-6).
- Failsafe: `PersonProvisioningException('no_event', ...)` when
`submission.event_id` is null on event_registration; publish guard
`SchemaHasLinkedEvent` blocks at config time.
§3 Q9 v1.1 addendum — `form_schemas.default_crowd_type_id` replaces
`CrowdType::oldest()`:
- Session 2's PersonProvisioner used a silent oldest()-in-org heuristic
for the new Person's `crowd_type_id` (NOT NULL). Fragile, undocumented,
cross-org broken.
- v1.1 adds `form_schemas.default_crowd_type_id` (nullable ULID) as the
explicit, versioned schema attribute. `RequiresDefaultCrowdType` publish
guard wires into `EventRegistrationGuards`. Runtime failsafe in
`PersonProvisioner::resolveCrowdTypeId()` throws
`PersonProvisioningException('no_default_crowd_type', ...)` when null.
- Schema-level FK omitted intentionally (SQLite cascade-delete on
ALTER TABLE ADD FOREIGN KEY observed in WS-5b/c backfill tests).
Application-level integrity (publish guard + runtime failsafe +
Eloquent `belongsTo`) is sufficient because writes always go through
`FormSchemaService::publish()`.
- Snapshot impact: none. Provisioning reads from live FormSchema by
FK; audit replay uses whatever the schema's current
`default_crowd_type_id` is at retry time.
ARCH-BINDINGS.md §6.4:
- Now references "RFC Q8 + Q9, v1.1" in the heading.
- Default-crowd-type bullet replaces "first active CrowdType in the org"
(the session-2 oldest() heuristic) with the schema attribute lookup.
- Multi-tenancy paragraph clarified for cross-event scoping.
Cross-references touched up:
- `PersonProvisioner::resolveCrowdTypeId()` docblock: §3 Q8 → §3 Q9.
- `RequiresDefaultCrowdType` class docblock: §3 Q8 → §3 Q9.
- `SCHEMA.md` v2.7 changelog and `default_crowd_type_id` column note:
§3 Q8 → §3 Q9.
Document history entry added in §10 documenting v1.1 + the snapshot
dual-key cleanup and route-model-binding fix landed in earlier commits
on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session 2 wrote both 'binding' (singular) and 'bindings' (plural)
in form_submissions.schema_snapshot for backward compatibility. With
no production data yet and dev seeders re-running every cycle, dual-
key state has no upside. Snapshots now write 'bindings' only;
all readers updated to match.
FormFieldBindingService::snapshotShapesFor() simplified to return
only ['bindings' => $all]. Pre-existing
SchemaSnapshotEmbedsBindingFromRelationalTableTest updated to assert
the applicator shape (with id, merge_strategy, trust_level,
is_identity_key) on bindings[0]; new
SnapshotOnlyContainsBindingsKeyTest enforces the no-legacy-key
contract going forward.
FormBuilderDevSeeder template snapshot embeds 'bindings' => [] for
form-owned fields (Pattern B) instead of 'binding' => null.
Other 'binding' string occurrences in the codebase (FormFieldResource,
FormFieldService, request validation rules, BindingConflictResolver
internal helper key) are unrelated to snapshot dual-state and remain
untouched.
Refs: WS-6 session 2 deviation #9 cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>