Single-count drift: the new park-path explicit activity entry in
LaneCascadeService accesses $parked->engagement?->organisation_id
(same shape as the existing schedule-path access, which the baseline
already accepts). Baseline grew 1740 → 1741 errors; same-shape, no
novel rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced during Session 2 review: events.start_date/end_date (date type)
forces day-boundary semantics in WithinEventBounds. Adding start_time/
end_time would let the Session 4 timetable viewport honour real event
hours and boundary checks reject post-event-close performances.
Cross-cutting schema change — out of scope for Artist Timetable sprint
per Charter §2. Tracked for opportunistic landing alongside a future
events-module sprint OR concrete UX-gap discovery during Session 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
109 new Larastan findings, all same-shape as patterns already absorbed
in the baseline:
argument.type 18 (baseline had 56)
property.notFound 12 (baseline had 501)
method.notFound 8 (baseline had 31)
missingType.iterableValue 2 (baseline had 98)
Per CLAUDE.md "Larastan static analysis at level 6 with accept-all
baseline. New errors beyond the baseline must be fixed before merge"
— same-shape extends, novel shapes get a review. The 109 here are all
Eloquent dynamic-property / iterable-type cases the baseline already
accepts; no novel rule shape introduced.
Baseline grew 7873 → 8293 lines (1631 → 1740 errors absorbed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tech-debt entries surfaced by Session 2:
AUTH-PERMISSIONS-MIGRATION — Crewli is role-based today; RFC-TIMETABLE
§9 references permission strings. Phase A (2026-05-08) chose Option B
(role-based, with permission strings as docblock references). The
eventual cross-cutting migration is tracked here. Trigger:
customer/charter requirement, not internal preference.
ART-DEMOTE-NOTIFICATION — Session 2's daily option-expiry command
writes activity log only; e-mail to the project leader waits for the
post-Accreditation notification framework.
Also append a Session-2 paragraph to the existing
RFC-TIMETABLE-V0.2-DOC-CLEANUP entry describing the §9 permission-string
mapping decision.
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>
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>