# CC Soccer D11 - Session Handoff
**Date:** August 1, 2026
**🔴 PRIORITY — live PROD bug, queued not started:** admin accept of a tournament invitation fatals and half-writes. Three fixes needed in `GroupInvitationsForm.php`. **Do not use the admin Group Invitations accept dropdown on any tournament until this ships.** Full detail in the 🔴 PRIORITY section below (it sits after Andrew's July 29–30 sections — the merge interleaved them). Deliberately NOT started — `fix/bulk_notification_sms` closes out first.
**Active branch:** `fix/bulk_notification_sms` — 9 commits, pushed, **not merged**. All four pre-merge items are now done and verified on LOCAL (the "Remaining on this branch" list in the July 28 evening section below is stale). Needs the **full deploy chain** — `updb` for 9071/9072, `cim` for field config — not the code-only chain.
**Merged since last update:** `fix/team_display` via PR #119 (`7fd60bf`), plus Andrew's July 29–30 work — PRs #120 (roster audit + reconciliation plan), #121 (tournament roster gender/skill display), #122 (board member report access docs).
**PROD is on 11.4.4** as of July 30 — the soak is over, all 14 update hooks ran, Authorize.net verified live, cron healthy.

---

## Session Work — July 30, 2026 — Board Member report access; tournament `invitation_status` (CF12) (Andrew + Claude)

**Documentation only. No code, no config, no database changes.** Working tree is four `.md` files.
Both threads below need a decision before any code is written.

### Thread 1 — Board Members can't download the Jersey Report → `BOARD_MEMBER_ADMIN_THEME.md` (NEW)

Download CSV does nothing for a Board Member, works for Admin. Not a permissions problem: only
`content_editor` holds `view the administration theme`, so Board Members see admin pages in
`ccsoccer_theme`, whose views table has no `views-table` class (that comes from Claro's template override,
not from Views). The button's `querySelector('.views-table')` returns null and returns silently.

Reviewed all four reports at `/admin/ccsoccer/reports`: **City Payment and Insurance are theme-proof**
(trivial forms → server-side TCPDF); **Tournament Deposits functions but is unstyled with invisible sort
arrows**; **Jersey is broken**. All four paths are already `_admin_route` via core's `AdminRouteSubscriber`,
so one permission is the entire gate.

**Recommendation: grant `view the administration theme` to `board_member`** — one config change, fixes all
four at once, keeps Board Members in the environment the reports were built and tested in. Seven cons
documented; two need a conscious call (it hides the fragility rather than removing it; it entrenches the
Claro-only CSS). Paired hardening in §8 decouples the CSV button from the theme regardless.
**Andrew is taking this to Caleb** — four questions for him in §11.

Incidental finds: `ccsoccer_theme/css/user-pages.css:401-478` and `css/insurance-report.css` are written
against Claro-only classes and are dead code in the front-end theme. `js/admin-mobile.js` is the
counter-example — written defensively for this exact scenario.

### Thread 2 — Group Invitations shows confirmed players as "Pending" → **CF12**

Lunch Crew reads `Accepted: 7` in the stats bar while three member dropdowns read Pending.
`createTournamentRegistration()` never sets `invitation_status`, so tournament registrations store the
field default `'none'` — not one of the dropdown's options, so the browser renders the first one,
"Pending". `buildStatsBar()`'s `match()` default counts the same rows as accepted, hence the contradiction.

**A June 26 regression, traced to a commit.** `84dac45` unified the checkout `group_id` so these players
appear on the page at all; its repair hook 9068 marked the *existing* rows `accepted` while the ongoing
write path never got the same line. (`8483a20`, Jun 23, invite-only join, is **not** the cause — it
reroutes to `token_accept`, which never set the field either.) So pre-Jun-26 players read Accepted,
post-Jun-26 players read Pending.

> **⚠ OPERATING RULE until CF12 ships: do not click _Save_ on the admin Group Invitations page for a
> tournament group.** Saving with no edits persists a real `invitation_status = 'pending'`
> (`GroupInvitationsForm:909,919`) — the page turns its own display fault into data. Reading is safe.

Also settled: **D-10** (map to `accepted` — update hook 9068 already made this call) and **D-11** (captain
stays `'none'`). Scope correction to **CF9**: `OrderCompleteSubscriber:821` is inside the `'join'` branch,
dead since June 23 — three live sites, not four; recommend deleting the branch.

### Files changed

| File | Change |
|---|---|
| `BOARD_MEMBER_ADMIN_THEME.md` | **NEW** — thread 1 in full, with cons, test plan, deploy steps, questions for Caleb |
| `ROSTER_RECONCILIATION_PLAN.md` | CF12 added to §5 + full brief in §10.3 (after CF2); D-10/D-11 in §7; CF9 scope correction; commit 2 is now CF2 + D2 + CF12; §10.0 and §11 notes |
| `SEASON_TOURNAMENT_DRIFT_AUDIT.md` | **D19 promoted ⚪ Low → 🟠 High** with the full finding and the June timeline; D15 annotated as half-retired |
| `OUTSTANDING_ISSUES.md` | D19 moved out of Parked into P1 with the operating rule; D15 marked half-live; D2 cross-referenced to commit 2 |

### Next

1. Andrew ↔ Caleb on the admin theme grant. Nothing to build until that lands.
2. CF12 ships with CF2 + D2 in commit 2 of `fix/roster_sync` — the Save trap is inside the lines CF2
   rewrites, so splitting them means touching those lines twice.
3. Before writing CF12: run the verification query in its brief (§10.3). Every affected row should postdate
   June 26. If any predate it, there is a third write path not yet found — stop and locate it.
4. `ROSTER_RECONCILIATION_PLAN.md` §10.2 pre-flight items 2 (run the audit on TEST) and 3 (confirm the
   nudge fix reached PROD) are **still open** from July 29.

---

## Session Work — July 29, 2026 — Tournament roster builder gender colours; CF10/CF11 written up (Andrew + Claude)

**Working tree was clean at the start of this session** — the July 28 in-flight work has all landed on
`main`: `fix/team_display` merged as PR #119 (`7fd60bf`), the two roster-builder controller changes as
`b457883`, the audit + plan docs as PR #120 (`959c466`). The "resolve the in-flight work first"
pre-flight in `ROSTER_RECONCILIATION_PLAN.md` §10.2 item 1 is **satisfied**; items 2 (run the audit on
TEST) and 3 (confirm the nudge fix reached PROD) are still open.

### Code change — tournament roster builder shows women in red (UNCOMMITTED, UNLINTED, UNTESTED)

Andrew: the season roster builder colour-codes men blue / women red / goalie green; the tournament
builder was all blue. SLO Friendly is coed, so the director needs the gender mix per team at a glance to
distribute free agents evenly.

**It was not a missing feature — it was a case-sensitive comparison.**
`TournamentRosterBuilderForm.php:380-385` did `$gender === 'female'` against `user.field_gender`, whose
allowed values are **capitalised** (`Male`/`Female`/`Other`/`Prefer not to say`, per
`config/sync/field.storage.user.field_gender.yml`). It never matched, so every woman was classed
`player-male`. The `player-female` class, the `data-gender` attribute, the red CSS rule
(`roster-builder.css:324-327`) and the stylesheet attachment all already existed and were all correct.

The season board works because it uses `TeamBalancerService::isPlayerWoman()` — `strtolower()` plus a
value list. Two implementations of one rule; the newer copy drifted. Same shape as every finding in the
drift audit.

**⚠ Environment-dependent, and the trap is the wrong way round:** the dev seeder writes **lower case**
(`CcsoccerCommands.php:1870`), so on a seeded LOCAL the broken comparison *worked*. It only fails on real
or D7-migrated data. **Test with capitalised values or a real migrated user**, or this looks fine when it
is not — in both directions.

**Changed:**

- `TeamBalancerService.php` — `isPlayerWoman()` and `getPlayerPrefersGoalie()` `protected` → `public`.
  No logic touched; docblocks now record why they are public and warn against re-inlining.
- `TournamentRosterBuilderForm.php` — injects `ccsoccer.team_balancer`, calls both shared predicates
  instead of the two inline checks. **`prefers_goalie` was broken too** and is fixed in the same change:
  the form read only the registration field and skipped the service's fallback to the user's
  `field_prefers_goalie`, so the green `G` under-reported.
- `css/tournament-roster-builder.css` — 4px left border stripe, red for women / blue for men. Needed
  because `buildPlayerCard()` renders the gold `C`/`CC` badge **instead of** the skill badge, and the
  skill badge is the only thing the gender colour touches — so a woman captain would otherwise be
  invisible. ASS FC's captain is a woman, so that is not hypothetical. **Captain badge stays gold with
  `C` regardless of gender** (Andrew's call, agreed).

Scoped to `.tournament-roster-builder-content` **and** `.ccsoccer-pool-section` — `pool_section` is a
*sibling* of `content` in the form array, not a descendant, so one selector would have missed pool
players. No JS change needed; no `services.yml` change needed (the form uses `create()`, and the service
already exists).

**Andrew declined** a per-team women count, a `needs-women` warning, and a header total — the colour is
the mechanism, and player count + average age are enough in the footer.

Full write-up, test plan and the declined items: **`TOURNAMENT_ROSTER_GENDER_DISPLAY.md`** (new).

### Same commit — two more wrong-value reads on the same screen (UNCOMMITTED, UNLINTED, UNTESTED)

Both found while implementing the gender colours. Written up in **`TOURNAMENT_SKILL_DISPLAY_FIXES.md`**
(new); §1 site 1 and §2 are implemented, the rest are proposals.

- **Zombie-field read — `Registration.self_score` no longer exists.** `ccsoccer_update_9048()` moved
  self-assessment to `User.field_self_score`; four readers were never updated. Guarded by `hasField()`,
  so they fail **silently** — same class as the July 20 `field_has_jersey` finding. Effect on the
  tournament roster builder: **every player displayed skill 3.** Fixed at
  `TournamentRosterBuilderForm.php:354-358` by calling `TeamBalancerService::getPlayerSkill()`
  (`protected` → `public`; note this one was *not* widened by the gender change, contrary to an earlier
  draft of the doc). Checkout is not at fault — `PlayerInfoPane` collects skill as required 1-5 radios
  for tournaments too. **The badge was kept rather than removed because it is what carries the red/blue
  gender colour.** Three other sites still read the dead field and are left for later:
  `TournamentController:819-823`, `Team.php:556-562`, `SeasonController:591-594` (a **season** screen).
- **The roster builder footer showed the wrong quantity.** `buildTeamColumn()` rendered
  `Team.admin_skill_level` into `.avg-skill`, and `updateTeamStats()` replaced it on every drag with an
  average of the cards' `data-skill` — which, because of the bug above, was **always exactly `3.00`**. So
  a team the board rated 4.5 reported 3.00 after one drag. Display only, nothing persisted.
  **First fixed by removing the JS overwrite, then reversed — see below.**

### Reversal, same session — the footer now shows average player skill (Andrew's call)

Once the skill data was correct, Andrew decided the roster builder *should* show the team's **average
player skill**, live. So the overwrite was never the bug — **the PHP rendering the wrong quantity was.**
The schedule builder keeps using the board's Assigned Skill from the Tournament Teams page; seeing the
true roster average *before* setting that rating is what helps, and it helps place free agents. Net
result: the tournament board now reads exactly like the season board.

- **Per-team `avg_skill` computed in PHP** — `total_skill` was already accumulated (`:442`) and
  `avg_skill` already initialised (`:328`) but never calculated, so this was one line beside the age
  calculation. Footer tooltip corrected to **"Avg Player Skill"**.
- **`number_format(…, 2)`, not `round()`** — `round(2.80, 2)` renders `2.8` while the JS's `toFixed(2)`
  gives `2.80`, so the value would visibly change format on the first drag. **The season board still has
  this latent** (`TeamBalancerService:1186` uses `round()`); invisible today only because its averages
  happen to have two significant decimals.
- **`-` for an empty roster**, in both PHP and JS — no stale average, no divide by zero.
- **Sidebar "Roster Avg Skill" changed meaning too.** It was averaging the *teams' Assigned Skills*
  (28 ÷ 10 = the `2.8` in Andrew's screenshot); it is now the average across **all players**, matching
  the season board (`TeamBalancerService:1143`) and the average age directly beneath it. Useful property:
  that definition is **drag-invariant**, so no JS needs to update it and neither file does.
- **JS recalculation restored** with `toFixed(2)` and the empty guard. **`js/roster-builder.js` still
  untouched** — verified.
- `$team['admin_skill']` is still populated in the form's team data and now unused; left deliberately, in
  case a future "show both" wants it.

### Answered — Andrew asked when `Team::calculateSkillLevelFromPlayers()` runs

**Almost never, and finding out why turned up a third calculator that is worse.** There are three:

| | Method | Reads | Runs | Today |
|---|---|---|---|---|
| A | `TeamBalancerService::getPlayerSkill()` | user fields, 1-5 | season builder, Suggest Rosters | ✅ correct |
| B | `Team::calculateSkillLevelFromPlayers()` | `field_skill_level` **÷2** → dead field | schedule builder, only if `calculated_skill_level` is NULL | 💤 dormant |
| C | `TournamentController::calculateTeamSkillLevel()` | dead field **only** | **every Teams page load — and it saves** | ⚠️ always `3.00` |

B has one caller (`TournamentScheduleGeneratorService:534-538`) behind a NULL check that C has already
satisfied, because C runs in `teamsPage()`'s render loop and persists (`TournamentController:682-689`).
So B's `/2` scale bug is dormant, and `calculated_skill_level` on every tournament team is a stored,
fabricated `3.00`. C also means **every Teams page load writes N entities on a GET request.**

**Harmless today only because the board assigns a skill to every team** — `getSkillLevel()` prefers
`admin_skill_level`, so the fallback is never reached and the schedule builder matches on real numbers
(verified against Andrew's screenshots: Ball Hogs 4.5, ASS FC 4.0, Gorilla Warfare 1.0, identical in the
grid). **The exposure is a forgotten assignment:** an unassigned team is silently treated as 3.0 with no
warning. Recommendation in §3 — delete B and C, and have the schedule builder name the teams it guessed
for. Not implemented; own commit.

**Also settled (Andrew):** `field_skill_level` has only ever been used as 1-5, so `getPlayerSkill()`
using it without conversion is correct and the season balancer is fine — no query needed, nothing to
repair. Two stale artefacts remain: B's `/2` (disappears with B), and the field *description* saying
*"Admin-set skill level (1-10)"* — which is what sent this investigation off in the wrong direction, and
is a **config** change, so it should ride with the next `cex` rather than this code-only commit.

Archives: `archive/TeamBalancerService_2026-07-29.php`,
`archive/TournamentRosterBuilderForm_2026-07-29.php`, `archive/tournament-roster-builder_2026-07-29.css`,
`archive/tournament-roster-builder_2026-07-29.js` (all pre-edit, so they cover the whole commit).

### Docs — CF10 and CF11 added to the reconciliation plan

Andrew asked when a season "group" comes into existence, and whether a solo player is already a manager.
**Answer: a season group is created lazily by `invite()`** — `group_id` is NULL until a player sends their
first invitation (`OrderCompleteSubscriber:481,557` create season registrations with it NULL). So a solo
player is not in a group; `manage()` computes `$is_manager = empty(invited_by)` — TRUE — which is why they
see an invite form, while the roster query is gated on `$group_id` and renders empty. Others *can* invite
them. All correct behaviour.

Two real bugs fell out, now written up as **CF10** and **CF11** in `ROSTER_RECONCILIATION_PLAN.md` §10.3
(§5 table rows, commit 8, decisions **D-7**/**D-8**/**D-9**):

- **CF10** — `invite()` saves the new `group_id` *before* validating the invitee, so a **rejected** invite
  still converts the inviter into a permanent group-of-one. Fix: move the generate-and-save below the
  checks. Tournaments are unaffected (their `group_id` comes from the Team entity).
- **CF11** — a season manager has **no way out of their own group**: `leaveGroup()` refuses managers, and
  neither `removeMember()`, `leaveGroup()` nor `deleteInvitation()` clears the manager's own `group_id`
  when the group empties. Andrew's scenario: P1 invites P2, P3 invites P2, P2 accepts one — the loser is
  stranded and must email an admin. Fix: a player-facing "Disband Group" for a manager who is the group's
  only live member, reusing CF8's extracted dissolve service. **Must re-count live members at submit
  time** or a late accept gets silently ejected.
- **D-7 decided** (solo only, not a general manager-dissolve). **D-8 open** — should a group auto-clear
  the manager's `group_id` when it drops to one member? That would close the other three routes into the
  stranded state rather than just providing an exit. Andrew's call.

### Next session

1. **Lint and LOCAL-test before commit** — `ddev php -l` on the two PHP files, then
   `TOURNAMENT_ROSTER_GENDER_DISPLAY.md` §4 **and** `TOURNAMENT_SKILL_DISPLAY_FIXES.md` §5. The two
   checks that matter most, because they cover the interaction between the three changes:
   **(a)** a woman's skill badge is red *and* now shows her real number; **(b)** a footer average with a
   trailing zero (`3.00`) **stays** two-decimal after a drag rather than flipping to `3` — the
   `number_format`/`toFixed` agreement, the likeliest thing to have gone wrong; **(c)** the schedule
   builder still matches on the Teams page's **Assigned Skill**, not the roster builder's new averages —
   that boundary is what the whole change rests on; and **(d)** the season roster builder is unchanged
   and Suggest Rosters still balances. `TeamBalancerService` now has a second consumer, so (d) is not
   optional.
2. Suggested commit split: the **four** code files as one commit (`TeamBalancerService.php`,
   `TournamentRosterBuilderForm.php`, `css/tournament-roster-builder.css`,
   `js/tournament-roster-builder.js` — all three fixes are one story: the tournament roster builder
   displayed values read from the wrong place), then the docs as another
   (`TOURNAMENT_ROSTER_GENDER_DISPLAY.md`, `TOURNAMENT_SKILL_DISPLAY_FIXES.md`,
   `ROSTER_RECONCILIATION_PLAN.md`, this handoff). Deploy is code-only, `drush cr`.
3. **Answer D-8** before CF11 is built.
4. Still queued, unchanged: run `ROSTER_DATA_AUDIT.sql` on TEST (classes TM/SJ size CF4); confirm
   `fix/tournament_nudge_500` reached PROD; 11.4.4 soak → PROD; `feature/update_registration_page` PR;
   recursion-guard warning fix; verify Avi's refund (order 87). See `OUTSTANDING_ISSUES.md`.
5. **Follow-ups from `TOURNAMENT_SKILL_DISPLAY_FIXES.md`, none blocking:** delete skill calculators B
   and C and warn on unassigned teams (§3, own commit); `field_skill_level` description 1-10 → 1-5 (§4,
   config, batch with the next `cex`); the three remaining `Registration.self_score` readers (§1 sites
   2-4, one of them a season screen).
6. **Still open from `TOURNAMENT_ROSTER_GENDER_DISPLAY.md` §5:** ~15 dead `.tournament-player-card` CSS
   rules — nothing emits that class, so **do not write new tournament CSS against it**. Worth a cleanup
   pass to either rename the emitted class or delete the block.

---

## Previous Handoff Header — July 28, 2026
**Uncommitted:** (a) two new docs from the July 28 evening session — `ROSTER_DATA_AUDIT.sql` and
`ROSTER_RECONCILIATION_PLAN.md`, docs only, no code, safe to commit as-is; (b) the earlier July 28
roster-builder changes — 2 code files + docs, **not linted, not tested, not committed**. Decide
branch/commit with Andrew. **(b) must be resolved before the roster-sync code work starts — it
touches the same two files CF4 and CF5 do.**
**Branch:** `fix/team_display` — 1 commit (`9dfe05f`), **not yet tested, not deployed** (see July 27 evening section below)

---

## Still open, not tied to a session

**Also open:** `feature/update_registration_page` — 2 commits, tested on LOCAL, needs PR + deploy (July 8 work, still pending)

August 1 PROD incident (Sean Gibson / SLO Your Roll) is the top section below — data repaired, code fix queued.

Earlier session detail (June 23 – July 5) archived in `archive/SESSION_HANDOFF_2026_07_08.md`. July 17–18 sessions archived in `archive/SESSION_2026-07-18.md` and `archive/SESSION_2026-07-18b.md`; July 20 daytime sessions archived in `archive/SESSION_2026-07-20.md` (near-duplicate snapshot noted in `archive/SESSION_2026-07-20b.md`); July 20 late-night through July 26 archived in `archive/SESSION_2026-07-27.md`; July 27–28 archived in `archive/SESSION_2026-07-29.md` before this update (filename is the archive date; the work it covers is July 27–28).

---

## 🔴 PRIORITY — Admin accept of tournament invitation fatals (D2, live on PROD) — data repaired Aug 1, **code fix NOT started**

**Queued deliberately.** Close out `fix/bulk_notification_sms` before opening this. Suggested branch when it starts: `fix/admin_accept_tournament_invite`, off `main`.

### Until it ships
**Do not use the accept option in the admin Group Invitations form (`/admin/.../group-invitations/{group_id}`) for any TOURNAMENT group.** It fatals every time and leaves the data half-written. Decline, promote, release, and dissolve are unaffected. Season accepts are fine — only the tournament branch is broken.

### This is D2 from `SEASON_TOURNAMENT_DRIFT_AUDIT.md`
Logged July 17 as one of three critical divergences, never fixed. It has now bitten on PROD. Worth noting how it presented: not as a clean error, but as a player who appeared to be simultaneously in the group and pending, which read as a data-integrity mystery rather than a known open finding.

### Root cause — confirmed against watchdog, not inferred
`GroupInvitationsForm::declineOtherInvitations()`, tournament branch:

```php
$properties['tournament'] = $context_id;
$other_invitations = $invitation_storage->loadByProperties($properties);
```

**The `ccsoccer_invitation` entity has no `tournament` field.** Tournament invites carry `team` only — see `Invitation::baseFieldDefinitions()`, which defines `season`, `group_id`, and `team`. Entity query throws:

```
Drupal\Core\Entity\Query\QueryException: 'tournament' not found
  in Drupal\Core\Entity\Query\Sql\Tables->ensureEntityTable()
  core/lib/Drupal/Core/Entity/Query/Sql/Tables.php:415
```

Watchdog wids 210047 and 210050, 2026-08-01 19:17:42 and 19:18:15 — Caleb's two attempts.

### Why it half-writes (the part that makes it worse than a plain 500)
`acceptInvitation()` saves the **registration** first, then returns TRUE, and only then does `submitForm()` set the invitation to `accepted`. The fatal lands between those. So every failed attempt leaves:
- registration: `team`, `group_id`, `invited_by`, `invitation_status = accepted` — **written**
- invitation: still `pending` — **not written**

That is exactly the "in the group AND pending" display that triggered this investigation.

### Three bugs, not one
1. **Phantom `tournament` field** in `declineOtherInvitations()`. Fix must resolve sibling teams via the tournament and query by `team`, mirroring `GroupController::acceptTeamInvitation()`, which already does this correctly (it loads all pending invitations for the invitee and filters on `isTeamInvite()` + matching tournament).
2. **`Team.players` is never updated.** The admin path sets `Registration.team` and stops. The user-side `acceptTeamInvitation()` pushes onto `$team->get('players')`. So a successful admin accept — if it ever got that far — would still produce a ghost: registration says the player is on the team, roster says otherwise. This is the same `Registration.team` ↔ `Team.players` drift class as T2/T5/D7.
3. **No guards before overwriting.** No check that the invitee is already on another team in the tournament, and no `isFull()` check. It just overwrites `Registration.team`. Since registrations aren't revisionable, a prior team assignment would be unrecoverable from the reg row (recoverable only because `Team.players` isn't touched — bug 2 accidentally limits the blast radius of bug 3).

**Also missing:** `sendPlayerJoined()` fires on the user-side accept, not the admin one. Inviter gets no notification. Third gap between the twin paths.

### PROD incident + repair (Aug 1) — Sean Gibson / SLO Your Roll
Layne (uid 90901, captain of team 51 "SLO Your Roll", tournament 5 SLO Friendly 2026) invited Sean Gibson (uid 91060), who was already registered as a free agent. Caleb accepted on Sean's behalf from the admin form → WSOD, twice. Layne then deleted the invitation from his side, which hard-deletes the row (`deleteInvitation()`) and touches nothing else — leaving the registration pointed at team 51 with no invitation behind it.

**State found:** registration 5130 — `team=51`, `group_id=10b03f7c-e4aa-4113-b62c-f9251ca2023e` (matches team 51), `invited_by=90901`, `invitation_status=accepted`, `status=paid`. Zero rows for uid 91060 in `team__players`, on any team.

**Scoped the blast radius before touching anything** — compared `team__players` counts against registration counts for all 11 teams in tournament 5. Only team 51 was off (5 roster rows vs 6 registrations). Sean was the sole drift in the entire tournament. No other cleanup needed.

**Confirmed nothing was lost:** no `team__players` row for him anywhere, so he was a genuine free agent before the invite — no prior team assignment was overwritten. (Invitation 82 in his name is a *season* invite from Caleb, unrelated — `team` NULL, `group_id` set.)

**Repair:** Sean stays on the team (desired outcome). Added uid 91060 to team 51's `players` via `drush php:eval` on the entity rather than raw SQL, so cache tags invalidate and the delta is assigned properly. Idempotent — checks membership first. Logged to watchdog with the reason.

### Diagnostic queries — reusable, keep these
The roster-vs-registration comparison is the general detector for this whole drift class and is worth keeping around for the ghost-member reconciliation work that keeps getting deferred (T2/T3/T5, D6/D7):

```sql
-- Rosters (Team.players)
SELECT t.id AS team_id, t.name, tp.delta, tp.players_target_id AS uid, u.mail
FROM team__players tp
JOIN team t ON t.id = tp.entity_id
LEFT JOIN users_field_data u ON u.uid = tp.players_target_id AND u.default_langcode = 1
WHERE t.tournament = <TID> AND tp.deleted = 0
ORDER BY t.id, tp.delta;

-- Registrations (Registration.team) — counts must match the above per team
SELECT r.id, r.player, u.mail, r.team, r.group_id, r.invited_by, r.invitation_status, r.is_captain, r.status
FROM ccsoccer_registration r
LEFT JOIN users_field_data u ON u.uid = r.player AND u.default_langcode = 1
WHERE r.tournament = <TID>
ORDER BY r.team, r.id;
```

Note `tp.deleted = 0` and `u.default_langcode = 1` — both needed or the counts lie.

### Column-naming reference (verified Aug 1, saves a lookup next time)
Single-property base fields on a base table are stored as the **bare field name** (`player`, `team`, `tournament`, `group_id`, `invited_by`), not `*__target_id`. Multi-value base fields get a dedicated table with `{field}_{property}` — hence `team__players.players_target_id`.

### When this is picked up
1. Branch `fix/admin_accept_tournament_invite` off `main`.
2. Archive `GroupInvitationsForm.php` first — check for an existing `archive/GroupInvitationsForm_2026-*.php` before writing, per the July 27 process note.
3. Fix all three bugs together; they're in the same two methods.
4. Consider reordering `submitForm()` so the invitation and registration are written as one unit — the half-write is what turned a 500 into a data problem, and that ordering hazard survives even after the query is fixed.
5. Test on LOCAL: admin-accept a tournament invitation where the invitee holds other pending invites; verify the invitation flips, `Team.players` gains the row, other invites decline, and the season path is unchanged.
6. Also worth folding in: `GroupInvitationsForm.php:24` docblock cleanup, already noted under the July 3 security review's G-residual item.

---

## Session Work — July 28, 2026 (evening) — Bulk notification: preference bypass, SMS encoding, environment gating, field_sms_body (Caleb + Claude)

**Branch `fix/bulk_notification_sms`** — 4 commits, pushed, NOT merged. Deploying it needs the **full chain** (`updb` for 9071, `cim` for field config), not the code-only chain the last several deploys used.

### The trigger: a real bulk notification where nobody got SMS
PROD node 3, 691 recipients, 2026-07-27 20:06. Emails went out and the `[VERIFY]` board copies arrived, but not one SMS. Investigated three hypotheses (the 9070 zombie-field cleanup, Clickatell max-parts, credentials) and all three were wrong.

**Root cause: bulk notifications had never honored notification preferences and had never sent SMS.** `sendBulkNotification()` called `sendBulk()` without the 4th `$sms_body` argument, so it defaulted to `''`. `NotificationQueueWorker::processItem()` has a branch that treats an *explicitly-empty* `sms_body` as "email only" and calls `sendEmail()` **directly, bypassing `send()`** — and `send()` is where the preference check lives. So:
- players set to `none` were emailed anyway (compliance problem)
- players set to `text` got email instead of the text they asked for
- nobody got SMS from a bulk send, ever

Proof in the log: not one `User @user has field_notification_preference set to:` line anywhere in the bulk block, while the nudge path minutes earlier correctly logged `Notification skipped for jordanhix7@gmail.com - opted out`.

**Why it survived:** `ccsoccer_notification_send_test_submit()` (the green **Test** button) builds its SMS inline and calls `sendSms()` directly — so Test always worked. "We tested notifications and they worked" and "bulk never sent SMS" were both true.

Note the sharp edge in that queue-worker branch: `?? ''` means a *missing* key also yields an empty `$sms_body`, but `array_key_exists()` is FALSE so it takes the `else` and calls `send()`. "Key absent" and "key present but empty" behave completely differently.

### The Â bug — an encoding fault, not verbosity
`stripHtmlForSms()` collapsed whitespace with `preg_replace('/\s+/', ' ', $text)` — **no `/u` modifier**, so PCRE ran in byte mode and `\s` matched ASCII whitespace only. `html_entity_decode()` turns `&nbsp;` into U+00A0, bytes `C2 A0`; neither byte is ASCII whitespace, so the non-breaking space survived untouched. The `C2` byte rendered in a Latin-1 context is the stray `Â` seen in delivered messages.

The expensive part: **U+00A0 is not in GSM-7**, and one non-GSM-7 character forces the entire message to UCS-2, cutting per-part capacity from **153 to 67**. CKEditor emits `&nbsp;` constantly. This is almost certainly the real cause of the earlier `ERR: 113` max-parts failures, which were read at the time as messages being too wordy.

Measured: a typical 122-character notification is **1 part after the fix vs 4 before**. Clickatell bills per part, so ~640 billable SMS instead of ~2,560 at current list size.

### Three-tier environment gating restored
Intended design was local = SMS to the developer's own number only, test = board/beta only, production = everyone. In practice **local and test both ran the same board allowlist**, so every bulk send from a dev machine texted all six board members for real. (Confirmed the hard way: Andrew got a text from Caleb's laptop.)

`dev_override_phone` was documented in `settings.local.php` with the comment "ALL SMS in dev goes to this number" — and **no code ever read it**.

Now enforced **inside `sendEmail()` and `sendSms()`** so no caller can bypass it, and as an **intersection, not a redirect**: a send aimed at 200 people delivers one text if the developer is among them and none if they are not. Email is unrestricted on local since DDEV routes everything to Mailpit — it was previously filtered, which hid most local mail behind a "blocked" log line instead of letting it land where it could be read.

`[VERIFY]` board copies stay a deliberate broadcast to all board members on **both test and production**: bulk sends queue and drain over minutes, so that immediate copy is the only confirmation a send fired at all. Beta testers are excluded — they receive genuine traffic, not monitoring copies.

### **Pattern worth internalizing: five unwired mechanisms in one subsystem**
All five existed, none was read by sending code:
1. `dev_override_phone` — documented in settings, never read → **now wired**
2. `ccsoccer.test_mode` config — written by `TestModeSettingsForm`, never read → **form deleted**
3. `test_phones` — same form → **deleted with it**
4. the empty-`sms_body` bypass — a workaround that silently removed the preference check → **removed**
5. `field_delivery_method` — `required: true`, default `both`, options email/sms/both, **hidden in form display, zero code references** → **pending deletion**

This is the same shape as the two jersey panes, the two order-completion pipelines, `userMeetsSeasonRequirements()` vs `checkUserAge()`, and the six missing-status-filter bugs. **Before adding a mechanism to this subsystem, grep for an existing one first.**

### getRecipientIds() extracted
Recipient selection existed as ~120 near-identical lines in **both** `calculateRecipientCount()` (the on-screen number) and `sendBulkNotification()` (the actual send). The count an admin saw and the people who received the message were computed by two copies that could drift. Both now call `getRecipientIds()`; `sendBulkNotification()` dropped from ~130 lines to ~40.

### field_sms_body + live counter
`ccsoccer_update_9071` adds `field_sms_body`, **`string_long` not `text_long`** — a text format means CKEditor, which means `&nbsp;` and smart quotes, i.e. the exact characters that break encoding. Optional, not required.

The counter is the safeguard for the field being optional: it shows characters, parts, encoding, and *which* characters forced UCS-2, with the empty state naming the consequence — "0 characters, no SMS will be sent, N of these recipients asked for text only." That count is **scoped to the selected seasons and filters**, refetched from `/api/notification/recipient-count` on every change (the endpoint now returns `sms_only` alongside `count`).

GSM-7 tables are handed to JS from the PHP constants via `getSmsCounterSettings()`, so the browser and `getSmsInfo()` cannot disagree. `notification-confirm.js` exposes its `fetchWithCsrf` helper rather than a second CSRF cache.

### Also fixed
- **`sendSms()` phone guard** — rejects anything not matching `^1[2-9]\d{9}$` before spending an API call. 77 accounts hold 4-digit D7 migration residue (`6732`, `7798`) that `normalizePhone()` passed through untouched; sending those produces a burst of gateway failures, which is how a sender gets flagged.
- **`truncateForSms()`** — word-boundary cap at `SMS_MAX_PARTS` (10, confirmed against the Clickatell account) as a permanent `ERR: 113` backstop. Clickatell blocks an over-budget message entirely rather than trimming it, so truncation beats silence.
- **Cost projection** in `sendBulk()`, logged **before** the non-production early return: `recipients × parts = billable SMS`, with encoding.
- **Log placeholder bug** — several calls used `'{instance}'` where the template said `@instance`; Drupal only substitutes `@`/`%`/`:`, so watchdog showed a literal `[@instance]`. Fixed; blocked-SMS logs now print the normalized number.

### Verified on LOCAL
Real text delivered to the dev override number only, five board numbers correctly blocked, GSM-7, curly apostrophe and en dash both folded, no `Â`. Counter tested empty / ASCII / pasted-from-Docs / 18-part overflow. Generate-from-body produces GSM-7 output.

### Remaining on this branch before merge
1. **Test button** — duplicates `buildDefaultSmsBody()` inline and ignores `field_sms_body`, so Test sends different text than a real send. Most important of the four.
2. **Double send path** — `ccsoccer_notification_form_submit()` and `ccsoccer_node_insert()` both call `ccsoccer_send_notification()`. Only handler ordering prevents a double send to 691 people: the custom handler runs after save, so `isNew()` is already FALSE and only `node_insert` fires. Incidental, not designed.
3. **Delete `field_delivery_method`** — own update hook, and its `config/sync` YAMLs must go too or the leftover-YAML resurrection bug applies (same as `field_notification_prefs`).
4. **Presave normalization** — fold pasted characters on save, so the counter is honest about what will send.

**Merge sequencing:** TEST is still soaking 11.4.4 + the nudge fix + Andrew's D4/roster work, and Andrew hasn't reported. Don't stack notification changes on that until it clears.

### Open elsewhere (not this branch)
- **Nudge fix (`fa76cfd`) still not on PROD** — tested on LOCAL and TEST, ready since yesterday, code-only deploy.
- **`CartEventSubscriber` debug logging live on PROD** — three notice rows per add-to-cart, plus a severity-7 line logging each username with their notification preference. Same class as the July 18 `NotificationService` cleanup, which didn't reach this file.
- **Registration confirmation SMS is 171 units / 2 parts** — every registration confirmation costs 2 billable SMS. Trimming the template under 160 halves that year-round.
- **Andrew's `CcsoccerCommands.php` change** on `fix/tournament_nudge_500` — 17 lines, mostly deletions, unexplained. Not merged. Still needs asking about.
- **Recursion-guard warning** fires on every completed order, not just flagging branches, and its message text says otherwise. Parked with a proposed fix (check `ccsoccer_completion_processed` before warning).
- `drush config:status` misreported two `core.entity_view_mode.node.search_*` entries as "Only in sync dir" when they existed in both. Trust `git status` after `cex` over `config:status` before it.

---

## Session Work — July 28, 2026 (evening) — Roster data audit + reconciliation plan (Andrew + Claude)

Andrew asked for the data-hygiene pass before roster building: investigate first, review real numbers
before any writes — the same investigate-then-repair pattern that worked for the invitation data
repair. **Nothing was written to any database, no code was changed, and no git commands were run.**
Output is two new files at the repo root, both uncommitted.

### What was produced

**`ROSTER_DATA_AUDIT.sql`** — read-only, SELECT and SHOW only. 26 mismatch classes plus a control
total, covering both 2026 Early Fall seasons and SLO Friendly 2026 (the only containers on this site).
Section 0 is inventory + a run-time schema check; section 1 is the summary counts; **section 2.1 is
the headline** — per tournament team, `isfull_today` (what `isFull()` returns now, counting raw
`Team.players`) beside `isfull_after_cleanup` (counting only entries backed by a live registration),
so a team reporting full with real room is visible at a glance. Sections 3-5 are sample rows.

```
drush sql:cli < ROSTER_DATA_AUDIT.sql > roster_audit_TEST_2026-07-28.txt 2>&1
```

**`ROSTER_RECONCILIATION_PLAN.md`** — source of truth per flow, resolution per mismatch class, nine
code fixes CF1-CF9, and **§10, a self-contained implementation brief** written so a fresh session can
open it and build without re-deriving context: ordered work items with file, method, the change, the
decision each encodes, LOCAL test steps, branch/commit plan, deploy steps, and a do-not list.

**The audit has never been run.** No MySQL and no PHP in the authoring sandbox — the script is
statically verified only (see below).

### Findings worth carrying forward

- **The split brain is tournament-only.** `Team.players` is written exclusively by tournament code;
  season teams have it permanently empty and `getMaxRosterSize()` returns NULL for them, so `isFull()`
  can never return TRUE on a season team. Ghosts and false team-is-full states are *structurally*
  impossible on the season side. The season equivalent is narrower — cancelled rows keeping `group_id`
  and inflating group counts (D13/R4), plus the `reset()` shape.
- **`TournamentTeamManager::unassignPlayerFromTeam()` has zero callers** — verified repo-wide,
  including tests and drush. It removes from `Team.players` *and* clears `team`/`group_id`/`invited_by`
  (`TournamentTeamManager.php:489-495`), so it is more cleanup than D6 credits it with. Two caveats
  found: it resolves the team from `Registration.team` and silently skips the roster cleanup when that
  is NULL — exactly the ghost shape it exists to clean — and it refuses when `is_captain` is set.
- **`Registration.status` is a two-value field in practice.** Only `paid` and `cancelled` are ever
  written. Registrations are created in exactly two places, `OrderCompleteSubscriber.php:553` (season)
  and `:855` (tournament), both `'status' => 'paid'`; the only other write is `cancelled` from the two
  cancel forms. `active`, `pending`, `waitlist` and `expired` are vestigial enum values — every
  `'status' => 'active'` hit in the module belongs to Override or Credits, every
  `'status' => 'pending'` to Invitation. **So the paid-only `TeamBalancerService` filter cannot
  currently be dropping anyone**, and the only route to another value is an admin hand-editing a
  registration at `/admin/ccsoccer/registration/{id}/edit`.
- **Duplicate registrations arise only from register → cancel → re-register.** `addSeasonToCart()`
  (`RegistrationController.php:960-977`) and `addTournamentToCart()` (`:1051-1068`) both refuse when a
  non-cancelled registration already exists. That cancelled+live pair is the population every bare
  `reset()` resolves wrongly, so audit classes **TM** and **SJ** were added specifically to count it;
  section 3.10 shows per player what `reset()` takes vs what it should take.
- **D2 blocks CF2.** Admin-accepting a tournament invitation still crashes at
  `GroupInvitationsForm.php:1279`, so the accept half of CF2 cannot be tested until D2 is fixed. They
  ship together.
- **CF4 is larger than the earlier docs implied** — roughly fifteen bare `reset()` sites on
  registration collections module-wide, not the two in the roster builder. Scope it from the audit: if
  TM and SJ are both 0, the bug cannot fire on current data and CF4 is pure hardening.
- **CF3 is ten sites, not seven** — the team-invite branch of `invitationHasMatchingRegistration()`
  (`GroupController.php:827-830`) was missing from the earlier count. Implementation note: most use
  `loadByProperties()`, which cannot express `NOT IN`, so each site needs `getQuery()` or a post-filter.
- **CF9 drops `OrderCompleteSubscriber:707`** — that is a team *create* seeded with its captain, not
  an append, so it cannot produce a duplicate.

### Decisions recorded

- **No data repair this cycle (Andrew).** The league is casual, cancellations and declines are rare,
  teams are essentially never deleted. Existing rows stay as they are; the work is code-only, aimed at
  the next season and the next tournament. **Consequence, stated plainly in §9 and §11 of the plan:**
  the fixes are preventive. Ghosts already stored stay stored, and an affected tournament team will
  still misreport full during the SLO Friendly build. The later options are a read-side capacity fix
  (T2's own alternative — count what has a live registration) or a targeted repair.
- **D-2 — dissolve, do not promote.** When a group manager cancels, the group is dissolved and the
  remaining members are notified that they are no longer in a group; they can coordinate and form a
  new one. Encoded in CF8.
- **D-3 — stale invitations go `declined`,** not `expired` (nothing sets or reads `expired` on an
  Invitation — E5).
- **D-4 — canonical "live" is exclude `cancelled`/`expired`,** not an allowlist of `paid`/`active`.
  That is `pickLiveRegistration()`'s own reasoning and what the D14 nudge fix adopted; behaviour-neutral
  today given the finding above, and robust to a hand-edited row later.
- **D-1, D-5, D-6 are moot** while there is no repair.
- **CF6 resolved as no-change:** `dissolveGroupConfirmed()` leaving `Registration.team` intact is
  correct — a friend group is not a roster. Recorded so a future review does not re-flag it.

### Verification performed on the audit script

- All 67 statements parsed as MySQL: 59 SELECT, 7 SHOW, 1 UNION, **zero write nodes**. Keyword scan
  outside comments and string literals confirms no INSERT/UPDATE/DELETE/ALTER/CREATE/SET.
- All 990 qualified column references checked against the field definitions in the entity classes.
  Table names confirmed against the ddev db snapshot (`team__players` with column `players_target_id`).
- A review pass found six real defects in rev 1, all fixed in rev 2: row-multiplying joins inflating
  TB/TC/TH, an `ORDER BY` in 5.1 that buried every finding past the LIMIT, a missing season scope on
  the pending-invite count, NULL-swallowing comparisons in SG, missing tournament scoping on TD/TG, and
  name joins duplicating rows in the COUNT-bearing queries (now scalar subqueries).
- A second pass fact-checked every line reference in the plan against source and corrected four claims.

### Operational note — no PHP on Andrew's Mac

macOS stopped bundling PHP in Monterey. Use **`ddev php -l <file>`** — that is PHP 8.3, matching
`.ddev/config.yaml`, so lints run against the version the site actually uses rather than whatever
Homebrew's latest is. Lint everything changed:

```
git diff --name-only HEAD -- '*.php' '*.module' '*.install' | xargs -I{} ddev php -l {}
```

### Next session

1. **Run the audit on TEST** and read classes **TM** and **SJ** first — they size CF4 — then section
   2.1 for the tournament capacity picture and 4.2 for season group integrity.
2. **Then the code-only session:** open `ROSTER_RECONCILIATION_PLAN.md` §10 and work the list. Order
   is CF1 (tournament cancel → unassign), CF2 + D2 (admin accept/decline → sync `Team.players`), CF8
   (season cancel + dissolve + notify), then CF3, CF4, CF5, CF9, CF7. **Commits 1-3 are the ones that
   stop new drift — if time runs short, land those and stop.**
3. **Pre-flight (§10.2):** clear the working tree first. The earlier July 28 roster-builder changes and
   `fix/team_display` (`9dfe05f`, untested) touch the same files CF4 and CF5 do. Also confirm
   `fix/tournament_nudge_500` reached PROD, so CF4 can include `nudge()`'s inline `reset()` duplicate.
4. Line numbers throughout §10 are from July 28 and shift as soon as the first file is edited —
   **re-locate by method name.**
5. Acceptance test is behavioural, not a row count (no repair, so counts will not drop): run the audit
   on LOCAL, perform the actions that used to create drift — cancel a tournament player on a team,
   admin-decline, admin-accept, cancel a season group manager — re-run, and **every class count must be
   unchanged.**

---

## Session Work — July 28, 2026 — Review-doc cleanup + roster-builder admin overrides (Andrew + Claude)

Andrew asked for a pass over the six standing review docs (`INVITATION_FLOW_ANALYSIS.md`, `ORDER_FLOW_EDGE_CASES.md`, `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md`, `SEASON_TOURNAMENT_DRIFT_AUDIT.md`, `ADMIN_TOOLING_REVIEW.md`, plus the main handoff) to separate what has shipped from what is still open, focused on the roster/schedule/tournament work coming over the next few weeks. Explicitly out of scope per Andrew: abandoned-cart/abandoned-order edge cases (S3, S4, E5, E7, M-series) — ~200 players/season, affected players can email. Two small roster-builder code changes came out of the discussion; everything is uncommitted on the working tree.

### New tracker + doc annotations
- **`OUTSTANDING_ISSUES.md`** (new, repo root) — single prioritized list of open items: in-flight branches to land, P1 roster building, P2 scheduling, P3 tournament flow, P4 game-day, plus parked (abandoned-order) items and a "completed since these docs were written" summary. This is the pruned source of truth; the six review docs stay as detailed analysis of record.
- Added a **STATUS as of July 28** banner to the top of all five named review docs pointing at the tracker and summarizing shipped vs open for each.

### Code change 1 — season roster builder, admin group-cap override (`RosterBuilderController::mergeToGroup()`)
Andrew wanted to Shift+drag a player into a friend-group past the player-facing cap (3 coed / 4 mens) while arranging rosters — e.g. dragging Tom Do into a group already at 3. The Shift+drag path hard-blocked at `max_group_size` (`RosterBuilderController.php:365`, the "Group is at maximum size (3)" toast).

Traced the three distinct "caps" so we weren't conflating them: the **group cap** (`max_group_size`, friend-group size, what was blocking), the **team roster cap** (`getMaxRosterSize` — NULL/no limit for season teams), and the balancer's **`target_size + 2`** (auto-Suggest only). Only the group cap was in play.

Verified the only real downstream interaction before removing the block: the completion-time capacity guard in `OrderCompleteSubscriber::createSeasonRegistration()` (E2/E6) counts confirmed members vs max when a **pending** invitee completes checkout, and drops them from the group + flags the order if over. Dragging already-registered players never trips it; only an oversized group that still has an outstanding pending invitation is at risk.

Change: removed the hard block on the Shift+drag path only (admin path, gated on `generate rosters`). Player-facing `GroupController::invite()` still enforces the cap. Added a non-blocking warning to the success message when the resulting group is at/over max **and** still has a pending invitation, naming the risk so the admin resolves the pending invite rather than being surprised at checkout. `createGroup()` (Alt+drag) left alone — it starts at two players and any growth beyond goes through `mergeToGroup()`. Confirmed no client-side gate in `js/roster-builder.js` (server was the sole block).

### Code change 2 — tournament roster builder, over-capacity warning (`TournamentRosterBuilderController::move()`)
T1 (multi-drag force-adds past the team roster cap via `addPlayerToTeam(..., TRUE)`) was reviewed and **accepted as designed**: admin-only (`manage tournaments`), single-day SLO Friendly with no scores/standings, board validates team sizes before publishing. Verified nothing downstream breaks on an over-cap team — schedule generation reads registration counts as display metadata only and schedules team-vs-team regardless of size (`TournamentScheduleGeneratorService.php:516-544`); the checkout path re-checks `isFull()`/`isFullIncludingPending()` and flags `ccsoccer_team_capacity_exceeded` rather than silently overflowing (`OrderCompleteSubscriber.php:765,813`); `Team.players` is unbounded.

Change: added a non-blocking over-capacity warning to `move()` so an accidental overflow is visible at drag time. Force-add behavior itself is unchanged. Flagged that the real risk to the "validate before publish" workflow is not this overflow but the ghost-member/cancelled-registration drift (T2/T3/T5, D6/D7) — those make the count being validated unreliable. Ghost reconciliation deliberately deferred to a future session.

### Decisions recorded (as-designed / severity), no code
- **T1** — accepted as designed (above). Marked in `ADMIN_TOOLING_REVIEW.md` T1 row + banner + tracker.
- **R2** ("Suggest Rosters" leaves unplaceable players in the workbench without the toast saying so) — **downgraded HIGH → LOW.** Andrew's operating model is the compensating control: the board runs Suggest repeatedly as the league fills, then manually reviews the whole board and drags the workbench onto teams before publishing, so unplaced players are always seen and placed. The 14 players in the season-48 workbench were late registrants who signed up after the last Suggest run — expected, not a fault. Two modes documented: Mode B (leftover individuals go to `unassigned` silently — the common case) and Mode A (an oversized group ~11+, not reachable by 3/4 player groups, errors and clears the board after `clearRosters()` — theoretical, loud, recoverable). Optional future nicety noted only: surface the unassigned count in the toast.

### Files touched (all uncommitted)
- `web/modules/custom/ccsoccer/src/Controller/RosterBuilderController.php` (mergeToGroup override + warning)
- `web/modules/custom/ccsoccer/src/Controller/TournamentRosterBuilderController.php` (move over-capacity warning)
- `OUTSTANDING_ISSUES.md` (new), `ADMIN_TOOLING_REVIEW.md`, `INVITATION_FLOW_ANALYSIS.md`, `ORDER_FLOW_EDGE_CASES.md`, `SEASON_TOURNAMENT_DRIFT_AUDIT.md`, `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` (banners / status)
- Pre-edit archives: `archive/RosterBuilderController_2026-07-28.php`, `archive/TournamentRosterBuilderController_2026-07-28.php`

### Next session
1. **Lint + LOCAL test the two code changes before commit** — no PHP in the authoring sandbox, so `php -l` was not run. Season: Shift+drag a 4th player into a coed group (should now succeed), confirm the pending-invite warning appears when applicable, confirm a normal player-facing invite still hits the cap. Tournament: multi-drag a group onto a near-full team, confirm the over-capacity warning appears and the players are added.
2. Decide branch/commit with Andrew (suggested split: two code files as one commit, the markdown docs as another). Deploy is code only, `drush cr`.
3. Still queued from prior sessions (unchanged): land `fix/team_display` (untested), nudge fix to PROD, `feature/update_registration_page` PR, 11.4.4 soak → PROD, recursion-guard warning fix, verify Avi's refund (order 87). See `OUTSTANDING_ISSUES.md` for the full prioritized list.

---

## Session Work — July 27, 2026 (evening) — Tournament Manage Group display + removeMember, G3 reinvestigation (Andrew + Claude)

Branch `fix/team_display`, one commit `9dfe05f`. **Code is untested — no `php -l`, no LOCAL click-through, not on TEST.** That is the first thing to do next session.

### The report: the same team page showed different numbers to different people

Andrew, captain of Lunch Crew (SLO Friendly 2026), saw `Max 16 / Accepted 6 / Pending 9 / Spots left 1` at `/my-group/5093`. Yong Pong, on the same team, saw `Max 16 / Accepted 0 / Pending 9 / Spots left 7` at `/my-group/5096` — and no Team Roster table at all.

**There is only one view.** Both URLs hit `GroupController::manage()` and `ccsoccer-group-manage.html.twig`; nothing branches on captain vs player for the stats box. What it branched on was **the viewer's own registration row**: `$group_id = $registration->get('group_id')->value` at the top, then `if ($group_id)` wrapped the roster build. The tournament roster comes from `Team.players` and has no dependence on that field, so the gate was a leftover from the season path. Pending was already team-driven, which is exactly why the two numbers contradicted each other on screen — and the missing roster table was the confirming detail, since it meant `group_roster` was empty rather than miscounted.

Why Yong had no `group_id`: the Tournament Roster Builder **deliberately** leaves it NULL when an admin drags a free agent onto a captained team (`TournamentRosterBuilderController.php:160`, with a comment saying so). Andrew confirmed from the roster builder that Yong was dragged on by the tournament director, not invited by the captain. So this is a legitimate state and the display was wrong, not the data — no repair needed.

### What shipped in `9dfe05f`

**`manage()`** — roster and sent-invitations lookups now key on the Team entity for tournaments and on `group_id` for seasons only. Side effect: closes a latent fatal where a tournament registration with `group_id` set but no team fell through to the *season* branch and dereferenced `$season`, which is never assigned on the tournament path.

**`$is_season` added**, derived from `registration_type` rather than from `!$is_tournament`. Andrew pushed back on this as a significant change and the audit was worth it: `registration_type` is a required two-value enum, so the two are equivalent for every real row, and the behavior is byte-identical for anything typed `season` or `tournament`. All nine registration-creating `->create([…])` sites across the module pass the field explicitly. The only divergence is a row with an empty or unrecognized type, which previously took the season branch and now takes neither. `myRegistrations()` (line 121) and `removeMember()` already used strict positive matching, so this follows existing convention. **Not verified against PROD data** — one query would settle it:

```sql
SELECT id, player, registration_type, season, tournament, group_id, status
FROM ccsoccer_registration
WHERE registration_type NOT IN ('season','tournament') OR registration_type IS NULL;
```

Zero rows means the change is provably inert. Non-zero means those rows need fixing, not the guard weakening.

**`removeMember()` — D4 fixed**, per the drift audit's own prescription. Tournaments now authorize on `$team->isTeamLeader()`; seasons keep the `invited_by` rule. `$is_tournament`/`$team` had to move above the authorization check, which is why the diff is larger than the one-line fix D4 implies.

**Captain protection — required by the D4 fix, not defensive.** Until now no co-captain could pass the authorization check, so nothing needed to stop one from removing the *captain*. The template renders a Remove button on every row that is not your own, so granting co-captains access opened a one-click path to stripping the captain out of `Team.players` while `Team.captain` still pointed at them. `removeMember()` splices `Team.players` inline and never calls `TournamentTeamManager::removePlayerFromTeam()`, where the equivalent guard already lives (line 177). **Anyone reviewing D4 in isolation would have missed this.**

**T9 cleanup:** removing a co-captain now clears `Team.co_captain`, which otherwise kept pointing off-roster and left `isTeamLeader()` granting rights over a team that player had left.

**Membership check** is now `Registration.team` match **or** presence in `Team.players`, rather than `group_id` equality. Decision recorded because a strict check would also have fixed Yong's case: the on-screen roster renders from `Team.players`, and the two sources are known to drift (T2/T5/D7 — an admin-declined invitation clears `Registration.team` but leaves the player in `Team.players`), so a strict check would refuse to remove rows the page itself had just drawn. Neither arm can reach outside the team.

**Missing status filter — 6th confirmed instance.** `manage()`'s tournament roster loop loaded each player's registration by player + tournament with no status filter, and `reset()` takes the lowest id — so a player holding both a cancelled and a live registration had the **cancelled** row wired to the Remove button. The click would have appeared to do nothing. Extracted as `pickLiveRegistration()` (excludes cancelled, takes most recent). `nudge()`'s inline copy of the same pattern was deliberately left alone because it is mid-deploy to PROD.

**Docs:** D4 marked implemented in `SEASON_TOURNAMENT_DRIFT_AUDIT.md`. **D8 corrected** — it was listed open but shipped July 20 in `086d04d` and passed checklist section F on TEST; the doc was stale, not the code.

### G3 (3 pm cancellation reminder) — reinvestigated, **no code changed**, recommendation is deletion

Written up in full in `ADMIN_TOOLING_REVIEW.md` under a new "G3 — reinvestigated" section. Summary:

**What the cron actually does:** one thing — a second league-wide email + SMS to every `paid`/`active` player in the seasons with a cancelled game today. **It does not drive the status banner.** That was the assumption worth killing: `ccsoccer_page_top()` recomputes the banner on every page request from `date('G') >= 15`, so the old-site behavior of the header flipping at 3 pm is already reproduced with no cron involvement.

**Why it should go (Andrew's operational context):** the admin sends the cancellation with the rainout credits early in the day, and that is the only notification players get — it carries the reason and the credit action, which the reminder does not. Anyone checking later sees the banner and the schedule, which already renders rained-out games. The reminder re-notifies the same roster hours later with strictly less information, on an unmonitored mass-SMS path.

**The finding's second half is wrong.** Core's `Cron::run()` holds a global `cron` lock for 900 s before `invokeCronHandlers()`, so the "double-blast from concurrent cron" is not reachable and the recommended lock solves a non-problem. A different flaw was missed: send runs before the `reminder_sent` write. `sendBulk()` queues, so that window is milliseconds — but note the narrow 15:00–15:04 window is what currently contains it, so widening the window without fixing the ordering would have made things worse.

**A claim I made and retracted:** I reported the reminder had "never fired" based on 8 logged cancellation days with `reminder_sent` at 0. Andrew corrected me — that log is from the LOCAL dev site, where cron barely runs, so it proves nothing. The timing defect stands on its own reading of the code; the "never fired" evidence does not. **Check PROD before removing anything:** `drush config:get ccsoccer.game_status notification_log` and `crontab -l`.

**Season scoping is already correct** and needs no change: both notification paths collect season IDs from the games being cancelled, so a Tuesday rainout notifies Coed only and a Thursday one Mens 35+ only.

**Two existing findings this invalidates.** Rainouts are all-or-nothing — one park, multiple fields — so any concern about mixed-status dates is moot, including the banner's `$cancelled_count === $total_count` test. Same reason retires **G7's** "cancel hits all leagues sharing the date": only one league plays per night.

### Also noticed, not fixed

- **`notification_log` is runtime state stored in exported config.** `ccsoccer.game_status` is tracked in `config/sync`, so every notification writes active config and shows as drift in `drush config:status` — the same check the 11.4.4 deploy leaned on — and a `cim` rolls the live log back to the git snapshot. Cannot cause a double-send (wiping `initial_sent` only makes the guard stricter). Cure is `\Drupal::state()` plus an update hook. Same object as G7's unbounded-growth note.
- **`$is_manager` quirk in `manage()`:** a *tournament* registration with no team falls to the season `else` and gets `is_manager = empty(invited_by)` — TRUE for a pool player, who is then shown the "Invite a Player" form. `invite()` presumably rejects it, but the form should not render.
- **Four NULL-season deref sites in `manage()`** (461, 502, 554, and 635 in the render array). 635 fires unconditionally, so a season registration whose Season entity was deleted 500s the page regardless of the other guards. Low likelihood, pre-existing.
- **Banner caches 5 minutes**, so the 3 pm flip can lag to 15:05.

### Process note — my mistakes this session, recorded so they are not repeated

- I ran `git reset` to clear what I thought was stray staging. It was Andrew's, on a branch he had just created. The sandbox then could not delete git's lock files, leaving stale `HEAD.lock`, `ORIG_HEAD.lock`, `index.lock` and `refs/heads/fix/team_display.lock` that blocked his commit until he removed them by hand. **Do not run git write commands in this repo** — hand Andrew the command instead.
- I also overwrote `archive/GroupController_2026-07-27.php`, which already existed from the nudge fix, and had to restore it from HEAD; the new snapshot is `archive/GroupController_2026-07-27b.php`. Check for an existing archive filename before writing one.

### Next session

1. **Test `9dfe05f` on LOCAL** — `php -l`, then click through: captain removes a member, captain removes a co-captain, co-captain removes a member, co-captain attempts the captain (must block), a season group removal (must be unchanged). Then TEST, then PROD.
2. **Run the `registration_type` query above** to confirm `$is_season` is inert.
3. **Decide G3** — check PROD's `notification_log` first, then remove the reminder if it is as redundant as it looks.
4. Everything still queued from the earlier July 27 session below: **nudge fix to PROD**, 11.4.4 soak then PROD, recursion-guard warning fix, verify Avi's refund (order 87).
5. Ask Andrew about the unexplained 17-line deletion in `CcsoccerCommands.php` on `fix/tournament_nudge_500` — still unanswered.

---

## Session Work — July 27, 2026 — PROD verification, composer restore, tournament nudge fix, core 11.4.4 (Caleb + Claude)

### PROD confirmed for all three July 20 deploys
`086d04d` (invitation timing), `7e1dd4b` (jersey cleanup), `d466b97` (recursion fix) all on PROD — and confirmed *deployed*, not merely code-arrived. That distinction mattered: the broken composer binary sits first in `ccsProdDeploy`'s `&&` chain, so a failure there would silently skip `updb`/`cim` while leaving `git log` looking perfect. Verified via `updatedb:status` (clean) and, the real proof for `9070`, `config:get` on all three zombie fields returning "does not exist". Only drift is the three known media_library display entries — now confirmed present on PROD too, so it's a universal artifact, not a LOCAL quirk.

### `field_notification_preference` vs `field_notification_prefs` — right one deleted, confirmed
Two separate fields with near-identical names. **`field_notification_preference`** (singular) is alive and driving notifications — order 387 read it at runtime, got `text`, and routed to SMS accordingly, *after* 9070 ran on PROD. **`field_notification_prefs`** (plural) is the dead one 9070 removed. Allowed values on the live field: `email` / `text` / `both` / `none`. Note `none` is user-selectable and suppresses transactional mail too — relevant to any "reminders aren't arriving" report.

### Order 387 — recursion guard fired on PROD, but not for the reason the message says — **PARKED, needs a follow-up fix**
One warning at 04:27 ("nested place transition detected"). Investigated as a possible flagged-order incident; it isn't one. Order 387 is a clean registration (Emily Greene, uid 94626, $97, season 48 + jersey, registration 5361 created, SMS delivered) and its `data` column carries **no flag at all**.

Watchdog ordering shows the re-entry came *after* `Created registration 5361` — i.e. from the **save at the end of `onOrderPlace()`**, not a mid-method flagging save. That save always re-dispatched harmlessly (it sets `ccsoccer_completion_processed` before saving, so re-entry hits the persistent idempotency guard). The new static guard now sits in front of that one and logs on the way out. Two consequences:
1. **The warning fires on every completed order, forever**, and its own text ("expected when a flagging branch saves the order mid-completion") is wrong in the common case.
2. **It retroactively weakens the Kelly/E verification below.** "Exactly one warning (wid 189209)" was treated as proof the guard caught the capacity branch — but a normal order produces exactly one warning too. The fix is still almost certainly correct on its substance (registration created, `group_id` NULL, invitation pending, order flagged); that log line just wasn't the confirming evidence it was read as.

**Proposed fix (not built):** inside the static-guard branch, check `ccsoccer_completion_processed` on the in-memory order first. Set → known-benign end-of-method save → return silently. Not set → the mid-method case that used to blow the stack → warn. Restores the warning as a real signal.

### Composer restored on the server — root cause found
`/opt/cpanel/composer/bin/` exists but is **empty**, and both it and its parent are dated **Jul 20 21:10** — the same evening the deploy chain broke. A cPanel/EA update removed the package. Not local drift.

Fixed: installed composer **2.10.2** to `~/bin/composer` via getcomposer.org installer, then `sed`'d both `ccsDeploy` and `ccsProdDeploy` in `~/.bashrc` to `$HOME/bin/composer` (single-quoted so `$HOME` expands at run time, same as `$PATH` already did). Verified working through a real `ccsDeploy`. Backup at `~/.bashrc.bak-2026-07-27` — note it was taken *after* the first sed, so it holds the corrected version.

**Do not** wire the chain to `/opt/cpanel/ea-wappspector/composer.phar` — root-owned, inside another app's tree, equally liable to vanish on the next cPanel update.

### Tournament nudge 500 (D14) — fixed, `fa76cfd`
**Andrew's `fix/tournament_nudge_500` contains no code.** Three files: his handoff edits, the new `TOURNAMENT_NUDGE_500_FIX.md`, and an unexplained 17-line mostly-deletion in `CcsoccerCommands.php`. `GroupController.php` is not in the diff — his doc header says so ("NOT YET APPLIED"), held pending coordination with the invitation PR, a reason now stale.

His diagnosis was correct and verified against the real code: `nudge()`'s redirect block passed the invitation's `season` target_id to `loadByProperties()` unconditionally; team invitations carry no season, so NULL went in as an entity-query condition, which throws in D10/11. The block sits *outside* the if/else, so the 48h throttle path crashed too. His claim that `$is_tournament`/`$team` are scoped inside the >48h branch also checks out — fresh locals are genuinely required.

**Applied his patch plus one addition.** The redirect lookup had no status filter, so it returned every registration the user held for that season/tournament and `reset()` took the lowest id. Confirmed live on LOCAL: a cancelled tournament registration (4025) was picked over the live one (5083), producing "Reminder sent." followed by "This registration has been cancelled" on redirect. Now skips cancelled and takes the most recent. **5th confirmed instance of this missing-status-filter shape** (after `userSearch()`, `isFirstTimeRegistration()`, `invite()`'s eligibility check, `available()`'s already-registered lookup).

Excluding `cancelled` was chosen over allowlisting live statuses — the full status set isn't confidently known, and an allowlist that misses one silently breaks the redirect.

**Tested:** LOCAL — tournament nudge sends and lands on the right Manage Team page; 48h throttle path returns cleanly (both previously fatal). TEST — season nudge regression check passes, redirects to Manage Group as before. **Deployed to TEST. NOT YET ON PROD.**

**Important for anyone re-sending:** the crash happened *after* the email was sent and `notified` was stamped. Reminders that showed the error page **did deliver** — do not manually re-send. Andrew's framing ("captains are nudging and it isn't working") is half right: the mechanism worked, the feedback lied. Only the throttle path sent nothing.

### Core 11.4.4 + security updates — deployed to TEST, soaking
`composer audit` surfaced **14 advisories across 4 packages**; `composer outdated --direct` structurally could not show most of them, since `dompdf`, `guzzle`, and `webauthn-lib` are transitive. Worth remembering: **audit, don't just check outdated.**

Also learned: **11.3.14 would not have been enough.** `core-recommended` 11.3.13 pins `guzzlehttp/guzzle ~7.12.1`, making 7.15.x unreachable on 11.3.x — the minor bump is the delivery mechanism for the guzzle fixes.

Shipped in one commit (`d75ce2e`): core 11.3.13 → 11.4.4 (SA-CORE-2026-010/011/012), guzzle 7.12.3 → 7.15.2 (4), dompdf 3.1.4 → 3.1.6 (6), commerce 3.3.6 → 3.3.8, better_exposed_filters 7.1.2 → 7.1.3. **13 of 14 advisories closed.**

Also in the commit: the `default.settings.php` scaffold drift outstanding since the June core bump; a new `web/.gitignore` from 11.4 scaffold (ignores `/autoload_runtime.php`); and `allow-plugins: symfony/runtime` in `composer.json` — **required**, or `composer install` prompts and fails non-interactively on the servers.

**Config export was safe here specifically because `config:status` was clean immediately before the update** — every difference was traceable to a named update hook, none was ambient drift. 11.4 splits search: `search_update_11400` installs Search Node, `11401` installs Help Search (hence `core.extension`); `field_post_update_clear_purge_batch_size` removes `field.settings`.

**Gotcha, will recur on PROD:** TEST carried its own uncommitted copy of the June `default.settings.php` drift, which blocked `git pull`. Diff was pure scaffold churn (a commented-out oEmbed doc block), discarded with `git checkout`. That file is a template Drupal never reads at runtime — real config lives in `settings.php`/`settings.local.php`, neither in git — so discarding is safe. **PROD took the same June bump and will hit the identical block.**

Before `cim`, compared enabled-module counts: TEST 85, LOCAL 87 — exactly the two new search modules, so `cim` could only add, never uninstall. Worth repeating before any `cim` that carries `core.extension`.

TEST deploy clean: all 14 update hooks ran in the same order as LOCAL, `cim` imported without complaint.

**Deliberately deferred:**
- `drupal/symfony_mailer` 1.6.2 → **2.0.2 (major)** — touches every transactional email, and the pipeline has real customization layered on it (inline-CSS + theme-wrapper policy chain, per-environment from-address overrides, the "never enable the User override" gotcha). Needs release notes + upgrade guide read first. **Own session.** Note: `symfony/mailer` v7.4.14 in this update is the Symfony *component* riding along with core — different thing.
- `web-auth/webauthn-lib` → 5.3.5 — the 14th advisory, severity **low**, and it concerns `SimpleFakeCredentialGenerator` producing predictable decoy credentials (username-enumeration hardening), not the live passkey auth path. Tied to checklist item 15, which needs a full auth flow test not currently runnable.

### New findings, logged not fixed
- **`CartEventSubscriber` debug logging is live on PROD** — `onCartEntityAdd fired`, `Product type / ID / Quantity`, `First add (quantity = 1), allowing`, three notice rows per add-to-cart, plus a severity-7 line logging each user's notification preference by username. Same class as the `NotificationService::send()` DEBUG noise removed July 18; that pass just didn't reach this file.
- **Andrew's `CcsoccerCommands.php` change** on `fix/tournament_nudge_500` — 17 lines, mostly deletions, unmentioned in his doc, which is otherwise explicit about what to keep out of the PR. Not merged (we applied the fix directly instead). **Ask him.** That file holds `send-pending-email-invites`, already run live on PROD.
- **Season confirmation SMS reads "CC Soccer"** — checklist item 33 scopes that string fix to tournament deposit + jersey only; it's in season confirmations too.
- **`drush config:status` misreports** — listed two `core.entity_view_mode.node.search_*` entries as "Only in sync dir" when they existed in both active config and `config/sync`. Second unreliability from this command (after the Symfony Console "bold" crash on TEST). Trust `git status` after `cex` over `config:status` before it.

### Next session
1. **Deploy the nudge fix to PROD** — `ccsProdDeploy && ccsProdCr`, code only, no `cim`/`updb`. Tested on both LOCAL and TEST; the only thing holding it is sequencing.
2. **Let 11.4.4 soak on TEST** with Andrew testing — at least a day. Focus areas matched to what moved: schedule PDF export (dompdf), checkout end-to-end (Commerce + guzzle, which Authorize.net's client uses), email **rendering** not just delivery (`sabberworm/php-css-parser` 9.2 → 9.4 inlines the CSS, twig 3.27 → 3.28), and views with exposed filters (BEF post-update added a param key). Plus the tournament nudge, new on TEST.
3. **PROD 11.4.4** after the soak — full chain, expect the `default.settings.php` block, verify Authorize.net credentials after `cim`.
4. **Recursion-guard warning fix** (parked above).

---

## Session Work — July 20, 2026 (late night) — TEST verification of the invitation-timing fix (Caleb + Claude)

Both July 20 fixes (invitation timing + jersey cleanup) deployed to TEST earlier tonight. This session worked the `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` checklist live against TEST with real accounts (Myk Stoner as manager, Caleb's own account + a `Caleb@ccsoccer.com` test account as invitees), verifying against the DB directly at each step rather than trusting UI state alone.

### Results — 7 of 9 sections passed, 1 failed (found a real crash bug), 1 not started
- **A0 (zero-invitation happy path):** passed — Myk registered clean, no group fields set.
- **B (Bug 1 — abandon after selecting an invitation):** passed. Invitation confirmed `pending` all the way through Player Info → Group (selected) → Agreements, before payment. This is the core assertion of the whole fix.
- **A (full accept-on-completion flow):** passed. Same invitation flipped to `accepted` only once checkout actually completed; registration correctly linked to the manager's `group_id` on both sides (verified inviter's own registration row matches).
- **F (Bug 2 — `removeMember()` invitation cleanup):** passed. Removing the member flipped the invitation to `declined` (not left stuck `accepted`), registration group fields cleared, and — the real proof — re-inviting the same person immediately succeeded with no "already accepted" block.
- **C (token pre-selection):** passed for the core mechanic — clicking a fresh magic-link token correctly pre-selected "Join X's group" as the default radio choice, and it stayed `pending` through checkout, flipping to `accepted` only at completion, same as the non-token path. **Still not done:** the identity-check half (E3) — needs a fresh token plus a genuinely clean account (Caleb's and the test accounts all have registration history now, which routes them into the *other* broken path documented below rather than the one E3 tests).
- **D (back-button persistence):** passed, and more thoroughly than the checklist asked for. Explicit selection ("Join Caleb's group", deliberately not the token default) survived Continue → Back, survived leaving checkout entirely via Home → Register and returning, and survived clicking the *other* invitation's banner link (correctly blocked by the existing "already in cart" duplicate guard rather than silently swapping the selection). On completion, the selected invitation → `accepted`, the other → `declined`, `sendPlayerJoined` text notification fired correctly. Both invitations stayed live and selectable throughout the back-and-forth — which is the point: under the old code the first Continue would have already accepted one and declined the other.
- **G (Bug 3 — stale orphan doesn't block re-invite):** passed. Fabricated the orphan state (Myk invited `orphan-g-test@example.invalid`, then flipped it to `accepted` via SQL with no registration behind it), then re-invited the same address — went through cleanly, new pending invitation created, no block. `invitationHasMatchingRegistration()` working as designed. **Note:** required a `drush cr` first — the raw SQL write wasn't visible to `invite()`'s entity lookup until cache was rebuilt, which produced a confusing "already sent an invitation" (the *pending* message, not the accepted one) on the first attempt. Third time tonight this exact staleness pattern bit us.
- **E (post-payment group capacity guard): FAILED — crashed checkout.** See the dedicated section below. The guard's *logic* worked correctly (right group, right user, correct "player NOT added" decision, correctly flagged the order), but completing checkout under that condition produced a PHP fatal (`Maximum call stack size ... reached. Infinite recursion?`) and a white error page at `/checkout/37/payment`.
- **H (tournament flow untouched, sanity check):** not started.

### 🔴 E's failure: infinite recursion in `onOrderPlace()` — root cause found and fixed same session

**Symptom.** Kelly Smith's checkout, with her group deliberately capped at 2 (already full), crashed at the payment step. Watchdog showed `"Order 37 completed, creating registrations"` and `"Group af71cb53-... was full at checkout completion"` alternating repeatedly, all within the same second, ending in a stack-exhaustion fatal in `Map->get()`.

**Root cause.** `commerce_order.place.post_transition` fires *after* the order has already been written, which is why every "flag the order and bail" branch calls `$order->save()` itself — a bare `setData()` at that point wouldn't persist. But saving the same order object again from *inside* the handler re-presents the same draft→completed state delta to state_machine, which re-dispatches the place transition and re-enters `onOrderPlace()` synchronously, on the same call stack. It hits the same branch, saves again, recurses — unbounded.

**The detail that explains why this never surfaced before.** The save at the *end* of `onOrderPlace()` has the identical risk and has never crashed, purely because of statement ordering: it sets `ccsoccer_completion_processed` on the object *before* saving, so the re-entry hits the persistent idempotency guard at the top of the method and returns immediately — depth 2, terminated. The mid-method saves have no such protection, because that flag isn't set yet, so re-entry sails straight past the guard. (Confirmed by the logs: repeated full `"Order 37 completed"` messages rather than `"already processed; skipping duplicate"` proves re-entry from the middle.)

**Scope is wider than the new code.** Tonight's `ccsoccer_group_capacity_exceeded` branch is what actually crashed, and it's ours — but it copied an existing pattern. **Five pre-existing branches share it**: `season_inactive`, season `capacity_exceeded`, `team_name_collision`, `tournament_full`, `team_capacity_exceeded` (plus `ccsoccer_credit_shortfall` in `processCredits()`). Tonight's manual test appears to be the first time *any* of these branches has actually executed on a completed order, on any environment. Season-capacity in particular is not an exotic race — a popular season filling while several people are mid-checkout is ordinary — so this was a latent landmine, not purely a new bug.

**Fix implemented (same session, `OrderCompleteSubscriber.php`, +64/-0):** a request-scoped `protected static $processingOrders` array. `onOrderPlace()` now checks it on entry (logs a warning and returns if the order is already being processed), registers the order ID, delegates the entire original body to a new `doOrderPlace()` method, and releases in a `finally` block so a thrown exception can't leave an order stuck. Chosen over removing the mid-method saves because those saves exist for a real reason — durability of the flag at the moment of detection, in branches that `return` immediately after — and removing them would trade away that guarantee. This approach keeps the guarantee, fixes the crash regardless of which save triggers the re-dispatch, and protects any save added in future. Purely additive, no business logic touched, `php -l` clean.

**Verification still needed on TEST:** re-run Kelly's checkout with the group still capped at 2. Expect: checkout completes normally, registration created with `group_id` NULL, invitation still `pending`, order flagged `ccsoccer_group_capacity_exceeded`, **and one new warning reading "nested place transition detected ... skipping re-entrant call."** That warning is the actual proof the guard fired — if the flag is set correctly but no warning appears, the diagnosis was wrong and this needs Xdebug rather than another theory.

**Not fully understood, and worth naming:** *why* saving the order re-presents the state delta to state_machine rather than being seen as a no-op. The guard makes that question moot in practice, but it hasn't been traced to Commerce/state_machine internals.

**TEST data left behind:** order 37 exists in a crashed/incomplete state and Kelly (uid 92740) has no registration for season 43. Season 43's `max_group_size` is still **temporarily set to 2** for this test — **restore it to its original value (4) once E is re-verified.**

- **`GroupController::acceptSeasonInvitation()` has no capacity check** — confirmed live tonight. With season 43's group max temporarily at 2 and the group already at 2/2, Kelly clicked Accept from her Manage Group page and was added anyway, putting the group at 3/2. Same class of gap as the checkout-side one we just guarded: capacity is enforced at order completion but not in the accept-from-My-Registrations paths (`acceptSeasonInvitation()`, `acceptTeamInvitation()`, and `acceptSeasonInvitationDirectly()`/`acceptTeamInvitationDirectly()`). Note the UI already contradicts itself here — the manager's view correctly says "Your group is full. No more invitations can be sent" while the invitee's view still offers an Accept button into that same full group.
- **Missing user-facing messaging around group-full (design decision made, not yet built).** Two places need it:
  1. **In checkout (`GroupPane::buildPaneForm()`):** if a pending invitation's group is already full, still show it but disabled — e.g. "Join Caleb Cross's group (full)". Decided against hiding it silently (invitee got an invite email; a missing option with no explanation is more confusing than a disabled one) and against auto-declining (S2 in Andrew's audit was exactly that bug — over-inviting and seeing who registers first is a legitimate workflow, so invitations must stay pending).
  2. **At checkout completion:** when the order-completion capacity guard fires, the player currently gets a totally normal confirmation and has no idea they weren't placed in the group. Needs a message on the completion page along the lines of "the group you selected filled up — your registration is confirmed, but you're not in a group yet; you can still form your own group." This is the more important of the two — the pane change makes the race rarer, but the guard firing silently is the part that actually misleads someone.
  Note the pane fix does NOT replace the order-completion guard: a group can fill between pane selection and payment landing. That race is exactly what the guard exists for and it stays.

### Real findings surfaced during testing, none of them regressions from tonight's fix — all pre-existing
- **E4 confirmed as a real, live issue** (previously only theoretical in the analysis doc): a cancelled registration keeps its old `group_id`/`invited_by` set, which then blocks a legitimate re-invite via `invite()`'s "already in another group" check. Hit directly during setup for test B — had to manually null out `group_id`/`invited_by` on a cancelled registration via SQL before Myk could invite Caleb. Worth a real fix at some point (not urgent, not part of tonight's scope).
- **`userSearch()` (invite autocomplete) has no status filter**, unlike `myRegistrations()`/`isFirstTimeRegistration()` which correctly exclude cancelled. A cancelled-only registrant still shows a "Registered" badge in the invite-player autocomplete dropdown. Confirmed cosmetic only — doesn't block sending the invite — but worth cleaning up so the badge isn't actively misleading.
- **Override mechanisms: two of them, inconsistently wired — CORRECTED diagnosis.** Last night's note here blamed the `permanent_override` *role*; that was wrong, and the wrong thing was tried (`drush user:role:add`). The actual mechanism is a **`field_permanent_override` checkbox on the user profile**, not a role. But checking it still didn't get Kelly Smith through Mens 35+ registration, and the real reason is worse: **there are two separate eligibility functions in `RegistrationController` and only one of them honors the checkbox.** `userMeetsSeasonRequirements()` (controls whether a season is *listed* on `/register`) checks `field_permanent_override` and returns TRUE immediately. `checkUserAge()` (the actual *enforcement* gate, called from `addSeasonToCart()`) never looks at that field at all — it only recognizes a formal per-season override entity via `ccsoccer.override_manager`. Net effect: the checkbox makes a season *appear* registerable without actually being registerable. Confirmed live tonight; worked around by editing the test user's DOB directly. **Fix needs a product decision, not just refactoring:** extract one shared eligibility method both call, and decide whether to keep both override mechanisms (blanket profile checkbox + formal per-season entity) or consolidate to one. Same "duplicate logic that drifted apart" pattern as the jersey panes and the two order-completion pipelines found earlier today.
- **`acceptSeasonInvitationDirectly()` / `acceptTeamInvitationDirectly()` never clear the session invite token**, unlike `GroupPane::submitPaneForm()` which does. `RegistrationController::available()` writes the token to session unconditionally at the top, before any branching — so when an already-registered user clicks a link, the token is stored, the direct-accept path runs, and the token is left behind in the session indefinitely. Surfaced tonight as a confusing but harmless symptom: a stale token from last night's Kyle incident was still in Caleb's session, and because masquerading reuses the same PHP session, it pre-selected an invitation during a later test that looked intentional but wasn't. One-line fix, low severity, worth doing alongside the identity-gap fix below since it's the same file and same methods.
- **`/register?invite=TOKEN` did not auto-add the season to cart** as the flow is documented to work (`INVITATION_FLOW_ANALYSIS.md`'s path-A description). Landed on the register page with the pending-invitation banner visible, but had to manually click Register for the season rather than being carried straight into checkout. The token still worked correctly once inside checkout (session-based lookup, not cart-dependent), so this didn't block testing — but the documented behavior and actual behavior don't match. Not something tonight's changes touched (`RegistrationController.php` wasn't in this fix's file list) — pre-existing, worth understanding separately.
- **`/opt/cpanel/composer/bin/composer` missing/moved on the server** — `ccsDeploy`'s composer step failed with "Could not open input file" partway through tonight's second deploy, breaking the `&&`-chained command (subsequent `ccsUpdb`/`ccsCim`/`ccsCr` had to be run manually afterward). Not blocking tonight since neither deploy touched `composer.json`, but will block the next PR that does. Needs a look: `ls -la /opt/cpanel/composer/bin/composer` / `which composer`.
- **`drush config:status` crashed** (`Invalid option specified: "bold"`) on first attempt on TEST tonight — unrelated Symfony Console formatting bug, not a real config problem (confirmed via direct SQL and `--format=csv` instead). Cosmetic/environment issue, not investigated further.

- **NEW BUG FOUND (not from tonight's fix — pre-existing, in code we never touched): `RegistrationController::acceptSeasonInvitationDirectly()` (and its team-invite twin `acceptTeamInvitationDirectly()`) has the same E3 identity gap we closed tonight in `GroupPane`, just in the already-registered token path.** Confirmed live, not theoretical: Caleb (already registered for season 43, including one cancelled + one paid registration) clicked a token link addressed to Kyle Genevay's email. Two compounding root causes, both confirmed by reading the actual code:
  1. `available()`'s "is this user already registered" lookup (`loadByProperties(['player'=>uid,'season'=>id])`) has no status filter — same pattern as `userSearch()`/`isFirstTimeRegistration()`/`invite()`'s eligibility check, now the 4th confirmed instance of this exact bug shape tonight. It grabbed Caleb's *cancelled* registration (5073) instead of his real paid one (5101).
  2. `acceptSeasonInvitationDirectly()` never compares the invitation's `invitee_email` to the logged-in user at all — so it proceeded to accept Kyle's invitation using Caleb's identity, onto Caleb's cancelled registration. Real effects: invitation flipped to `accepted` (Kyle's real path now broken until reset), "joined group" notification fired to the inviter, green success message shown, then a red "registration has been cancelled" error on redirect (from `GroupController::manage()`'s existing cancelled-status guard) — both messages in the screenshot fully explained, nothing mysterious.
  3. **Cleaned up on TEST:** invitation 26 reset to `pending`, registration 5073's group fields cleared back to NULL/none. Confirmed via `SELECT` after.
  4. **Not fixed yet, not blocking tonight's PROD decision** — real scope, but narrow real-world trigger (requires clicking a token addressed to someone else while already holding *any* registration, even cancelled, for that season). Worth its own scoped fix: add the same email-match guard here that already exists in `acceptSeasonInvitation()`/`acceptTeamInvitation()` (the "Accept" button path, which already does this correctly), and add a status filter to the `available()` lookup.

### Status
- **✅ E re-verified and PASSING on TEST after the recursion fix.** Kelly's checkout completed cleanly: registration 5104 created `paid` with `group_id` NULL and `invitation_status` none, invitation left `pending`, order 38 flagged `ccsoccer_group_capacity_exceeded`, and — the confirming detail — exactly one watchdog warning (wid 189209) reading "nested place transition detected while already processing; skipping...". One entry, not many: recursion terminated at depth 2 as designed. The diagnosis was correct, not a coincidental non-reproduction.
- **8 of 9 checklist sections now pass.** Only H (tournament sanity) and C's identity half remain, both non-blocking.
- **The PROD blocker is cleared.** Code deployed — confirm PROD deploy status at the start of the next session.
- TEST cleanup done: season 43's `max_group_size` restored to 4.
- TEST data left behind (harmless): order 37 in a crashed/incomplete state from the pre-fix attempt; Kelly is in Caleb's group at 3/2 from the Accept-button test above.

---

## Session Work — July 20, 2026 (later) — Has Jersey zombie-field and legacy duplicate-pipeline cleanup (Caleb + Claude)

Andrew asked whether jersey-requirement logic uses a previous-season heuristic and whether the profile Has Jersey checkbox still means anything. Investigation confirmed both field_has_jersey and field_jersey_size on the user entity are dead, written in two places, read nowhere, and both writers were themselves broken. While tracing the writers, found the second writer was inside a legacy hook that a deeper check showed was entirely dead, not just its jersey lines. Scope grew from remove two field writes to remove the whole legacy pipeline plus finish an old incomplete field deletion, all narrowly justified by evidence.

### Jersey requirement itself, confirmed correct, untouched
PlayerInfoPane (the pane actually wired into checkout) determines jersey eligibility from registration history, isFirstTimeRegistration, zero prior ccsoccer_registration rows, not from any checkbox. Working as intended, no changes made here. One pre-existing nuance decided: a player whose only registration was cancelled still counts as returning and wont be prompted for a jersey at checkout. Decision: leave as-is, handle via the standalone jersey product page or manual admin action if it comes up.

### ccsoccer_commerce_order_paid_in_full, confirmed entirely dead, deleted in full
This function plus its _ccsoccer_process_tournament_registration helper implements a hook that does not exist in Commerce 2.x, this sites version. Commerce fires via the commerce_order.place.post_transition event instead, which OrderCompleteSubscriber already listens to. Confirmed dead three independent ways before deleting: (1) zero rows in PROD watchdog for its unconditional log message, (2) grep of vendor/drupal/commerce star src for the hook name returns nothing, (3) grep of the custom module for paid_in_full only finds the function's own definition, no invokeAll anywhere. Deleted entirely from ccsoccer.module, not just its jersey lines. This also removes a full second, less-safe copy of season and tournament registration creation that was never actually running.

### field_has_jersey, field_jersey_size, field_notification_prefs: finishing an incomplete May 6 fix
All three were already deleted once in ccsoccer_update_9061, same batch as field_credits_balance, but only field_credits_balance got the belt-and-suspenders follow-up in ccsoccer_update_9063 on May 6 after its config/sync YAMLs were found still resurrecting it via drush cim. These three had the identical leftover-YAML problem, unfixed until now.

Registration.jersey_size, a separate field on the registration entity, was also always saving NULL since PlayerInfoPane stores the cart key as jersey_variation while both writers read jersey_size. Decision: delete the write, not fix the key, since the jersey report already reads accurate sizes from order items via SQL.

JerseySelectionPane.php confirmed genuinely dead, a separate abandoned earlier attempt from PlayerInfoPane: its plugin ID is not in the checkout flow config's panes list at all, so it can never render. Deleted.

### What changed
1. ccsoccer.module: deleted ccsoccer_commerce_order_paid_in_full and _ccsoccer_process_tournament_registration in full.
2. ccsoccer.module: removed field_has_jersey from ccsoccer_form_user_form_alter's admin-only-fields list.
3. OrderCompleteSubscriber.php onOrderPlace: removed the jersey-writing block, kept self-score, renamed the shared jersey_handled guard to profile_update_handled.
4. OrderCompleteSubscriber.php createSeasonRegistration: dropped the always-NULL jersey_size line from the registration create array.
5. JerseySelectionPane.php moved to archive (archive-via-move, no delete tool available).
6. Moved 6 leftover YAMLs to archive (storage plus instance times three fields).
7. Stripped all three fields from the three shared user display YAMLs (dependencies and content/hidden sections).
8. Added ccsoccer_update_9070 to ccsoccer.install, same belt-and-suspenders pattern as 9063, covering all three fields. LOCAL had none of the three in active config or as DB tables, confirmed via direct SQL, not just drush status commands which gave misleading readings at one point. TEST and PROD are the real target, user 94291's manually-checked value lives there.

### Verification on LOCAL
php -l clean on all three touched PHP files. drush updb ran 9070 cleanly, no-op on LOCAL as expected. drush cim picked up the three display-YAML edits. drush config:status shows no differences between DB and sync directory. Direct config dump confirms none of the three fields appear anywhere in active config.

### Status
- Code and config: complete on LOCAL, verified clean, uncommitted.
- Not yet done: functional smoke test, profile edit form load and one season checkout, planned before commit.
- Deploy note, different from the invitation-timing fix: this one needs both cim and updb, ccsDeploy and ccsUpdb and ccsCim and ccsCr, since this genuinely touches config and adds a real update hook. On TEST and PROD watch the updb output for 9070, expect it to actually list all three field names as removed, not not found like LOCAL.
- Commit as its own commit, separate from the invitation-timing fix.

---

## Session Work — July 20, 2026 — Season invitation timing fix, code complete on LOCAL (Caleb + Claude)

Built items 1–4 and 6 of the `INVITATION_FLOW_ANALYSIS.md` Section 5 checklist — the actual fix for Brent/Myk, which Andrew's branch (above) did not touch. Item 5 (bulk data-repair drush command) deliberately deferred to its own phase — not part of this pass.

### What changed (3 files, all in `web/modules/custom/ccsoccer/src/`)

1. **Bug 1 — accept moved to order completion** (`Plugin/Commerce/CheckoutPane/GroupPane.php` full rewrite, `EventSubscriber/OrderCompleteSubscriber.php::createSeasonRegistration()`): `GroupPane` no longer calls `$invitation->accept()`/`decline()` at pane-submit — it only stores the selection on the order. Accept/decline now happens in `createSeasonRegistration()`, gated on `$invitation->isPending()`, mirroring the tournament `token_accept` pattern exactly. An abandoned checkout after selecting a group now leaves the invitation untouched (`pending`) instead of falsely `accepted`.
2. **GroupPane collapsed to one code path (item 6):** dropped the separate token-branch UI (accept/decline confirm + fallback radios) entirely — a token invitation now just sets the default radio selection in the same unified pending-invitations list every registrant sees. Removed `buildFallbackOptions()`, `declineOtherInvitations()`, `getGroupMembers()` (all dead code once the split UI was gone).
3. **E3 identity check, closed as a side effect of #2:** the token only defaults a selection if that invitation survives `getPendingInvitations($user_email, ...)` — i.e. only if the logged-in user's email matches the invite. A forwarded magic link to a different account no longer does anything.
4. **E2/E6 post-payment capacity guard**, new in `createSeasonRegistration()`: confirmed-members-only count (not pending-inclusive) against `Season::getMaxGroupSize()` right before accepting. If already full, invitation stays pending, registration is still created (payment captured) but without a group, order flagged `ccsoccer_group_capacity_exceeded` for admin follow-up.
5. **Bug 2 — `GroupController::removeMember()`:** now declines the removed member's `accepted` invitation (copies `leaveGroup()`'s pattern, keyed to the removed player rather than the current user). Fixed for **both** season and tournament branches — tournament had the identical gap (drift audit D8).
6. **Bug 3 (narrow) — `GroupController::invite()`:** new `invitationHasMatchingRegistration()` helper; the duplicate-invite check no longer blocks a re-invite when the existing `accepted` invitation has no matching registration (a stale orphan). The orphaned row itself is left alone — inert until a Phase 2 bulk sweep (item 5).

All three files archived before editing: `archive/GroupPane_2026-07-18.php`, `archive/OrderCompleteSubscriber_2026-07-18.php`, `archive/GroupController_2026-07-18.php`. `php -l` clean on all three.

### Status
- **Code:** complete on LOCAL, uncommitted.
- **Testing:** not yet run. Full checklist (regression, Bug 1 abandon-checkout proof, token+identity, back-button, capacity guard, removeMember both types, Bug 3 orphan contrast case, tournament-untouched sanity, **plus the zero-invitation happy path — highest blast radius if broken, since `createSeasonRegistration()` runs on every season order**) is in `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` (delivered as an artifact this session — not yet saved into the repo).
- **Next:** commit + push to `main`, deploy to TEST, Caleb + Andrew test there together per the checklist. Risk assessed as moderate-to-high (money path, every season order flows through the touched code) — sections 1–3 of the checklist (zero-invitation path, Bug 1 proof, core accept flow) are the ones that must pass before PROD; the capacity guard (section E) is a safety net for a rare race and shouldn't gate the deploy.

### PROD data — two more Bug 1 victims found and fixed while the bug is still live (fix not deployed yet)

Same mechanism as Brent/jenaepackard/cysaallstar from the original investigation, confirming this is ongoing, not historical:

- **Michael Ponomaroff (uid 92087) / Tyler Cota (uid 94607), season 47 (Mens 35):** Tyler's invitation showed `accepted` but his registration (id 5316) had landed in a different, self-generated `group_id` with `invited_by` NULL — identical pattern to Brent. Checked Tyler's phantom group for other invitees first (none). Fixed: `UPDATE ccsoccer_registration SET group_id = '20118175-c8c2-4d75-9210-36e27c211df0', invited_by = 92087, invitation_status = 'accepted' WHERE id = 5316;` + cache rebuild.
- **Broader scan run against PROD** (season-only, excludes team invitations — join on `r.registration_type = 'season'` + `i.team IS NULL`) turned up two more with no registration at all yet:
  - Invitation 131 (Tenayaney22@gmail.com, season 48) — reset to `pending`.
  - Invitation 140 (garret.mcelveny@gmail.com, season 47) — reset to `pending`.
  - Both via `UPDATE ccsoccer_invitation SET status = 'pending', responded_at = NULL WHERE id IN (131, 140);` + cache rebuild.
- **Invitation 5 (Haley Raymer, season 43)** also matched the scan — this is the same season-43 ghost row from the original investigation. Reconfirmed: leave as-is, closed season, no practical effect.
- This scan is the read-half of item 5's eventual repair command; the write-half (link vs. reset-to-pending decision logic) is still Phase 2 work, not built as a reusable command yet.

---

## Session Work — July 18, 2026 (later) — Review of `fix/invitation_flow_analysis` (Caleb + Claude) — ✅ MERGED, validated on TEST

Reviewed Andrew's branch against the original ask (Brent/Myk group-invitation bugs). Findings:

- **Bug 1 (Brent — early accept in `GroupPane::submitPaneForm`) and Bug 2 (Myk — `removeMember()` doesn't decline the invitation) are both still OPEN.** Neither file is touched anywhere in this branch. Andrew's work is real and adjacent (D1 refund bug, Bug 4 email delivery, S2 tournament auto-decline, T6/R1 admin-tooling fixes below) but does not fix, and does not put at risk, the invitation-timing bug itself. That's still a separate PR — scope per `INVITATION_FLOW_ANALYSIS.md` Section 5 (move accept to order completion, rebuild pane from order-data selections, fix `removeMember`, data-repair migration for already-orphaned `accepted` rows).
- Reviewed the shipped fixes (S2, D1, Bug 4, T6, R1) line-by-line for regressions — none found. Each is narrowly scoped and matches its doc entry.
- Removed leftover `DEBUG:` notice-level logging in `NotificationService::send()` (not Andrew's — pre-existing noise burying the real signal in dblog, called out as H1 in `ORDER_FLOW_EDGE_CASES.md` but never fixed). Lint-checked clean.

**Merged.** `f41a3d4` on `main` (`git merge --no-ff fix/invitation_flow_analysis`, plus the debug-log-removal commit on top). Deployed to TEST (`ccsDeploy && ccsCr`, no cim/updb needed as expected) — Andrew confirmed validated and good on TEST. PROD deploy status: not confirmed in this thread — verify before assuming it's live there.

**Carried forward, status updated end of day July 20:**
- Andrew to manually refund Avi's $133 (order 87, Payments tab) and email him — **~90% confident this is done**, Caleb believes he saw Andrew do it during a shared screen session, but not confirmed via the Payments tab record itself. Worth a 30-second verify next time someone's in that order.
- The July 17+18 LOCAL test checklist below — not yet confirmed run.
- `drush ccsoccer:send-pending-email-invites` on PROD (Bug 4 data repair) — **✅ DONE**, run live on PROD.

---

## Session Work — July 18, 2026 — Admin tooling follow-ups (Claude), UNCOMMITTED

Worked through `ADMIN_TOOLING_REVIEW.md` findings with Andrew. Three resolved **as designed** (intent now documented in code so future reviews don't re-flag), two **fixed**, two **on hold**. All uncommitted, on top of the July 17 changes. Deploy: code only, `drush cr`.

### As-designed — documented in code + UI
- **TS1 (tournament format):** builder is purpose-built for the single-day SLO Friendly round-robin — no brackets/pools/scores/multi-day. Scope docblocks on `TournamentScheduleGeneratorService` + `TournamentScheduleBuilderForm`; info notice now renders atop the schedule builder; Tournament `format` field options labeled "(not implemented)" for bracket/pool_play (options kept so existing data stays valid).
- **Decision #2 (destructive regenerate):** by design for the pre-season board-meeting workflow (generate candidates → snapshot → restore winner → publish). By-design comments at `clearSchedule()` in both generators. **Hardened both `restoreSnapshot()`s:** restoring into an empty grid (after Clear All Games) now errors with "generate first, then restore" instead of a false success; partial restores WARN with the count of snapshot entries that had no matching slot. Operating rules recorded in the review doc: finalize teams/slots/fields/dates before generating candidates; no Clear All Games mid-meeting; never regenerate after week 1.
- **S3 (jersey colors) — original finding was WRONG-way-round; league is Red vs White, no home/away mentality.** Grid (red top row / white bottom), next-game banner, and iCal feed were all consistent: home_team slot = Red. Only the Game entity docblocks were backwards. Fixed the docs (home_team = the Red team, away_team = the White team, with a do-not-"fix" warning comment) and renamed the one player-facing home/away verbiage — `exportPdf` column headers → "Red Team"/"White Team". Admin field labels left as Home/Away Team (renaming ripples through admin forms; descriptions explain the mapping).
- **S7 (red/white coin flip per game):** as designed — flip only decides jersey color for the night; no balance tracking wanted. Comment at the flip in `ScheduleGeneratorService`.

### Fixed
- **T6:** unguarded `new \DateTime($dob)` in `TournamentRosterBuilderForm` — one malformed DOB (D7-migration risk) 500'd the whole roster builder. Now try/catch → default age 30 + watchdog warning naming the player. Sweep confirmed every other DOB parse in the module was already guarded; this was the one drifted site.
- **R1:** admin Teams list showed "0 players" on every season team (counted `Team.players`, which the season roster builder never writes — season truth is `Registration.team`). Season mode now counts registrations (`status IN paid/active` — cancelled players drop out automatically); tournament mode keeps `Team.players`. Deliberately no dual-write (that pattern caused the tournament ghost-member desyncs).

### On hold — Andrew deciding, NO changes made
- **S4/TS3 (drag-swap validation):** blocking is NOT wanted — mid-rearrangement legitimately passes through broken states, and the workbench was built as the staging area to avoid the hard block direct moves have. Proposed design recorded in the review doc (`findScheduleConflicts()` + non-blocking warning banner + single confirm at publish); awaiting Andrew's decision. Note current inconsistency: season moves hard-block, swaps check nothing, tournament checks nothing.

### Files touched July 18 (all in `web/modules/custom/ccsoccer/`)
`src/Service/ScheduleGeneratorService.php`, `src/Service/TournamentScheduleGeneratorService.php`, `src/Form/TournamentScheduleBuilderForm.php`, `src/Form/TournamentRosterBuilderForm.php`, `src/Entity/Tournament.php`, `src/Entity/Game.php`, `src/Controller/ContentController.php`, `src/TeamListBuilder.php`, plus `ADMIN_TOOLING_REVIEW.md`.

### Still open from the small-fixes list
G4/G5 (cancelled games render as normal in public per-season iCal + printable PDF — per-user feed is correct), G3 (3 pm cancellation-reminder cron only fires if a tick lands in 15:00–15:04, plus unlocked read-modify-write dedupe race).

### LOCAL test checklist for July 17+18 changes (before PROD)
1. Refund path: cancel a test season registration with Commerce refund on TEST gateway → succeeds, payment flips to Refunded. **(Also: Andrew to manually refund Avi's $133 — order 87 Payments tab → Refund — and email him.)**
2. Email-only invite → Mailpit email w/ working link (board address); non-allowlisted address → warning message, Send Reminder available.
3. Tournament reg with pending team invite via "no team yet"/pool → invitation stays pending.
4. Snapshot: save → Clear All Games → restore = instructive error; regenerate → restore = schedule back.
5. Roster builder: bad DOB row renders (age 30 + watchdog warning); `/admin/ccsoccer/teams` season mode shows real counts.
6. Schedule builder shows the single-day scope notice.

---

## Session Work — July 17, 2026 — Audits (Claude) + 3 fixes, UNCOMMITTED on main working tree

### Four review documents produced (repo root) — reference these when reviewing PRs

1. **`INVITATION_FLOW_ANALYSIS.md`** — all season/tournament invitation+acceptance paths documented. Confirms Caleb's hypothesis on the Brent/Myk data issues, with one refinement: the early-accept happens in `GroupPane::submitPaneForm` (checkout step 2), not the token link itself. 4 bugs + 7 edge cases + **Section 5 = review checklist for Caleb's upcoming invitation PR** (incl. required data repair for orphaned `accepted` invitations). Tournament pane already does it right (defers accept to order completion) — the fix pattern is in our own codebase.
2. **`ORDER_FLOW_EDGE_CASES.md`** — order pipeline silent failures. Top items: transactional emails (receipts, invitations) suppressed by notification preference `none`/`text`-without-phone; flagged "paid but broken" orders still have no admin surface (checklist item 18 — now live-critical); waitlist offers never expire (entry stuck `offered`, next-in-line never offered, reserved spot held forever); DEBUG noise in `send()` burying real errors in dblog.
3. **`SEASON_TOURNAMENT_DRIFT_AUDIT.md`** — systematic twin-pair comparison (season↔tournament). 20 divergences, 3 critical: D1 refund 100x (fixed today, see below), D2 admin-accept of tournament invitation crashes on a nonexistent `tournament` field query (`GroupInvitationsForm.php:1279`), D3 tournament order completion never re-checks active/visible (season's "Finding F" guard was never ported). D4: co-captains see Remove buttons but every click fails.
4. **`ADMIN_TOOLING_REVIEW.md`** — roster builders + schedule generators + game-day ops (~45 findings) ahead of first board use this fall. **Three board decisions needed:** (a) tournament schedule builder has NO pool/bracket support, no score fields, single-day only — decide format plan for fall tournaments NOW; (b) regenerate on both schedule builders hard-deletes all game history — operating rule until fixed: snapshot before regenerate, never regenerate after week 1; (c) cancellation notices don't reach preference-`none` players and don't work at all for tournament dates. Also: admin Teams list shows "0 players" for season teams (reads wrong field), jersey colors inverted in player schedule (verified), one bad DOB 500s the tournament roster builder.

### Production incident — order 87 (Avi, $133, cancelled Jul 8) — VERIFIED refund never happened

Season cancel form's "Refund via Commerce" requested a 100x amount ($13,300); Commerce rejected it; registration cancelled anyway; **no money moved, no email sent to player**. Payments tab shows "Refunded: $0.00"; credits $0.
- **TODO Andrew:** refund $133 via order 87 → Payments tab → Refund button (Commerce core form, safe) → verify payment state flips to Refunded → email Avi (he was never notified).

### Three code fixes made today — UNCOMMITTED, all independent, none touch Caleb's season/GroupPane territory

1. **D1 — refund 100x** (`src/Form/CancelRegistrationForm.php`): dropped the `* 100` (Price takes dollars — tournament forms were already correct); reject $0 refunds; failure message now says explicitly that no money moved + manual remediation steps. **Deploy first, own commit — live money bug.**
2. **BUG 4 — email-only invitees never got the invite email** (`src/Service/NotificationService.php` + `src/Controller/GroupController.php` + `src/Drush/Commands/CcsoccerCommands.php` + `templates/ccsoccer-group-manage.html.twig`): new `sendInvitationEmail()` (raw address, reply-to inviter, board allowlist still applies); wired into `invite()`/`nudge()`; `notified` now stamped only after successful send (failure shows warning, invite stays and Send Reminder works immediately); template guarded against empty `notified` (was fataling — found in Andrew's LOCAL test). **After deploy run `drush ccsoccer:send-pending-email-invites --dry-run` on PROD, then live** — repairs existing pending email-only invites (Matt Willis et al.).
3. **S2 — tournament registration silently declined ALL pending team invitations** (`src/EventSubscriber/OrderCompleteSubscriber.php`): decline now gated on `if ($team)` — pool/"no team yet" registrants keep invitations pending (mirrors season's `invitation_status === 'accepted'` guard); also un-breaks the capacity-exceeded fallback's leave-pending promise.

Deploy for all three: code only, no cim/updb, `drush cr` in each environment. LOCAL test notes are in each doc next to the ✅ IMPLEMENTED markers. Syntax-validated (PHP 8 parser); not yet run through a full LOCAL regression — test before PROD.

### Coordination with Caleb
- His invitation-timing PR: evaluate against `INVITATION_FLOW_ANALYSIS.md` Section 5 (esp.: accept must move to order completion, pane must rebuild from order-data selections not pending status, `removeMember` invitation cleanup, and the data-repair migration — code fix alone leaves prod data inconsistent).
- Today's fixes touch `GroupController::invite()/nudge()`, `OrderCompleteSubscriber::createTournamentRegistration()`, `NotificationService` — flag to him to rebase/merge before his PR if he's in those files.

---

## Session Work — July 8, 2026 (later session) — branch `feature/update_registration_page`

### 1. Registration page shows already-registered items (commit 3330566)

Problem: `/register` was empty for fully-registered players (bad UX — they had
to know to go to `/my-registrations`). Root cause: `RegistrationController::available()`
already split visible seasons/tournaments into registered vs available arrays,
but the registered arrays were **built and never rendered**.

- Registered cards now render in the same Leagues/Tournaments sections, after
  available ones: muted card (`season-card--registered`), green "✓ You Are
  Registered" box, **outline** Manage Group / Manage Team button (My
  Registrations style). Solid red buttons now mean only "Register".
- Tournament registered button renamed "Review Team" → "Manage Team" to match
  My Registrations.
- **Filter pills now honored by registered cards too** (second iteration —
  first pass showed registered items on every filter). Filter check moved
  ahead of the registered/available split in both loops. Coed shows only coed
  (available + registered), Men's only mens, Tournaments only tournaments,
  All shows everything. Side benefit: filtered-out items skip per-user
  registration queries.
- Added `url.query_args:filter` cache context (page previously varied only by
  user — cross-filter cache bleed risk under dynamic page cache).
- Empty-state check simplified — all four arrays honor the filter now.
- Home page buttons: dropped "Open" → "See Coed Registrations", "See Men's
  Registrations", "See Tournament Registrations" (`ContentController.php`).
  Rationale: page now also shows registered items, and players registered for
  the current season (summer) must still find the next season (fall) there.
  `CONTENT_PAGES_CSS_CHECKLIST.md:85` updated to match.
- Files: `RegistrationController.php`, `registration.css`,
  `ContentController.php`, `CONTENT_PAGES_CSS_CHECKLIST.md`.

### 2. Tournament teams: captain badged inline (commit 44d337b)

Captain appeared twice on `/tournament-teams` cards ("Captain:" line + roster).
Now: no "Captain:" line; captain sorts first with red CAPTAIN pill, co-captain
second with gray CO-CAPTAIN pill, rest alphabetical. Current-user highlight
unchanged. Fallback: "Captain:"/"Co-Captain:" line still renders **only** when
that person is not in the players list (admin-created teams, unregistered
captains). Files: `ContentController::tournamentTeamsPage()`,
`content-pages.css` (pill styles matching My Registrations badge).

### Deploy — code only, no cim/updb; run in EACH environment
```bash
drush cr
```
Verify after deploy: `/register` as a fully-registered player (cards, not
empty), filter pills show only their category, `/home` button labels,
`/tournament-teams` captain pills + fallback line on captain-only teams.

---

## Previous Session Work — July 8, 2026 (earlier) — ✅ MERGED via PR #115

### Season + tournament dates on player-facing pages

Players could not see season/tournament dates anywhere on `/register` or
`/my-registrations` — dates were admin-only. Root cause on the tournament card:
`buildTournamentCard()` checked a **nonexistent `tournament_date` field** (dead
code — entity uses `start_date`/`end_date`), which is why the SLO Friendly card
showed no date. Season cards never read dates at all.

#### What changed (6 files)
- **`src/DateRangeFormatTrait.php`** (new) — shared formatter. Single day →
  "Saturday, September 5, 2026"; same-year range → "Aug 20 – Oct 15, 2026";
  cross-year range → years on both ends. Falls back when only one date set;
  returns '' when neither. (`formatDateRange()` remains duplicated in
  SeasonListBuilder/TournamentListBuilder — refactor to the trait is optional
  cleanup later.)
- **`src/Controller/RegistrationController.php`** — season cards add
  "Season Dates: …" under the League line; tournament cards use
  start_date/end_date ("Date: Saturday, September 5, 2026"); pending
  invitations banner shows dates.
- **`src/Controller/GroupController.php`** — `myRegistrations()` adds a
  preformatted `dates` string to season, tournament, and pending-invitation
  rows (entities already loaded, no extra queries).
- **`templates/ccsoccer-my-registrations.html.twig`** — muted date line under
  the title on season cards, tournament cards, and invitation cards.
- **`css/registration.css`** — `.registration-card__dates`,
  `.invitation-card__dates`, `.invitation-item__dates` (small, muted).

#### Test on LOCAL (drush cr first — new class + template + CSS cache-bust)
1. `/register` logged out and logged in — dates on both league cards and the
   tournament card.
2. `/my-registrations` — dates under Mens/Coed/SLO Friendly cards.
3. Pending invitation cards on both pages show dates.

#### Deploy — run in EACH environment (LOCAL → TEST → PROD)
Code only; no `cim`, no `updb`.
```bash
drush cr
```

#### Unrelated note
`web/sites/default/default.settings.php` scaffold drift (from Caleb's core
bump — see June 26 archive note) is still uncommitted. Keep it OUT of the
date-display commit; commit separately with the core upgrade.

---

## Anonymous checkout lockdown — step 2 DONE on LOCAL; steps 1, 3, 4 still open

Full write-up: `ANONYMOUS_CHECKOUT_LOCKDOWN.md`. Reviewed against Caleb's
email July 5; the doc's 4-step plan stands. His route-subscriber default-deny +
anonymous route sweep is good follow-on hardening (slot next to checklist item
17/CSP), not a launch blocker.

### Background (Wayne / order 61)
Wayne (Bakersfield captain) bought "Team Fee — SLO Friendly 2026" while NOT
logged in — order 61, no user account tied to it. Root cause in `config/sync`:
the checkout flow's `login` pane is `_disabled`, and the anonymous role had
`access checkout` + `view commerce_product`. Season/tournament/jersey are
protected only per-product, not by a global login gate — so any product
exposing a standard add-to-cart form was anonymously buyable (Team Fee, any
`default`-type product, leftover `security_metrics_test_product_ty`). Risk:
orphaned orders and **card testing against the live Authorize.net gateway**.
Invite flow NOT affected (invitees authenticate before checkout). Caleb
manually reconciled Wayne's team + disabled the product; next year's team-fee
flow should require login (checklist item 34).

### Plan status
1. **OPEN** — Re-enable `login` checkout pane, require login. NOT config-only:
   `CCSoccerCheckoutFlow::getSteps()` defines no `login` step, so flipping
   `step: _disabled` alone points the pane at a nonexistent step. Proper fix:
   add a `login` step first in `getSteps()` + pane config `step: login`,
   `allow_guest_checkout: false`. Decision open: `allow_registration`
   true/false (false probably fine). Test LOCAL → TEST before PROD.
2. **✅ DONE on LOCAL** — removed `access checkout` from anonymous
   (`config/sync/user.role.anonymous.yml`, `commerce_checkout` dependency
   removed). Validated: logged-out `/checkout` is 403; logged-in checkout and
   invite flow unaffected. This alone closes the carding hole.
   Deploy to TEST then PROD: `drush cim -y` (no cr, no updb), then verify
   logged-out `/checkout` 403 + a logged-in checkout completes.
3. **OPEN** — Remove `security_metrics_test_product_ty` test product type/product.
4. **OPEN** — Keep Team Fee unpublished; next year's flow login-required (item 34).

### Team pages PII check (Caleb's question) — ✅ CLEAR
No member phone/email/names reach anonymous visitors on any team surface;
iCal feed token-validated; anonymous lacks `access user profiles`. Minor nit
(no action): `/teams` gate lives in the controller, not the route.

### Discovered: media display config drift loop (pre-existing)
`core.entity_view_display.media.image.default`, `…media.image.media_library`,
`…media.document.media_library` show `Different` immediately after `cim` —
media_library re-tweaks its own displays after save. Likely fix: `cex` just
those three files (remember beta_tester gotcha). Flag to Caleb.

---

## Open security follow-ups (from July 3 review)

Details in `CC_Soccer_Security_Review_2026_07_03.md` + archive.

- **E (MEDIUM)** — `JerseySelectionPane::submitPaneForm` trusts submitted
  `variation_id`; verify it is a jersey (SKU `JERSEY-%`) before adding to cart.
- **I (LOW)** — Scrub + rotate Authorize.net **test** creds and reCAPTCHA
  **secret** from git.
- **K (LOW)** — Generic exception messages (stop returning `$e->getMessage()`),
  move inline `onchange` in `ReportController.php:393`, escape admin notes on
  any future display.
- **G residual** — gate `invitee_email` fee waiver on the `invitee` reference
  before team fees scale next year; docblock cleanup
  (`GroupInvitationsForm.php:24`, `Team.php:655-657`).
- **Flagged-orders admin report** — surface `ccsoccer_credit_shortfall`,
  `ccsoccer_season_registration_failed`, `ccsoccer_team_name_collision`,
  `ccsoccer_tournament_full`, `ccsoccer_team_capacity_exceeded` (checklist
  item 18; design in `FLAGGED_ORDERS_REPORT_PROPOSAL.md`). Interim: filter
  `/admin/reports/dblog` by `ccsoccer` channel.
- **#3 (watch)** — `filter_htmlcorrector` removed from full_html; unclosed tags
  in pasted rules HTML could break page DOM. Fix when rules re-entered natively
  next year.

---

## Current State

### 🎉 SITE IS LIVE at ccsoccer.com
Soft-launch mode. Board members + beta testers testing. Tournament registration open for SLO Friendly 2026. Team-paid feature live and dark (no-op until a team is flagged).

### Code — main has PR #115 (date display) merged; `feature/update_registration_page` (2 commits) awaiting PR. TEST/PROD deploy status of both: verify.

---

## Combined Pre-Launch Checklist

### 🚨 Immediate

1. ~~**Caleb** — Audit what `drush cim` overwrites on PROD.~~ ✅ DONE

2. **Caleb** — Team names refactor (Phase 1 complete)
   - 2a. ✅ DONE
   - 2b. `/admin/ccsoccer/team/add` pre-filter by series — still open
   - 2c. ✅ DONE
   - 2d. Phase 3: Roster builder verification
   - 2e. Add league/series columns and filter to team names taxonomy overview
   - 2f. ✅ DONE
   - 2g. Admin "team-name collision review" view — still open

---

### Before Opening Whitelist to Public

3. **Andrew** — Vanishing CAPTCHA: confirm stays visible after correct CAPTCHA + wrong password.

4. ✅ DONE **Andrew** — Create real SLO Friendly 2026 season + tournament.

5. **Caleb** — Commerce checkout end-to-end with real card on PROD (season registration + jersey-only paths).

6. **Geo-blocking** — Site is now public and receiving Russian spam bot traffic via the contact form.
   - 6a. **Replicate D7 modules (preferred first step)** — Smart IP + Country Block. Smart IP needs IP2Location LITE BIN file (free, monthly update). Try before Cloudflare.
   - 6b. **Cloudflare geo-blocking (fallback)** — requires DNS rerouting through Cloudflare proxy.

7. **Caleb** — Remove IP whitelist from `web/.htaccess` on go-live day (both servers, skip-worktree protected).

8. **Andrew** — Done - Delete `ccsoccer-d11-migrated-200users.sql` from repo root if present.

9. ✅ DONE — Pre-launch security checklist.

10. ✅ DONE — Security Metrics test product.

11. ✅ DONE — PROD cron running every 15 min.

---

### Mid-term

12. ~~`test@ccsoccer.com` mailbox / from address.~~ ✅ DONE — June 20, 2026.

13. Andrew's local environment DB update.

14. `slofriendly` config role reference cleanup (Andrew).

15. **Caleb** — WebAuthn passkey 2.0.0-rc7 → 2.1.0-beta1. Composer advisory now resolved — do next session with full auth flow test.

16. Backup strategy verification.

17. CSP headers in report-only mode.

18. Admin view/report to surface orders flagged `ccsoccer_team_name_collision`, `ccsoccer_team_capacity_exceeded`, `ccsoccer_tournament_full`.

19. Confirm InMotion server's transactional email passes SPF/DKIM under DMARC `p=quarantine`.

20. Module/core updates — **11.4.4 + security batch deployed to TEST July 27,
    soaking.** 13 of 14 advisories closed. Remaining: `symfony_mailer` 2.x
    (major, own session) and `webauthn-lib` (item 15). See the July 27 session
    notes above for the PROD deploy gotchas.

---

### Post-Launch / First 72 Hours

21. ✅ DONE — Assign `permanent_override` role.

22. ✅ DONE — Verify credit balances against D7.

23. ✅ DONE — Check credits/registrations after April 16 dump date.

24. Remove `beta_tester` role from all users.

25. `slofriendlysoccer.com` URL forward.

26. Archive/delete D7 waivers; eventually delete `ccsoccer_site_d7_archive/`.

27. Monitor login rate, password resets, unhandled exceptions for first 72 hours.

28. Add Devel back to TEST.

---

### Development (Deferred)

29. Three-tier button methodology pass.

30. CSS consolidation — design tokens across ~33 CSS files.

31. Team handling refactor.

32. Profile picture migration — board decision pending.

33. Fix remaining `CC Soccer` → `CCSoccer` in tournament deposit + jersey notification subjects/SMS bodies.

34. `#slofriendly` `#tournament-nextyear` — Team Fee product can be purchased anonymously (not logged in). Order 61: Wayne (Bakersfield captain) bought "Team Fee — SLO Friendly 2026" as Anonymous (not verified), no user account tied to the order. Not fixed now — Caleb manually reconciling Wayne's team + disabling the product. Next year's productized team-fee flow should require login (or capture/match purchaser identity) before allowing purchase.

---

## DB Quick Reference

### Production DB (live)
- DB: `n6ac4b5_d11live`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### TEST DB
- DB: `n6ac4b5_d11test`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### D7 archive DB
- DB: `n6ac4b5_ccsoccer`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### Local D11 DB
- Admin: `admin` / `TJ4XxyYGCd`

---

## Server Directory Structure
```
/home/n6ac4b5/public_html/
  ccsoccer_site/            ← PRODUCTION (ccsoccer.com)
  ccsoccer_site_d7_archive/ ← D7 archive (subdomain removed; files still on disk)
  test_ccsoccer_site/       ← TEST (test.ccsoccer.com)
  slofriendly_redirect/     ← slofriendlysoccer.com redirect
```

---

## Email Architecture

### Pipeline
```
NotificationService / OrderCompleteSubscriber
  → Symfony Mailer pipeline
    → Inline CSS + Theme wrapper (email.html.twig)
    → URL to absolute + Wrap and convert
  → Google Workspace SMTP (TEST + prod)
  → Mailpit (local DDEV only)
```

### Key Files
- `web/modules/custom/ccsoccer/src/Service/NotificationService.php`
- `web/modules/custom/ccsoccer/src/Plugin/QueueWorker/NotificationQueueWorker.php`
- `web/modules/custom/ccsoccer/src/Commands/NotificationCommands.php`
- `web/themes/custom/ccsoccer_theme/templates/email/email.html.twig`

### Notification gating
- `site_instance = 'local'` → board members only (Mailpit catches all)
- `site_instance = 'test'` → board members + beta_testers only (real emails)
- `site_instance = 'production'` → all users

### Local invite email blocking
Non-production environments block invite emails to non-allowlisted addresses by design
(watchdog: "Email blocked to @to (not in board allowlist)"). This is correct behavior —
emails are generated but not sent. To test invite email content on LOCAL, invite a
board-member email address; it will land in Mailpit.

### Queue Worker — SMS suppression
When `sms_body === ''` (explicitly empty), `processItem()` calls `sendEmail()` directly,
bypassing `send()` which would strip HTML for SMS.

### From address — per environment (via settings.local.php, NOT config_ignore)
- **LOCAL**: `local@ccsoccer.com` — caught by Mailpit, never delivered
- **TEST**: `test@ccsoccer.com` — Google Workspace alias, same inbox as prod
- **PROD**: `ccsoccer@ccsoccer.com` — canonical value in `system.site.yml`; no override

Override pattern: `$config['system.site']['mail'] = 'test@ccsoccer.com';` in `settings.local.php`.

### SMTP config (in settings.local.php, never in git)
- Host: `smtp.gmail.com`, Port: `465`, TLS: true
- User: `ccsoccer@ccsoccer.com`
- App password: `sqygkfykzwrziota`

### reCAPTCHA Keys
- Site key: `6LchguosAAAAAC5kLFmKj0xCGdEWXNntLacANpVN`
- Secret key: `6LchguosAAAAANBJ8ikcrZvYQMxs6-FM2YHbzPEl`
- Domain-locked to production. LOCAL intentionally disabled — protected via `config_ignore`.

### Authorize.net Live Credentials (PROD only — never commit)
- API Login ID: `9Fus5B2a`
- Transaction Key: `7MA8r27R9KQrs3Kd`
- Public Client Key: `5ep4C2xSvyY4jpra4694guKsJyV6XGq2CB39SHRtrs59wHH47avwTfKM7R7xJ7hF`
- Mode: Live / Plugin: Authorize.net Accept.js
- Configure at: `/admin/commerce/config/payment-gateways`

### Drupal Update Notification Settings (per-environment, protected via config_ignore)
- **TEST**: weekly, `noreply@example.invalid`, security updates only
- **PROD**: weekly, `ccsoccer@ccsoccer.com`, all newer versions

---

## Key Facts / Gotchas

### config_ignore — environment-specific config that must never sync
```yaml
ignored_config_entities:
  - 'commerce_payment.commerce_payment_gateway.*'
  - 'update.settings'
  - 'recaptcha.settings'
  - 'captcha.captcha_point.*'
```
**When adding entries:** hand-edit the file directly, `git add` by name only — never blanket `cex`.
**After changing:** run `drush cim` on LOCAL to import the updated ignore list into active config.

### config drift check
```bash
drush config:status
```
Run before any `cex`, after deploys, and before merging feature branches. Reports `Only in sync`
(not imported), `Only in active` (drift — not exported), and `Different` (mismatch). `Different`
is the dangerous one. Edit config YAML directly in `config/sync` rather than via UI to avoid drift.

### Role config drift on LOCAL — unresolved, watch for recurrence
June 16: `user.role.anonymous`, `user.role.board_member`, `user.role.tournament_director`
showed unexpected drift. Cause unknown. Reverted via `drush cim`.

### beta_tester role — do not delete from config/sync
Always `git checkout config/sync/user.role.beta_tester.yml` after `drush cex`.

### Google SMTP App Password
`sqygkfykzwrziota` — in `settings.local.php` on TEST and PROD servers only.

### Symfony Mailer — User override
Do NOT enable the "User" override — replaces HTML with plain text.

### DB import fix for InMotion
```bash
gunzip -c dump.sql.gz | sed 's/DEFINER=[^*]*\*/\*/' | gzip > dump-clean.sql.gz
```

### Two-file CSS sync
- `web/modules/custom/ccsoccer/css/ccsoccer-base.css` (admin)
- `web/themes/custom/ccsoccer_theme/css/base.css` (public)

### Commerce cart view mode
The cart's "Item" column renders the variation in its `cart` view mode (not the order item title).
New product types need `core.entity_view_display.commerce_product_variation.{type}.cart.yml`
or the cart falls back to rendering fields (showing "Price" as the label).

### composer install on servers
Composer lives at `~/bin/composer` (self-installed 2.10.2, July 27). The old
cPanel path `/opt/cpanel/composer/bin/composer` was **emptied by a cPanel/EA
update on Jul 20 21:10** — don't go back to it, and don't use
`/opt/cpanel/ea-wappspector/composer.phar` either.
```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php $HOME/bin/composer install --ignore-platform-req=ext-intl
```
If it vanishes again, reinstall:
```bash
mkdir -p ~/bin && cd ~/bin
curl -sS https://getcomposer.org/installer -o composer-setup.php
/opt/cpanel/ea-php83/root/usr/bin/php composer-setup.php --install-dir=$HOME/bin --filename=composer
rm composer-setup.php
```

### Security updates — audit, don't just check outdated
`composer outdated --direct` only shows packages you declared. Transitive ones
(`dompdf` via entity_print, `guzzle` via core, `webauthn-lib` via drupal/wa)
never appear, and on July 27 that was 11 of 14 advisories. Always:
```bash
ddev composer audit
```

### TournamentCancelRegistrationForm — hidden number field gotcha
Remove `#min`/`#max` from hidden number fields; validate server-side instead.

### ConfirmFormBase + entity-typed route parameters
Do NOT put entity-typed parameters in route paths for `ConfirmFormBase` forms — use raw integer only.

### DMARC aggregate reports
Daily from `noreply-dmarc-support@google.com` and `dmarcreport@microsoft.com`.
Gmail filter to skip inbox + label. Check after any outbound mail config change.

### team_paid flag
Set on Team entity edit form (admin/TD only). Not visible to captains. Dark deploy — no-op
until explicitly set. Next year: Team Fee product checkout sets it automatically.
TeamPaidOrderProcessor (priority 200) applies a LINE-ITEM adjustment (not order-level) so
getSubtotalPrice() reflects the zero — prevents double-discount with DiscountOrderProcessor (100).

---

## Server Quick Reference
```bash
# SSH in
ssh ccsoccer

# Full deploy with DB updates
ccsDeploy && ccsUpdb && ccsCim && ccsCr
ccsProdDeploy && ccsProdUpdb && ccsProdCim && ccsProdCr

# Drush full path (TEST/PROD)
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php vendor/drush/drush/drush.php -r web [command]
```

## .htaccess — both servers
Not in git — protected via `git update-index --skip-worktree web/.htaccess`

## Git Workflow
- Always `git pull` before `git push`
- `main` is the primary branch
- `settings.local.php` is NOT in git
- Always `git checkout config/sync/user.role.beta_tester.yml` after `drush cex`
- When editing `config_ignore.settings.yml`, commit that file by name only
