# Waitlist Override Redemption — Analysis and Implementation Plan

**Filed:** August 30, 2026 (Andrew + Claude)
**Verified against:** `ab5d702` (clean tree)
**Triggering incident:** Season 48 (Coed 2026 – Early Fall). Five spots reserved and offered to
waitlisted players; none of them can complete registration.
**Status:** ✅ **IMPLEMENTED AND TESTED. Committed to `fix/waitlist_override_redemption`; unmerged,
undeployed.** `bb28595` (the six patches), `04eb803` (registers), `ad03f9e` (CSRF on the two waitlist
routes), plus the reserved-spot fix in §16.
**Redemption was confirmed working end to end on LOCAL on Sep 6** — see §16, which also records the
two real bugs that test found and one claim in this document that it disproved.
Corrections to earlier drafts are marked **[Corrected …]** throughout.

---

## 1. Headline

**An override cannot be redeemed once a season is hidden or past its registration close date — which
is exactly and only when overrides are ever issued.**

The waitlist feature exists to backfill a roster when an active player drops out mid-season. That
event is *always* after `registration_close` and *always* after an admin has flipped
`registration_visible` off. So the redemption path has never worked in the scenario the feature was
built for. It would only work during the open registration window, when nobody needs it.

The waitlist machinery itself is correct. Offers are created correctly, `reserved_spots` is
incremented and decremented correctly, the notification sends correctly. **Only redemption is
broken.** Nothing in the season registration path ever asks *"does this player hold a valid
override?"* before rejecting them.

---

## 2. Live state that produced this (season 48, from `/admin/ccsoccer/season/48`)

| Field | Value |
|---|---|
| Registration period | May 23 – **Aug 7, 2026** (closed 23 days ago) |
| `registration_visible` | **FALSE** |
| `active` | TRUE |
| `max_players` | 144 |
| Registered (`paid` + `active`) | 140 |
| `reserved_spots` | **5** |
| Spots remaining (displayed) | **−1** = `144 − 140 − 5` |
| On waitlist | 16 |

The `−1` is a red herring — see §4.5. Capacity is not what is blocking anyone.

---

## 3. What already works (do not rebuild this)

Establishing this so the fix stays surgical:

- **`CancelRegistrationForm:429-436`** — when a player cancels and a waitlist exists for the season,
  `reserved_spots` is incremented. The mid-season drop-out correctly protects a seat.
- **`WaitlistController::offerSpot()` → `WaitlistManagerService::offerSpot()`
  (`WaitlistManagerService:110-134`)** — marks the waitlist entry `offered`, creates an Override via
  `OverrideManagerService::createOverride()` with `override_type = 'waitlist'` and a 7-day
  expiration, invalidates the player's cache tag, sends the notification. All correct.
- **`OverrideManagerService::getValidSeasonOverride()`** — resolves and self-expires correctly.
- **`OrderCompleteSubscriber:405-424`** — *does* recognise the override, mark it used, and decrement
  `reserved_spots`. The logic is right; its **position** in the method is wrong (§5).
- **`OrderCompleteSubscriber:598-604`** — **[Corrected Aug 30]** *does* close out the waitlist entry:
  it loads the entry, checks `getStatus() === 'offered'`, and calls `markConverted()`. The first
  draft of this document claimed nothing did this. **That was wrong** — see §7.2 Patch 5.

So: the key is minted correctly. The lock doesn't have a keyhole.

---

## 4. The blockers, in the order a player hits them

### 4.1 — W1 · The emailed link is rejected (this is what your players are seeing)

`NotificationService::sendWaitlistSpotOffered()` (`:1587-1610`) builds the offer email around a
single link:

```php
$register_url = \Drupal\Core\Url::fromRoute('ccsoccer.register.add_season',
  ['season' => $season->id()], ['absolute' => TRUE])->toString();
```

That route (`ccsoccer.routing.yml:1051`, `/register/season/{season}`, `_user_is_logged_in: TRUE`)
lands on `RegistrationController::addSeasonToCart()`. Its first guard, **`RegistrationController:1129-1135`**:

```php
// Guard: season must still be open for registration.
if (!$season->get('registration_visible')->value) {
  $this->messenger()->addError($this->t('Registration for @season is no longer available.', [
    '@season' => $season->label(),
  ]));
  return $this->redirect('ccsoccer.register');
}
```

Season 48 has `registration_visible = FALSE`. **Every offered player gets "Registration for Coed 2026
– Early Fall is no longer available."** and is bounced to `/register`.

**Provenance:** introduced in `a019bb2` (June 4, 2026, *"feat: enforce registration_visible and
active flags at checkout"*). That commit added three correct guards — cart-add for seasons, cart-add
for tournaments, and order-completion — and none of them carry an override exemption. It is a
straightforward oversight, not a design disagreement: the override concept predates it by months.

### 4.2 — W2 · The fallback page doesn't list the season either

`RegistrationController::register()` at **`:214-220`**:

```php
// Load only seasons with registration_visible = TRUE
$season_storage = $this->entityTypeManager->getStorage('season');
$season_query = $season_storage->getQuery()
  ->condition('registration_visible', TRUE)
  ->accessCheck(FALSE);
```

The season is excluded at the query level, before any per-user state is computed. So the player
bounced by W1 arrives at a page that does not mention season 48 at all — no error, no card, no
explanation. From the player's side this reads as "the league lost my spot."

### 4.3 — W3 · Even with visibility on, the closed date kills the card

`getSeasonState()` (`:587-655`) computes `'registration_open' => $season->isRegistrationOpen()`
(`:596`). `Season::isRegistrationOpen()` (`Season.php:366-388`) returns FALSE because today is past
the Aug 7 `registration_close`. Then at **`:637-654`** the state is stamped `status = 'closed'`.

`buildSeasonCard()` (`:664+`) branches in this order:

| Line | Branch | Renders |
|---|---|---|
| 665 | `$state['is_registered']` | "You Are Registered" |
| **714** | **`$state['status'] === 'closed'`** | **"Registration Deadline Has Passed" — no button** |
| 724 | `$state['status'] === 'not_yet_open'` | "Registration Opens …" |
| **735** | **`$state['registration_open']`** | *(everything below is nested here)* |
| 737 | └ `$state['has_override'] or !$state['is_full']` | "Spot Reserved For You" + Register button |
| 788 | └ `waitlist_status === 'pending'` | "You Are On The Waitlist" |
| **794** | └ **`waitlist_status === 'offered'`** | **"Spot Offered From Waitlist" + Register button** |
| 826 | └ `else` | "Season Full" + Join Waitlist button |

**The two branches written specifically for override holders — 737 and 794 — are nested inside
`elseif ($state['registration_open'])` at 735, which is unreachable once `status` is `closed` at
714.** The "Spot Reserved For You" and "Spot Offered From Waitlist" messaging is dead code from the
moment `registration_close` passes.

This is a second, independent blocker. Fixing W1 alone leaves the card mute; fixing W3 alone leaves
the button 404-equivalent. **Both must land together.**

### 4.4 — W4 · Order completion burns the override, then rejects the registration

See §5 — this is the one with money attached.

### 4.5 — Capacity: not a blocker *on season 48* — ⚠ **[Corrected Sep 6] but the reasoning below
concealed a real bug**

The observations here are all accurate. The conclusion drawn from them was too narrow, and the Sep 6
LOCAL test proved it (§16). **Read this section together with §16 or it will mislead you.**

- `Season::isFull()` / `getSpotsRemaining()` (`Season.php:339-361`) return `144 − 140 − 5 = −1`,
  i.e. full. **Neither `addSeasonToCart()` nor the cart subscriber consults them.**
- `CartEventSubscriber::onCartEntityAdd()` (`:91-124`) counts `status = 'paid'` only against
  `max_players`: `140 < 144` → passes.
- `OrderCompleteSubscriber:447-455` does the same `paid`-only count: passes.

On season 48's numbers, four seats of headroom meant capacity gated nothing and the displayed `−1`
was cosmetic. **That was true, and it was the wrong thing to conclude from.** What these three
bullets actually show — and what nobody, including this document, said out loud — is that
**`reserved_spots` appears in no capacity comparison anywhere.** It is incremented on cancel,
rendered on two admin screens, and decremented on redemption, but never *subtracted*. A reserved
seat therefore reserved nothing, and any player with the registration URL could take one.

The tell was sitting in the first bullet the whole time: `getSpotsRemaining()` is the only function
in the codebase that subtracts `reserved_spots`, and the same sentence notes that nothing consults
it. "Capacity is not a blocker here" was read as "capacity needs no attention," and the gap survived
into the shipped branch until a test with real numbers found it. Fixed in §16.

(The three-checkpoints-disagree problem remains **P6 / Decision 2** in `OUTSTANDING_ISSUES.md` and
is still out of scope — see §11.)

---

## 5. W4 in detail — the data-integrity bug

`OrderCompleteSubscriber::createSeasonRegistration()` currently runs in this order:

```php
// :405-424  — CONSUME
$override = $override_manager->getValidSeasonOverride($user->id(), $season->id());
if ($override) {
  $override_manager->markOverrideUsed($override);          // status → 'used', saved
  $reserved_spots = (int) $season->get('reserved_spots')->value;
  if ($reserved_spots > 0) {
    $season->set('reserved_spots', $reserved_spots - 1);   // saved
    $season->save();
  }
}

// :426-428  — reload
$season = $this->entityTypeManager->getStorage('season')->load($season->id());

// :430-446  — REJECT
if (!$season->get('active')->value || !$season->get('registration_visible')->value) {
  // logs "Payment was collected — admin follow-up required"
  $order->setData('ccsoccer_season_registration_failed', ['reason' => 'season_inactive', ...]);
  $order->save();
  return;                                                  // no registration created
}

// :447-478 — capacity check, same shape, same `return`
```

**The override is consumed before the guards that can reject the registration.** If either guard
fires, the outcome is:

- payment captured ✅
- `Override.status = 'used'` — unrecoverable without manual edit ❌
- `Season.reserved_spots` decremented — the seat that was being held for this player is gone ❌
- no `ccsoccer_registration` row ❌
- waitlist entry still `offered` (nothing resets it)
- order tagged `ccsoccer_season_registration_failed`, surfacing only in dblog (**S3** — the admin
  report for these flags is not built)

The order flag is a good instinct and should stay, but it records the *money*, not the *override* or
the *seat*. An admin resolving one of these by hand today would have to notice, unprompted, that
`reserved_spots` needs incrementing back and the Override needs flipping from `used` to `active`.

**Practical exposure right now:** low but non-zero — a player who added season 48 to their cart
before it was hidden and pays afterward. **After the §7 fix it becomes zero for the override path**
(the guard will no longer fire for override holders), but the ordering should be corrected anyway,
because the `active = FALSE` and capacity guards can still fire.

This is the same family as **D3** in `OUTSTANDING_ISSUES.md` §P3. **[Corrected Aug 30]** D3 is half
done and the register was misleading about it: `1b317f5` (Aug 23, *"Block registration for a
tournament that has already taken place"*) added an `end_date` guard to **`addTournamentToCart()`**.
It was never ported to **`createTournamentRegistration()`**, so cart-add is guarded and order
completion is not. See §13.

---

## 6. Immediate ops workaround — ✅ APPLIED to season 48 (Andrew, Aug 30)

**Set `Registration Visible = TRUE` on season 48** (Edit Season). This unblocks the emailed offer
links today.

> **Applied and confirmed.** Season 48 is visible again and `/register` shows the card reading
> **"Registration Deadline Has Passed"** with no button — which is exactly the W3 behaviour predicted
> below, now observed live. The emailed offer links should work from this point; the public card
> stays inert. **This is the workaround functioning as designed, not a new fault.**
>
> Still outstanding before the offers can actually convert: **re-issue or extend the expired
> overrides** (see the second caveat below, and Patch 6 for why the existing Extend button will not
> do it correctly today).

Why it does *not* re-open public registration:

| Path | With `registration_visible = TRUE` and close date passed |
|---|---|
| `/register` card | `status = 'closed'` → branch 714 → "Registration Deadline Has Passed", **no button, no Join Waitlist button** |
| Emailed link `/register/season/48` | `addSeasonToCart()` checks `registration_visible` ✅, `active` ✅, age, photo — **never checks `isRegistrationOpen()`** → proceeds to cart |
| Cart add | `140 paid < 144` → passes |
| Order complete | `registration_visible` now TRUE ✅, `140 < 144` ✅ → **registration created, override marked used, `reserved_spots` decremented** |

So the season is invisible-but-reachable, which is very nearly the behaviour we actually want.

**Two caveats before doing this:**

1. **`/register/season/48` becomes open to anyone who has or guesses the URL.** It is not restricted
   to override holders — `addSeasonToCart()` does not check for one. Exposure is bounded by the four
   seats of headroom under `max_players` (144 − 140), and by the URL not being published anywhere,
   but it is real. This is the single strongest argument for doing §7 properly rather than living on
   the workaround.
2. **Check the Overrides page first.** `offerSpot()` grants **7 days**. Offers made before ~Aug 23
   are already expired, and `getValidSeasonOverride()` silently flips them to `expired` on read.
   Anything stale needs re-issuing (or extending via `/admin/ccsoccer/override/{id}/extend`) or the
   player will hit "not available" for a different reason. Related: **S4** — a waitlist entry stays
   `offered` forever with no cron pass, so some of the 16 waitlist / 5 reserved may be stale in both
   directions.

**Revert plan:** flip `Registration Visible` back to FALSE once the five seats are filled or the
offers lapse.

---

## 7. The fix

### 7.1 The design decision to make first

> **An override is a key that opens a closed door for one named player.**

Concretely, a valid override for `(player, season)` should exempt that player from:

- ✅ `registration_visible = FALSE` — **yes.** This is the whole point.
- ✅ `registration_close` in the past — **yes.** Same reason.
- ✅ `Season::isFull()` — **yes**, already the intent; `reserved_spots` is what makes the seat real.
- ❌ `active = FALSE` — **no.** An inactive season is archived/deleted-ish; nobody should register.
- ❌ `max_players` hard cap at order completion — **no.** Keep as the last-resort backstop.
- ❌ Age/gender eligibility — **already handled separately** by `override_type = 'age'` via
  `getValidAgeOverride()` (`RegistrationController:1554-1560`). Don't merge the two concepts.

**This needs Andrew + Caleb sign-off before coding**, because it is a genuine policy statement, not
a bug fix. The alternative framing — "an override only exempts capacity, and admins must re-open the
season to use one" — is defensible and much cheaper (§7.3), but it makes every mid-season backfill a
two-step manual dance with a public-exposure window, which is what we have today.

### 7.2 Patch set — five touch points

All in `web/modules/custom/ccsoccer/`. Suggested branch: `fix/waitlist_override_redemption`.

---

**Patch 1 — `src/Controller/RegistrationController.php:1129-1135` (addSeasonToCart)**
*The load-bearing fix. Without this nothing else matters.*

```php
// BEFORE
if (!$season->get('registration_visible')->value) {
  $this->messenger()->addError($this->t('Registration for @season is no longer available.', [
    '@season' => $season->label(),
  ]));
  return $this->redirect('ccsoccer.register');
}

// AFTER
$override_manager = \Drupal::service('ccsoccer.override_manager');
$has_override = !$this->currentUser()->isAnonymous()
  && (bool) $override_manager->getValidSeasonOverride($this->currentUser()->id(), $season->id());

// A valid override is a key for a closed door — it exempts the holder from the
// visibility gate and the registration_close date, but never from `active`.
// See WAITLIST_OVERRIDE_REDEMPTION_PLAN.md §7.1.
if (!$season->get('registration_visible')->value && !$has_override) {
  $this->messenger()->addError($this->t('Registration for @season is no longer available.', [
    '@season' => $season->label(),
  ]));
  return $this->redirect('ccsoccer.register');
}
```

The `active` guard immediately below (`:1136-1141`) is **left exactly as is** — no exemption.

> **Note:** this method is also reached from `available()` (`:127`, invite-token arrivals) and from
> `getSeasonState`-driven card buttons. Injecting `ccsoccer.override_manager` properly via the
> constructor is cleaner than `\Drupal::service()`, but the class already uses `\Drupal::service()`
> for this exact service at `:181` and `:1556`, so matching local style is acceptable if the
> implementer prefers a minimal diff. **Pick one and be consistent within the file.**

---

**Patch 2 — `src/Controller/RegistrationController.php:214-220` (register listing)**

The `registration_visible` filter cannot stay a pure query condition, because seasons the current
user holds an override for must also appear. Two options:

**2a (recommended) — union the override'd season IDs into the load:**

```php
// Load seasons with registration_visible = TRUE, PLUS any season this user
// holds a valid override for (which is how a hidden, closed season reaches
// exactly the players who were offered a spot).
$season_storage = $this->entityTypeManager->getStorage('season');
$season_ids = $season_storage->getQuery()
  ->condition('registration_visible', TRUE)
  ->accessCheck(FALSE)
  ->execute();

if (!$current_user->isAnonymous()) {
  foreach ($override_manager->getUserOverrides($current_user->id()) as $override) {
    // getUserOverrides() already filters to valid (active + unexpired).
    if (!$override->get('season')->isEmpty()) {
      $sid = $override->get('season')->target_id;
      $season_ids[$sid] = $sid;
    }
  }
}
$seasons = $season_storage->loadMultiple($season_ids);
```

`OverrideManagerService::getUserOverrides()` already exists and already filters to valid overrides,
so this adds one service call and no new query patterns. Note it returns **both** season and
tournament overrides — the `->get('season')->isEmpty()` check is required.

**2b — drop the query filter, gate per-season inside the loop.** Simpler to read, but loads every
season on every `/register` hit. With ~50 seasons in the table today that is survivable but wasteful.
**Recommend 2a.**

⚠️ **Cache:** `/register` output must vary per user for this to be correct. `offerSpot()` already
invalidates `user:{uid}:registrations` (`WaitlistManagerService:130`), so confirm that tag is
actually attached to the `/register` render array — **if it is not, the fix will appear to work for
the implementer and silently fail for real players behind page cache.** Worth an explicit check;
this is the most likely way this patch set ships broken.

---

**Patch 3 — `src/Controller/RegistrationController.php:596` + `:637` (getSeasonState)**

`has_override` is already computed at `:618-621`, but the closed-date logic at `:637` overwrites
`status` without consulting it. Make the override survive:

```php
// In the "Determine status" block at :637, wrap the whole thing:
if (!$state['registration_open'] && !$state['has_override']) {
  // ... existing not_yet_open / closed logic, unchanged ...
}
```

Effect: an override holder keeps `status = 'open'`, and `registration_open` stays FALSE in the state
array (which is still true and still useful for messaging). Patch 4 then needs to branch on the
override rather than on `registration_open`.

**Also add** `'has_valid_override_seat' => ...` or similar if you want the card to distinguish
"reserved for you" from "offered from waitlist" — but `waitlist_status` at `:628-632` already carries
that, so probably unnecessary.

---

**Patch 4 — `src/Controller/RegistrationController.php:714` and `:735` (buildSeasonCard)**

Hoist the override branch above the closed/not-yet-open branches:

```php
if ($state['is_registered']) {
  // ... unchanged (line 665)
}
elseif ($state['has_override']) {
  // NEW — highest-priority non-registered branch. An override holder sees a
  // live Register button regardless of visibility or the close date.
  // Message text depends on origin: waitlist offer vs. admin-granted.
  $message = $state['waitlist_status'] === 'offered'
    ? 'Spot Offered From Waitlist'
    : 'Spot Reserved For You';
  // ... status-box + Register button, lifted verbatim from the existing
  //     :737-825 body (product lookup, anonymous vs. logged-in link) ...
}
elseif ($state['status'] === 'closed') {
  // ... unchanged (line 714)
}
// ... rest unchanged
```

Then **delete** the now-dead `$state['has_override']` test from the condition at `:737` (it becomes
`if (!$state['is_full'])`) and **delete** the `waitlist_status === 'offered'` branch at `:794`,
whose body has moved up. Leaving duplicates in place is how this drifts.

⚠️ An expiry hint in the card would help a lot here — the player has 7 days and currently only the
email says so. `$override->get('expiration_date')` is available; consider threading it into
`$state`.

---

**Patch 5 — `src/EventSubscriber/OrderCompleteSubscriber.php:405-455` (ordering + exemption)**

Two changes in one edit:

1. **Move the override consumption block (`:405-424`) to *after* both hard blocks**, so an override
   is never burned by a registration that then fails to be created.
2. **Exempt override holders from the `registration_visible` half of the guard at `:433`** — the
   `active` half stays absolute.

```php
// 1. LOOK UP the override (do not consume yet).
$override_manager = \Drupal::service('ccsoccer.override_manager');
$override = $override_manager->getValidSeasonOverride($user->id(), $season->id());

$season = $this->entityTypeManager->getStorage('season')->load($season->id());

// 2. HARD BLOCK — `active` is absolute; `registration_visible` yields to a valid override.
if (!$season->get('active')->value
    || (!$season->get('registration_visible')->value && !$override)) {
  // ... existing logger + setData('ccsoccer_season_registration_failed') + return, unchanged ...
}

// 3. HARD BLOCK — capacity backstop, unchanged (:447-478).

// 4. CONSUME the override, now that the registration is definitely being created.
if ($override) {
  $override_manager->markOverrideUsed($override);
  $reserved_spots = (int) $season->get('reserved_spots')->value;
  if ($reserved_spots > 0) {
    $season->set('reserved_spots', $reserved_spots - 1);
    $season->save();
  }
  $this->logger->notice('Override @oid used for user @uid season @sid', [...]);
}
```

⚠️ **Watch the reload.** The current code reloads `$season` at `:428` *because* the consume block
saved it. After reordering, the reload must still happen before the guards (season may have been
edited mid-checkout), and the consume block at the end must operate on a `$season` that is fresh —
re-read `reserved_spots` off the reloaded object, which the snippet above does.

⚠️ **[Corrected Aug 30] The waitlist entry is already closed out — do not add this.** The first
draft of this document claimed nothing moved `ccsoccer_waitlist.status` off `offered`. **That was
wrong.** `OrderCompleteSubscriber:598-604`, at the end of the same method, already does:

```php
$waitlist_entry = $waitlist_manager->getUserWaitlistEntry($user->id(), $season->id());
if ($waitlist_entry && $waitlist_entry->getStatus() === 'offered') {
  $waitlist_entry->markConverted();
  $this->logger->notice('Waitlist @wid converted for user @uid season @sid', [...]);
}
```

`Waitlist::markConverted()` exists (`Waitlist.php:147-150`) and `converted` is a declared allowed
value. **The only thing to check when reordering Patch 5** is that this block still sits after the
early `return`s — it does, and it must stay there, or a blocked registration would mark the waitlist
entry converted while the player has no roster spot.

---

**Patch 6 — Re-offer an expired waitlist spot** *(added Aug 30 at Andrew's request)*
`src/Controller/WaitlistController.php:118-145` + `src/Service/WaitlistManagerService.php:110-134`
+ `src/Controller/OverrideController.php:441-462`

**The problem.** Once a spot is offered, the entry is a permanent dead end on the Manage Waitlist
page. Two things cause that:

1. **`WaitlistController::offerSpot():~176`** opens with a hard guard:
   ```php
   if (!$waitlist->isPending()) {
     $this->messenger()->addError($this->t('This waitlist entry is not pending.'));
     return $this->redirect('ccsoccer.waitlist.manage');
   }
   ```
2. **The button only renders for `isPending()`** (`WaitlistController:118-129`). An `offered` row
   gets only **Cancel**.

So when a 7-day offer lapses, the admin's only route is Cancel-and-rejoin, which destroys the
player's queue position (`created` is what `getNextPending()` sorts on). That is the exact hole
Andrew hit on season 48.

**What already exists elsewhere.** `OverrideController` has two relevant actions, but only on
`/admin/ccsoccer/overrides` — not on the waitlist page:

- **`extendOverride():441-462`** — pushes `expiration_date` out by 7 days.
- **`nudgeOverride():467-511`** — re-sends `sendWaitlistSpotOffered()` with a 48-hour cooldown
  tracked in the `last_nudged` field.

Between them that *is* "offer again," just not reachable from where the admin is looking.

**⚠️ Two bugs to fix as part of this, or the button will lie:**

**(a) `extendOverride()` extends from the wrong baseline.**

```php
$current_expiration = new \DateTime($override->get('expiration_date')->value);
$new_expiration = clone $current_expiration;
$new_expiration->modify('+7 days');
```

For an override that expired Aug 6, this produces **Aug 13 — still in the past**, and
`isValid()` still returns FALSE. The admin sees "Override extended to Aug 13, 2026" and nothing
works. **This affects the season 48 cleanup right now.** Fix:

```php
$now = new \DateTime();
$current = new \DateTime($override->get('expiration_date')->value);
$new_expiration = ($current > $now ? clone $current : $now);
$new_expiration->modify('+7 days');
```

**(b) A naive re-offer mints a duplicate Override row.**
`WaitlistManagerService::offerSpot()` calls `createOverride()` unconditionally. Called a second time
for the same `(player, season)`, it creates a **second** Override while the first sits `expired`.
**This is very likely the source of P11's "two override records may exist in parallel."** The
re-offer path must reuse the existing row:

```php
// In WaitlistManagerService::offerSpot(), before creating:
$override = $override_service->getValidSeasonOverride($player->id(), $season->id());
if (!$override) {
  // Also look for a non-valid (expired) row for this player+season and revive it,
  // rather than minting a parallel record. Only create if there is genuinely none.
}
```

**Recommended shape.** Add a single **"Re-offer Spot"** action on the waitlist page for rows where
`isOffered()`, which in one click: extends (or revives) the *existing* override by 7 days from today,
resets `last_nudged` so the cooldown does not block the send, re-sends the offer email, and leaves
`created` untouched so queue position survives. Route it as
`ccsoccer.waitlist.reoffer` → `WaitlistController::reofferSpot()`, mirroring the existing
`ccsoccer.waitlist.offer` route.

**Open question O2 (§8) covers the button label and whether the 48h cooldown should apply.**

---

### 7.3 Cheaper alternative, if §7.1 is rejected

If the board would rather not give overrides door-opening power: keep all guards absolute, and
instead add an admin action **"Open for waitlist redemption"** that sets `registration_visible = TRUE`
while a new `waitlist_only = TRUE` flag suppresses the public card and the Join Waitlist button.
Roughly the §6 workaround, made explicit and safe. Cost is lower on logic but adds a field, a form
element, an admin action and a migration. **Recommend §7.2 instead** — it is less machinery and it
matches what "override" already means everywhere else in the module.

---

## 8. Decisions — ✅ SETTLED (Andrew, Aug 30, 2026)

| # | Question | Decision |
|---|---|---|
| **1** | Adopt §7.1 — does a valid override exempt a player from `registration_visible` and `registration_close`? | ✅ **Yes.** A valid override allows registration after registration is closed and hidden. |
| **2** | Should cart-add still refuse non-override holders on a hidden season? | ✅ **Yes.** Only players with a valid override may register once registration is closed and hidden. |
| **3** | Patch 2a (union query) vs 2b (load-all)? | ✅ **2a.** |
| **4** | Waitlist terminal status on successful registration | ✅ **Already solved, no work needed.** See below. |
| **5** | Apply the §6 workaround to season 48 now? | ✅ **Yes — done.** Applied Aug 30; behaves as predicted (§6). |
| **6** | Port to tournaments? | ❌ **No.** Tournaments already have a working equivalent: registration closes for free agents while captains can still invite players to their teams. Do not touch `addTournamentToCart()`. D3 stays a separate tracker item. |

### Note on decision 4 — answered by the code, not a judgement call

`Waitlist.status` is a `list_string` with **five** allowed values, not three
(`Waitlist.php:66-77`):

| Value | Label | Written by |
|---|---|---|
| `pending` | Pending | Default on `joinWaitlist()` |
| `offered` | Offered | `markOffered()` ← `offerSpot()` |
| **`converted`** | **Converted** | **`markConverted()` ← `OrderCompleteSubscriber:602`** |
| `cancelled` | Cancelled | `markCancelled()` ← `cancelWaitlist()` |
| `expired` | Expired | **nothing — dead value** |

So `converted` is the value you were looking for, and it is **already wired up**. A player who
successfully registers off an offer already lands on `Converted`; `WaitlistController:111` even
renders it green. Nothing to build.

The genuinely dead value is **`expired`** — declared, styled grey at `WaitlistController:114`, and
never written by anything. That is the other half of **S4** (no cron pass ages out a stale offer).
Patch 6 gives the admin a manual way to recover from a lapsed offer; it does not make `expired`
start being written. Filing that as a follow-up rather than folding it in.

---

## 8b. Open questions before implementation *(Andrew, Aug 30)*

Three narrow ones, each with a default. "Go with your defaults" is a complete answer.

- **O1 — Should the offer email be re-sent automatically when Patch 1 lands?** The five current
  offers were emailed a link that failed. Once cart-add accepts overrides, that same link starts
  working — but the players have already tried it and been told no. **Default: no automatic re-send**
  (nothing in the patch set sends mail), and Andrew re-offers the five by hand via Patch 6 once it
  ships. Alternative: a one-off drush command to re-notify holders of valid overrides on season 48.

- **O2 — Patch 6 button label and cooldown.** Options were "Renew Offer" / "Extend Offer" /
  "Offer Again". **Default: "Re-offer Spot"** — it parallels the existing "Offer Spot" and says what
  happens, where "extend" reads as a silent date change with no email. On the 48-hour cooldown
  `nudgeOverride()` enforces: **default is to bypass it for an explicit admin Re-offer** (the admin
  is deliberately acting on a lapsed offer, not nudging a live one) while leaving the cooldown in
  place for the existing Nudge button.

- **O3 — The two waitlist counts disagree, on screen, right now.** The season page shows
  **16 on waitlist** (`SeasonController:148` counts `status = 'pending'` only) while Manage Waitlist
  shows **25 on waitlist** (`WaitlistController:66` uses `getSeasonWaitlist()` with no status
  filter, so it includes cancelled, offered and converted rows). Neither number is wrong; the labels
  are. **Default: leave the counts alone and relabel the Manage Waitlist heading** to
  "(25 entries — 16 pending)". Low risk, five-line change, but it is scope creep on this branch —
  say the word and it goes in, otherwise it becomes its own tracker entry.

---

## 9. Verification plan

**There is no automated test suite in this module** (`find . -name "*Test.php"` → nothing, no
`tests/` directory). All verification is manual. Do it on DEV against a cloned season, then repeat
step 6 on PROD.

Set up a fixture season with: `registration_visible = FALSE`, `registration_close` in the past,
`active = TRUE`, `reserved_spots = 1`, one waitlisted test player with a fresh offer.

| # | Step | Expected after fix |
|---|---|---|
| 1 | Offered player opens the emailed link | Reaches checkout — no "no longer available" error |
| 2 | Offered player loads `/register` | Season card appears, "Spot Offered From Waitlist" + Register button |
| 3 | **Non**-offered logged-in player loads `/register` | Season absent — *this is the regression that matters most* |
| 4 | Non-offered player pastes `/register/season/{id}` | "Registration for … is no longer available." |
| 5 | Offered player completes payment | Registration created; `Override.status = 'used'`; `reserved_spots` 1 → 0; waitlist entry no longer `offered` |
| 6 | Set `active = FALSE`, retry step 1 | Blocked — override does **not** override `active` |
| 7 | Fill season to `max_players`, retry step 1 | Blocked at the capacity backstop; **confirm the override was NOT consumed** (Patch 5) |
| 8 | Expire the override (`expiration_date` in the past), retry step 1 | Blocked; override auto-flips to `expired` |
| 9 | Anonymous hit on `/register` | No fatal from the `getUserOverrides()` call — anonymous guard works |
| 10 | Log in as offered player in one browser, non-offered in another, same season | Cards differ — **proves the page cache varies per user** (see Patch 2 cache warning) |
| 11 | Let the offer lapse, then click **Re-offer Spot** (Patch 6) | Expiration moves to **today + 7**, not old-expiry + 7; email sends; `created` unchanged so queue position holds |
| 12 | After step 11, check the Overrides admin page | **Exactly one** Override row for that player+season — no parallel record (Patch 6b) |
| 13 | Complete registration off a re-offered spot | Waitlist entry shows **Converted**, not stuck on Offered |

Steps 3, 7, 10 and 12 are the ones that catch a bad implementation. Do not skip them.

---

## 10. Related tracker entries

Cross-reference when filing; none of these are superseded by this document.

- **S4** (`OUTSTANDING_ISSUES.md`, Parked) — waitlist offers expire in the email but never in the
  system: no cron pass, entry stays `offered` forever, next in line is never offered, reserved spot
  held indefinitely. **Patch 5's waitlist-status change is a partial fix; the cron expiry is not.**
- **S3** (Parked) — the five money-collected-but-action-failed flag states have no admin report.
  §5's failure mode writes one of them.
- **P6 / Decision 2** — three season-capacity checkpoints disagree on both status set and
  `reserved_spots`. **Deliberately out of scope** (§11), but Patch 5 touches the same method, so
  whoever does P6 should read this first.
- **D3** (§P3) — tournament order completion never re-checks `active`/`registration_visible`. Same
  family as Patch 5; see decision 6 in §8.
- **P11** — **[Corrected Aug 30, after Caleb's `392578b`] my "parallel records" theory was wrong.**
  This document previously guessed that Patch 6b identified the mechanism — that `offerSpot()`
  calling `createOverride()` unconditionally was minting the parallel PROD rows. Caleb disproved it
  the same day: there were never two stores in play on PROD. `dailyOverrideReminders()` reads
  `ccsoccer_registration.override_expires`, which returns **zero rows and always has**; overrides
  live solely as `ccsoccer_override` entities. **Patch 6b is still a correct fix** — an unconditional
  `createOverride()` on re-offer really would mint duplicates going forward — but it explains nothing
  about what was already on PROD. See §15.
  The other half of P11, *"cancelling a waitlist entry does not revoke the override"*, **is fixed by
  this branch** (§13 R1).
- **D3** (§P3) — **[Aug 30] partially closed by this branch.** Decision 6 stands: the season
  override work is not ported to tournaments. But the *date* half of D3 is now fixed at order
  completion (§13), which is a different question from override redemption and was requested
  separately. What remains open in D3 is the `active` / `registration_visible` / `status` re-check at
  tournament completion, which is deliberately **not** being added — see §13 for why.

---

## 11. Explicitly out of scope

- **Unifying the capacity checkpoints** (P6 / `CapacityManagerService`). Patch 5 edits
  `createSeasonRegistration()` but must not redefine `Season::isFull()` or `getSpotsRemaining()` —
  see the standing warning in P6.
- **Automated waitlist progression** — stays manual by requirement.
- **Cron expiry for waitlist offers** (S4) — separate, and only worth it if season waitlists get
  real use. Five live offers on season 48 suggests they now do. Patch 6 gives the admin a manual
  recovery for a lapsed offer, which takes the urgency off this without closing it.
- **Writing the `expired` waitlist status.** Declared and styled, never written (see §8 note on
  decision 4). Belongs with the S4 cron pass, not here.
- **Tournaments** — decision 6. Not touching `addTournamentToCart()`.
- **The `−1 spots remaining` display.** Arithmetically correct; the confusion is that the number
  gates nothing on this path. Consider a tooltip, not a logic change.


---

## 12. Implementation record — Aug 30, 2026

All six patches applied as **working-tree edits only. Nothing staged, nothing committed.**
Every file passes `php -l`; `ccsoccer.routing.yml` parses and the new route resolves.

| File | Patches | Lines |
|---|---|---|
| `src/Controller/RegistrationController.php` | 1, 2, 2b, 2c, 3, 4 | +189 |
| `src/EventSubscriber/OrderCompleteSubscriber.php` | 5 | +61 |
| `src/Service/WaitlistManagerService.php` | 6b | +119 |
| `src/Controller/WaitlistController.php` | 6c | +62 |
| `src/Controller/OverrideController.php` | 6a | +20 |
| `ccsoccer.routing.yml` | 6c | +13 |

### Verified during implementation

- **`buildSeasonCard()` branch order is now:** `is_registered` → **`has_override`** → `closed` →
  `not_yet_open` → `registration_open`. The override branch sits above every date-derived branch,
  which is the whole point of Patch 4.
- **Both dead branches are gone.** The `has_override ||` test in the open arm collapsed to
  `!$state['is_full']`, and the unreachable `waitlist_status === 'offered'` branch was removed (a
  comment marks where it was and where its body went).
- **`OrderCompleteSubscriber` order is now** lookup (`:404`) → reload (`:416`) → active/visibility
  block (`:419`) → capacity block (`:454`) → **consume (`:476`)** → `markConverted()` (`:619`).
  Confirmed by inspection that **no early `return` exists between the consume block and the
  registration save**, so the override and the seat can no longer be spent on a registration that
  never gets created.

### Two additions beyond the written spec

Both are small, both are defensive, both are called out so they get looked at rather than skimmed:

- **Patch 2c — an override does not surface an INACTIVE season.** The union in Patch 2 filters on
  `active = TRUE`. Without it, an override on a deactivated season would put a card with a Register
  button on `/register` that `addSeasonToCart()` then rejects on the very next click. The override
  lifts the *visibility* gate only, which is what §7.1 says.
- **Patch 2b — `ccsoccer_override_list` added to the page's cache tags.** Overrides now decide
  whether a hidden season appears at all, so any override write has to rebuild `/register`.
  `offerSpot()` invalidated only the per-user tag, and an override created straight from the admin
  forms invalidated nothing.

### Cache question from Patch 2 — resolved, no change needed

The warning said this patch set's most likely silent failure was `/register` not varying per user.
It already does: the render array's cache contexts at `RegistrationController:~383` are
`['user', 'url.query_args:filter', 'url.query_args:invite']`. The `user` context is what makes the
override union safe. **Verification step 10 is still worth running** — the context being declared and
the page actually varying are two different claims.

### Not done, by decision

- Tournaments (decision 6) — `addTournamentToCart()` untouched.
- Automatic re-send of the five season 48 offer emails (O1) — nothing in this patch set sends mail
  on deploy. Re-offer them by hand with the new button.
- The waitlist count relabel (O3) — left for its own tracker entry.

### What to do on DEV first

1. `drush cr` — the new route will not resolve until the router is rebuilt.
2. Walk §9 steps **3, 7, 10 and 12**. Those four are the ones that catch a bad implementation:
   a non-offered player must NOT see the season, a capacity block must NOT consume the override,
   the page must actually vary per user, and a re-offer must leave exactly one Override row.


---

## 13. Post-implementation review — Aug 30, 2026

An independent adversarial review was run over the finished change set. **It found eight issues, two
of them High.** All were caused or escalated by this branch. Seven are fixed below; one is recorded
as a follow-up.

### Fixed: R1 — cancelling a waitlist entry left a live key `[High]`

`WaitlistManagerService::cancelWaitlist()` marked only the Waitlist entity. **P11 already logged this
as a tidiness item, and it was right to be relaxed about it — an orphaned override pointed at a
hidden, closed season and could not be redeemed. This branch turns it into a working key.**

*Failure it allowed:* admin offers the season 48 spot to A. A stalls. Admin clicks Cancel on A's row
and offers to B. A's Override stays `active` for the rest of its 7 days — A loads `/register`, sees a
live Register button on the hidden season, pays, and consumes the seat now promised to B.

Fixed in both directions, because half an invariant is how this comes back:
- `cancelWaitlist()` now revokes the backing override.
- `OverrideController::revokeOverride()` now returns an `offered` entry to `pending`, so the player
  goes back in the queue with `created` untouched and the Offer Spot button reappears.

### Fixed: R2 — age exceptions became gate keys `[High]`

`getValidSeasonOverride()` does not filter `override_type`, and all four new call sites used it.
§7.1 said explicitly *"don't merge the two concepts"* — the first implementation merged them.

*Failure it allowed:* an admin grants an Age Exception for season S. Registration closes, S is
hidden. That player still gets S in the `/register` union, sees a card reading **"Spot Reserved For
You"**, passes the cart guard, and at completion the age override is marked `used` and
`reserved_spots` decremented — an age grant silently eating a waitlisted player's held seat.

Fixed with a new **`OverrideManagerService::getValidGateOverride()`**, used at all three gates. It
excludes `age`, scans every active row rather than returning the first (so a player holding both an
age exception and a waitlist override still resolves to the waitlist one), reads legacy NULL
`override_type` as `waitlist`, and — unlike `getValidSeasonOverride()` — does **not** write-on-read
to mark rows expired, which was invalidating the override list cache tag for every user on every
`/register` render. Correctness does not depend on that write: `isValid()` checks the date, so a
date-expired row is refused whatever its stored status says.

> `getValidSeasonOverride()` now has no callers in the module. Left in place as public service API
> rather than removed.

### Fixed: R3 — the override lifted the OPEN date too `[Medium-High]`

`Season::isRegistrationOpen()` returns FALSE for **both** "not yet open" and "closed", so
`!registration_open && !has_override` let an override holder register *before* the public window
opened. §7.1 sanctioned lifting the close date only.

`getSeasonState()` now tells the two apart, and `not_yet_open` was hoisted **above** `has_override`
in `buildSeasonCard()`. Final chain: `is_registered` → `not_yet_open` → `has_override` → `closed` →
`registration_open`.

### Fixed: R5 — my "unreachable branch" claim was wrong `[Medium]`

§12 claimed the deleted `waitlist_status === 'offered'` branch "never was" reachable. **It was:**
season still open and full, entry `offered`, override lapsed. Since nothing ever writes the `expired`
waitlist status (S4), entries sit at `offered` indefinitely, so this is routine rather than exotic.
Those players were falling through to "Season Full / **Join Waitlist**", and clicking it re-sent the
"you have been added to the waitlist" confirmation for an entry that already existed.

The pending branch now covers `pending` and `offered`, and a lapsed offer reads **"Your Waitlist
Offer Has Expired — contact the league to have it renewed."**

### Fixed: R6 — legacy rows would mint a duplicate override `[Low-Med]`

`findReusableSeasonOverride()` filtered `override_type = 'waitlist'` in the query. Rows predating
that field hold NULL and would not match, so a re-offer would create a second override beside the
legacy one — and since only one gets marked `used` at redemption, **the other would survive as a
live key for a hidden season after the player had already registered.** Now filtered in PHP via
`getOverrideType()`, which reads NULL as `waitlist`. Also stopped clobbering an admin's hand-written
`reason` on reuse.

### Fixed: R4, R7 — two small ones

- `extendOverride()`: `clone $now` on both ternary arms; `->modify()` was mutating `$now` through the
  shared handle. Harmless today (`$now` was never read again) but a trap.
- The new `ccsoccer.waitlist.reoffer` route now requires `_csrf_token`. It is a state-changing GET
  that sends email. The override routes already require one; **`waitlist.offer` and
  `waitlist.cancel` still do not — pre-existing, not touched here, worth its own entry.**

### NOT fixed — recorded as follow-ups

- **The Extend button cannot reach an expired override.** `extendOverride()`'s revival half is
  correct but unreachable from the UI: Extend renders only from `buildActiveOverridesTable()`, fed by
  `loadByProperties(['status' => 'active'])`, and the Expired section is a view with no operations
  column. **This does not block the season 48 cleanup** — Patch 6's *Re-offer Spot* button handles
  lapsed overrides directly, which is the path to use. Fixing the Extend button is its own change.
- **Stale card after a silent expiry.** `$build['#cache']` sets no `max-age`, and nothing invalidates
  when an override lapses purely by the passage of time, so a "Spot Reserved For You" card can
  outlive its deadline until something else busts the page. Not a hole — `addSeasonToCart()`
  re-checks and refuses — but the card lies until then.
- The `expired` waitlist status still has no writer (S4).

### Note on the reload in `createSeasonRegistration()`

The review flagged that `EntityStorageBase::load()` returns the statically cached object, so the
`$season` reload is not a true database re-read and the "guards against a mid-checkout race" framing
overstates it. **No behavioural bug** — the object is identical either way, and this predates the
branch — but the comment is optimistic. Left as is; noted here so the next reader is not misled.

---

## 13b. Tournament order completion — end-date guard (Andrew, Aug 30)

**Separate from the override work.** Requested directly, and it closes the date half of D3.

`1b317f5` added an `end_date` guard to `addTournamentToCart()`. It was never ported to
`createTournamentRegistration()`, so the cart was guarded and completion was not. A cart lives 48
hours, so a player can add a tournament and pay two days later — after the event — and a Team plus a
taxonomy term get spawned for a finished tournament with nothing downstream noticing.

Added to `createTournamentRegistration()`, mirroring the cart-add guard exactly: `end_date` with a
23:59:59 boundary so a multi-day tournament stays valid on its final day, falling back to
`start_date`. On a block it logs and tags the order
`ccsoccer_tournament_registration_failed` (reason `tournament_already_ended`), matching the season
path's flag so the deposit surfaces for a refund rather than being silently kept (S3).

**Deliberately NOT gated on `registration_close`, `registration_visible` or `status`** — the same
reasoning `1b317f5` recorded for the cart guard: invited players reach checkout through the
invite-token path *after* public registration closes, so gating on those fields would lock out
exactly the captains' invitees that invitation-only mode exists to admit. The date is the one
unambiguous fact. **That is why D3 is only half closed, and closing the other half would be a bug.**

Andrew's note that registration closes a week before the tournament and carts expire in 48 hours is
what makes this low-risk in practice — the guard is a backstop, not a live incident.

---

## 14. Verification status

`ccsoccer.routing.yml` parses; the reoffer route resolves with its CSRF requirement. All six PHP
files brace/paren/bracket-balance cleanly.

⚠️ **`php -l` has NOT been re-run since the §13 fixes.** The five files linted clean after the
original patch set, but staging to the linting environment failed afterwards
(`untrusted_device` — the desktop app's sign-in went stale). **Run `php -l` on the six touched PHP
files, or just `drush cr` and load `/register`, before trusting this branch.**


---

## 15. Reconciliation with `main` — Aug 30, 2026

Checked after Caleb merged. **No conflicts. Three code changes made in response, all small.**

### Branch and merge state

| | |
|---|---|
| `fix/waitlist_override_redemption` HEAD | `5552f21` |
| `origin/main` | `5552f21` — **the same commit** |
| local `main` | `25a8fae`, **4 behind and stale** |
| `git log HEAD..origin/main` | empty |

**This branch already contains everything Caleb merged.** Nothing to rebase, nothing to pull. Local
`main` is just a stale pointer; `git fetch` cannot run from this session (no credentials in the
sandbox), so that state is as of the last fetch on the machine.

Also worth knowing: **two commits on this branch are not from this session** — `03866cf` (security
update: `drupal/entity` 1.7.0 → 1.8.0 for SA-CONTRIB-2026-113, `entity_print` 2.18.0 → 2.19.0) and
`5552f21` (First/Last Name columns on the admin People view). The uncommitted work sits on top of
both. Neither touches any file this branch modifies.

Caleb's PR **#140** merged `WAITLIST_OVERRIDE_REDEMPTION_PLAN.md` itself into `main` — so the plan is
already upstream, and the §13/§14/§15 edits are modifications to a tracked file rather than a new one.

### File-level overlap with Caleb's four commits

Only `d557ee5` (the `Merge origin/main`) touches a file this branch also modifies —
`ccsoccer.routing.yml`, +27 lines. It is already in this branch's history and adds no waitlist
routes, so the new `ccsoccer.waitlist.reoffer` block is the only local change to that file. The other
three commits touch nothing this branch touches.

### What Caleb's `392578b` changes about this work

His finding — PS-3's daily job reads an empty store — is documentation, not code, so it cannot
conflict. But it lands on three things here:

**C1 — a renewed override must be reminder-eligible again `[code changed]`**
Caleb identified `ccsoccer_override.reminder_sent` as the entity's own duplicate guard, and says to
use it rather than Registration's unused `override_notified`. Patch 6 was clearing `last_nudged` on
re-offer but not `reminder_sent`. Nothing reads that field *today* — which is exactly why it would
have been missed — but the day PS-3 is repointed at the entity, a re-offered override would carry
`reminder_sent = TRUE` from its first offer and its renewed 7-day window would never be reminded.
`issueOffer()` now clears it.

**C2 — stop writing a second date format `[code changed]`**
Caleb flagged that `expiration_date` is `varchar(255)` and that whoever rewrites the reminder query
has to parse whatever is stored. `createOverride()` and the re-offer path write `Y-m-d`;
`extendOverride()` was the lone writer using `Y-m-d H:i:s`. It now writes `Y-m-d` too, so there is
one format to parse instead of two. `Override::isExpired()` discards the time component either way,
so this is behaviourally inert.

**C3 — a name collision with a column Caleb wants dropped `[code changed]`**
Caleb proposes dropping `ccsoccer_registration.has_override` and `override_expires` as dead columns.
Neither is read or written by this branch — but Patch 3 had introduced a **local `$state` array key
literally named `override_expires`**, which would surface in exactly the grep someone runs before
writing that update hook. Renamed to `override_expiration`. (`$state['has_override']` is left alone:
it long predates this branch and renaming it would bloat the diff for no safety gain — noted here so
it is not mistaken for the column.)

### A doc update this branch has earned but did not make

P11 still lists **"🟡 Cancelling a waitlist entry does not revoke the override"** as open, and
`SESSION_HANDOFF.md:699` repeats it. **This branch fixes it** (§13 R1) — and, more to the point,
that item stops being a tidiness note the moment overrides can open a hidden season, which is what
this branch does.

**Deliberately not edited here.** `OUTSTANDING_ISSUES.md` changed on `main` hours ago and is Caleb's
working file; editing it from this branch is how a pointless merge conflict gets manufactured. It
should be updated when this branch merges, not before.

### Two things confirmed unchanged

- **`Season.max_players` on season 48 is untouched.** `SESSION_HANDOFF.md:910` records that 144 is a
  hard cap "for many reasons — not to be raised". This branch does not raise it, and does not exempt
  an override holder from it: the completion-time `max_players` backstop is the one guard an override
  explicitly does **not** lift (§7.1).
- **P6 / `CapacityManagerService` is still untouched and still unbuilt.** Patch 5 edits
  `createSeasonRegistration()`, which P6 will also touch, but changes only the *ordering* of the
  override block — it does not add, remove or redefine a capacity count, and does not touch
  `Season::isFull()` or `getSpotsRemaining()`.


---

## 16. LOCAL test run — Sep 6, 2026. ✅ **Redemption confirmed working**

The §9 plan was run on LOCAL against **season 43, Coed 2026 – Summer**. **Redemption works: an
offered player registers off the emailed link on a hidden, closed season, the override is consumed,
`reserved_spots` decrements, and the waitlist entry converts.** That is the thing this branch exists
to do, and it does it.

Getting there took two more fixes. **One of them is more serious than anything in the original six
patches**, and this document had walked past it twice.

### 16.1 — `reserved_spots` reserved nothing `[Critical, fixed]`

**The failure, exactly as observed.** Season 43: `max_players` 10, 9 paid, 1 seat reserved for
`testuser603`, who had been offered it and held a valid override.

1. `testuser725` — not on the waitlist, no override — was handed the offer link and used it. Cart
   check: `9 >= 10`? No. **Allowed.** Checked out, took the seat.
2. `testuser603` then used the link properly. Cart check at `9` passed; by the time their order
   completed the count was `10 >= 10`. **Charged, refused, no registration row.**
3. Both received "Registration Confirmed" emails (**M1**). The players list showed `testuser725` and
   not `testuser603`.

**Root cause.** `reserved_spots` is incremented on cancel, displayed on the season and waitlist admin
pages, and decremented on redemption — but **it appears in no capacity comparison anywhere in the
registration path**. Both checkpoints compared a raw `paid` count against `max_players`. The seat was
never actually held for anyone.

Every visible signal said the feature worked, which is precisely why this survived: the number went
up, showed on screen, and went back down.

**Fix.** Both checkpoints now measure a player *without* a valid gate override against
`max_players − reserved_spots`. A holder measures against the full `max_players`, because the
override **is** the claim on one of those reserved seats. Identical rule in both places, so they
cannot disagree about who may take the last one. The `capacity_exceeded` order flag now records
`reserved_spots`, `effective_max` and `had_override`.

Re-run with the fix: `testuser725` is refused at cart-add (`9 >= 9`); `testuser603` completes
cleanly.

### 16.2 — the refusal stranded the player on "Access denied" `[fixed]`

`CartEventSubscriber` removes *and deletes* the order item during `addEntity()`.
`addSeasonToCart()` had no way to learn that, so it redirected to a checkout for an order with no
items — which Commerce answers with **Access denied**. Three statements on one page, two of them
false: *the season is full*, *the item was added to your cart*, and *your account doesn't have
permission to view this page*.

Now: the controller confirms the item survived, drops the stale "added to cart" message Commerce had
already queued, and returns the player to `/register`. The subscriber stays silent for an override
holder — "please join the waitlist" is wrong for someone already on it who has been offered a spot —
and `addSeasonToCart()` explains the reserved-spot case instead.

### 16.3 — Patch 5 proved itself

When `testuser603` was charged and refused, **their override survived and `reserved_spots` stayed
put**, so the offer was still live and the card still read "Spot Offered From Waitlist". Under the
consume-before-guard ordering this branch replaced, that offer would have been destroyed by the same
failed payment — money gone, seat gone, override spent, nothing to show for it.

That reorder looked like hygiene when it went in (§5). It was not.

### 16.4 — a false alarm worth recording

The same test appeared to show that **any** player could register on a closed season. Real, but it
was a fixture artifact: the registration end date had been moved back and forth during testing, so
the season was *closed but visible*, and `addSeasonToCart()` checks `registration_visible` and
`active` — never `isRegistrationOpen()`.

**Deliberately not changed.** Adding a close-date gate there would break season group invitations,
which legitimately arrive after public registration closes — the same trap `1b317f5` recorded for
tournaments. Doing it safely needs two exemptions (valid override **or** pending invitation), and
that is a design decision, not a bug fix. **Andrew's call, not taken.**

### 16.5 — what this does not fix

The general capacity race (**P6**) is still open. Two override holders, or two ordinary players, can
still both pass the cart check and have the second refused at completion. §16.1 narrows the window to
unreserved seats; it does not close it.

### 16.6 — commits

| Commit | Contents |
|---|---|
| `bb28595` | The six patches: override redeemable on a hidden, closed season; consume-after-guard; Re-offer Spot; tournament end-date guard at completion |
| `04eb803` | `OUTSTANDING_ISSUES.md` + `SESSION_HANDOFF.md` |
| `ad03f9e` | CSRF token on `waitlist.offer` and `waitlist.cancel` |
| *(this one)* | §16.1 and §16.2 — reserved spots actually reserve; refusal no longer strands the player |

**Still true and still worth doing before merge:** `php -l` has not been run on any of it (the
desktop sign-in went stale mid-session and blocked the lint environment). Structural checks pass.
Lint, or `drush cr` and walk §9 again, before this leaves the branch.
