# CC Soccer — Outstanding Issues Register

**Rebuilt:** August 5, 2026 · **Supersedes:** the July 28 – August 4 version, archived verbatim at
`archive/OUTSTANDING_ISSUES_2026-08-05.md`

> ### ✅ Verified against the code — August 23, 2026 (second pass, at `fc5069f`)
>
> Every item that could be settled by reading the tree was opened this session. Items that were
> checked carry **`[verified Aug 23]`** with the method or file that proves it. Items that depend on
> a server, on PROD data, or on a person are tagged **`[server]`** / **`[waiting]`** and were **not**
> checked — nothing in this file claims a deploy it did not see.
>
> **Nine items were found already done and are closed** — they are in
> [Closed since July 28](#closed-since-july-28--do-not-re-open). **Seven more were stale in their
> premise rather than their status and have been rewritten** — most importantly **S2**, which now
> describes the opposite of what happens, and **G3**, whose code moved out from under it. That list is
> in [Corrections applied](#corrections-applied).
>
> The ten items the Aug 22 sessions left stranded in `SESSION_HANDOFF.md` are now carried here, in
> **P4**, **P8** and the new **P11**. The handoff no longer owns any open item.
>
> **Merged with `d902769`** — Caleb reviewed and deployed PR #136 the same evening and rewrote §0 and
> §P3 from the servers. Where the two passes disagreed about deploy state, **his first-hand checks
> win**; where they disagreed about code, the verification above stands. Both are now in this file.

---

## How to use this file

This is **the single list of what is open.** It is built from a full read of the thirteen documents
that were carrying status on Aug 5, reconciled against `git log` and the working tree. Where a doc and
the code disagreed, **the code won** — see [Corrections applied](#corrections-applied) for what changed.

| You want to know | Look here |
|---|---|
| What is still open, and what to do next | **This file** |
| What is deployed | **git** — no document claims this any more |
| What happened last session / how to resume | `SESSION_HANDOFF.md` |
| How a fix is meant to be implemented | the brief named on the item |
| Why the code is the way it is | analysis docs, now mostly in `archive/` |

**The rule that keeps this working:** analysis and implementation briefs describe *how the code works
and what is wrong with it.* **They never say whether it has been fixed.** Only this file says that.
When you close an item here, you do not need to go update five other documents.

### ID scheme — read this once, the collisions are real

Six ID families are in play and **three pairs of them collide.** They are not interchangeable:

| Family | Example | Means | Lives in |
|---|---|---|---|
| **CF*n*** | CF3, CF9 | a *code fix* in the roster-reconciliation cluster | `ROSTER_RECONCILIATION_PLAN.md` §10.3 |
| **D-*n*** (hyphen) | D-8, D-13 | a *decision* recorded during that work | `ROSTER_RECONCILIATION_PLAN.md` §7 |
| **D*n*** (no hyphen) | D3, D19 | a *finding* from the season↔tournament drift audit | `archive/SEASON_TOURNAMENT_DRIFT_AUDIT.md` |
| **R/T/S/TS/G *n*** | R3, T2, G4 | a finding from the admin-tooling review | `ADMIN_TOOLING_REVIEW.md` |
| **S/M/H *n*** | S1, M5, H2 | severity-prefixed order-flow findings | `archive/ORDER_FLOW_EDGE_CASES.md` |
| **E*n* / B*n* / Q-B*n* / Q*n*** | E4, B-7, Q-B4, Q10 | invitation analysis · code-quality backlog · D-8 open questions · option-D open questions | various, see the doc map |

**Three collision traps, all live in this file:**

1. **`D-n` (hyphen) ≠ `Dn`.** `D-8` is the decision about when a season `group_id` should exist;
   `D8` is the `removeMember` invitation-cleanup finding, shipped July 20. The same clash applies to
   **D-1/D1, D-2/D2, D-3/D3, D-4/D4, D-6/D6, D-7/D7, D-9/D9, D-10/D10, D-11/D11, D-12/D12, D-13/D13
   and D-14/D14.** Mind the hyphen — it is the only thing distinguishing them.
2. **`S*n*` means two different things.** Admin-review S1–S9 are *season scheduling*; order-flow
   S1–S4 are *severity-critical order findings*. So **S2** is both "regenerate leaves games
   unpublished" (open, P2) and "tournament auto-decline" (shipped to PROD). **S3** is both the
   flagged-orders report (parked) and the jersey Red/White convention (closed as designed).
   **S4** is both drag-swap validation (on hold) and waitlist expiry (parked). Each entry below
   names its source doc where the ID is ambiguous.
3. **`Q-B*n*` is numbered independently in two documents.** `archive/GROUP_CLEANUP_BRIEF.md` §11
   Q-B4 = "notify the surviving manager on a route-4 dissolve"; `archive/D8_OPTIONS_COMPARISON.md`
   Q-B4 = "should `groups_locked` block the accept paths." Both appear below, disambiguated.

---

## 0. Deploy — ✅ DONE. This section is closed.

**[Closed Aug 23, 2026.]** PR #124 (CF1, CF1b, CF2 + D2, CF12, CF4, CF5, CF8) and PR #129
(D-8 = CF10 + B + E) are **on PROD**, along with everything merged after them through `1ecee79`.
Update hook `9073` ran on TEST and PROD.

**[Confirmed against the servers later the same day — Caleb, `d902769`.]** This section originally
read *"Source: Andrew's confirmation — not verified against the server,"* and the Aug 23 verification
pass repeated that caveat because it could not check a server either. It has since been checked
first-hand: the PR #136 deploy pulled PROD from `db81afa` to `fc5069f`, which carries everything
above, and `config:status` on PROD returns only the three known `media_library` entries.
**Deploy state in this file is no longer second-hand.**

**Closes here:** D2, D19, D6, D7. **Operating rule 1 (admin Group Invitations read-only for
tournament groups) is LIFTED.**

**PR #136 has since deployed as well** — invitation-only tournament registration, hook `9074`, all
three environments, Aug 23. **Live but inert** until the checkbox is ticked on a tournament.
See **§P3**, *Invitation-only registration*, below.

> ### ⚠ Operating rule 2 has NOT lifted — and as of Aug 22 it covers **seasons too**
>
> **Keep `schedule_visible` OFF while iterating schedule candidates — tournament *and* season.**
> It guards **TS2** and, since the Aug 22 export change, **S2** as well (§P2): neither public view
> reads per-game `published` any more, so on a visible season or tournament a regenerate replaces the
> live schedule instantly. It lifts when TS2/S2 are fixed, not when code ships. **`[verified Aug 23]`**
> — `ContentController::exportIcal()` and the season schedule query carry no `published` condition;
> `TournamentScheduleGridBuilder` filters on `schedule_visible` only.

### What did not happen — retired deliberately, not completed

Items 0.1–0.6 were **pre-deploy safety steps and none of them ran.** The deploy went out anyway and
nothing has broken. **Andrew's call on Aug 23: close them as moot rather than carry them forward.**
Recorded rather than deleted, because one of them was load-bearing and its retirement is a decision,
not an oversight:

| Was | What it was for |
|---|---|
| **0.1** Add the orphaned-group health query (query B) to the audit script (now `archive/ROSTER_DATA_AUDIT.sql`) | ⚠ **This was the entire safety net for the D-8 "B" design** — the only mechanism that would have revealed a missed fourteenth `Invitation.group_id` call site. Retiring it means **if a call site was missed, nothing will surface it.** The D-8 work has now been live for two weeks with no reported symptom, which is the argument for closing it |
| **0.2** Record baseline counts before deploying | The "before" window closed at deploy; unrecoverable |
| **0.3** Settle the §9 test-record contradiction | Which of the 29 `GROUP_CLEANUP_BRIEF.md` cases actually ran is now unknown and will stay unknown |
| **0.4** Click-test CF10's failed-invite paths (cases 1–4) | Never click-tested. Live on PROD |
| **0.5** `drush role:list` on PROD vs `config/sync` | The last open caveat on decision Q-B9 |
| **0.6** TEST deploy + the 49-box spot-check sheet | `archive/TEST_SPOT_CHECKS_2026-08-05.md` — **0 of 49 ticked.** PROD went out regardless |

**0.9 — Caleb's design review of the D-8 package: status unknown.** He ran the deploy, so he may have
reviewed it in passing or not at all. **Ask him.** `archive/D8_OPTIONS_COMPARISON.md` §10 is still
the brief if the conversation is worth having; option D remains *held*, not rejected.

### The one thing worth carrying forward

If a D-8 regression ever does appear — a group that will not dissolve, an invitation pointing at a
group that no longer exists, a manager-less group — **query B from `archive/GROUP_CLEANUP_BRIEF.md`
§9.4 and query E from `archive/GROUP_ID_AT_ACCEPT_BRIEF.md` §12.4 are the diagnostics to reach for.**
They were never added to the audit script, so they have to be copied out of those briefs by hand.
**`[verified Aug 23]`** the script itself now lives at `archive/ROSTER_DATA_AUDIT.sql` — it was moved
out of the repo root and is untouched since `3942c4b`. That is the whole residue of §0.

---

## 1. Decisions needed — these block or shape work below

| ID | Question | Who | Blocks |
|---|---|---|---|
| **Decision 2** | **Allowlist vs exclude-list for "live registration."** ⬜ Open. **Full analysis: `DECISION_2_ANALYSIS.md` (Aug 6)** — four options costed, recommendation made, every line number verified against `8e7b0be`. It **supersedes** the framing in `archive/SESSION_HANDOFF_2026-08-05.md`, whose counts were wrong in four places. Verified: **four** conventions, not three — allowlist `IN ('paid','active')` at **49**, `paid`-only at **7**, exclude-list `NOT IN ('cancelled','expired')` at **2** + the trait at **25 sites / 8 classes**, and a previously-uncounted partial exclude-list `<> 'cancelled'` at **6** sites, four of which gate a duplicate *purchase* and are wrong under every option. Also: `waitlist` is live per the trait and not-registered per `AdminController:270`; the three season-capacity checkpoints disagree on `reserved_spots` as well as status, and **that** half is not behaviour-neutral. *(The old note "does not block the §0 deploy" is retired — that deploy is done.)* | Andrew + Caleb | **CF3**, the capacity-race fix (P6), B-15 |
| **D17** | No tournament equivalent of `groups_locked` — no way to freeze a tournament roster before scheduling. If the board expects one, the mechanism does not exist | Board | tournament roster work |
| **G1** | Should game cancellation notices bypass notification preference? Players on `none`, or `text` with no phone, are silently skipped | Board | G1 fix, and S1 below |
| **Q-B3 / CF11** | Build the player-facing "Disband Group" button? The four-weeks-of-query-B plan **died with item 0.1** — query B was never added and never will be. So the decision now rests on observed symptoms instead: D-8's B has been live since Aug ~9 with **no reported stranded solo group**, which is the same evidence, weaker. Decide on that or drop CF11 | Andrew | CF11 |
| **S4 / TS3** | Drag-swap bypasses validation (Team X vs Team X, same-night double-booking, saved silently and publishable). Blocking is explicitly *not* wanted. Proposed instead: `findScheduleConflicts()` + a persistent non-blocking warning banner + one confirm at publish | Andrew (on hold) | S4/TS3 |
| **Standalone jersey credits** | `CreditsPane` only appears when a `season_registration` is in the cart, so a jersey-only purchase can't be paid with credits. Long-standing (Dec 2025). May well be as-designed | Board | — |
| **Gender colour policy** | On the tournament roster builder, `Other` and `Prefer not to say` render blue, as men — the colour means "self-identified woman". Confirm that is still acceptable now that the screen is used to balance gender mix | Andrew | — |
| **Profile picture migration** | Pre-launch checklist item 32 | Board | — |
| **Option D** | **Held, not rejected.** Never mint a season `group_id` until an invitation is accepted. Full spec in `archive/GROUP_ID_AT_ACCEPT_BRIEF.md`; why it lost in `archive/D8_OPTIONS_COMPARISON.md`. **Revisit trigger:** query B does not settle at zero within a month, or group churn grows. **The escalation ladder does not jump straight to D** — first add the missed call site, then consider the entity-hook variant in `archive/D8_OPTIONS_COMPARISON.md` §8 | Andrew | — |

---

## P1 — Roster building (do before rosters are built)

### Cancelled-registration hygiene — the read side is still open

The write side is fixed and merged: **CF8** clears `group_id`/`invited_by`/`invitation_status` on season
cancel and dissolves the group if the manager left; **CF4** stops every write path resolving to a
cancelled row; **CF1/CF1b** stop new tournament ghosts. **The read side is not.**

- **CF3 — nine season group queries still count cancelled registrations.** ⬜ Not started.
  **`[verified Aug 23]`** — no `applyLiveRegistrationFilter()` exists anywhere in the module; the only
  helpers present are CF4's `pickLiveRegistration()` / `isDeadRegistrationStatus()` in
  `LiveRegistrationTrait`, used at 25 sites across 8 classes. This is the
  "counts will be wrong going in" half. One shared helper, not nine copies — build it on
  `LiveRegistrationTrait::isDeadRegistrationStatus()`, ideally as a query-level sibling
  `applyLiveRegistrationFilter($query)`. Note `loadByProperties()` cannot express `NOT IN`; switch to
  `getQuery()` or post-filter. **The tenth site is exempt by construction** inside `GroupDissolveService`.
  Brief: `ROSTER_RECONCILIATION_PLAN.md` §10.3 — ⚠ **its line numbers are stale; re-locate by method
  name.** Nine sites: `GroupController::getGroupSize()`, `myRegistrations()` ×2, `manage()`,
  `invitationHasMatchingRegistration()` (+ its team-invite branch), `RosterBuilderController::mergeToGroup()`,
  `GroupInvitationsForm` ×2 — one of which is also a CF4 site, so treat it as a twofer. The tenth is the
  `GroupDissolveService` write query, exempt by construction.
  - **CF3-a, sub-item:** two *already-filtered* allowlist counts will contradict D-4 the moment CF3's
    helper lands — `OrderCompleteSubscriber` (`status IN ('paid','active')`) and
    `TeamBalancerService::syncGroupToRoster()` (`status = 'paid'`). Convert both in the same commit, or
    record an explicit carve-out. Neither is mentioned in the CF3 brief.
- **D15 (residual half)** — missing status filter on the tournament `has_registration` lookup
  (`GroupInvitationsForm`); an unpaid or cancelled tournament registrant can be attached to a team by an
  admin. Belongs with CF3's sweep. *(The option-list half was retired by CF12.)*
- **R4 (HIGH)** — `mergeToGroup()` does not filter registration status, so a cancelled registration counts
  toward the group max **and can be picked as group manager.** Same sweep. The balancer's read path is
  already correct. **`[verified Aug 23]`** — CF4 fixed the *incoming player* lookup in that method
  (`pickLiveRegistration()`), but the group-membership lookup right below it is still a bare
  `loadByProperties(['season' => …, 'group_id' => …])` with no status condition. Easy to mistake for
  done because the method now carries a `[CF4]` comment.
- **Existing drift is not repaired.** Andrew's July 28 decision stands: code fixes only, no data repair
  this cycle. Rows already carrying stale refs stay as they are.

### Roster fixes not yet written

- **CF9 (⬜ not started — highest-value item remaining).** Inline `Team.players` appends do not
  dedupe; route them through `TournamentTeamManager::addPlayerToTeam($team, $uid, TRUE)`, which does.
  **`[verified Aug 23]` — four sites, not three, and every line number in the old entry had drifted:**
  `OrderCompleteSubscriber:818` (token-accept branch), `OrderCompleteSubscriber:871` (inside the dead
  `join` branch, so step 4 removes it rather than converting it), `GroupController:1605`,
  `RegistrationController:429`. `force = TRUE` preserves the `isFull()`/`isFullIncludingPending()` guards
  that drive the `ccsoccer_team_capacity_exceeded` flag. Four steps, in order: (1) inject
  `TournamentTeamManager` into the three classes + a `ccsoccer.services.yml` argument for the subscriber →
  **mandatory `drush cr`**; (2) swap the appends; (3) restructure so append + `set('group_id')` do not
  double-save — three saves are currently possible via `getOrCreateTeamGroupId()`; (4) delete the dead
  `action = 'join'` branch — **now at `OrderCompleteSubscriber:851`**. Ships standalone (decision **D-14**).
  Researched Aug 4; findings folded into the brief in `ROSTER_RECONCILIATION_PLAN.md` §10.3.
- **CF7 (⬜ not started).** Deleting a Team must NULL every `Registration.team` pointing at it — there is
  no team predelete hook (**`[verified Aug 23]`** — `ccsoccer.module` has no `*_predelete()` of any kind;
  the only hook-shaped thing near it is the Aug 4 comment recording the two deleted misnamed hooks). Add `ccsoccer_team_predelete()`, `accessCheck(FALSE)`, no
  exceptions escaping, **plus a watchdog notice on hook entry that the test asserts** — a hook silently
  not firing is a demonstrated failure mode in this module. ⚠ The brief's open question about two hooks is
  **already decided**: `e5317f8` deleted them. Do not re-litigate. Related: **B-9**, the broader
  Season/Tournament cascade delete.
- **CF11 (⬜ optional, do not build yet).** Player-facing "Disband Group" for a solo season manager.
  Route 4 clears itself under D-8's B, so this may be dead code. Decide after four weeks of query B
  (blocked on **0.1**). If built: use `archive/GROUP_ID_AT_ACCEPT_BRIEF.md` §9, which is more current than
  the plan's CF11 brief, and **`notify` must be `FALSE`, not `TRUE`** — the brief is wrong. D-7 stands:
  solo groups only. This is the only item in the cluster currently generating admin email tickets.
- **Destination resolution in roster-builder group ops (MED).** `mergeToGroup()`/`createGroup()` derive the
  *destination* team from `reg.team`; a ghost resolves it to NULL and the pair lands nowhere. Same root
  cause as CF5, opposite direction — CF5 fixed the source side only.
  `TournamentTeamManager::getPlayerTeam()` already has the roster-scan fallback that would fix it.
  Deserves its own small brief.

### Roster builder — admin tooling findings

- **R3 (HIGH)** — coed "≥1 woman per team" is a scoring *bonus*, not a constraint; teams can be finalized
  with zero women and the only signal is a CSS class. Needs a hard pass + a validation summary.
- **T2 (CRIT) — new ghosts stopped, existing ghosts remain.** Ghost members inflate `isFull()`, so an admin
  drags onto a visibly half-empty team and is told "Team is full". CF1/CF1b stop new ones; **no repair was
  run**, so affected teams keep misreporting until someone clears them by hand. The durable fix is
  read-side: capacity should count what is displayed, or reconcile ghosts on load. `isFull()` is still
  called bare at `TournamentRosterBuilderController:96` — **`[verified Aug 23]`, line still exact.**
- **Q-B7** — the admin accept path **silently overwrites** a player's `group_id` with no confirmation and
  no way back (registrations are not revisionable). Both player-facing paths refuse. Same shape as D2 bug
  3, which CF2 fixed on the tournament side of the same method; **the season side never got it.**
- **Phantom captain view (LOW, cosmetic).** A tournament registration with no team renders as a manager
  with an Invite form — `manage()` falls back to `$is_manager = empty(invited_by)` when `$team` is NULL
  (near `GroupController:419`, drifted) — then errors "No team found for this registration" on submit.
  Pre-existing; alarming to a player; cheap if someone is already in `manage()`.
- **D-9 residual (one line, deferred).** `manage()`'s `if (!$group_id)` gate hides invitations from a solo
  manager. Mostly self-resolving under D-8. Verify against the roster-builder screens before widening it.

### Roster builder — the remaining admin-review findings

These sat in `ADMIN_TOOLING_REVIEW.md`'s tables but were never in any list. **T7, T8 and R7 are in the
direct path of the roster work the Aug deploy unblocked**; the review itself recommends batching T7/T8 with
the invitation/status-filter work.

| ID | Sev | Item |
|---|---|---|
| **T4** | HIGH | The pool flag is write-only in this UI: dragging a pool player to Unassigned bounces them back to Pool. `ccsoccer_pool` is deliberately never cleared here, while the service method this path bypasses *does* clear it |
| **T7** | MED | Admin placement uses `isFull()`, not `isFullIncludingPending()` — it can fill slots reserved by outstanding captain invitations, so the team overflows when those invitees accept. **Batch with CF3/R4** |
| **T8** | MED | The builder never touches invitations at all: placing an invited player leaves their pending invite live (counted as confirmed *and* pending); removing one leaves a dangling invite. **Batch with T7** |
| **T10** | MED | No team-status guard — players can be dropped onto withdrawn or DQ'd teams |
| **T11** | LOW | Synthetic `captain_<id>` group ids break Shift+drag merge ("Target group not found"); no concurrency guard; no notification on placement; synthetic skill/age defaults (3 / 30) silently skew the average headers the board balances by |
| **R5** | MED | The legacy `save` endpoint clears **every** registration's team then rewrites from the posting tab — a stale tab or an old bookmark wipes concurrent work. The UI no longer uses it: remove it, or wrap it in a transaction + confirm. `move` also has no concurrency guard (last write wins between two board members) |
| **R6** | MED | Group cohesion is visual only. A player who accepts an invite *after* rosters are built can sit on a different real team than their group shows — the builder displays them together, the printed roster splits them. Persist the consolidation, or call `syncGroupToRoster()` after changes |
| **R7** | MED | `groups_locked` is enforced everywhere player-facing but **ignored by every roster-builder endpoint** — merge and create-group still work after the lock. Decide whether that is an intended admin override |
| **R8** | MED | Late registrants land silently in the workbench: no badge, no alert. And **no player notification exists on roster publish** — if the board expects "you're on Team X" emails, that feature does not exist |
| **R9** | LOW | The goalie badge reads the registration field only while the balancer also reads the profile field, so an admin cannot see why a team "refuses another goalie" |
| **R10** | LOW | `clearTeamsForSeason()` deletes Teams without nulling `Registration.team` → dangling refs if it is ever invoked post-assignment. **Same family as CF7 and B-9** |

---

## P2 — Scheduling

- **S2 (HIGH) — ⚠ rewritten Aug 23; the old description is now the opposite of what happens.** It read
  *"after a season regenerate the season stays VISIBLE while every new game is unpublished: players see
  an empty schedule."* **`[verified Aug 23]`** that is no longer true. The Aug 22 change
  (`03ef38d`, `84f607a`, `db81afa`) made `Season.schedule_visible` the only gate on every surface, and
  **nothing now reads per-game `published`** — the season schedule page, both PDF routes and both iCal
  feeds all load games with no `published` condition. So a regenerate on a visible season **publishes
  the candidate schedule to players the moment it is generated.** Same failure as TS2, opposite symptom
  to the one recorded: not an empty schedule, a live wrong one. The fix is the same for both — gate the
  public views on something a regenerate resets, or make regenerate clear `schedule_visible`.
- **TS2 (HIGH)** — the tournament public view checks only `tournament.schedule_visible` and ignores
  per-game `published`, so a regenerate replaces the live schedule instantly. **`[verified Aug 23]`** —
  `TournamentScheduleGridBuilder:555` conditions on `schedule_visible` and nothing else.
  **Operating rule until S2 and TS2 are fixed: keep `schedule_visible` OFF while iterating — on
  seasons as well as tournaments.**
- **S8 (MED)** — a deleted team leaves "Unknown"/"TBD" opponents and can block regenerate with a
  "Team count mismatch"; `moveTeam` can leave a published game with a NULL side. Warn on publish for
  incomplete games.
- **S4 / TS3 (⏸ on hold — Andrew deciding)** — see [Decisions needed](#1-decisions-needed--these-block-or-shape-work-below).
- **Operating rules already agreed (decision 2 of the admin review):** finalize teams/slots/fields/dates
  before generating candidates; snapshot before any regenerate; never regenerate after week 1 of a live
  season.

### Remaining scheduling findings from the admin review

*(These use the admin-review S/TS numbering — S1 here is **not** the notification-preference S1 in P5.)*

| ID | Sev | Item |
|---|---|---|
| **TS4** | HIGH | **No transaction or lock on generate.** Two admins — or a generate and a manual edit at the same time — interleave delete/create. The grid dedupes by slot, so the duplicates are invisible while the validation warnings go strange |
| **TS5** | MED | Infeasible configs under-schedule silently. 5 teams × 3 games is mathematically impossible; the generator floors it and returns success, and the dropped matchups leave blank shells with only a buried warning |
| **TS6** | MED | Publish or change notifies nobody — captains are expected to poll the site |
| **TS7** | LOW | Slot-distribution scoring hardcodes an 8-slot day; two divergent grid renderers (admin vs public) will drift; snapshots are position-keyed and silently lossy |
| **S1** (admin review) | MED | Regenerate destroys game history. The `fill_only` plumbing exists but is never exposed in the UI |
| **S6** | MED | Round-robin is greedy, not a true rotation — over a 10-week season some pairs meet three times and others once, with no report. Fine for rec **if the board knows**; the circle-method fix is the alternative |
| **S9** | LOW | No concurrency or double-submit guard on generate; no player notification on publish (possibly by design — players check the site) |

---

## P3 — Tournament flow

- **D3 (CRIT)** — tournament order completion never re-checks that the tournament is still
  `active`/`registration_visible`. A player checking out while an admin cancels still gets a `paid`
  registration, and a Team plus a taxonomy term are spawned for a dead tournament. **Port the season guard
  verbatim** — the pattern is at `OrderCompleteSubscriber:364`, including the flag write.
- **D5 (HIGH)** — tournament cancellation notifies nobody: the player is silently dropped and possibly
  silently refunded. Season pattern is `sendRegistrationCancelled` in `CancelRegistrationForm`.
  Cross-check **S1** first — the season email is preference-gated, which is its own bug.
- **D12 (MED)** — tournament "closed" derives only from `registration_close`; if that is unset,
  registration stays open forever. The Tournament entity already has `isRegistrationOpen()`; the
  controller does not use it.
- **D10 (MED)** — tournament eligibility is enforced only at add-to-cart, and `checkUserAge()`'s override
  branch is gated to `season`, so a tournament age-override is not honored.
- **D11 (MED)** — gender is checked for display, not for action: `addSeasonToCart` re-checks age but not
  the mens35 rule.
- **D16 (LOW, policy)** — tournament players have no self-leave path; `leaveGroup()` blocks tournaments.
- **D20 (LOW)** — admin accept/decline sends no notification on either side.

### Invitation-only registration — ✅ ON PROD, enforcement half still unverified

Merged as **PR #136 (`fc5069f`)** and deployed to LOCAL, TEST and PROD on Aug 23. Hook `9074` ran on
all three; `Tournament.registration_invite_only` field storage verified on each by direct query;
`config:status` clean on LOCAL and PROD (three known `media_library` entries only). Design, behaviour
table and the 17-step test plan are in `archive/TOURNAMENT_INVITE_ONLY_PROPOSAL.md`; that document
describes what should be true, this entry is the only place that says what is.

**Live but inert** — the field defaults to FALSE on every existing tournament, so nothing changes
until the checkbox is ticked. **It has not been ticked on SLO Friendly yet.**

> **[Wrong twice in one day — Aug 23.]** This entry said "built on a branch, not pushed," was
> corrected to "merged, not deployed," and is now "on PROD." Each was true when written. **Check git
> and the servers before trusting the status line here a third time** — the design notes age well,
> the status does not.

- **◐ Still only the display half is verified.** Andrew confirmed the `/register` cards on LOCAL for
  an anonymous visitor, an invited player (who correctly still gets a Register button), and a
  logged-in player with no invitation — that feedback produced `da0e8cc`. **The enforcement half has
  never been exercised**, and deploying did not change that. Run §5 steps **4** (bookmarked
  `/register/tournament/{id}` still blocked — the gate, as opposed to the card), **9** (stale form
  posting `none`), **11** (in-flight cart dead end) and **12** (the switch is reversible). A UI-only
  implementation passes everything else and fails exactly those four. **Do these before ticking the
  box on a live tournament.**
- **⚠ Two operational constraints while the box is on**, both deliberate, both undocumented in the
  field description, both worked around by unticking for a minute:
  **(a) no admin bypass** — an unregistered board member cannot register; the pane has no matching
  bypass, so a front-door bypass would dead-end one step later with a cart already created.
  **(b) `create` is closed too** — a late captain, including a pre-paid one, cannot form a team. The
  single flag cannot express "no new free agents but a new team is fine"; all three uninvited routes
  close together.
- **The code was verified against the tree on Aug 23**, separately from the deploy: hook `9074` at
  `ccsoccer.install:4981`, the base field at `Tournament.php:131`, `isInviteOnly()` at `:485`,
  `TournamentForm:72` defaulting it FALSE on new tournaments, and the 259-line
  `TournamentInviteService`. ⚠ **`isInviteOnly()` is `hasField()`-guarded, so it fails open** — code
  without `updb` reads every tournament as open and the checkbox silently does nothing. Safe
  direction, but ship code and hook together.
- **Partially resolves D12 above.** `addTournamentToCart()` now refuses a tournament whose `end_date`
  has passed, so registration no longer stays open forever when `registration_close` is unset. D12's
  wider point — that the controller ignores `isRegistrationOpen()` — still stands, and is now a
  deliberate choice rather than an oversight: `isRegistrationOpen()` returns FALSE on `isFull()`, and
  wiring it in would close the card to invited players too. **Do not "fix" D12 by adopting
  `isRegistrationOpen()` in `addTournamentToCart()` without reading the warning comment there first.**

**Known limitations, accepted on purpose — each is commented at the site in code:**

- **An order already at `review`/`payment` can still complete as a free agent.** Not gated at
  `createTournamentRegistration()`, because blocking post-payment charges the card and creates no
  registration — the Aug 4 shape (orders 304/309, P6). Bounded by the 2-day cart lifetime.
- **No admin bypass**, and **`create` closes with the uninvited routes** — both are covered above
  under *Two operational constraints*; unticking the box for a minute is the workaround for each.
- **An invitee who signs up under a different email than the captain invited is now locked out.**
  Pre-existing gap (E3 in `INVITATION_FLOW_ANALYSIS.md`): all three matching sources miss, and before
  this change they could at least register as a free agent for an admin to fix up. Workaround is a
  re-invite to the address they actually used.
- **A cart holding two tournaments, one invitation-only, is blocked on both.** `getInvitedTeams()`
  resolves against the first tournament in the cart while `OrderCompleteSubscriber` loops over all of
  them, so failing safe was the only correct answer. Unreachable while one tournament runs at a time.

**🟡 New, found while building this:** the `/register` page had no `ccsoccer_invitation` cache tag, so
a captain sending an invitation did not invalidate the invitee's cached page. Harmless before (a stale
page still showed a working Register button), load-bearing now. Fixed in `3f329fd` — noted because the
same gap may exist on other pages that read invitations.

---

## P4 — Game-day ops

- **G1 (HIGH, decision needed)** — cancellation notices skipped for preference `none` / `text`-without-phone.
  Same root cause as **S1**.
- **G2 (HIGH)** — on a tournament-only date the cancel flow is season-keyed: it previews "0 players will be
  notified", sends nothing, credits nothing, and reports success. **`[verified Aug 23]`** —
  `GameStatusForm` contains no reference to tournaments at all.
- **G4 (HIGH)** — the public per-season iCal feed emits cancelled games as `STATUS:CONFIRMED`.
  **`[verified Aug 23]` still open, now at `ContentController:1459`** (inside `exportIcal()`; the line
  moved when the `schedule_visible` gate landed). The per-user feed at `:1335` is correct — it writes
  `STATUS:` from a computed `$status_ical`.
- **G6 (MED)** — the legacy Views iCal feed at `schedule-feed/%/ical` is public and maps
  summary/description/UID to the numeric game id. Delete it or fix the display. **`[verified Aug 23]`** —
  `config/sync/views.view.game_schedule_feeds.yml`, display `ical_feed`, path `schedule-feed/%/ical`,
  `ical_summary_field: id` and `ical_description_field: id`. Unchanged.
- **G3 — ⚠ the code moved on Aug 22; only the product question survives.** The recommendation is still
  **deletion, not repair**: the 3 pm cancellation reminder is a redundant second league-wide email + SMS,
  and it does **not** drive the status banner, which recomputes live per request. **What changed
  (`[verified Aug 23]`):** `ccsoccer_cron()` no longer exists (`f7b0fb3`), so the old removal scope
  *"`ccsoccer.module:460-536`"* points at a comment block. The reminder now lives in
  `CcsoccerCommands::dailyGameReminders()` (called from `ccsoccer:daily`, cPanel `5 15 * * *` on PROD)
  → `NotificationService::sendGameCancellationReminder()`. The `'reminder'` branch is **no longer dead**
  either — `logNotification()` became `markNotified()` and `reminder_notified` is the command's duplicate
  guard. **Both prerequisites are answered:** the `notification_log` config object was deleted by update
  `9073`, and the cron entries are documented in `SESSION_HANDOFF.md`. **Deleting it now means:** drop
  the `--only=games` arm of `ccsoccer:daily`, `sendGameCancellationReminder()`, and the PROD cron entry;
  keep `reminder_notified` or remove it in the same update hook as the other two audit fields.
- **G7 residual (MED)** — `GameStatusForm` only lets you edit the **next four dates**; a date's
  cancelled-state is derived from the *first* game only, so mixed-status dates are misrepresented.
  *(The "cancel hits all leagues sharing the date" half is moot in practice — only one league plays per
  night.)*
- **G8 (MED) — synchronous notification drain, and it now has a symptom.** Up to 50 notifications are
  sent inside the admin's form submit, with live SMTP and Clickatell calls in-request. **Aug 22: the
  Game Status form returns a 502 after confirming** — the work completes (games saved, notices sent,
  fields stamped) and nothing appears in watchdog or the PHP error log, which is the signature of the
  request being killed after the work but before the response. Harmless on one game; a full-season
  cancellation will be slower and may well repeat it. **Pre-existing, not introduced by the Aug 22
  work.** Separately, and unrelated: the iCal text is not RFC-escaped, so a comma in a team name
  corrupts the event.
- **Banner caches for 5 minutes** (`max-age: 300`), so the 3 pm flip can lag to 15:05. Cosmetic.

> **G5 stays closed, but its evidence was pointing at dead code — corrected Aug 23.** The finding
> claimed the printer-friendly PDF has no `published` and no status filter, and the Aug 5 rebuttal cited
> `ContentController::exportPdf()`, which does filter and does strike cancelled games through.
> **`[verified Aug 23]`: no route points at `exportPdf()`** (see P8) — it has not served a user in a
> long time. The PDF a player actually gets is `ReportController::schedulePdf()` /
> `mySchedulePdf()` → `SchedulePdfService`, both gated on `Season.schedule_visible` since `db81afa`.
> G5 remains closed because the live path is gated and renders cancelled games, **but do not re-derive
> that from `exportPdf()`.** And **do not close G4 with any of it** — G4 is genuinely still open.

---

## P5 — Invitation and order gaps

- **S1 (policy + fix, HIGH)** — `NotificationService::send()` honours `field_notification_preference` for
  **everything**, so preference `none` means a paying player gets no receipt at all, and `text` skips email
  entirely and silently no-ops without a phone or on a Clickatell failure — while the inviter is still told
  "Invitation sent". **Recommend transactional sends (receipt, invitation, waitlist offer) bypass the
  preference; keep it for reminders and announcements.** This is the root cause of **G1** and of the
  false-positive receipt below. Relevant to any "notifications aren't arriving" report.
- **`sendRegistrationConfirmation()` sends a false-positive receipt (HIGH).** It gates on cart contents,
  not on whether a registration was actually created — which is exactly why neither Garret nor Tenaya
  reported the Aug 4 incidents. **Same method as S1. One fix, two faults.** **`[verified Aug 23]`** —
  `OrderCompleteSubscriber:1102` still walks `$order->getItems()` and returns early only when all three
  item buckets are empty. It never consults the registration it just tried to create.
- **Accept-from-My-Registrations has no capacity check.** `acceptSeasonInvitation()` /
  `acceptTeamInvitation()` and their `*Directly()` twins let a player accept into an already-full group.
  Only `OrderCompleteSubscriber` — **1 of 4 accept paths** — checks capacity at all.
  **`[verified Aug 23]`** — `GroupController::acceptSeasonInvitation()` (`:1460`) checks
  `groups_locked` and nothing about size.
- **`groups_locked` is enforced on 1 of 4 accept paths.** `acceptSeasonInvitationDirectly()`,
  `OrderCompleteSubscriber` and `GroupInvitationsForm::acceptInvitation()` do not check it. Pre-existing,
  unrelated to which D-8 option shipped, **still wrong.** Table in `archive/GROUP_ID_AT_ACCEPT_BRIEF.md`
  §5.7. *(Tracked elsewhere as Q-B4 and Q12 — same bug, three IDs, now one entry.)*
- **`invite()` never checks the caller is the group manager** — it checks registration ownership only. The
  manager-only rule lives in Twig (`ccsoccer-group-manage.html.twig:107`), so a non-manager member can POST
  the invite route directly. **`[verified Aug 23]`** — `GroupController::invite()` (`:874`) gates on
  flood limit, `player == currentUser`, and `groups_locked`. There is no `invited_by`-is-empty test.
- **Q-B2 — the five auto-decline sites notify nobody.** **`[verified Aug 23]`** — still exactly one
  caller of `sendInvitationDeclined()`, at `GroupController:1704` inside `declineInvitation()`; the four
  auto-decline loops do not. This is the one user-visible regression of the D-8 change: P3 is never told P2 chose someone else,
  and now P3's group quietly disappears too. Deliberately kept out of the D-8 commit to avoid changing
  email volume and behaviour together. **When fixing, confirm the notification fires on an *auto*-decline,
  not just a manual one.**
- **Q-B5 residual — the two Accept buttons disagree.** One kills the invitation; the other leaves it
  retryable. My Registrations routes through `GroupController`, the Register page through
  `RegistrationController`. Record it, fix separately.
- **Q-B4 *(cleanup brief §11)* — should a route-4 dissolve notify the surviving manager?** Open. Check
  what the manager is told today via `sendPlayerRemoved()` and the cancel notifications; if that already
  covers it, change nothing and close this.
- **Q-B1 — the carve-out counts *all* pending invitations for the group, not just the manager's.** Shipped
  that way and it is the right answer, but it was never formally recorded. Mark it answered-by-implementation
  so nobody re-derives it.
- **E3 residual** — the already-registered token path (`acceptSeasonInvitationDirectly()` /
  `acceptTeamInvitationDirectly()`) still lacks the invitee-email identity check `GroupPane` now has.
  Narrow trigger. Add the same email-match guard plus a status filter to `available()`'s lookup.
- **Group-full user messaging (designed, not built)** — (1) in `GroupPane`, show a full group's invitation
  disabled ("Join X's group (full)") rather than omitting it; (2) on the completion page, tell a player
  whose group filled mid-checkout that they are registered but not grouped. **The completion-page half is
  the more important one.**
- **Invitation data-repair drush command** — the reusable bulk sweep for orphaned `accepted` invitations
  was never built. PROD data has been patched by hand as cases surfaced. Low urgency now that Bug 1 is
  fixed, but it does not exist if a batch is ever needed.
- **Legacy `team_<id>` group_ids on invitations were never repaired or audited.**
  `_ccsoccer_repair_legacy_team_group_ids()` covers Registration rows only.
- **Q5 (open factual gap)** — is `Team::getRosterStats()`'s season branch live or inert? It counts by team
  id "because `group_id` would be 0", a condition that stopped holding when `getOrCreateTeamGroupId()`
  landed, so it may double-count. Grep for any path that gives a *season* Team a `group_id`; if inert,
  comment it rather than delete it.

---

## P6 — Capacity race (found Aug 4, two players paid and got no registration)

Two confirmed incidents with real money — orders 304 (Tenaya, season 48) and 309 (Garret, season 47) —
both manually resolved on PROD, both with a `ccsoccer_flag_resolved` audit key added. **Design agreed, not
built.**

- **⚠ [Corrected Aug 23] There IS a capacity check at cart-add — it is just in the wrong place and
  counts the wrong thing.** This entry used to say *"no capacity check exists at add-to-cart… the only
  check in the entire flow is the completion-time guard."* **`[verified Aug 23]`**:
  `CartEventSubscriber::onCartEntityAdd()` (`:91-124`) counts `status = 'paid'` registrations against
  `Season.max_players` and, when full, removes the order item and tells the player to join the waitlist.
  So the flow has **two** checkpoints, not one. What is actually wrong with it:
  **(a)** it is a `paid`-only count, so it ignores `active`, `reserved_spots` **and** every other draft
  cart holding a seat — the exact race that produced orders 304 and 309;
  **(b)** it lives on the cart *event*, not in `addSeasonToCart()`, so nothing on the `/register` card
  or in the controller knows about it;
  **(c)** **tournaments have no equivalent at all** — `addTournamentToCart()` carries an explicit
  "no capacity check here, deliberately" comment (`RegistrationController:921`), because tournament
  player capacity is a per-team roster limit.
  The `CapacityManagerService` below should **replace** this check rather than sit beside it — a third
  count of "how full is this season" is what this cluster exists to stop.
- **Planned shape:** a new `CapacityManagerService` (pattern-matching `CreditManagerService` /
  `WaitlistManager`) with explicitly-named methods `isEffectivelyFull()` / `getEffectiveSpotsRemaining()`,
  called from three checkpoints: page load, add-to-cart, and a checkout pane at the `review` step (before
  `payment_process`), validating in `validatePaneForm()`.
- **"Effective capacity" = live paid/active registrations + draft-cart holds for that product, counting
  only carts updated within the last N days** (proposed 2, matching the cart-expiration window). This makes
  correctness independent of cron ever running.
- ⚠ **Deliberately do not touch `Season::isFull()` / `getSpotsRemaining()`** — they are used where a strict
  live-only count is wanted, and silently redefining them is exactly the drift shape this whole cluster
  exists to stop.
- ⚠ **Do not invent a fourth definition of "live."** `LiveRegistrationTrait::isDeadRegistrationStatus()`
  already encodes D-4 and D-13. See [Decision 2](#1-decisions-needed--these-block-or-shape-work-below)
  before writing this.
- **Bundle as one cluster** with the false-positive receipt fix (P5) and the flagged-orders reconsideration
  below — same root cause, likely the same session. **~7–10 hrs** for season + tournament, ~5–6 season-only.
- **`FLAGGED_ORDERS_REPORT_PROPOSAL.md`'s July 3 deferral is now demonstrably wrong.** It assumed players
  self-report by email; the false-positive confirmation is why they did not. Revisit whether to build the
  admin report. Related: **S3** under Parked.

---

## P7 — Reports and admin theme

- **✅ The Jersey CSV button is fixed — closed Aug 22 (`28a40d1`).** **`[verified Aug 23]`** the handler
  in `config/sync/views.view.jersey_report_view.yml` now finds the table via
  `btn.closest('[class*="js-view-dom-id-"]')`, a class the Views module's own template emits in every
  theme, and logs to the console when it misses. Neither option from the old theme brief was taken;
  `BOARD_MEMBER_ADMIN_THEME.md` has been **moved to `archive/`**. ⚠ That fix is a **config file** —
  it needs a config import wherever it has not landed yet.
- **Residual (LOW, cosmetic):** Board Members still see `/admin/ccsoccer/reports` in the front-end theme
  rather than Claro, because only `content_editor` holds `view the administration theme`. Nothing else
  is known to break because of it. **Tournament Deposits loses its table styling and sort arrows** there
  — it functions, and `tournament-deposits-table` is styled **nowhere in either theme**, so that is new
  CSS whenever someone picks it up, not something a permission grant would fix.
  The two PDF reports (City Payment, Insurance) return `Response` objects and are theme-independent.
- **`ReportController::reportsLanding()` gates the Jersey link on `view reports` only** while the other
  three accept `view reports` *or* their specific permission. Invisible today because Board Member holds
  both. Align it next time someone is in that file. **`[verified Aug 23]`** — `ReportController:178`,
  still the odd one out of the four.
> **The Insurance Report is built.** `InsuranceReportForm` + `InsuranceReportService::generateReport()`,
> route `ccsoccer.reports.insurance_report`, gated on `access insurance report`. It is one of the four
> reports affected by the theme bug above. The old `PROJECT_STATUS.md` item PS-1 ("never defined, still
> wanted") was stale and is closed.

---

## P8 — Display and cleanup (small, batch when the file is open)

- **Delete tournament skill calculators B and C** (~60 lines out, 1 in). Three competing calculators exist;
  Assigned Skill reaches the schedule builder correctly today, so this is not urgent — the exposure is that
  a forgotten or cleared Assigned Skill silently becomes 3.0. **`[verified Aug 23]` still present:**
  `TournamentController::calculateTeamSkillLevel()` at `:802` (+ its write-on-GET save block) and
  `Team::calculateSkillLevelFromPlayers()` at `:526`, whose only caller is still
  `TournamentScheduleGeneratorService:536`.
  ⚠ **[Aug 8] The premise moved — re-read before acting.** PR #131 removed the `/ 2` from
  `calculateSkillLevelFromPlayers()`, so the old reasoning that it *"caps `calculated_skill_level` at 3.0
  and makes the scheduler's `>= 4.0` field tier unreachable"* is now **history, not current behaviour**.
  Calculator C is no longer wrong, merely redundant, which lowers the urgency further and makes the
  lighter alternative below more attractive than deletion.
  Keep `getSkillLevel() ?? 3.0` but have the schedule builder **name** the teams it guessed for. Leave the
  `calculated_skill_level` field; if it is kept, an update hook should NULL the fabricated `3.00` values.
  **Lighter alternative:** point calculator C at `getPlayerSkill()` instead — Andrew's call.
  Detail: `archive/TOURNAMENT_SKILL_DISPLAY_FIXES.md` §3, which holds the only written comparison of the
  three calculators.
- **~15 dead CSS rules** in `css/tournament-roster-builder.css` target `.tournament-player-card…`, a class
  nothing emits (lines 83, 101, 105, 109, 115, 121, 155, 257, 347, 352, 362, 401, 426, 445, 460). **The
  trap:** new CSS written against that class silently does nothing. Rename the emitted class or delete the
  block. **`[verified Aug 23]`** — 15 occurrences in the stylesheet, zero in any PHP, Twig or JS file.
- **Dead Claro-scoped CSS** — `ccsoccer_theme/css/user-pages.css:401-478` and
  `ccsoccer/css/insurance-report.css`. Cleanup only. ⚠ **[Aug 23] The reason to keep the `user-pages.css`
  block has expired.** It was held because it was the whole of Option D in `BOARD_MEMBER_ADMIN_THEME.md`
  — supply `.views-table` from the view's Table style and the CSV button works. **The CSV button was
  fixed a different way on Aug 22** and that brief is archived, so nothing is waiting on this block any
  more. It is dead because nothing emits `.views-table`; both files can go.
  **`[verified Aug 23]`** — `.views-table` appears in exactly two places in the repo: this stylesheet and
  the warning comment in the jersey view's JS telling you not to use it.
- **`NotificationService::send()` docblock is wrong** (**`[verified Aug 23]`**, still at `:386`): *"If not
  provided, body will be stripped."* Since the Aug 2 rewrite an empty `$sms_body` means **send no SMS**, silently and with no
  per-user log line. It documents the exact trap CF8 nearly fell into. **Caleb's rewrite — his to correct.**
- **`getUserDisplayName()` was not widened to public** during CF8, and the inline duplicates remain.
  **`[verified Aug 23]`** — still `protected` at `NotificationService:2119`; the open-coded
  `trim($first . ' ' . $last) ?: getAccountName()` shape is at `CancelRegistrationForm:154, :367` and
  `GroupController:186, :555, :737`. Deliberate deviation; still worth doing separately.
- **B-10 — delete the two empty service stubs.** **`[verified Aug 23]`, and they are emptier than the old
  VERIFY entry guessed:** `SeasonRegistrationService.php` and `TournamentRegistrationService.php` are
  **6 lines each with zero methods**, and both are registered in `ccsoccer.services.yml` (`:7`, `:11`) —
  so the container builds two objects that can do nothing. Nothing injects them. Delete both files and
  both service definitions; **`drush cr`**.
- **Delete `src/Access/TeamCaptainAccessCheck.php` — it has never run.** **`[verified Aug 23]`** the
  class is registered nowhere: no `access_check` tag in `ccsoccer.services.yml`, no route names it, and
  the string `TeamCaptainAccessCheck` appears nowhere in the module outside its own class declaration.
  It implements `AccessInterface` and reads like live security — *"admin always has access"*,
  *"season teams cannot be managed by captains"*, *"you are not the captain of this team"* — which is
  exactly why it should go rather than sit there: **the next reader will believe those rules are
  enforced at the route layer.** They are not. The captain-facing routes (`/my-group/{registration}`
  and friends) are gated on `_user_is_logged_in` plus ownership checks written inline in
  `GroupController` — and per the P5 item above, one of those inline checks tests registration
  *ownership* rather than group *management*, which is the gap this file would have closed had it ever
  been wired up. Deleting it is safe; **reading it first is worth ten minutes**, because it is a
  written statement of the access model someone intended.
- **One stale docblock** describing the dead `team_<id>_<id>` group_id format, at
  `GroupInvitationsForm.php:24-29`. **`[verified Aug 23]` the `Team.php` twin is already gone** — the
  docblock there now describes the season/tournament invitation-linking split and is accurate. Half of
  this item was done by someone and never recorded.
- **`CartEventSubscriber` trace logging is live on PROD** — **`[verified Aug 23]`**: six notice-level rows
  at `:64, :80, :130, :140, :151, :174`, every line number still exact. Same class as the `NotificationService` DEBUG noise already removed.
  *(Separately, `NotificationService::getUserPreference():254` logs each user's preference by username at
  debug level — that is the "unexplained preference lookups" item below.)*
- **Recursion-guard warning still logs a warning for a benign event.** **`[verified Aug 23]` — half done.**
  The text was corrected (`OrderCompleteSubscriber:126` now ends *"(This is expected when a flagging branch
  saves the order mid-completion.)"*), so it no longer misleads a reader of dblog. What was proposed and
  **not** done is silencing it: inside the static-guard branch check `ccsoccer_completion_processed` first —
  set → benign end-of-method save → return silently; not set → warn. Lower value now that the text is
  honest.
- **⚠ [Corrected Aug 23] The `CC Soccer` → `CCSoccer` SMS fix was never applied anywhere.** This item said
  the fix "was scoped to tournament deposit + jersey only" and had been missed on season confirmations.
  **`[verified Aug 23]`: every SMS body in the module still opens with `CC Soccer`** — season
  confirmation (`OrderCompleteSubscriber:1280`), jersey confirmation (`:1344`), deposit refund
  (`NotificationService:2051`), deposit forfeit (`:2105`), jersey purchase notice (`:2434`). The only
  `CCSoccer` string in the codebase is the iCal `SUMMARY` prefix at `ContentController:1315`. So this is
  a five-site rename, not a one-site catch-up — and worth confirming that "CCSoccer" is still the wanted
  convention before doing it.
- **Replace `slofriendly` role references with `tournament_director`.** Decided Aug 2, not done.
  `sendBulk()` queries a role that does not exist and silently matches nothing. **`[verified Aug 23]`** —
  the live one is `NotificationService:835`, `$other_privileged_roles = ['administrator', 'slofriendly']`.
  The other hits are a comment, a URL, and the `slofriendly_test` seed user in `CcsoccerCommands` — those
  are not role lookups and must not be swept up. **Grep first — only *role*
  references change;** menu names, CSS classes, URLs and tournament labels legitimately keep the word.
  Behaviour change: tournament directors start getting immediate `[VERIFY]` copies and are excluded from the
  queue.
- **`getRecipientIds()` uses `status IN ('paid','active')`** — behaviour-neutral today, but it is the
  allowlist. Merge with B-15 / Decision 2. **`[verified Aug 23]`** — five such conditions in
  `NotificationService` (`:1836, :1899, :1962, :2176, :2252`), so this is a small sweep, not one line.
- **Unexplained preference lookups** — three rounds of four `field_notification_preference` debug lines fire
  before a send, but neither `getRecipientIds()` nor `countSmsOnlyRecipients()` calls `getUserPreference()`.
  Possibly the Test button. Investigate. **`[verified Aug 23]`** — the lines come from
  `NotificationService::getUserPreference()` itself (`:254` and `:261`, one on each branch), so finding
  the caller is the whole of this task. Deleting the two debug lines would also close it.
- **🟡 The next bulk notification send needs a huddle first and validation after.** Three components of the
  pipeline are structurally untestable on TEST and will execute for the first time on PROD. **The protocol
  and its seven pass conditions are in `archive/SESSION_2026-08-02.md`** — they were dropped from the live
  handoff. Talk to Andrew before the next send.
- **Ask Andrew** about the unexplained 17-line deletion in `CcsoccerCommands.php` on
  `fix/tournament_nudge_500`.
- **Verify Avi's refund** — order 87 ($133), Payments tab. ~30 seconds; has slipped several sessions.

### Schedule-export dead code and the `published` residue *(harvested from the Aug 22 sessions, Aug 23)*

All four came out of the session that made `Season.schedule_visible` the single gate. They had been
recorded only in `SESSION_HANDOFF.md`, which is not where open items live.

- **🟡 `ContentController::exportPdf()` is unreachable — delete it.** ~150 lines. **`[verified Aug 23]`** —
  it is at `:1482` and **no route in `ccsoccer.routing.yml` points at it**; `/schedule/{season}/pdf` is
  served by `ReportController::schedulePdf`. ⚠ It is also the method the **G5** rebuttal cites, so read
  that note in P4 before deleting: the reasoning survives, the citation does not.
- **🟡 `ReportController::scheduleIcal()` is an unimplemented stub — delete it.** **`[verified Aug 23]`** —
  `:138`, adds a warning message and throws `NotFoundHttpException`, and nothing routes to it. The real
  per-season feed is `ContentController::exportIcal()`.
- **🟡 `Game.published` has no readers left. Decide: drop the field or leave it inert.**
  **`[verified Aug 23]`** — the only `->condition('published', …)` anywhere is inside the dead
  `exportPdf()` above. Everything else that touches it is write-side or snapshot plumbing:
  `ScheduleGeneratorService` (`:810, :939, :1157, :1346, :1518`) and
  `TournamentScheduleGeneratorService` (`:282, :822, :1820, :2000, :2118, :2255, :2424`).
  Dropping it touches `getScheduleState()`, `saveSnapshot()`, restore, `setPublished()`, both tournament
  equivalents, and needs an update hook. **Zero snapshots exist in either environment**, so there is no
  format migration if it goes. See **S2** in P2 — the same field is why that item's description flipped.
- **🟡 Tournaments carry the same duplication.** `TournamentScheduleGeneratorService::setPublished()`
  writes game `published` *and* `tournament.schedule_visible` in one method — tidier plumbing, same
  two-representations problem, and the tournament edit form is still an unsynced second entry point.
  No tournament iCal exists (one-day events), so `published` there has **zero** readers of any kind.

---

## P9 — Launch and infrastructure debt (the pre-launch checklist, harvested Aug 5)

> **This harvest was overdue.** `DOC_CLEANUP_CONSOLIDATION_WIP.md` §7 step 7 named the pre-launch
> checklist as the *last* outstanding harvest, and it was never done — then the section holding it was
> deleted from `SESSION_HANDOFF.md` on Aug 4. It survives only at `archive/SESSION_2026-08-04.md`
> (search "Combined Pre-Launch Checklist"). **These are the live items, carried here at last.** Numbers
> are the original checklist numbers.

**Security and access — the sharp end:**

- **6 — geo-blocking (open, and the site is taking traffic).** ccsoccer.com is public and receiving
  Russian spam-bot traffic through the contact form. **6a:** replicate the D7 modules first — Smart IP
  (needs the free IP2Location LITE BIN file, monthly update) + Country Block. **6b:** Cloudflare as the
  fallback, which needs DNS rerouted through their proxy — see `CLOUDFLARE_PROPOSAL.md`.
- **7 — remove the IP whitelist from `web/.htaccess` on both servers.** ⚠ That file is
  skip-worktree-protected, so **it will never show up in `git status`.** Easy to forget forever.
- **15 + 20 — the last two of fourteen security advisories.** Both **deliberately deferred on Aug 23**
  when everything else was updated; the reasoning is in that session's handoff entry.
  - **15 — WebAuthn passkey.** ⚠ **The target is `2.1.0-beta2`, not `beta1`** as this item said.
    Still on `2.0.0-rc7`. It is the **only** remaining `composer audit` advisory:
    `web-auth/webauthn-lib`, **low severity, no CVE**, in `SimpleFakeCredentialGenerator` — weak
    only when configured with an empty secret. Fix needs `>=5.3.5`. **rc → beta is a move to a less
    stable label**, on the login path, for a low-severity decoy-credential helper. **Before doing
    it:** `composer why web-auth/webauthn-lib` to confirm the module bump actually reaches 5.3.5 —
    if it does not, the bump closes nothing. Then a full auth-flow test: register a passkey, log in
    with an existing one, log in with username+password, and password reset.
  - **20 — Mailer Plus (DSM+) 1.6.2 → 2.0.2.** A major, on the thing that sends every email. The
    update report lists 2.0.2 as *"Also available"* rather than *"Recommended"*, so 1.6.2 is still
    supported — no pressure. Own session. Do not enable the "User" override.

  Everything else was updated Aug 23: core 11.4.5, Entity API 1.7, Message 1.9, reCAPTCHA 3.5.
  Better Exposed Filters 8.0.0-alpha1 skipped as an alpha.
- **19 — confirm InMotion transactional email passes SPF/DKIM under DMARC `p=quarantine`.** Deliverability
  risk, and DMARC aggregate reports arrive daily.
- **34 — the Team Fee product can be purchased anonymously.** Order 61 (Wayne, Bakersfield captain) bought
  "Team Fee — SLO Friendly 2026" while not logged in, so no user account is tied to the order. Caleb
  reconciled it by hand and disabled the product. **Next year's productized team-fee flow must require
  login, or capture and match purchaser identity, before allowing the purchase.**
- **3 — vanishing CAPTCHA: ✅ confirmed working, Aug 23.** On PROD, with reCAPTCHA 3.5 freshly
  deployed: correct CAPTCHA + wrong password → error page, **and the widget was still rendered**
  (checkbox and all). That is exactly the failure this item was watching for, and it does not
  happen. Observed incidentally rather than as a deliberate test, so if anyone later reports the
  symptom, treat it as a fresh report rather than a regression from a known-broken state.
- **17 — CSP headers in report-only mode.** Prerequisite for security finding **K-2**.
- **24 — remove the `beta_tester` role from all users.** ⚠ But *keep* `user.role.beta_tester.yml` in
  `config/sync` — see the handoff.

**Team-names refactor (Caleb, phase 1 done):**

- **2b** — `/admin/ccsoccer/team/add` pre-filter by series.
- **2d** — phase 3, roster-builder verification.
- **2e** — league/series columns and a filter on the team-names taxonomy overview.
- **2g** — an admin "team-name collision review" view. *(Related to **S3** under Parked — the flagged
  `ccsoccer_team_name_collision` orders have no admin surface either.)*

**Operational:**

- **5** — Commerce checkout end-to-end with a real card on PROD (season registration *and* jersey-only
  paths). Never done.
- **13** — Andrew's local environment DB update. **[Diagnosed and done, Aug 8.]** LOCAL was two update
  hooks behind — `ccsoccer_update_9071` (add `field_sms_body`) and `9072` (remove `field_delivery_method`),
  both from late July. Symptom in `drush config:status` was **`Only in DB` + `Only in sync dir` on related
  field names**, which reads like config drift but means *pending database updates*. Fixed with
  `updb` → `cim` → `cr`; LOCAL is back to the three known `media_library` entries.
  ⚠ **One trap found on the way, and it is unresolved for TEST and PROD.** Hook 9071 calls
  `FieldStorageConfig::create()` **without a uuid**, so it mints a random one, while
  `config/sync/field.storage.node.field_sms_body.yml` carries `uuid: 84d410ee-…`. A `cim` after that
  hook therefore **deletes and recreates the field storage** — visible as Create *and* Delete for the same
  config in the import table — **and deleting a field storage deletes its data.** Harmless on LOCAL
  (the field was seconds old and empty); **not harmless on PROD if any notification node holds SMS body
  text.** The documented sequence `ccsDeploy && ccsUpdb && ccsCim && ccsCr` runs `updb` before `cim`,
  which is exactly the order that triggers it. **Before deploying: run `drush updatedb:status` and
  `drush config:status` on TEST and PROD, and if 9071 is pending or `field_sms_body` reports `Different`,
  check for existing SMS body content before running any `cim`.** Same family as the
  `field_notification_prefs` resurrection recorded in update_9070.
- **16** — backup strategy verification.
- **25** — `slofriendlysoccer.com` URL forward.
- **26** — archive or delete the D7 waivers; eventually delete `ccsoccer_site_d7_archive/` from the
  server.
- **28** — add Devel back to TEST. **It is already installed via composer** (Devel 5.5.0, shows as an
  uninstalled module in the update report), so this is `drush en devel` on whichever box wants it,
  not a composer require.
- **31** — team handling refactor.

**Deferred front-end work:**

- **29** — three-tier button methodology pass.
- **30** — CSS consolidation: design tokens across ~33 CSS files. See `LOOK_AND_FEEL_STRATEGY.md` and
  `CONTENT_PAGES_CSS_CHECKLIST.md`.
- **32** — profile-picture migration (board decision pending).

*(Checklist items **14** and **33** are tracked under P8; **18** is **S3** under Parked; **9, 10, 11, 12,
21, 22, 23, 27** and **1, 2a, 2c, 2f, 4, 8** are done.)*

---

## P10 — Code quality (harvested from `CODE_QUALITY_BACKLOG.md`, ruled Aug 2)

> Splitting rule used: **if it has an observed symptom it is a tracker item; if it is a pattern we would
> prefer not to repeat, it is a convention.**

**Evidenced — real items:**

| ID | Item |
|---|---|
| **B-14** | Duplicate return in `Registration.php`. **`[verified Aug 23]`** — `baseFieldDefinitions()` still ends `return $fields;` twice, now at `:267` and `:269`. **Two-minute fix, top of the list.** |
| **B-7** | **No automated test coverage** — nothing on payment, credits, registration, notifications. The strongest item in that doc: this codebase has produced the *same bug shape* six or more times (missing status filter, bare `reset()`, drifted twin paths) — precisely what tests catch. Start with kernel tests for `OrderCompleteSubscriber` and `CreditManagerService`. Blocked in part by B-12. |
| **B-4** | N+1 in skill calculation and team tables — ~300 queries for 20 teams. Corroborated by the "All Players view is slow" complaint. Batch-load before loops. |
| **B-1** | XSS via `#markup` concatenation — `CompletionPane`, `AgreementsPane` (waiver text rendered raw), `TournamentController`, `AdminController`. Overlaps security finding K. |
| **B-15** | Magic status strings → `StatusConstants`. **Merged with D-4** — same finding from two directions. Do together, after Decision 2. |
| **B-9** | Missing cascade delete (Season/Tournament orphan Games, Teams, Registrations). **Merged with CF7**, which is the narrow case. |
| **B-6** | Aggressive cache disabling (`max-age 0`; `Registration.php` invalidates on every save). Follow the July 5 series-rules cache-tag fix as the pattern. |
| **B-8** | Missing transaction boundaries — credit splitting, bulk roster ops, schedule generation. **Money is involved in the first.** |
| **K-1** | Raw `$e->getMessage()` in JSON responses. The only security item touching regular users: group-management AJAX is player-facing and exception text leaks file paths, entity ids and SQL fragments. Any logged-in player can trigger it. Cheap fix. |
| **G (security)** | `invitee_email` fee-waiver spoofing on the "join an invited team" tournament branch. Live, but the attacker must know an invited address, gains $35, and the captain would see a stranger on the roster. *Caleb, Aug 2: odds are nil.* Fix when convenient: gate on the `invitee` entity-reference match rather than the email string. |

**Needs sizing before it becomes an item:** **B-2** audit 139+ `accessCheck(FALSE)` — that is a grep count,
not a finding; **sample 15 first** and judge the hit rate, or it burns a week for nothing. **B-5** missing
DB indexes — a `SHOW INDEX` settles whether it is real. **B-3** AJAX input validation — plausible, no
exploit shown, endpoints are admin-gated.

**Conventions doc (⬜ not created) — style items with no symptom:** B-11 generic entity access handlers ·
B-12 DI inconsistencies · B-13 fat controllers · B-16 inconsistent date formatting · B-17 hardcoded values
(store id 1, cents×100, 48\*60\*60) · B-18 JS `fetch()` error handling · B-19 inline styles in PHP form
builders. **Plus the allowlist-vs-exclusion rule**, once Decision 2 lands: *fail toward the less harmful
error* — notifications should use the exclusion filter (an unknown status still gets notified), capacity and
rosters should use the allowlist (an unknown status does not count). The codebase currently does both by
accident.

---

## P11 — Accounts, overrides and PROD data *(harvested from the Aug 22 sessions, Aug 23)*

These were logged in `SESSION_HANDOFF.md` on Aug 22 with a note that they needed tracker entries.
They are **`[server]` / `[waiting]`** items — none can be settled by reading the code.

- **🟠 Players are repeatedly prompted to change their password. `[waiting]`** Three affected, all D7
  migrants: `diggspot` (91655), `Garret` (93671), `jaredhagemann` (93768). **Three theories tested and
  dead:** no custom forced-reset code exists anywhere in the module; all three have complete profiles
  (`pic=1 dob=1 gender=1`), so the required-photo wall is not it; all three have `changed` timestamps
  20–30s after `login`, so they have saved their accounts. **Still live:** *username vs email at login*
  (migrated usernames are D7 leftovers, core authenticates on username only, and the failure message
  mentions passwords), and core's **Current password** field on `/user/{uid}/edit`, reachable straight
  from the My Account hub — migration set `bin2hex(random_bytes(16))`, so a player who never set one
  cannot satisfy it. Caleb reset Josh's password by hand and asked which link he uses and where it
  lands. **Blocked on that reply.**
- **🔴 CONFIRMED Aug 30 — `dailyOverrideReminders()` queries a store that is empty, and always has been.**
  This was logged as *"two override records may exist in parallel"*. It is worse and simpler than that:
  there is **one** live store and the daily job reads the other one.
  - `SELECT ... FROM ccsoccer_registration WHERE override_expires IS NOT NULL` on PROD returns
    **zero rows.** The column exists, the query is valid, nothing has ever populated it.
  - Overrides actually live as **`ccsoccer_override` entities** — own table, own fields:
    `expiration_date`, `status`, `override_type`, `reason`, **`reminder_sent`** (its own duplicate
    guard, parallel to Registration's unused `override_notified`), `last_nudged` for the manual nudge
    button. The whole extend/nudge/revoke route group is already built on `entity:ccsoccer_override`.
  - So the job is not broken — it correctly logs *"no overrides expiring … nothing to send"* every day,
    because the field it reads is empty. **It has never sent a reminder and never will as written.**
    This also explains why the old `user_id` bug went unnoticed: that code never matched a row either.
  - ⚠ **`ccsoccer_override.expiration_date` is `varchar(255)`, not an int timestamp** — Registration's
    `override_expires` is `int(11)`. So this is not a field-name swap: the "expires tomorrow"
    comparison has to be rewritten for whatever string format is stored. **Dump rows before coding:**
    `SELECT id, player, season, expiration_date, status, override_type, reminder_sent FROM ccsoccer_override ORDER BY id DESC LIMIT 20`
  - **Use `reminder_sent`** as the duplicate guard, not `override_notified`.
  - **Falls out of this:** `ccsoccer_registration.has_override` and `override_expires` look like dead
    columns from a design that moved to entities. Confirm nothing writes them, then drop them in an
    update hook. Same shape as the `Game.published` residue in §P8.
  - The Aug 28 watchdog window has already aged out of dblog (~5,500 rows/day, mostly the
    `CartEventSubscriber` trace noise in §P8 — **that noise cost us the diagnostic**). The empty-table
    result above is stronger evidence anyway.
- **✅ PS-3 (override expiration reminders) is built — `[verified Aug 23]`, and it has never sent.**
  `CcsoccerCommands::dailyOverrideReminders()` (`:2356`) runs a plain "expires tomorrow" date-range
  query from `ccsoccer:daily`, sends via `sendOverrideExpirationReminder()`, and stamps
  `override_notified` (`:2411`) as its duplicate guard. **Now known to be reading the wrong store — see
  the item above.** The heartbeat itself is healthy: PROD logged a correct, fully-substituted
  `ccsoccer:daily started at 2026-08-29 15:05:03 (America/Los_Angeles)` with both arms reporting
  "nothing to send".
- **🟡 Cancelling a waitlist entry does not revoke the override. `[server]`** Two rows that expired
  Aug 6 still show under "Active Overrides (3)" on PROD.
- **🟡 Two PROD registrations point at deleted seasons. `[server]`** Regs 5073 and 5074 reference
  seasons 43 and 44, which do not exist. Harmless today — the `!$season` guard catches them — but it is
  a dangling foreign key, and it is the kind of row that makes a future report or export lie.
  Same family as **CF7** / **B-9** (missing cascade delete).
- **🟡 UI: password fields on the profile form.** Group them visually and add a *"don't know your
  current password? request a reset"* link to `/user/password`. Small, and it gives migrated and
  passkey-only users the exit that already exists. Do this whatever the answer to the first item.

---

## VERIFY bucket — cheap to confirm, may already be done

**Five of the eight were cleared on Aug 23** by reading the tree; what each resolved to is in
[Corrections applied](#corrections-applied). These three are what is left, and only one is free.

- ⬜ **Check Layne Smith's PROD roles. `[server]`** Stale `board_member` was removed on TEST Aug 2 (she is
  off the board). If PROD still has it she is in the non-production allowlist and receives `[VERIFY]`
  broadcasts and board alerts.
- ⬜ **`ROSTER_DATA_AUDIT.sql` rev 2 has still never been run.** Largely moot for CF4 (that question was
  answered by hand-reproduction), but it is the drift-class baseline: run it, perform the four
  drift-creating actions, re-run, and confirm every class count is unchanged. **`[verified Aug 23]` the
  file moved to `archive/ROSTER_DATA_AUDIT.sql`** — several documents still call it a root file.
- ⬜ **`CC Soccer - Entity Specification Template.pages`** in repo root — a Mac Pages file, probably
  superseded by `ENTITY_SPECIFICATION_TEMPLATE.md`. Confirm and delete.

---

## Security follow-ups (July 3 review)

- **🔴 I — rotate the reCAPTCHA secret key.** It was printed to a terminal during Aug 2 PROD verification
  and is now in shell scrollback and a chat transcript, on top of git history. New pair at
  google.com/recaptcha/admin → enter via `/admin/config/people/captcha/recaptcha`, **not** config export —
  `recaptcha.settings` is in `config_ignore` and lives only in each environment's database. The site key is
  public and does not matter. Test credentials are also in history; rotating is the fix, deleting the file
  is not.
- **`verify_hostname: false` on PROD.** With it off, a stolen site key produces valid tokens from any
  domain. `use_globally` is also false and the site relies on per-form captcha points, so this is the
  meaningful control. **Test on TEST first** — it can break a site reached via multiple hostnames.
- **K-1** — see P9. **K-2** (inline `onchange` at `ReportController:393`) is **parked**: admin-only,
  server-built value, and it is a prerequisite for CSP enforcement rather than a standalone fix.
  **K-3** (admin notes written unescaped on the deposit forfeit/refund forms) is **latent, not live** —
  currently rendered nowhere. It is a trap, not a bug: it becomes XSS only if someone later prints it with
  `|raw` or `#markup`. Cheapest defusal is escaping on write plus a comment saying why.
- **Cleared Aug 2, do not re-check:** H (invite email masking — done, verified by screenshot), E (jersey
  `variation_id` — dropped, all variations are the same price), A/B/C/D/F/J, J-optional (splitting a finance
  permission — nothing to split), and the seven-item PROD verification list, six of seven clean.

---

## Downstream / wants (not scheduled)

- **Insurance Report — built and shipping.** *(PS-1 closed; see P7.)*
- **Automated registration reminders** — 6 / 2 / 1 weeks before season close, to previous-season players
  not yet registered, excluding those already registered. Needs cron + queue. Saves recurring per-season
  effort.
- **Team messaging** — captains notify their teams.
- **Auto-subtract jersey cost ($25) on late cancellations.**
- **Hardcoded settings** — credit expiration period, reminder timing. Worth doing alongside the push to get
  config into repo files so it is not overwritten.
- **Performance / optimization bucket** — roster builder at 200+ players, schedule builder at 20+ teams,
  bulk queue, query optimization; and the All Players view refactor (slow view → MySQL-view pattern; must
  update all links).
- **Menu UX polish** — board / slofriendly / player / admin, tested per role.
- **Backup strategy verification.**
- **Schedule iCal — another pass.** Merge with G4 and G6 rather than tracking separately.
- **Explicitly not doing:** automated waitlist progression (staying manual by requirement), referee
  management, player statistics, social features, bulk cancellation tools, refund reporting, mobile app,
  tournament bracket generation.

---

## Parked (Andrew, July 28 — abandoned-cart / abandoned-order shape; ~200 players a season can email)

**These are not fixed. They are deprioritized.**

- **S3** — the five money-collected-but-action-failed flag states (`ccsoccer_season_registration_failed`,
  `ccsoccer_credit_shortfall`, `ccsoccer_team_capacity_exceeded`, `ccsoccer_team_name_collision`,
  `ccsoccer_tournament_full`) exist only as order data plus a rotating dblog entry; the admin report is not
  built. Interim: `sendBoardNotification()` on each flag write. **See P6 — the deferral reasoning has since
  been disproved.**
- **S4 (waitlist)** — offers expire in the email but never in the system: no cron pass, the entry stays
  `offered` forever, the next in line is never offered, and the reserved spot is held indefinitely. Only
  relevant if season waitlists get real use.
- **E5** — invitation `expired` status is defined but never set. **Partially relieved:** D-3 settled that
  nothing should ever write `expired` to an Invitation, and D-12 made a dissolve decline both pending and
  accepted invitations. Invitations outside a dissolve still reserve capacity indefinitely.
- **E7** — dead-token UX: a non-pending emailed link behaves as if it never existed, with no message.
- **M1** — the confirmation email always says "You're registered," including for flagged or failed
  registrations.
- **M2** — `ccsoccer_completion_processed` is written *before* the confirmation emails, so a crash between
  the two leaves a half-done order with no alert; and dedupe can resurrect a cancelled registration on an
  admin re-place.
- **M3** — a jersey add-on during re-registration reuses stale player info. Back-button behaviour of every
  pane belongs in the PR test matrix; `CreditsPane` is the correct model.
- **M4** — `donateCredits()` donates the balance at completion, not at consent. Fix: snapshot
  `balance_cents` at pane submit and donate `min(snapshot, current)`.
- **M5** — cancellation leaves group/team state behind. *(Largely closed by CF8 + CF1/CF1b; the residual is
  the reserved-spot bump firing on every cancellation, so serial cancellations over-reserve.)*
- **H2** — `Settings::get('site_instance', 'production')` defaults to production, so a clone missing
  `settings.local.php` emails real players with prod SMTP credentials. **This is a live hazard for the TEST
  deploy — see 0.6.**
- **H3** — confirmation email HTML built from unescaped `getDisplayName()` and season labels.
- **H4** — `NotificationQueueWorker::processItem`'s catch block: swallow (drops queued notifications) vs
  rethrow (a poison item blocks cron). Unverified, two-minute look.
- **D9** — the season list shows inactive seasons: `active = FALSE, visible = TRUE` renders a live Register
  button that dead-ends (`RegistrationController:176-179`).
- **D18** — season cancel records no `cancellation_date` (port from tournament); the season redirect trusts
  a hidden field. *(The `$0`-refund half appears to have been fixed by the D1 July 17 change; D18 was never
  updated to say so.)*
- **Three admin entity-form paths deliberately left uncovered by the D-8 cleanup** — delete a registration,
  hand-edit a registration's `status` to `cancelled`/`expired`, delete an invitation from the admin
  collection. Decision: rely on query B rather than cover them. **A non-zero query B trend is the trigger**
  to build the entity-hook variant (`archive/D8_OPTIONS_COMPARISON.md` §8).

---

## Known-broken behaviour that is being accepted for now

Not items to fix — things to know when reading a bug report.

1. **The D-8 invariant is maintained, not guaranteed.** A solo group can exist between the event and the
   cleanup, and permanently if a call site was missed. Anyone writing a future group query must still
   assume "this might be one person."
2. **Thirteen call sites is thirteen chances to miss one.** The first draft missed two of thirteen plus one
   prerequisite bug — **all three in the admin surfaces** (`GroupInvitationsForm`,
   `RosterBuilderController`). Assume there is a third. Query B (item 0.1) is how you would find out.
3. **There is no data repair.** Query C will never return zero; the useful reading is "not rising."
   Pre-existing stranded rows survive indefinitely.
4. **`group_id` still means "somebody clicked Invite,"** not "two or more people agreed to be a group."
   That was option D's whole point, and D was not taken.
5. **A benign race exists:** two members leaving simultaneously produces a *missed* cleanup, never a
   corrupt one.
6. **`loadByProperties(['group_id' => NULL])` throws `InvalidQueryException`** — it does not silently
   mis-match. Worth remembering; it is why option D could not have been shipped incrementally.
7. **Line numbers in every archived brief drift** on any edit to `GroupController.php` and friends.
   **Re-locate by method name, always.**
8. **`fix/roster_sync` never existed.** Several older documents cite it. The real branches were
   `fix/CF8_dissolve_group` and `fix/solo_group_auto_delete`.

---

## Closed since July 28 — do not re-open

**On PROD, verified:** Bug 1 (early accept at checkout step 2) · Bug 2 (`removeMember` invitation cleanup) ·
Bug 3 (narrow orphan re-invite) · E2/E6 post-payment capacity guard · `onOrderPlace()` infinite recursion
(`d466b97`) · Bug 4 (email-only invitees get the email) · **S2** (tournament auto-decline of all pending
invitations) · **D1** (100× refund money bug) · **D8** (`removeMember` cleanup, both branches) ·
**D4 + T9** (co-captain remove and cleanup, PR #119) · **D14** (tournament nudge 500, PROD-verified Aug 2) ·
core 11.4.4 + the security batch · season roster-builder admin group-cap override · tournament
over-capacity warning · **H1** (DEBUG logging in `send()` — verified gone) · **R1** (season teams showing
"0 players") · **T6** (one malformed DOB 500-ing the whole builder) · **S5**/restore-snapshot hardening ·
security finding **H** (invite email masking).

**✅ On PROD (confirmed Aug 23):** CF1, CF1b, CF2 + D2, CF12, CF4, CF5, CF8, CF10, and D-8's B + E.
**D6**, **D7**, **D19** and the option-list half of **D15** are closed by this.

**✅ Closed Aug 22–23 — the nine, each verified in the tree rather than taken on report, plus two
changes worth knowing about:**

- **`notification_log` in exported config.** Gone. Update `9073` deleted the `ccsoccer.game_status`
  config object and its YAML; runtime state now lives in three timestamp base fields on Game
  (`cancellation_notified`, `reminder_notified`, `uncancelled_notified`), and
  `logNotification()` became `markNotified()`. `config/sync/ccsoccer.game_status.yml` no longer exists.
- **`ccsoccer_cron()`.** Deleted in `f7b0fb3`; scheduling moved to the `ccsoccer:daily` drush command
  plus a cPanel entry. **This did not close G3** — see the rewritten item in P4.
- **The Jersey Report CSV button** (`28a40d1`). Scoped to the Views wrapper class; works in both themes.
- **Both PDF schedule routes are now gated.** `ReportController::schedulePdf` / `mySchedulePdf` had
  **no visibility gate at all** and were `access content`; both now check `Season.schedule_visible`
  (`db81afa`). This was a real hole, closed quietly — worth knowing it existed.
- **The two iCal feeds and both PDFs read one flag.** `Game.published` is no longer consulted by any
  live reader (`03ef38d`, `84f607a`).
- **PS-3, override expiration reminders.** Built as `dailyOverrideReminders()`. Now tracked in P11 as
  *built but never exercised*, not as an open build.
- **`sendSms()` gating comment.** Committed in `fc1034b`; the item asked for a lint and a commit.
- **`Team.php`'s stale `team_<id>_<id>` docblock.** Already rewritten; only the `GroupInvitationsForm`
  twin survives (P8).
- **`INVITE_EMAIL_MASKING_PROPOSAL.md`** — it is in `archive/`, where the VERIFY entry guessed it was.
- **The five `.sql.gz` dumps** — gone from the repo root and untracked (`git ls-files '*.sql.gz'` is
  empty). Pre-launch item 8 was marked done and this time it is.
- **iCal cancellations** — the per-user feed writes a computed `STATUS:` per event
  (`ContentController:1335`), so cancelled games arrive struck through rather than vanishing. The
  per-season feed is still hardcoded `STATUS:CONFIRMED` — that is **G4**, which stays open.

**✅ On PROD — merged Aug 7 as PR #131** (`ba495ea`, `5fb6baa`, `83813db`):

- **The dead `Registration.self_score` reads are gone.** All three sites now go through
  `User.field_self_score` / `TeamBalancerService::getPlayerSkill()` — `SeasonController`,
  `TournamentController` display, and `TournamentController:821`, where every player had been defaulting
  to 3 and making all tournament teams compute the same average. *(This was two P8 items; both deleted.)*
- **`field_skill_level` description corrected** to `(1-5)` in `config/sync`. This was the one config file
  that had to ride along with the deploy. **⚠ Worth one check: it was imported on LOCAL on Aug 8, but
  whether the targeted partial import ever ran on TEST and PROD was never recorded.** If it did not,
  `drush config:status` on those boxes reports that file as `Different` forever — a fourth member of the
  "three known `media_library` entries" baseline everything else tells you to trust. The description is
  inert (nothing reads it programmatically), so this is a drift-hygiene check, not a behaviour bug:
  `drush config:get field.field.user.user.field_skill_level description` on each.
- **`Team::calculateSkillLevelFromPlayers()` no longer halves.** See the ⚠ note on the calculator-deletion
  item in P8: it changes that item's premise.
- Two UI fixes: player photo hover popup 300→600px with viewport clamping, and My Schedule showing the
  opponent in the user's own games (`ScheduleGridBuilder:405`).

**Closed by analysis — no code, do not re-flag:**

- **CF6** — `dissolveGroupConfirmed()` leaving `Registration.team` intact is correct; the tournament
  equivalent is benign.
- **T1** — multi-drag force-add past the roster cap is **as designed**: admin-only, single-day friendly with
  no scores or standings, board validates sizes before publishing, nothing downstream breaks. A
  non-blocking over-capacity warning was added.
- **R2** — "Suggest Rosters" leaving players in the workbench is **accepted**: the workbench *is* the review
  surface, and the board runs Suggest repeatedly then reviews manually. Optional nicety: surface the
  unassigned count in the toast.
- **TS1 / decision 1** — tournament format is single-day round-robin only. No pools, brackets, scores or
  multi-day.
- **Decision 2 (admin review)** — destructive "Regenerate" is the intended board-meeting workflow. Both
  `restoreSnapshot()` implementations hardened.
- **S3 / S7 (jersey)** — Red-vs-White with no home/away is a *documentation* bug, not a code bug. The
  proposed flip would have broken a consistent system. **Do not "fix" it.**
- **J-optional (security)** — no role needs reports-without-financials, so there is nothing to split.
- **G5** — see the note under P4; the finding was factually wrong.
- **Q-B9** — `groups_locked` should not block a dissolve; the premise was wrong. Both forms are
  `administrator`-only and are the only code anywhere that writes `status = 'cancelled'` to a registration.
  A guard would be a *new restriction on admins*, not a gap closed. One residual: item **0.5**.
- **Skill scale** — `getPlayerSkill()` treating `field_skill_level` as 1-5 with no conversion is correct.
  Both admin write paths clamp. `MigrateCommands:447` is unclamped but historical only.
- **`ccsoccer_registration_delete()` and `ccsoccer_registration_presave()`** — deleted 2026-08-04 in
  `e5317f8`. Both were misnamed (the correct hook for module `ccsoccer` and entity type
  `ccsoccer_registration` is `ccsoccer_ccsoccer_registration_delete()`) and had therefore never run.
  There is a comment at `ccsoccer.module:591` recording this. **Do not re-litigate.**
- **§2.3 / §2.4 of the gender doc** — per-team women count and header women total: **declined** by Andrew.

---

## Corrections applied

What the old documents said, versus what the code actually shows. Recorded so the new file can be trusted
and so nobody re-derives it.

### August 23, 2026 — this file, checked against the tree at `fc5069f`

**Items whose *premise* was wrong, not just their status. These are the ones worth reading:**

| What this file said | What the code shows |
|---|---|
| **S2**: after a regenerate "players see an empty schedule" | The opposite, since Aug 22. Nothing reads per-game `published` any more, so a regenerate on a visible season **publishes the candidate schedule immediately**. Same shape as TS2. Operating rule 2 now has to cover seasons |
| **P6**: "no capacity check exists at add-to-cart… the only check in the entire flow is the completion-time guard" | `CartEventSubscriber::onCartEntityAdd():91` counts `paid` registrations against `max_players` and removes the item when full. Two checkpoints, not one — and the `CapacityManagerService` should replace this one rather than become a third |
| **G3**: "verified still present at `ccsoccer.module:503-524`; removal scope `460-536`" | `ccsoccer_cron()` was deleted on Aug 22. That range is now a comment. The reminder lives in `ccsoccer:daily`, and its `'reminder'` branch is live code, not dead |
| **G5** (closed): rebutted using `ContentController::exportPdf()` | That method has no route. The reasoning survives — the live PDF path is gated — but the citation pointed at dead code |
| **P8**: the `CC Soccer` → `CCSoccer` SMS rename "was scoped to tournament deposit + jersey" | It was never applied anywhere. All five SMS bodies still read `CC Soccer` |
| **CF9**: "three inline appends" at `:792`, `:1604`, `:389` | Four sites, all three line numbers drifted: `OrderCompleteSubscriber:818` and `:871`, `GroupController:1605`, `RegistrationController:429` |
| **P3**: invitation-only "not pushed to origin" | Merged as PR #136 (`fc5069f`) — and **deployed to all three environments the same evening** (`d902769`), so the "not deployed" correction was itself stale within hours |

**Found already done and closed:** `notification_log` in config · `ccsoccer_cron()` · the Jersey CSV
button · PS-3 override reminders (built, never sent) · the `sendSms()` comment commit · `Team.php`'s
stale docblock · `INVITE_EMAIL_MASKING_PROPOSAL.md` (in `archive/`) · the five `.sql.gz` dumps · iCal
cancellation status on the per-user feed. **Nine.**

**Found still open, exactly as described** (line numbers re-checked): CF3, CF7, CF9, R4, T2, G2, G4, G6,
G8, TS2, the P5 invitation set, B-14, B-10, the dead CSS, the `CartEventSubscriber` trace logs, the
`slofriendly` role lookup, both skill calculators, and both security advisories.

### August 5, 2026 — the original rebuild

| Claim in the old docs | Reality |
|---|---|
| D-8 commit 3 is "uncommitted, not linted, not tested" — said by **all four** D-8 documents and the plan's START HERE block | It landed as `c7251c6`, merged via PR #129, followed by `7a5f57b` fixing a bug LOCAL testing found. All 13 call sites and both service methods verified present |
| `ADMIN_TOOLING_REVIEW.md` lists **T3** and **T5** as still open | Both fixed. `TournamentRosterBuilderController` uses `pickLiveRegistration()` at all three sites (`[CF4]`) and reconciles against every team (`[CF5]`, at two sites the brief did not even cover) |
| **G5** — printer-friendly PDF has no `published` and no status filter | It has both. `exportPdf()` filters on `published` and renders cancelled games struck through |
| `D8_OPTIONS_COMPARISON.md` §3 says **eleven** call sites | Thirteen. §12 admits this in passing; §3–§9 were never updated |
| Both tournament display docs say "not linted, not tested, not committed; no git write commands run" | Everything they describe is in tracked code at HEAD and the tree is clean |
| `TOURNAMENT_ROSTER_GENDER_DISPLAY.md` §5 flags "every tournament player shows skill 3" and "`updateTeamStats()` overwrites Admin Skill" as open | The first was fixed by the skill doc's §1; the second is now *intentional* — the span legitimately means average player skill |
| `TOURNAMENT_SKILL_DISPLAY_FIXES.md` says **four** sites read the deleted `Registration.self_score` | Five. `TournamentController:981-983` is the twin of `SeasonController:591-593` and was missed |
| `GROUP_ID_AT_ACCEPT_BRIEF.md` §13.2 says "the eleven open questions in §14" | §14 lists twelve, Q1–Q12 |
| `GROUP_CLEANUP_BRIEF.md` §15 says testing is "not started" | Contradicted by its own §9.2 cases 18 and 19a, which were rewritten from observed LOCAL behaviour, and by `7a5f57b` existing. **Unresolved — see item 0.3** |
| `SEASON_TOURNAMENT_DRIFT_AUDIT.md` header dated July 28/30 | Superseded throughout. Its analysis was harvested into this file; the doc is archived |
| `D8_OPTIONS_COMPARISON.md` never links `GROUP_CLEANUP_BRIEF.md` | The memo that decided the option never named the spec that implements it. Both are now in `archive/` and linked from here |
| Several docs cite branch `fix/roster_sync` | It never existed |
| `PROJECT_STATUS.md` PS-1: "Insurance Report — never defined" | **It is built and shipping** — `InsuranceReportForm`, `InsuranceReportService::generateReport()`, route `ccsoccer.reports.insurance_report`. `BOARD_MEMBER_ADMIN_THEME.md` lists it among the four *working* reports |
| Old tracker lists `ccsoccer_registration_delete()` as a misnamed hook to fix | It was **deleted** on Aug 4 in `e5317f8`, with a comment at `ccsoccer.module:591` explaining why |
| `ADMIN_TOOLING_REVIEW.md` G3's removal scope quoted as `ccsoccer.module:503-524` | The block to remove is `460-536`; 503-524 is only the inner reminder check and send |
| The pre-launch checklist was "the last harvest" | It was never harvested, and the section holding it was deleted from the handoff on Aug 4. **Harvested here as P9** — it held the only tracked record of live spam-bot traffic, an IP whitelist still in `.htaccess`, two unpatched advisories and a DMARC check |
| 20 `ADMIN_TOOLING_REVIEW.md` findings were in no list at all | R5–R10, T4, T7, T8, T10, T11, S1(admin), S6, S9, TS4–TS7, G7 residual, G8. Now in P1, P2 and P4 |

---

## Where everything lives now

**In root — actively worked:**

| File | Role |
|---|---|
| `OUTSTANDING_ISSUES.md` | **This file.** The only list of what is open |
| `SESSION_HANDOFF.md` | Current state, next actions, environment gotchas |
| `ROSTER_RECONCILIATION_PLAN.md` | Implementation briefs for CF3, CF7, CF9, CF11 + decisions D-1…D-14 §7. ⚠ line numbers stale |
| `ADMIN_TOOLING_REVIEW.md` | The 45-finding review + the as-designed decisions. Kept because the "do not re-flag" record prevents re-litigation |

**Moved to `archive/` on Aug 23** — five files this table used to list in root. The moves are in the
working tree; **commit them or `git status` will keep showing five deletions**:

| File | Why |
|---|---|
| `archive/TEST_SPOT_CHECKS_2026-08-05.md` | Was the TEST gate for the §0 deploy. Never used — 0 of 49 boxes; PROD went out without it |
| `archive/BOARD_MEMBER_ADMIN_THEME.md` | Its problem was solved another way on Aug 22 (see P7). Neither of its options was taken |
| `archive/TOURNAMENT_INVITE_ONLY_PROPOSAL.md` | Design + the 17-step LOCAL test plan for the merged feature. **§5 steps 4, 9, 11, 12 are still unrun** — that is why it is worth opening |
| `archive/INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` | The 33-item invitation test pass; source of several P5 items |
| `archive/ROSTER_DATA_AUDIT.sql` | Read-only audit script, rev 2, still never run. Query B was never added and item 0.1 retired. ⚠ **`.gitignore` line 24 is `*.sql`**, so the root copy was force-added and **the archive copy is ignored — `git add -f archive/ROSTER_DATA_AUDIT.sql` or committing the move drops the script out of git entirely** |

**Archived Aug 5 — moved, not deleted; every open item was carried into this file first:**

| File | Why archived | Still worth opening for |
|---|---|---|
| `archive/SESSION_HANDOFF_2026-08-05.md` | Replaced | Decision 2's full framing; the D-8 route table and option write-ups; the Aug 4 capacity-race session detail |
| `archive/OUTSTANDING_ISSUES_2026-08-05.md` | Replaced | The July 28 – Aug 4 state, verbatim |
| `archive/GROUP_CLEANUP_BRIEF.md` | Implemented and merged | **§9.2/§9.3 — the live 29-case test plan (item 0.3). §9.4 — the four audit queries (items 0.1, 0.2).** §6/§7 — the 13-call-site inventory. §11 — the Q-B9 permission evidence |
| `archive/GROUP_ID_AT_ACCEPT_BRIEF.md` | Option D held | §4 + Appendix A — the I-1…I-29 inventory of every `Invitation.group_id` site. §5.7 — the `groups_locked` table. **§9 — the CF11 spec, more current than the plan's.** §12.4 query E. Appendix C |
| `archive/D8_OPTIONS_COMPARISON.md` | Decision made and built | §6.1 — why the failure modes are not comparable. §6.2 — drift vs omission as failure shapes. **§8 — the entity-hook variant, the escalation path if query B misbehaves.** §10 — what to take to Caleb |
| `archive/SEASON_TOURNAMENT_DRIFT_AUDIT.md` | Findings D1–D20 harvested above | Per-finding diagnosis and code locations |
| `archive/ORDER_FLOW_EDGE_CASES.md` | Findings harvested above | Per-finding diagnosis; the ~10 verified-clean areas |
| `archive/TOURNAMENT_SKILL_DISPLAY_FIXES.md` | Implemented; residuals in P8 | **§3 — the only written comparison of the three skill calculators** |
| `archive/TOURNAMENT_ROSTER_GENDER_DISPLAY.md` | Implemented; residuals in P8 | §1 — the two traps: capitalised `field_gender` allowed values, and the seeder writing lower-case so the bug is invisible on LOCAL |
| `archive/DOC_CLEANUP_CONSOLIDATION_WIP.md` | **Its job is done — this file is its output** | The full Aug 2 rulings on TODO, PROJECT_STATUS, CODE_QUALITY_BACKLOG and the security review |

**Also in root, unchanged — analysis and reference:**

| File | Role |
|---|---|
| `INVITATION_FLOW_ANALYSIS.md` | Invitation findings E1–E7; source for several P5 items |
| `CC_Soccer_Security_Review_2026_07_03.md` | July 3 review. Fully harvested; keep for the "Corrected / non-issues" section |
| `FLAGGED_ORDERS_REPORT_PROPOSAL.md` | The unbuilt admin report (S3). **Its July 3 deferral was disproved — see P6** |
| `CLOUDFLARE_PROPOSAL.md` | The fallback for pre-launch item 6b, geo-blocking |
| `DECISION_2_ANALYSIS.md` | The four registration-status conventions, costed. Read before touching Decision 2 |
| `ARCHITECTURE_DECISIONS.md` | **[Aug 23] D-8 invariant added, and Decision 4's `group_id` format corrected** — it documented `{username}-{season-id}`, which has not been the format for a long time |
| `AUTOMATED_NOTIFICATIONS.md`, `CHECKOUT_STATE_MANAGEMENT.md`, `REPORTS.md` | Subsystem reference |
| `LOOK_AND_FEEL_STRATEGY.md`, `CONTENT_PAGES_CSS_CHECKLIST.md` | Front-end; home for pre-launch items 29/30 |
| `DEPLOYMENT_GUIDE.md`, `INSTALLATION_GUIDE.md`, `UPDATE_WORKFLOW.md`, `MIGRATION_*.md`, `ANDREW_SETUP.md`, `DEPENDENCIES.md`, `ENTITY_SPECIFICATION_TEMPLATE.md`, `REQUIREMENTS_TO_ARCHITECTURE.md`, `DEPOSIT_REFUNDS_HANDOFF.md` | Stable how-to and reference |

**Older archive files worth knowing about:** `archive/SESSION_2026-08-04.md` holds the pre-launch
checklist and the full narrative through Aug 2; `archive/SESSION_2026-08-02.md` holds the bulk-notification
PROD validation protocol; `archive/TODO_2026-08-02.md`, `archive/PROJECT_STATUS_2026-08-02.md` and
`archive/CODE_QUALITY_BACKLOG_2026-08-02.md` are the sources behind P9, P10 and the wants list.

---

## Keeping this file honest

1. **Close items here, not in five documents.** If an archived brief and this file disagree, this file is
   wrong or the item is not really closed — either way, fix it here.
2. **Do not add status banners back to the analysis docs.** That is what made the last round untrustworthy.
3. **When a fix ships, delete the item.** `SESSION_HANDOFF.md` records that it shipped; git records what
   changed. Closed items should not accumulate here — the "Closed since July 28" section above exists only
   to stop already-settled things being re-opened, and should be pruned once it stops earning its place.
4. **Re-locate by method name, never by line number.**
