# Decision 2 — "Live registration": allowlist vs exclude-list

**Written:** August 6, 2026 · **For:** Andrew + Caleb · **Tree state:** `main` at `8e7b0be`, working tree
dirty (Aug 5 doc reset uncommitted)

**Supersedes the framing in** `archive/SESSION_HANDOFF_2026-08-05.md` §*Decision 2* — that framing is
sound in its reasoning and **wrong in four of its facts**. Corrections are in §2.

**This is an analysis document.** It describes what the code does and what each option costs. It does not
say what has been fixed — `OUTSTANDING_ISSUES.md` is the only file that says that.

---

## 0. The short version

1. **This does not block the PROD deploy.** Nothing in `OUTSTANDING_ISSUES.md` §0 depends on it. It blocks
   CF3, the `CapacityManagerService` (P6) and B-15 — all post-deploy. **Deploy first, decide this after.**
2. **All four definitions currently in the tree agree on 100% of rows** — verified empirically against
   5,107 registrations, not just by reading code (§3).
3. **There are four conventions in the tree, not three.** The framing missed a partial exclude-list
   (`status <> 'cancelled'`, `expired` treated as live) at **six sites** — and those six are the
   *duplicate-registration guards*, i.e. the code that decides whether a player may pay for a second
   registration. They are wrong under **both** D-4 and the allowlist (§2.2).
4. **The real capacity problem is not the status definition.** Season capacity is checked at three points
   and those three disagree on status **and** on `reserved_spots`. That divergence, not this decision, is
   what produced the Aug 4 incidents (§5).
5. **Recommendation: keep the exclude-list, but move `waitlist` from live to dead** — reversing half of
   D-13. Full reasoning in §7. The one status where an admin's intent is unambiguously "not registered" is
   the one the trait currently counts as live, and the module's own admin UI already contradicts it (§4.3).

---

## 1. What is actually being asked

`LiveRegistrationTrait::isDeadRegistrationStatus()` currently answers **one** question for **five**
different callers. Those callers do not obviously want the same answer:

| # | Question | Who asks | Failure if wrong |
|---|---|---|---|
| **Q1** | Which row is *this player's current* registration? | CF4 / the trait — 25 call sites | Writes land on a dead row → split-brain roster |
| **Q2** | Does this player occupy a capacity slot? | `Season::getSpotsRemaining()`, the two cart/completion guards, `getGroupSize()`, Caleb's `CapacityManagerService` | Over-fill a hard-capped season, or turn away a paying player |
| **Q3** | Should this player appear on a roster / list / report? | ~35 display sites | A player is missing from, or wrongly on, a printed roster |
| **Q4** | Should this player receive this notification? | `NotificationService` ×5, `GroupDissolveService` | A real player is silently not told something |
| **Q5** | **Does this player already have a registration here?** | 6 duplicate-purchase guards | **A player is allowed to pay twice**, or is wrongly blocked from registering |

Q5 is the one nobody has been counting. It is also the one with money attached in the *opposite*
direction from Q2, which is why it changes the answer.

---

## 2. Corrections to the recorded framing

### 2.1 The counts

Every number below was re-derived from the tree at `8e7b0be` and each site was opened to confirm the
entity is `ccsoccer_registration` (the field name `status` is shared with Override, Waitlist, Credits,
Invitation, Team, SeasonCreditEvent, Game and commerce_order).

| Recorded in the Aug 5 framing | Verified | Delta |
|---|---|---|
| Allowlist `IN ('paid','active')` — **46** | **49** | 48 in PHP + 1 in a raw-SQL DB view (`ccsoccer.install:3465`, the jersey report) |
| `paid`-only — **9** | **7** | `TeamBalancerService` :277 :349 :1387 · `CartEventSubscriber`:99 · `OrderCompleteSubscriber`:454 · `GroupInvitationsForm`:717 · `CcsoccerCommands`:1928 (test seeder) |
| Exclude-list `NOT IN ('cancelled','expired')` — **2**, at `GroupController` :108 and :376 | **2**, at `GroupController` **:118** and **:385** | Line numbers had drifted ~10. :118 is a query; :385 is an in-PHP `in_array()` test, not a query |
| Trait — **8 classes** | **8 classes, 25 call sites** | Correct as stated |
| *(not recorded)* | **Partial exclude-list `<> 'cancelled'` — 6 sites** | **New. See §2.2** |
| *(not recorded)* | **Unfiltered registration queries — ~32 sites** | CF3's "nine" is the season-group subset only. See §2.3 |

### 2.2 ⚠ The fourth convention — six sites that treat `expired` as live

| Site | Method | What it decides |
|---|---|---|
| `RegistrationController.php:566` | `getSeasonState()` | the "already registered" badge |
| `RegistrationController.php:837` | `getTournamentState()` | same, tournament |
| `RegistrationController.php:998` | `addSeasonToCart()` | **whether a duplicate season purchase is blocked** |
| `RegistrationController.php:1089` | `addTournamentToCart()` | **whether a duplicate tournament purchase is blocked** |
| `OrderCompleteSubscriber.php:392` | `createSeasonRegistration()` | **whether a duplicate registration is created after payment** |
| `OrderCompleteSubscriber.php:631` | `createTournamentRegistration()` | same, tournament |

All six are `->condition('status', 'cancelled', '<>')`. They exclude `cancelled` and **not** `expired`.

> **`ROSTER_RECONCILIATION_PLAN.md:1473` labels four of these as *"exclude-lists — these already match
> D-4."* They do not.** D-4 and D-13 both name `expired` as dead. Under those decisions an expired
> registration should not block a re-registration; today it does. The doc line needs correcting whichever
> way Decision 2 goes.

These are the highest-consequence sites in the whole inventory and they are on their own convention.

### 2.3 The unfiltered sites are not nine

CF3's brief names **nine** sites (plus one exempt) — the season-group query cluster. The actual count of
registration queries with **no status condition at all** is **~32**. The rest fall into three groups:

- **Deliberately unfiltered, correctly** — `GroupDissolveService`:139 (the dissolve write must reach dead
  rows), `GroupInvitationsForm`:131 (the admin member table is *meant* to show cancelled rows),
  `RosterBuilderController::save()`:167.
- **Test/migration/install code** — `CcsoccerCommands` ×6, `MigrateCommands`, `ccsoccer.install` ×2.
- **Not-yet-triaged production reads** — `GroupController::userSearch()` :744 :754,
  `GroupController::manage()` :562 :572, `PlayerInfoPane::isFirstTimeRegistration()`:273,
  `CompletionPane`:53, `Team::calculateSkillLevelFromPlayers()`:552, `ccsoccer_cron()`:548.

*(`TournamentTeamManager` :510 and :587 are sometimes counted here but should not be — they are
`load($registration_id)` primary-key loads, which cannot carry a status condition. They still act on a
dead row without checking, which is a CF4-family concern, not a CF3 one.)*

**`ccsoccer_cron():548` is worth a look independently of this decision** — it builds the override-expiry
reminder recipient list with no status filter at all, so a cancelled registrant can be emailed.

### 2.4 D-4's stated rationale, and a fact it got right by accident

D-4's recorded reason was *"the full status set isn't confidently known."* That is false — the enum is
exactly six values, declared at `Registration.php:98-105`.

But there is a **second, undocumented** status field definition at `ccsoccer.module:1621`, inside
`ccsoccer_entity_bundle_field_info()`. It is gated at `:1536` on `$entity_type->id() === 'registration'`,
while the entity's actual id is `ccsoccer_registration` (`Registration.php:16`) — **so it never
executes.** It declares the same six values with different labels, and one of those labels is the only
surviving statement of original intent:

> `'expired' => 'Override Expired'`

That is what `expired` was for: a registration created under a capacity override that has since lapsed.
Which supports D-13's ruling that `expired` is dead. **Delete the dead definition** — it is a second
source of truth that will mislead the next person, and it is cheap to remove.

---

## 3. What the data actually says

From `roster_audit_LOCAL_2026-08-06.txt` §0.3 and §0.5 — **5,107 registrations, LOCAL:**

> ⚠ **Read the provenance before quoting this in a meeting.** This LOCAL copy lists seasons **1–46 only**.
> **Neither season 47 nor season 48 — the two Aug 4 incident seasons — appears in it at all.** The
> behaviour-neutrality claim below stands on its own (it is a property of the 5,107 rows that *are*
> here), but this file is **not** evidence about the incident seasons, and should not be offered as such.
> The 144-cap season in this data is season 46, `Coed 2026 - Early Fall`.

| status | rows |
|---|---|
| `paid` | **5,100** |
| `cancelled` | **7** |
| `pending` · `active` · `waitlist` · `expired` | **0** |

The 7 cancelled rows: season 46 ×1, tournament 2 ×4, tournament 1 ×2.

**Every one of the four conventions returns an identical row set on all 5,107 rows.** This is not a
prediction from reading code — it is measured. Two consequences:

1. **Any conversion, in any direction, is provably behaviour-neutral today.** It can be shipped without a
   data migration and without a behavioural test plan beyond "counts unchanged."
2. **There is no urgency and no way to be caught out by real data.** This can be decided calmly, which is
   exactly why it will silently diverge if it is not decided at all.

Audit class **SB** (cancelled/expired registration still carrying `group_id`/`invited_by`/`team`) is
**0**, so there is also no stale-linkage population waiting to change behaviour when a filter lands.

---

## 4. Where the definitions actually diverge

### 4.1 `pending` — the field default

`Registration.php:96` sets `->setDefaultValue('pending')`. Every `create()` in the module passes `status`
explicitly, so the default only applies to a row created **without** one: a hand-created row, an admin
entity-form save, or a future code path that forgets.

| | Allowlist | Exclude-list |
|---|---|---|
| Row counts toward capacity | ❌ no | ✅ yes |
| Row appears on rosters/lists | ❌ no | ✅ yes |
| Row receives notifications | ❌ no | ✅ yes |
| Row blocks a duplicate purchase | ❌ **no** | ✅ yes |
| Row is selectable as "this player's registration" | ❌ no | ✅ yes |

**Under the allowlist, a `pending` registration is a player who does not exist.** Under the exclude-list
they are fully live.

**Where does a `pending` row realistically come from in *this* codebase?** There is no bulk import path,
no public API, no anonymous registration write. The only two producers are (a) an admin using the
registration edit form at `/admin/ccsoccer/registration/{id}/edit`, and (b) a future `create()` that
omits `status`. **Both represent a real person.** This codebase does not generate junk registration rows,
which is the case the allowlist's fail-closed behaviour is designed to protect against.

### 4.2 `expired`

Never written. Intended (per the dead field definition) to mean "created under an expired override" —
dead under D-13, and both options would treat it identically **once the six §2.2 sites are fixed**. Today
those six treat it as live. No practical divergence between the options here; a real bug today under
either.

### 4.3 ⚠ `waitlist` — the case that breaks the current decision

D-13 ruled `waitlist` **LIVE**, on the reasoning that *"a waitlisted player holds a real uncancelled
registration and is the right row to write against."* That reasoning is correct for **Q1 (row selection)**
and wrong for **Q2 (capacity)** and **Q3 (display)**.

Two facts the framing did not have:

1. **`AdminController.php:270` already counts `status = 'waitlist'` as a *waitlist* column on the seasons
   overview** — presented to a board member beside the "registered" count as an explicitly
   not-yet-registered number. The module's own admin UI defines `waitlist` as *not live*. That column is
   structurally always `0` today, because the real waitlist is a separate `ccsoccer_waitlist` entity
   (`WaitlistManagerService`:58) and nothing writes `waitlist` to a registration.
2. **So the module contains two contradictory definitions of `waitlist` right now** — the trait says live,
   `AdminController` says not. Both are invisible only because the value is never written.

The moment an admin uses the edit form to mark someone `waitlist` — which is the most plausible hand-edit
anyone would make, and the label on the dropdown invites it — the exclude-list puts that player on a
roster and counts them against a hard-capped season. **That is the single strongest argument against the
exclude-list as currently defined, and it is fixable without abandoning the exclude-list.**

### 4.4 `active`

Never written; no admin intent attached; the allowlist and exclude-list both treat it as live. No
divergence.

---

## 5. The finding that matters most for Caleb's `CapacityManagerService`

**Season capacity is checked at three points, and no two of them agree.** This is independent of Decision
2 and is larger than it.

| Checkpoint | Status filter | Subtracts `reserved_spots`? | Site |
|---|---|---|---|
| **Page load** — `Season::getSpotsRemaining()` → `isFull()` | `IN ('paid','active')` | ✅ **yes** | `Season.php:354`, `:360` |
| **Add to cart** — `CartEventSubscriber::onCartEntityAdd()` | `= 'paid'` | ❌ **no** | `CartEventSubscriber.php:99` |
| **Post-payment** — `OrderCompleteSubscriber::createSeasonRegistration()` | `= 'paid'` | ❌ **no** | `OrderCompleteSubscriber.php:454` |

Two independent disagreements:

- **Status.** Behaviour-neutral today (§3).
- **`reserved_spots`.** **Not** behaviour-neutral. `reserved_spots` is a live, mutable counter —
  `CancelRegistrationForm.php:432` increments it, `OrderCompleteSubscriber.php:415` decrements it when an
  override is consumed. `Season::isFull()` honours it; the two guards that actually stop a purchase do
  not.

**Consequence:** on a season with `max_players = 144` and `reserved_spots = 2`, the page says *full* at
142 while add-to-cart and order completion both keep accepting players until 144. **This is a mechanism
that produces the Aug 4 shape**, and it fits the `SESSION_HANDOFF.md` note that *"Caleb used one of the
two seats reserved for pending waitlist offers."*

> This is a **code-derived** claim, not a data-derived one. The LOCAL audit does not contain seasons 47
> or 48 (§3), so it cannot confirm or refute that this was the actual mechanism in either incident.
> **Confirming it is one PROD query** — `reserved_spots` on seasons 47 and 48 at the time, against
> `max_players` and the paid count. Worth doing before P6 is designed around an assumption.

So the `CapacityManagerService` has **two** definitions to unify, and the one Decision 2 is about is the
harmless one. Whichever way this goes, `getEffectiveSpotsRemaining()` must settle the `reserved_spots`
question explicitly, and `OUTSTANDING_ISSUES.md` P6 should say so.

**Also relevant to P6:** `OrderCompleteSubscriber.php:522` (the *group*-cap check inside the same method
as the season-cap check at `:454`) uses the **allowlist** while `:454` uses **paid-only** — two
conventions, 68 lines apart, in one method.

---

## 6. The options

### Option A — Standardise on the **exclude-list** (continue D-4 / D-13 as written)

`dead = cancelled | expired`. Everything else is live, including `pending` and `waitlist`.

| | |
|---|---|
| ✅ | Already decided (D-4, D-13), already shipped in 8 classes / 25 sites, already LOCAL-tested; CF3 is written against it |
| ✅ | Fails **open**: an unknown-status row acts. In a codebase with no junk-row producer (§4.1), that is the right direction |
| ✅ | Q5 (duplicate-purchase guard) fails safe — a `pending` row blocks a second purchase |
| ✅ | No need to touch the field default. The allowlist requires setting it to `paid`, which makes a hand-created row *claim to be paid* — a money-integrity lie in the database |
| ❌ | **`waitlist` is live** — the one hand-edit an admin is most likely to make puts a player on a roster and into a hard-capped season count (§4.3) |
| ❌ | Currently the **minority** convention: converting means touching 62 sites (49 + 7 + 6) |
| ❌ | Q2 fails toward **under-fill** — a junk row could block a real registrant. Recoverable, but real |

### Option B — Standardise on the **allowlist** (revert D-4 / D-13)

`live = paid | active`.

| | |
|---|---|
| ✅ | Already the majority: **49** sites, no change needed. Conversion is 38 sites (25 trait + 7 paid-only + 6 partial), not 62 |
| ✅ | Fails **closed** on capacity: a junk row never consumes a real seat |
| ~ | *Partially* matches the `OUTSTANDING_ISSUES.md` P10 convention note (`:566-569`) — but that note is a **split** rule (*"notifications should use the exclusion filter … capacity and rosters should use the allowlist"*), which is **Option C**, not Option B. Do not claim P10 as support for B |
| ❌ | **Reverses merged, LOCAL-tested, undeployed code** (`b46c28b`, CF4). §10.8 has been run against the current behaviour; that regression evidence is partly invalidated |
| ❌ | **Q5 fails dangerous.** A `pending` row is invisible to the duplicate-purchase guard → the same player can pay twice. This is the money bug the allowlist creates |
| ❌ | **Q2 fails dangerous too.** Not counting a real row means the season *under*-counts and **over-fills** — a hard cap of 144 admits 145. §4.1 establishes that an unknown-status row here almost certainly *is* a real person |
| ❌ | Requires changing the field default from `pending` to `paid` (or accepting invisible players), i.e. writing "paid" into rows that were never paid |
| ❌ | The trait's `pickLiveRegistration()` would return NULL for a `pending` row, where the correct answer for Q1 is "that row" |

> **The P10 convention note has the capacity case backwards.** It reads *"capacity and rosters should use
> the allowlist (an unknown status does not count)"* on the theory that this is conservative. It is not:
> not counting a row that represents a real person **over**-fills the season. Correct that line whichever
> option wins.

### Option C — Per-question definitions

Capacity uses the allowlist; row selection, display, notification and the duplicate guard use the
exclude-list.

| | |
|---|---|
| ✅ | Each operation fails in its own least-harmful direction — the intellectually honest answer to §1 |
| ❌ | **This is exactly the state we are trying to leave.** Two definitions is what produced this decision |
| ❌ | Every new call site needs a judgement call about which family it belongs to; §2.3 shows those calls get made wrong |
| ❌ | Does not resolve §5 — the three capacity checkpoints would still need unifying separately |

**Not recommended**, but worth stating out loud so it is rejected deliberately rather than by omission.

### Option D — **Exclude-list, with `waitlist` moved to dead** ⭐

`dead = cancelled | expired | waitlist`. Live = `paid | active | pending`.

Option A's benefits, with its one real failure mode removed.

| | |
|---|---|
| ✅ | Everything in Option A's ✅ column |
| ✅ | Removes the §4.3 hazard, and resolves the existing contradiction with `AdminController:270` |
| ✅ | Behaviour-neutral today — `waitlist` has 0 rows (§3) |
| ✅ | Aligns the field with reality: the waitlist is a separate entity, so `waitlist`-on-a-registration means "holds a seat-less registration" — which is dead for Q2/Q3 and, correctly, also dead for Q1 (you should not write a team assignment onto a waitlisted row) |
| ❌ | Reverses half of D-13, which was decided five days ago. Needs an explicit D-13 amendment, not a silent change |
| ❌ | Same 62-site conversion cost as A |

---

## 7. Recommendation

**Option D.** Reasoning, in order of weight:

1. **The failure direction should follow where the rows come from, not abstract caution.** This codebase
   has no producer of junk registration rows (§4.1). An unrecognised status here means *a real person
   whose row was created by an admin or by a code path we have not written yet*. Fail open.
2. **Both money-adjacent operations fail dangerous under the allowlist.** Q5 lets a player pay twice; Q2
   over-fills a hard-capped season. Under the exclude-list both fail toward *recoverable* errors.
3. **`waitlist` is the one place the exclude-list is wrong, and it is cheap to fix.** An admin selecting
   "Waitlist" on a dropdown means "not registered." The module already agrees with that at
   `AdminController:270`. Moving it to the dead set costs one line in the trait and zero rows today.
4. **It is the smaller reversal.** Option B invalidates part of the §10.8 regression evidence on code that
   is merged and waiting to deploy. Option D amends one clause of D-13 and changes no shipped behaviour.
5. **`pending` stays live and the field default stays `pending`.** The alternative — defaulting to `paid`
   — writes an untrue payment claim into the database to work around a filter. Not worth it.

**What this explicitly does *not* claim:** the 49-vs-2 site count is a real argument for Option B and
should not be waved away. If Caleb's view is that matching 49 existing sites beats re-deciding, that is a
defensible position, and the deciding fact against it is §4.1 — whether you agree that an unknown-status
row in *this* codebase represents a real person. **If that premise is wrong, Option B is right.** That is
the crux to put to him.

---

## 8. What needs fixing — by option

### 8.0 Must fix regardless of which option wins

| # | Item | Sites | Why |
|---|---|---|---|
| **M1** | **The six `<> 'cancelled'` partial-exclude sites** | `RegistrationController` :566 :837 :998 :1089 · `OrderCompleteSubscriber` :392 :631 | Treat `expired` as live. Wrong under D-4, wrong under the allowlist, wrong under every option. Two of them gate a duplicate *purchase* |
| **M2** | **Correct `ROSTER_RECONCILIATION_PLAN.md:1473`** | doc | Two faults in one line. (a) It labels four of the M1 sites *"exclude-lists — these already match D-4."* They do not. (b) **Its line numbers are stale by 25–27** — it cites `RegistrationController:541, 812, 971, 1062`; those queries are now at **566, 837, 998, 1089**, and the old numbers land on unrelated render-array code. Also correct **RRP:411**, which names the wrong CF3 exemption — see §8.1 step 5 |
| **M3** | **Delete the dead field definition** | `ccsoccer.module:1536-1836` ⚠ | Never executes (entity id mismatch); a second source of truth. **The unreachable `if` block is :1536-1836** — roughly 200 lines of dead field definitions, of which `status` is only :1621-1639. Deleting a narrower range leaves orphans. **Preserve the `'expired' => 'Override Expired'` label (:1631) as a code comment on the live definition** — it is the only surviving statement of what `expired` means |
| **M4** | **Settle `reserved_spots` across the three capacity checkpoints** | `Season.php:360` vs `CartEventSubscriber.php:99` vs `OrderCompleteSubscriber.php:454` | §5. **Not** behaviour-neutral. This is the Aug 4 mechanism. Belongs to P6, not to this decision, but P6 must not ship without it |
| **M5** | **`OrderCompleteSubscriber` :454 vs :522** | one method | paid-only and allowlist, 68 lines apart |
| **M6** | **Add a status-distribution tripwire to `ROSTER_DATA_AUDIT.sql`** | audit | `SELECT status, COUNT(*) … GROUP BY status`. Today it returns two rows. **The day it returns three, this decision starts having consequences** — and nothing currently would tell you. Cheap; add it alongside queries B and E in deploy blocker 0.1 |
| **M7** | **`ccsoccer_cron():548`** | 1 | Override-expiry reminder recipients are unfiltered — a cancelled registrant gets emailed. Independent bug, surfaced by this audit |
| **M8** | **`AdminController:270`** | 1 | The seasons-overview waitlist column reads `Registration.status = 'waitlist'` while the waitlist lives in `ccsoccer_waitlist`. Structurally always 0. Point it at the real entity or remove the column |
| **M9** | **`StatusConstants` (B-15)** | — | Lands with whichever option wins. Same finding from two directions per `OUTSTANDING_ISSUES.md` P10 |

### 8.1 If Option A or D wins (exclude-list)

| Step | Work |
|---|---|
| 1 | **Extend the trait**: add `applyLiveRegistrationFilter(QueryInterface $query)` as the query-level sibling of `isDeadRegistrationStatus()`. Note `loadByProperties()` cannot express `NOT IN` — allowlist sites using `loadByProperties` must move to `getQuery()` or post-filter |
| 2 | **Option D only:** add `'waitlist'` to `isDeadRegistrationStatus()` (`LiveRegistrationTrait.php:86`), rewrite the docblock at **`:30-31`** which currently states the opposite (*"`waitlist` counts as LIVE on purpose"*), and **record the D-13 amendment in `ROSTER_RECONCILIATION_PLAN.md` §7** |
| 3 | **Convert the 7 `paid`-only sites.** `TeamBalancerService` :277 :349 :1387 · `CartEventSubscriber`:99 · `OrderCompleteSubscriber`:454 · `GroupInvitationsForm`:717 · `CcsoccerCommands`:1928 |
| 4 | **Convert the 49 allowlist sites.** Mechanical. **22 of the 49** are in four files — `ContentController` (8), `TournamentController` (6), `NotificationService` (5), `AdminController` (3) |
| 5 | **CF3's nine season-group sites** get the helper as originally planned. ⚠ **The exempt site is `GroupDissolveService.php:139`, NOT `GroupInvitationsForm:769`.** `ROSTER_RECONCILIATION_PLAN.md:411` still names the latter; that reference is stale — `:769` is now `$form_state->set(...)` bookkeeping inside `buildMembersTable()` and touches no registration. The dissolve write moved into `GroupDissolveService::dissolveGroup()`, where the unfiltered `loadByProperties(['group_id' => …])` already carries the comment *"Intentionally unfiltered by status."* **Filtering that one breaks CF8.** Correct RRP:411 while you are there |
| 6 | **Triage the remaining ~21 unfiltered sites** (§2.3). Most are correctly unfiltered; each needs a one-line comment saying so, or it will be "fixed" later by someone reading a grep |
| 7 | **Do not touch the field default.** `pending` stays; under the exclude-list it is already live |
| 8 | **Verify:** re-run `ROSTER_DATA_AUDIT.sql` and confirm every class count is unchanged. §3 guarantees it will be — a difference means a conversion error |

**Size:** 62 conversions + ~23 triage comments. Mechanical, provably behaviour-neutral. Realistically
**one focused session**, plus M1–M9. Best split into two commits: (a) trait + M1 + the 7 paid-only sites
(the load-bearing half), (b) the 49 allowlist conversions (pure mechanical sweep, easy to review as a
diff).

### 8.2 If Option B wins (allowlist)

| Step | Work |
|---|---|
| 1 | **Rewrite `isDeadRegistrationStatus()` as `isLiveRegistrationStatus()`** — `IN ('paid','active')`. The trait's whole docblock (`LiveRegistrationTrait.php:5-42`) argues the opposite case and must be rewritten, not patched |
| 2 | **Re-verify all 25 trait call sites.** `pickLiveRegistration()` returning NULL is already a behaviour change from the old `reset()`; the allowlist makes it return NULL in *more* cases. **§10.8's LOCAL evidence does not carry over** — the split-brain reproduction (item 1) and the admin-accept path (item 4) must be re-run |
| 3 | **Convert the 7 `paid`-only sites** to `IN ('paid','active')` |
| 4 | **Convert the 6 M1 sites** to the allowlist — but see the ⚠ below |
| 5 | **Decide the field default.** Either set it to `paid` (writes an untrue payment claim) or accept that a `pending` row is an invisible player. **This must be decided at the same meeting; it is not a follow-up** |
| 6 | **Rewrite `GroupDissolveService`'s live-member count** (`:264`) — it uses the trait and drives whether a group is dissolved. Under the allowlist a `pending` member stops counting and a group dissolves out from under a real player |
| 7 | **Reverse the D-4 and D-13 records** in `ROSTER_RECONCILIATION_PLAN.md` §7 and the corrected §2 note at `:262-277` |

> ⚠ **Step 4 is the one to argue about.** Converting the duplicate-purchase guards to an allowlist means a
> `pending` registration no longer blocks a second purchase. If Option B is chosen, **carve these six out
> and leave them on the exclude-list**, with a comment saying why — and accept that Option B therefore
> does not actually achieve one definition either.

**Size:** 38 conversions, but **not** behaviour-neutral in evidence terms — it invalidates part of the
§10.8 regression record on undeployed code, and forces the field-default decision. Materially riskier
than 8.1 despite being fewer sites.

### 8.3 If Option C wins (per-question)

Everything in 8.0, plus: write the two-family rule into the conventions doc **before** any conversion,
and give each family its own explicitly-named helper (`countsTowardCapacity()` vs
`isDeadRegistrationStatus()`) so that a call site's family is visible at the call, not in a doc. Do not
attempt this with one helper and a comment.

---

## 9. For the conversation with Caleb

**The five things to put to him, in order:**

1. **The crux (§4.1).** In this codebase, does a registration row with an unrecognised status represent a
   *real person* or *junk*? Everything else follows. There is no import path, no API, no anonymous write —
   which is the case for "real person," and therefore for failing open. If he disagrees, Option B is
   right and the rest of this document reverses.
2. **`waitlist` (§4.3).** The trait says live; `AdminController:270` says not registered. Which is it?
   This needs answering even if nothing else changes.
3. **The six duplicate-guard sites (§2.2).** New information. They are wrong under any option and two of
   them gate a payment.
4. **`reserved_spots` (§5).** This is his `CapacityManagerService` problem and it is bigger than Decision
   2. The three season-capacity checkpoints disagree on a *live mutable counter*, not just on a status
   nobody writes. **Worth leading with this** — it is the part with demonstrated real-money consequences.
5. **The 49-site count.** The honest argument for Option B. Do not skip it.

**What does not need deciding at this meeting:** anything in `OUTSTANDING_ISSUES.md` §0. Decision 2 does
not block the deploy, and the deploy should not wait on it.

**What must be recorded whichever way it goes:**

- The decision itself, in `ROSTER_RECONCILIATION_PLAN.md` §7 as an amendment to D-4 and D-13 (not a new
  ID — these collide enough already).
- The **rule**, in the conventions doc: *"one definition, expressed once, in
  `LiveRegistrationTrait`. Any new registration query uses the helper or carries a comment saying why
  not."* Note the P10 correction from §6.
- **M6**, the audit tripwire. It is the only thing that will tell you the day this stops being
  theoretical.

---

## Appendix — sources

| Claim | Source |
|---|---|
| 5,107 rows, `paid`/`cancelled` only | `roster_audit_LOCAL_2026-08-06.txt` §0.3, §0.5 |
| Status enum, default, form widget | `web/modules/custom/ccsoccer/src/Entity/Registration.php:93-108` (default `:96`, six values `:99-104`) |
| Dead second field definition | `web/modules/custom/ccsoccer/ccsoccer.module:1536` (gate: `$entity_type->id() === 'registration' && $bundle === 'ccsoccer_registration'` — the id is `ccsoccer_registration`, so never true), block `:1536-1836`, status field `:1621-1639`, `'Override Expired'` `:1631` |
| Trait definition and docblock | `web/modules/custom/ccsoccer/src/LiveRegistrationTrait.php:5-42` (docblock; waitlist-is-live at `:30-31`), `:62`, `:84`, `:86` |
| Trait consumers (8 classes, 25 sites) | `CreditManagerService`:20 · `TournamentTeamManager`:21 · `GroupDissolveService`:43 · `TournamentRosterBuilderController`:18 · `GroupController`:28 · `RosterBuilderController`:19 · `RegistrationController`:19 · `GroupInvitationsForm`:32 |
| Three capacity checkpoints | `Season.php:354-360` · `CartEventSubscriber.php:99` · `OrderCompleteSubscriber.php:454` |
| `reserved_spots` mutation | `CancelRegistrationForm.php:432` (increment) · `OrderCompleteSubscriber.php:415` (decrement) |
| Waitlist is a separate entity | `WaitlistManagerService.php:58`, `:87` |
| Only three status values ever written | `CancelRegistrationForm.php:360` · `TournamentCancelRegistrationForm.php:281` · `OrderCompleteSubscriber.php:563`, `:887` · `CcsoccerCommands` :948 :1094 :1431 :1475 :1524 :1551 · `MigrateCommands.php:646` |
| Prior framing | `archive/SESSION_HANDOFF_2026-08-05.md:180` |
| D-4 / D-13 as recorded | `ROSTER_RECONCILIATION_PLAN.md:464`, `:489` |
| CF3 brief | `ROSTER_RECONCILIATION_PLAN.md:411`, §10.3 |
| Capacity design | `OUTSTANDING_ISSUES.md` P6 |

**Line numbers verified against `8e7b0be` on August 6, 2026.** They drift — the Aug 5 framing's
`GroupController` references were already ~10 lines stale. Re-locate by method name if anything does not
match.
