# CC Soccer D11 — Session Handoff

**Date:** August 5, 2026 · **Reset:** the previous handoff was archived in full, unmodified, to
`archive/SESSION_HANDOFF_2026-08-05.md`. **Nothing was deleted.**

---

## What this file is

Current state, what to do next, and the environment knowledge that does not belong in a tracker.
**It does not list open issues** — that is `OUTSTANDING_ISSUES.md`, and it is the only file that does.

| Kind of thing | Home |
|---|---|
| What is deployed | **git** |
| What is open | **`OUTSTANDING_ISSUES.md`** |
| What happened, how to resume | **this file** |
| How a fix should be built | the brief named on the item |
| Why the code is like this | analysis docs, mostly in `archive/` |

**The rule:** analysis and implementation briefs describe how the code works and what is wrong with it.
**They never say whether it has been fixed.** Only the tracker says that. That is what lets a two-month-old
analysis doc stay trustworthy — reasoning about code does not expire the way status does.

**Sessions get a short entry below and are then archived.** Do not let narrative accumulate here.

---

## This section was copied by Andrew from `archive/SESSION_HANDOFF_2026-08-05.md` back into SESSION_HANDOFF (August 6)

## Carried-forward open items (unresolved as of Aug 2, not touched today)

- **🔴 Rotate the reCAPTCHA secret key.** It was printed to a terminal during Aug 2 PROD
  verification and is in shell scrollback + a chat transcript, on top of git history.
  New pair at google.com/recaptcha/admin → enter via `/admin/config/people/captcha/recaptcha`,
  **not** config export (`recaptcha.settings` is in `config_ignore`). Also `verify_hostname: false`
  on PROD — turn on, test on TEST first.
- **🟠 Admin accept of a tournament invitation fatals and half-writes.** `GroupInvitationsForm.php`
  queried a `tournament` field that doesn't exist on the Invitation entity. **The rule still stands:
  do not use the admin Group Invitations accept dropdown on any tournament until this reaches PROD.**
  **[Updated Aug 5] Downgraded from 🔴 to 🟠 — the fix (`59c12ec`, CF2 + D2 + CF12) has now been
  linted and exercised on LOCAL** as part of §10.8 item 4, which is the first time this code has ever
  run. **It is still not on TEST or PROD, so the operating rule does not lift yet** — but it is no
  longer unverified code guarding a live fatal. `fix/roster_sync` never existed; that branch name
  appears in several older documents and is wrong everywhere.
- **🟡 Next bulk notification send needs a huddle first, validation after.** Three components of
  the pipeline are structurally untestable on TEST and will execute for the first time on PROD.
  Protocol is in the archived Aug 2 handoff.
- **📄 Doc consolidation in progress** — see `DOC_CLEANUP_CONSOLIDATION_WIP.md`. Harvest is
  mostly done (TODO, PROJECT_STATUS, CODE_QUALITY_BACKLOG, security review all ruled). Remaining:
  pull the pre-launch checklist, draft the consolidated `OUTSTANDING_ISSUES.md`, review with Andrew.
  Today's findings below still need folding into that harvest.
  **[Aug 5] Three more documents to account for** — `GROUP_CLEANUP_BRIEF.md`,
  `D8_OPTIONS_COMPARISON.md` and `GROUP_ID_AT_ACCEPT_BRIEF.md` (held). All three are implementation/decision
  records for D-8, not status docs, so they should survive consolidation rather than be folded in — but
  the harvest should know they exist.

---

## Where things stand — August 5, 2026

**Nine code fixes are merged to `main` and click-tested on LOCAL. None of it is on TEST or PROD.**

| Cluster | Commits | State |
|---|---|---|
| **PR #124** — CF1, CF1b, CF2 + D2, CF12, CF4, CF5, CF8 | `e5317f8` `59c12ec` `8156bc1` `b46c28b` `b22734f` `7c9a7c5` | Merged Aug 4. **`ROSTER_RECONCILIATION_PLAN.md` §10.8 passed on LOCAL Aug 5.** ~1,100 lines that had never been executed have now run |
| **PR #129** — D-8 (CF10 + B + E) | `dde90b5` `995e286` `c7251c6` `7a5f57b` | Merged Aug 5. Linted, partly click-tested. `7a5f57b` fixes a dead-end admin page that LOCAL testing surfaced |

> **[Updated Aug 8] `main` is at `ac7a05e`.** The paragraph that used to sit here said `main` was at
> `8e7b0be` and that the Aug 5 doc reset was uncommitted with eight `archive/` files untracked —
> **all of that is resolved.** It landed as `39797b0` (PR #130). The working tree is clean apart from
> one untracked file, `roster_audit_LOCAL_2026-08-06.txt`, which `DECISION_2_ANALYSIS.md` cites as
> evidence. ⚠ **It is still the only copy — commit it or move it before anything runs `git clean`.**
>
> Three more PRs have merged since:
>
> | PR | Contents |
> |---|---|
> | **#130** `39797b0` | The Aug 5 doc reset — rebuilt `OUTSTANDING_ISSUES.md`, archived eight files |
> | **#131** `ba495ea` `5fb6baa` `83813db` | The Aug 7 work below. **Adds one config file to the §0 deploy** |
> | **#132** `7854a9c` | **Docs only.** ⚠ The branch was named `fix/download_jersey_report_implemented` and **nothing was implemented** — it carries two updated documents and no code or config. The jersey fix is still an open decision (`BOARD_MEMBER_ADMIN_THEME.md` Option D). Recorded here because a branch name that overstates what shipped is a trap this project has already fallen into once — see known-broken item 8, `fix/roster_sync` |

> ### ⚠ Two operating rules are in force until the PROD deploy
>
> 1. **The admin Group Invitations page is read-only for tournament groups** — no Accept, no Save.
>    Reading is safe. This guards a live PROD fatal (D2). Merging the fix did not lift it.
> 2. **Keep tournament `schedule_visible` OFF while iterating schedule candidates** (TS2).

---

## Next steps, in order

The full detail for each is in `OUTSTANDING_ISSUES.md` **§0 — Deploy blockers**.

1. **Add query B to `ROSTER_DATA_AUDIT.sql`** (`archive/GROUP_CLEANUP_BRIEF.md` §9.4). This is the entire
   safety net for the D-8 design — the only thing that would reveal a missed fourteenth call site. It
   should land *with* the deploy, not after.
2. **Record baseline counts** from §9.4 queries A and B before deploying anything.
3. **Settle what was actually tested.** `archive/GROUP_CLEANUP_BRIEF.md` §15 says the 29 cases have not
   run; its own §9.2 cases 18 and 19a say otherwise. Produce a per-case record. Time-boxed, the
   load-bearing six are **8, 9, 19, 21, 24, 27**, plus **cases 1–4** for CF10.
4. **`drush role:list` on PROD** vs `config/sync` — the last caveat on Q-B9.
5. **TEST deploy** → `TEST_SPOT_CHECKS_2026-08-05.md`. **Check `site_instance` first** or TEST emails real
   players.
6. **PROD deploy** — code only, no `updb`, no `cim`. Re-run §10.8 items 1 and 4 as the regression.
7. **Lift the two operating rules.**

**Then, in rough order of value:** CF9 (the inline `Team.players` appends — highest-value item left), CF3
(the nine status filters, blocked on Decision 2 — see `DECISION_2_ANALYSIS.md`), the capacity-race
cluster, CF7.

> ### Surfaced by the doc reset — these had fallen off every list
>
> The pre-launch checklist was named as the last outstanding harvest back on Aug 2, never harvested, and
> then deleted from this file on Aug 4. It is now **P9** in the tracker. Four items in it deserve a look
> soon, independent of the deploy above:
>
> - **The contact form is taking Russian spam-bot traffic** and geo-blocking was never put back
>   (checklist 6).
> - **The IP whitelist is still in `web/.htaccess` on both servers** (7). That file is
>   skip-worktree-protected, so it will never appear in `git status` — which is why it has been forgotten.
> - **Two of fourteen security advisories are still open** — WebAuthn `2.0.0-rc7` and `symfony_mailer` 2.x
>   (15, 20).
> - **InMotion's transactional email has never been confirmed to pass SPF/DKIM under DMARC
>   `p=quarantine`** (19).
>
> Separately, **20 findings from `ADMIN_TOOLING_REVIEW.md` were in no list at all** — including T7, T8, R7
> and TS4, which touch the roster and schedule builders this deploy is meant to unblock. They are now in
> P1, P2 and P4.

---

## Open questions for Andrew and Caleb

- **Decision 2 — allowlist vs exclude-list for "live registration."** Still open. Blocks CF3 and B-15,
  and shapes the `CapacityManagerService`. **Full analysis is now in `DECISION_2_ANALYSIS.md` (Aug 6)** —
  read that rather than the older framing. It supersedes the counts in
  `archive/SESSION_HANDOFF_2026-08-05.md`, which were wrong in four places: the module has **four**
  conventions, not three (allowlist **49**, `paid`-only **7**, exclude-list **2** + the trait at 25 sites,
  and a previously-uncounted partial exclude-list `<> 'cancelled'` at **6** sites — four of which gate a
  duplicate *purchase*). Three things worth knowing before the huddle: `waitlist` is defined as live by
  the trait and as not-registered by `AdminController:270`; the three season-capacity checkpoints disagree
  on `reserved_spots`, not just on status, and that one is **not** behaviour-neutral; and **this does not
  block the PROD deploy.**
- **Caleb has not reviewed the D-8 package.** The code was written without waiting, on the reasoning that
  option D is *held* rather than rejected — so a late objection would be about which option ships, not
  about the code being wrong. `archive/D8_OPTIONS_COMPARISON.md` §10 is the brief for that conversation,
  and its Appendix C is worth his time regardless: it documents where this part of the codebase is sharp.
- **Board:** D17 (tournament roster freeze) and G1 (should cancellation notices bypass notification
  preference).

---

## Aug 23, 2026 — invitation-only tournament registration (branch, untested)

**Branch `feature/tournament_invite_only`, two commits, not pushed, nothing tested.**
Status lives in `OUTSTANDING_ISSUES.md` §P3. Design and the 17-step LOCAL plan are in
`TOURNAMENT_INVITE_ONLY_PROPOSAL.md`.

The ask: SLO Friendly is maxed on teams with pending invitations outstanding, so registration has to
stay open for captains' invitees and shut for everyone else.

| Commit | What |
|---|---|
| `1b317f5` | `addTournamentToCart()` refuses a tournament whose `end_date` has passed |
| `3f329fd` | Invitation-only registration: new Tournament checkbox, shared invite service, three enforcement surfaces |

**Deploy:** `drush updb` (hook **9074** — new base field) **and** `drush cr` (new service). No config
export; there is no `core.entity_form_display.tournament.*`, so the checkbox comes from the base-field
definition. Verified.

### Findings worth keeping

- **`addTournamentToCart()` is the single chokepoint** and it has exactly two callers: the `/register`
  card button and the `?invite=TOKEN` branch of `available()` — which the Pending Invitations banner
  also routes through. Both invited and uninvited arrivals are separable there. That is why this
  change is six files and not twenty.
- **The `/register` card never checked capacity, and still does not.** `getTournamentState()` reads
  dates and existing registrations only; `Tournament::isRegistrationOpen()` — which does check
  `isFull()` — is not used on the public page. Andrew's ruling: leave it. Player capacity in a
  tournament is enforced **per team** by `max_roster_size`; `max_teams` caps *teams*. A
  tournament-level player cap on that card would duplicate or contradict the roster limit.
- **⚠ A `registration_close` check in `addTournamentToCart()` would break the feature.** It is the
  obvious next line for a reader to add. The close date is what closes the *card*; invited players
  come through the same method via their token, so gating on it there locks out exactly the people
  this exists to admit. There is a warning comment in the code. Note this also retires the zero-code
  stopgap the proposal originally offered — it worked by exploiting that missing check.
- **Adversarial review earned its keep.** A subagent review of the finished diff found two real
  defects that reading it myself had not: (1) an invited player whose team filled up hit the
  "you have no invitation" dead end, because the pane drops full teams — now told the truth and
  pointed at their captain; (2) no `ccsoccer_invitation` cache tag on `/register`, so a captain's new
  invitation left the invitee looking at a cached buttonless card. It also caught a multi-tournament
  cart bypass and an admin bypass that was a dead end one step later. **Do not ship a diff of this
  size on a self-read.**
- **Removing an option is not a check.** Both uninvited actions are stripped from the radios *and*
  refused in `validatePaneForm()`, mirroring how the `create` branch re-checks `isFull()` rather than
  trusting that the option was hidden.
- **Post-payment enforcement was rejected on purpose.** Gating `createTournamentRegistration()` would
  charge the card and create nothing — the Aug 4 shape. Commented there at length, because the next
  reader will see an ungated writer and want to close it.

### Environment gotcha, cost about twenty minutes

The remote sandbox's bridge to the Mac **cannot delete files**, and git removes `.git/*.lock` at the
end of every write. So `git checkout -b` succeeded and then left a stale `index.lock`, jamming every
later git command. Workaround used throughout: `find .git -name "*.lock" -exec mv {} {}.stale` before
each git call. **There are `*.stale` files and loose `tmp_obj_*` blobs in `.git/` now.** They are
inert, but clean them from the Mac:

```bash
find .git -name "*.stale" -delete && git gc --prune=now
```

`_to_delete/` in the repo root holds two scratch copies from linting. Delete it. Neither is committed.

---

## Aug 22, 2026 (second session) — jersey CSV download; schedule exports gated on one flag

**Merged and deployed to TEST and PROD.** Code and one config file. No `updb`.

### 1. Jersey Report "Download CSV" did nothing for board members

The handler did `document.querySelector('.views-table')`. That class is added **only by Claro's**
`views-view-table` template override — under `ccsoccer_theme` the lookup returned `null` and the
handler hit `if (!table) return;`. Button rendered, looked clickable, did nothing. Board members see
`/admin/` pages in the front-end theme because only `content_editor` holds
`view the administration theme`.

**Took neither option from `BOARD_MEMBER_ADMIN_THEME.md`.** Option A (grant the admin theme
permission) is a large blast radius, and the doc's "fixes all four reports" framing counted two
reports that were never broken — City Payment and Insurance return PDF `Response` objects and are
theme-independent. Option D (supply `views-table` from the view's Table style) fixes the symptom but
leaves the JS depending on a class that can be cleared from the Views UI.

Instead the selector was scoped to the Views wrapper class `js-view-dom-id-*`, which comes from the
**Views module's own template** and is present in every theme. Verified in the rendered markup.
No permission change, no theme change, and the dependency is gone rather than relocated.

**Two assumptions the scrape still rests on**, documented in the view header: `pager: type: none`
(add a pager and the CSV silently exports only page one) and `style: type: table`. Both are the kind
of change someone would make without connecting it to a download button.

**Left alone:** Tournament Deposits still loses its table styling and sort arrows in the front-end
theme. It functions — sorting and filtering work. Note `tournament-deposits-table` is styled
**nowhere in the repo, in either theme**, so that is new CSS whenever it is picked up, not something
a permission grant would have solved.

### 2. `Game.published` vs `Season.schedule_visible` — the exports now use one flag

**The report:** the My Schedule `.ics` downloaded fine but imported as "no events". The file was
valid — 249 bytes, correct CRLF, zero `VEVENT`s.

**Two fields meant the same thing.** `Season.schedule_visible` is read by the schedule page, the
next-game banner, the seasons list badge. `Game.published` was read by `getTeamSchedule()` (the iCal
feed) and the PDF and iCal exports. Only the schedule builder's publish button wrote both;
the season edit form's `schedule_visible` checkbox wrote one. Set the checkbox and the page works
while every export returns nothing.

**`published` was never per-game.** `setPublished()` loads every game in a season and sets them all
to the same value; the builder grid ignores the field entirely (`ScheduleGridBuilder` has zero
references — it is a passenger in `getScheduleState()`). So it held the same single bit as
`schedule_visible`, denormalised across N rows, with no writer that could make them differ on
purpose.

**Decision: gate everything on `schedule_visible`; leave `published` in place but readerless.**
Rejected syncing the two — that keeps the duplicate and only prevents the drift. Rejected deleting
the field for now, because it round-trips through `getScheduleState()` and snapshots, and the
schedule builder is complex enough that touching it deserves its own change with a full test pass.

Also worth recording: per-game publishing would be **actively wrong for a subscription feed**.
Cancelling a game keeps it in the feed with `STATUS:CANCELLED`, so the player sees it struck
through. Unpublishing removes the event entirely and the calendar app deletes it — the game silently
vanishes.

| Route | Controller | Before | After |
|---|---|---|---|
| `/schedule/ical/{uid}/{token}` | `ContentController::myScheduleIcalFeed` | `schedule_visible` + `published` | `schedule_visible` |
| `/schedule/{s}/ical` | `ContentController::exportIcal` | `published` only | `schedule_visible` |
| `/schedule/{s}/pdf` | **`ReportController::schedulePdf`** | **no gate at all** | `schedule_visible` |
| `/my-schedule/{s}/pdf` | **`ReportController::mySchedulePdf`** | **no gate at all** | `schedule_visible` |

**The PDF routes had never been gated.** Both are `_permission: 'access content'`, so an unpublished
schedule was downloadable by anyone guessing a season ID. Not reachable by clicking — the
"Printer Friendly (pdf)" link only renders on a visible season — but the URL is guessable.

⚠ **`ContentController::exportPdf()` is dead code.** No route points at it. I edited it first,
assuming it served `/schedule/{season}/pdf`; it does not. That edit was reverted and the method
labelled. See open items.

### 3. Proven on live PROD data

**Season 48, Coed 2026 – Early Fall: 81 games, `schedule_visible=1`, `published=0`.** Every Coed
player has had a working schedule page and a broken calendar and PDF all season. Nobody reported it.
After deploy both return all 81 games. Mens 47 was in the same state until Caleb clicked
"Make Visible to Players" mid-session, which repaired it by writing both flags.

### 4. What the publish button does now

Unchanged — still writes `setPublished()`, `schedule_visible` and `roster_visible`. But the first of
those now writes a field nothing reads, and **the season edit checkbox is now sufficient on its
own**, so the two entry points can no longer disagree. The sync work previously scoped for the
Andrew conversation is no longer needed for correctness; what is left there is product shape:
whether the button should be relabelled ("Publish Schedule & Rosters"), split, or removed in favour
of the season form. Its success message still says "(N games published)", which now describes a
write nobody consults.

### New open items — need entries in `OUTSTANDING_ISSUES.md`

- **🟡 `ContentController::exportPdf()` is unreachable.** ~150 lines, no route. Delete.
- **🟡 `ReportController::scheduleIcal()` is an unimplemented stub** that throws
  `NotFoundHttpException` after setting a warning message. Check whether anything routes to it.
- **🟡 `Game.published` now has no readers.** Decide whether to drop the field (touches
  `getScheduleState()`, `saveSnapshot()`, restore, `setPublished()`, both tournament equivalents, and
  needs an update hook) or leave it inert. **Zero snapshots exist in either environment**, so there
  is no format migration if it goes.
- **🟡 Tournaments have the same duplication.** `TournamentScheduleGeneratorService::setPublished()`
  writes game `published` *and* `tournament.schedule_visible` in one method — tidier plumbing, same
  two-representations problem, and the tournament edit form is still an unsynced second entry point.
  No tournament iCal exists (one-day events), so `published` there has **zero** readers.
- **🟡 Two PROD registrations point at deleted seasons.** Regs 5073 and 5074 reference seasons 43
  and 44, which do not exist. Harmless today (the `!$season` guard catches them) but it is a dangling
  foreign key.

---

## Aug 22, 2026 — scheduled jobs moved out of hook_cron; password-loop investigation open

**Merged and deployed to TEST and PROD.** Update hook `9073` ran on both.

### 1. `ccsoccer_cron()` deleted, replaced by `ccsoccer:daily`

The 3pm cancelled-game reminder was gated on `$current_hour === 15 && $current_minute < 5`
inside `hook_cron()`. Every `notification_log` entry we could inspect had `reminder_sent: null`.

**Do not repeat the diagnosis we first reached.** The intuitive answer — "page-load cron is
unreliable, so the five-minute window never hit" — is wrong, and it is wrong in a way worth
recording for Andrew: **cPanel has been running `drush cron` every 15 minutes on both PROD and
TEST the entire time.** Drupal cron fired ~96 times a day and `*/15` hits `:00`, which satisfies
`minute < 5`. The original design was not compensating for absent infrastructure; it just did not
know the infrastructure was there.

The real cause was never pinned down. **PROD's `notification_log` was deleted by update 9073
before anyone read it** — we checked the repo copy (beta rows, Jan–Mar) and assumed it matched.
That record is gone. Best remaining candidate is the `initial_sent`/`reminder_sent` guard: the log
keyed on **date alone**, so two seasons playing one night collided.

The fix stands regardless. Scheduling now lives where it can be seen:

| Schedule | Command | Purpose |
|---|---|---|
| `*/5 * * * *` (PROD) / `*/15` (TEST) | `drush cron` | queue draining, core/contrib |
| `5 15 * * *` (PROD only) | `drush ccsoccer:daily` | game + override reminders |

`ccsoccer:daily` takes `--only=games|overrides` and `--dry-run`, and **logs a heartbeat on every
run** so "did not fire" and "fired, found nothing" are distinguishable after the fact. Both jobs
are plain date-range queries — no hour math. **If something needs a different interval, write a
new command and a new cron entry. Do not put a clock check back inside a hook.**

### 2. `notification_log` moved from config to the Game entity

`GameStatusForm::logNotification()` wrote runtime state into the `ccsoccer.game_status` **config
object** via `configFactory()->getEditable()`. That produced permanent config drift, could be
overwritten by `cim`, keyed on date alone, and grew unbounded.

Replaced by three timestamp base fields on Game — `cancellation_notified`, `reminder_notified`,
`uncancelled_notified` — following the `Registration.override_notified` precedent.
`logNotification()` → `markNotified($date_info, $type)`. Only `reminder_notified` is read by code;
the other two are audit. Config object and its YAML deleted. `configFactory` injection removed from
`GameStatusForm` (it had no other use — and an already-injected service is how this happened).

### 3. Two latent bugs fixed in passing

- **Override reminders had never worked.** The old cron block read
  `$registration->get('user_id')`; Registration's player reference is **`player`**. It would have
  thrown `InvalidArgumentException` on the first match. Core's per-module try/catch masked it.
- **`sendOverrideExpirationReminder()` typed `$expires` as `string`** and ran `strtotime()` on it,
  but `override_expires` is a timestamp field — so the formatted date would have rendered as 1970.
  Now `int`. Copy also reworded to "expires tomorrow, [day] at [time]", which is now literally true.

### Verified on TEST, end to end

Game 37 temporarily moved to today, cancelled via the admin form, then restored. Confirmed:
initial notice sent and `cancellation_notified` stamped; `ccsoccer:daily --only=games` found it,
sent the **reminder** (distinct message), and stamped `reminder_notified`; a second run reported
nothing to send. **The duplicate guard works.** Bulk is blocked on TEST, so board got `[VERIFY]`
copies and no players were contacted.

**Not exercised:** the uncancel path, and the override reminder *send*. The override query runs and
returns correctly — it is simply that nothing expires tomorrow.

### The natural next test — Aug 28

PROD has a live override: **mpalmerston, Mens 2026 – Early Fall, expires Aug 29**. Cron should
reminder him at **15:05 on Aug 28** with no setup. Check after:

```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php \
  vendor/drush/drush/drush.php -r web sql:query \
  "SELECT CONCAT(FROM_UNIXTIME(timestamp),' | ',message) FROM watchdog \
   WHERE message LIKE '%ccsoccer:daily%' ORDER BY wid DESC LIMIT 10"
```

If he is **not** picked up, that answers an open question — see below.

### Also changed

- `dblog.settings row_limit` **1000 → 10000**. It held ~11 hours of history, which made every
  after-the-fact diagnosis in this session impossible. Now roughly 4–5 days.
- `automated_cron.settings interval` **10800 → 3600**. It is now a **fallback only** — cPanel cron
  is primary, and `cron_last` is refreshed by any run, so automated_cron never fires while the
  server job is healthy.

---

## Aug 22, 2026 — new open items (need entries in `OUTSTANDING_ISSUES.md`)

Recorded here because they surfaced today; **the tracker is still the only file that owns them.**

- **🟠 Players report being repeatedly prompted to change their password.** Three affected, all D7
  migrants: `diggspot` (91655), `Garret` (93671), `jaredhagemann` (93768). **Three theories tested
  and dead:** no custom forced-reset code exists anywhere; all three have complete profiles
  (`pic=1 dob=1 gender=1`), so the required-photo wall is not it; all three have `changed`
  timestamps 20–30s after `login`, so they have saved their accounts. Still live: **username vs
  email at login** (migrated usernames are D7 leftovers, core authenticates on username only, and
  the failure message mentions passwords), and core's **Current password** field on
  `/user/{uid}/edit`, reachable straight from the My Account hub — migration set
  `bin2hex(random_bytes(16))`, so a player who never set one cannot satisfy it. Caleb reset Josh's
  password manually and asked what link he uses and where it lands. **Waiting on that reply.**
- **🟡 Two override records may exist in parallel.** The Overrides admin page lists rows the
  `ccsoccer_registration.override_expires` query does not obviously correspond to. If Aug 28 passes
  with no reminder to mpalmerston, `dailyOverrideReminders()` is querying the wrong store and should
  read the **Override entity** instead. This would also explain why nobody ever noticed the
  `user_id` bug — that code may never have matched a row either.
- **🟡 Cancelling a waitlist entry does not revoke the override.** Two rows expired Aug 6 still show
  under "Active Overrides (3)" on PROD.
- **🟡 Game Status form returns 502 after confirming.** The work completes — games saved,
  notifications sent, fields stamped, nothing in watchdog or the PHP error log. Cause is almost
  certainly `processNotificationQueue()` draining up to 50 items with live SMTP and Clickatell calls
  **synchronously inside the request**. Harmless on one game; a full-season cancellation will be
  slower. Pre-existing, not introduced today.
- **🟡 UI: password fields on the profile form.** Group them visually and add a "don't know your
  current password? request a reset" link to `/user/password`. Small, and it gives migrated and
  passkey-only users the exit that already exists.

---

## Aug 12, 2026 — admins can delete a player photo; players must have one to register

`field_player_picture` is `required: true`, so nothing in the UI could clear it — the widget's
Remove button works, but Save then fails with *Picture field is required*. New admin-only endpoint
`PlayerAdminController::deletePicture()` clears the field with a direct `save()`, which skips entity
validation. **Merged as `e30256e` (PR #134). Requires `drush cr` — new route.**

That exposed the gap the other way: `required: true` is a form constraint only, so accounts predating
the field — or one an admin just emptied — could still register with no photo. `RegistrationController`
now gates `addSeasonToCart()` and `addTournamentToCart()` after the age check and *before* the cart is
touched, with a warning banner on `/register`. Admins bypass. **Branch `feature/require_photo`, LOCAL
only.**

---

## Aug 7, 2026 — dead self_score field, skill scale, two admin UI fixes

**[Updated Aug 8] Merged as PR #131** — `ba495ea`, `5fb6baa`, `83813db`. This section used to say
"Uncommitted in the working tree," which it was when written and is not now.

> **Deploy:** `drush cr` **plus a one-file config import** — `cr` for the new `TournamentController`
> constructor argument (a stale container fatals the Tournament pages) and to bust JS/CSS aggregation
> for the two UI fixes; the import for the `field_skill_level` description change.
>
> **[Aug 8] This originally read "`drush cim` then `drush cr`", which conflicted with
> `OUTSTANDING_ISSUES.md` §0.7 ("no `cim`"). Both are now reconciled and neither is what you should run.**
> §0.7 already covered `cr`. The config side is **one file**, so use a targeted partial import rather
> than a blanket `cim` — command and the `config_ignore` caveat are in the §0 box of
> `OUTSTANDING_ISSUES.md`. **Do not run `cex`.**

**1. `Registration.self_score` was still being read in three places.** Surfaced by the empty
Skill (Self) column on the Season players page. `ccsoccer_update_9048()` moved the field to
`User.field_self_score`; all three sites guarded with `hasField()`, which returns FALSE for a
field that no longer exists, so they failed silently — the `field_has_jersey` shape again.
`SeasonController:592`, `TournamentController:982` (both display columns, empty) and
`TournamentController:821` (every player defaulted to 3, so all tournament teams computed the
same average). The two calculators now delegate to `TeamBalancerService::getPlayerSkill()`;
`TournamentController` gained the service as a constructor argument. Display columns keep a raw
read on purpose — they report what the player said, so they stay blank when unset rather than
falling back.

**2. The 1-10 skill scale was never real, and `Team.php` was halving for it.**
`calculateSkillLevelFromPlayers()` divided `field_skill_level` by 2 to "convert 1-10 to 1-5",
capping `calculated_skill_level` at 3.0 — which made the scheduler's `>= 4.0` field tier
unreachable and inflated gaps against teams written by the correct path. Removed, along with the
`(1-10)` field description it was derived from (`config/sync/field.field.user.user.field_skill_level.yml`,
`ccsoccer.install:421`). Already settled in `archive/TOURNAMENT_SKILL_DISPLAY_FIXES.md:318`; the
description outlived the decision.

⚠ **Tournament scheduling behaviour changes.** Team skill averages stop being uniformly 3.00, so
`getSkillGapTier()`, `getFieldTier()` and `assignFieldBySkill()` start producing real matchups.
Existing halved rows self-repair on any GET of the Tournament Teams page, which recomputes.

> **[Aug 8] Partly verified — Andrew, on LOCAL.** Team skills were updated and a roster was created;
> both looked correct. **That confirms the inputs**: skill averages are real rather than uniformly
> 3.00, which was the actual defect.
>
> **Still unverified, deliberately:** no schedule has been generated. The SLO tournament is next month,
> teams are still being created, and scheduling has not started — so **`getSkillGapTier()`,
> `getFieldTier()` and `assignFieldBySkill()` have not yet run against real (non-3.00) values.**
> That is the half of this warning that has not been exercised, and there is plenty of time.
>
> **What to do:** generate one throwaway schedule when you begin scheduling, before the real one, and
> sanity-check the matchups and field assignments. Not tracked in `OUTSTANDING_ISSUES.md` — it is a
> one-off pre-flight check on work already merged, not an open defect.
>
> **PROD note:** LOCAL rows recomputed on view. PROD rows still hold halved `calculated_skill_level`
> values and will self-repair the same way on the first GET of Tournament Teams after the §0 deploy.
> No action needed, but do not be surprised by the numbers moving.

**3. Two UI fixes.** Player photo hover popup 300px → 600px, viewport-clamped and flipping left
when it will not fit (`js/season-players.js`; the library is shared by four admin pages).
My Schedule now shows the opponent in the user's own games — `renderTeamCell()` evaluated
visibility per *cell* rather than per *game*, so the opponent's half of the user's own game was
blanked by the same rule that hides other people's games (`ScheduleGridBuilder:405`, muted via a
new `.opponent-cell` rule). `renderCancelledGameCell()` already had the game-level logic, which is
what pointed at it.

**Not done:** `ReportController::mySchedulePdf` is a separate code path and may still show only the
user's team. `Team.php`'s one caller sits behind a `=== NULL` gate that
`TournamentController::teams` almost always closes first, so that method is near-dead —
`OUTSTANDING_ISSUES.md:417` proposes deleting it outright.

---

## Aug 6, 2026 — Decision 2 analysed (docs only, no code)

**`DECISION_2_ANALYSIS.md` written.** Every registration-status call site in the module was enumerated and
each one opened to confirm the entity (`status` is a field name shared with eight other entities, which is
how the earlier counts drifted). Four options costed, recommendation made, then a second verification pass
re-checked every line number against `8e7b0be` and found seven errors in the first draft, all corrected.

### Findings worth keeping

- **There are four conventions, not three.** Six sites use `->condition('status', 'cancelled', '<>')` — a
  partial exclude-list that treats `expired` as **live**. Four of them are duplicate-purchase guards
  (`RegistrationController` :998 :1089, `OrderCompleteSubscriber` :392 :631). They are wrong under D-4 and
  wrong under the allowlist, so they need fixing whichever way Decision 2 goes.
- **`waitlist` has two contradictory definitions in the tree right now.** `LiveRegistrationTrait` says
  live (D-13, deliberately); `AdminController:270` counts it as a *not-registered* waitlist column on the
  seasons overview. Invisible only because nothing writes the value. That column is also structurally
  always 0 — the real waitlist is the separate `ccsoccer_waitlist` entity.
- **The season capacity checkpoints disagree on `reserved_spots`, not just on status.**
  `Season::getSpotsRemaining()` subtracts it; `CartEventSubscriber:99` and `OrderCompleteSubscriber:454`
  do not. Unlike the status question this is **not** behaviour-neutral: a 144-cap season with 2 reserved
  shows *full* at 142 while both purchase paths keep accepting to 144. That is a mechanism for the Aug 4
  shape — **code-derived, not confirmed against the incident data.** Seasons 47 and 48 are absent from
  `roster_audit_LOCAL_2026-08-06.txt` entirely, so that file cannot speak to either incident. One PROD
  query would settle it, and P6 should not be designed before it is asked.
- **`OrderCompleteSubscriber` uses two conventions 68 lines apart** — paid-only for the season cap at
  `:454`, allowlist for the group cap at `:522`, inside one method.
- **All 5,107 rows are `paid` or `cancelled`** (7 cancelled). Measured, not inferred — so any conversion
  in any direction is provably behaviour-neutral today, and nothing will warn you the day that stops being
  true. `DECISION_2_ANALYSIS.md` M6 proposes a one-query tripwire for the audit script.
- **Two stale references found in `ROSTER_RECONCILIATION_PLAN.md`.** `:1473` labels four of the six
  partial-exclude sites *"already match D-4"* (they do not) and its line numbers are 25 stale. `:411`
  names `GroupInvitationsForm:769` as the CF3 exemption; that line is now `$form_state->set()` bookkeeping
   — the site that must stay unfiltered is `GroupDissolveService.php:139`. **Acting on RRP:411 as written
  would break CF8.**
- **A second, unreachable `status` field definition** sits at `ccsoccer.module:1536-1836`, gated on an
  entity id that never matches. Its label `'expired' => 'Override Expired'` is the only surviving
  statement of what `expired` was for.

**Recommendation on the table:** exclude-list, with `waitlist` moved to the dead set — Option A's
benefits without its one real failure mode, costing one line in the trait and an amendment to D-13.
The 49-vs-2 site count is the honest argument the other way and is stated as such in §6.

---

## Aug 5, 2026 — D-8 decided, specified, and shipped to `main`

**Session 1 (docs only).** Researched Caleb's option D in full and wrote `GROUP_ID_AT_ACCEPT_BRIEF.md`. The
trace found `Invitation.group_id` is load-bearing in **29 places, not the seven** the previous handoff
estimated. Two adversarial review passes found **six blockers, two of them inside the fixes for the first
four** — the concurrency design was wrong three times running. Final estimate ~25–30 hrs with 12 open
questions. Andrew pushed back; `D8_OPTIONS_COMPARISON.md` was written to compare honestly, and the numbers
supported him: **~9–11 hrs vs ~25–30, none of the 29 sites touched, no concurrency changes, code-only
rollback.** Andrew chose **CF10 + B + E**. Option D is **held, not rejected.**

**Session 2 (code).** §10.8 passed on LOCAL — the single largest risk in this project for a week, closed.
Then all three D-8 commits, on branch `fix/solo_group_auto_delete`.

### Findings worth keeping

- **`loadByProperties(['group_id' => NULL])` throws.** It does not return a wrong row set — it compiles to
  `condition($field, [], 'IN')` and raises `InvalidQueryException`. That single fact is why option D could
  not have been shipped incrementally: the first invite after deploy would have fataled.
- **Every missed call site was in the admin surfaces.** `GroupInvitationsForm::submitForm()` has *three*
  loops, not one; `acceptInvitation()` overwrites `group_id` with no "already in a group" guard, unlike
  both player-facing paths; and `mergeToGroup()`/`createGroup()` never declined the placed player's other
  pending invitations, unlike every accept path in the module. **Assume there is a third missed site.**
- **The pattern across both reviews:** pure data-flow analysis held up every time. What did not was
  concurrency, Drupal API semantics, and display code.
- **`createGroup()` places two players, not one** — the brief's snippet assumed one. Both now get their
  incoming pending invitations declined; doing only the member would have left the same hole on the other
  player.
- **The `getGroupSize()` off-by-one was real, not assumed.** It is `count($accepted) + count($pending)`,
  both read from the database, so with nothing saved a brand-new group returns 0 where it used to return 1.
  Without the explicit count the group cap would have silently tightened by one.
- **Branch names matter.** `fix/roster_sync` never existed and is still cited in older documents as though
  it did. The real branches were `fix/CF8_dissolve_group` and `fix/solo_group_auto_delete`.

**Doc reset (this session).** Thirteen status-carrying documents were read end-to-end and reconciled against
git and the working tree; every open item was carried into a rebuilt `OUTSTANDING_ISSUES.md`; eight
superseded documents were moved to `archive/`. Thirteen stale claims were corrected — including that all
four D-8 documents still described commit 3 as uncommitted and untested when it had merged as `c7251c6`.
The list is in `OUTSTANDING_ISSUES.md` § *Corrections applied*.

---

## Aug 4, 2026 — two paid-but-unregistered players (Caleb)

Two players paid and got no registration: **order 304** (Tenaya, season 48) and **order 309** (Garret,
season 47). Root cause in both: `OrderCompleteSubscriber::createSeasonRegistration()`'s capacity guard
fired correctly — the season was full at the instant payment completed — so no registration was created,
but the order was still charged and marked completed. **The guard did what it was designed to do.** The
gaps are that nothing surfaces it to an admin, and that `sendRegistrationConfirmation()` fires regardless,
so both players were emailed that they were registered. That is why neither self-reported.

Both were fixed on PROD with one-off `drush scr` scripts mirroring `createSeasonRegistration()` exactly
(registrations 5426 and 5427), and both orders got a `ccsoccer_flag_resolved` key in `commerce_order.data`
as an audit trail. Season 48 is hard-capped at 144 "for many reasons" — **not to be raised**; Caleb used one
of the two seats reserved for pending waitlist offers.

The systemic fix — a `CapacityManagerService` plus three checkpoints — is designed and **not built**.
It is **P6** in `OUTSTANDING_ISSUES.md`.

**Detour worth remembering:** *"Order 334"* in the Commerce UI is `order_number`, not `order_id`. That order
is `order_id = 304`.

---

## Aug 4, 2026 — roster sync: CF1, CF2, CF12, CF8, CF4, CF5

Six fixes, branch `fix/CF8_dissolve_group`, merged as PR #124. **`ddev drush cr` is mandatory** — four
constructor/service changes landed.

### The finding worth reading: CF1 unmasked a data-corruption bug

Andrew tested the morning's work and hit a three-way split-brain roster. Sequence: register for a
tournament → captain invites → accept → **admin cancels** → register again → re-invite → accept.

`Team.players` had the player; `Registration.team` on the new row was NULL. The captain's page and
Tournament Teams showed them on the team; My Registrations and the Roster Builder showed them unassigned;
the player saw *"You have joined Mac Attack"* and *"This registration has been cancelled"* on the same
screen.

Root cause was a bare `reset()` at `RegistrationController:134`. `loadByProperties()` orders by id, so it
took the **oldest** row — the cancelled one — and wrote team linkage onto a dead registration.

**CF1 did not cause it, but it did unmask it.** Before CF1, cancelling left `Registration.team` set, so the
"already on a team?" guard fired and blocked the write. CF1 correctly clears `team`, removing the guard that
had been accidentally masking the bug. That is why CF4 was promoted four commits early.

Note the site has **two Accept buttons routing through different controllers** — My Registrations goes to
`GroupController`, the Register page to `RegistrationController`. Both were broken.

---

## Key Facts / Gotchas

### Scheduled jobs — cPanel cron, not hook_cron
**cPanel has been running Drupal cron all along** — this was not discovered until Aug 22, and not
knowing it is why the 3pm reminder was originally built as an hour check inside `hook_cron()`.
cPanel → Advanced → Cron Jobs:

| Env | Schedule | Command |
|---|---|---|
| TEST | `*/15 * * * *` | `drush cron` |
| PROD | `*/5 * * * *` | `drush cron` |
| PROD | `5 15 * * *` | `drush ccsoccer:daily` |

**Aliases do not work in cron.** It runs a bare non-interactive shell that never sources
`~/.bashrc`, so `ccsProdCr` and friends are unavailable — the entries use full paths and
`/usr/local/bin/ea-php83`. Every entry ends `>/dev/null 2>&1`; without it the `*/5` job emails
`ccsoccer@ccsoccer.com` 288 times a day. Errors still reach dblog.

**`automated_cron` (3600) is a fallback only.** Any cron run refreshes `system.cron_last`, so while
the server job is healthy page-load cron never fires. If the cPanel job dies, it takes over within
the hour — which is the point of leaving it non-zero rather than disabling it.

**Anything needing a schedule gets its own drush command and its own cron entry.** Do not add a
clock check inside a hook, and do not bolt a second job onto `ccsoccer:daily` unless it genuinely
wants 15:05 daily.

### 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.

Three `media_library` entries are known, expected drift. Anything else is real.

### 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`.

### site_instance — check it before generating anything on TEST
`Settings::get('site_instance', 'production')` **defaults to production** if the line is missing from
`settings.local.php`. TEST carries real PROD SMTP credentials and a copy of real player data, so a TEST box
missing that line emails real players.
```bash
drush php:eval "var_dump(\Drupal\Core\Site\Settings::get('site_instance', 'production'));"
```

### 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).

### commerce_order.order_number vs order_id
The number shown to customers and in the Commerce UI ("Order 334") is `order_number`, generated
only once an order is placed — **not** the internal `order_id`. They are frequently different
values. Query `order_number` when working from what a customer/admin reports seeing on screen;
`order_id` is what every other table (`ccsoccer_registration.commerce_order`, etc.) actually
foreign-keys against. Bit us on Aug 4 — worth remembering.

### 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
```

### Flood limits bite during manual testing
`invite()` is 10 per 5 minutes per user; `nudge()` is 5 per 10 minutes. Repeated testing as the same
manager will hit these. Use several accounts or wait it out.

### 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).

### Archive before editing
Copy any file you are about to change substantially to `archive/<name>_<date>.<ext>` first. That
convention has already paid for itself several times.

---

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

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

# Code-only deploy (what the current cluster needs — no update hooks, no config)
ccsDeploy && ccsCr
ccsProdDeploy && 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
