# CC Soccer D11 — Session Handoff

**Date:** August 28, 2026 · an Aug 28 entry was added to the session log below. Everything else is
unchanged from the August 23 verification pass described below.
The pre-Aug-23 version was archived in full, unmodified, to
`archive/SESSION_HANDOFF_2026-08-23.md`. **Nothing was deleted.**

> ### Deploy state — read this first
>
> **Everything described in this file is on PROD, except the Aug 28 and Aug 30 work.**
> Aug 28: two commits on `feature/tournament_roster_csv`, unmerged, undeployed, not yet run against
> a site. **Aug 30: `bb28595` on `fix/waitlist_override_redemption` — unmerged, undeployed, and not
> yet run against a site or linted.** See both entries below.
>
> ⚠ **A manual workaround IS live on PROD right now, ahead of that branch:** season 48 has
> `registration_visible` set back to TRUE so the five outstanding waitlist offer links work. It does
> not reopen public registration — the close date has passed, so the card reads "Registration
> Deadline Has Passed" with no button — but `/register/season/48` is reachable by anyone who has the
> URL until the branch lands. **Set it back to FALSE once the seats are filled or the branch
> deploys.**
> `feature/tournament_invite_only` merged as **PR #136 (`fc5069f`)** and was deployed to LOCAL, TEST
> and PROD on Aug 23. Hook `9074` ran on all three and the field storage was verified on each by
> direct query. See the deploy entry below.
>
> **It is live but inert.** `registration_invite_only` defaults to FALSE on every existing
> tournament, so nothing changes until the checkbox is ticked on a specific tournament.
>
> **Also on PROD as of Aug 23:** Drupal core **11.4.5**, Entity API 1.7, Message 1.9,
> reCAPTCHA 3.5 (`4755bc1`). **WebAuthn is deliberately still on 2.0.0-rc7** — see the third-session
> entry for why.
>
> **This box is first-hand now**, which it has not been before: it used to rest on recollection with
> a caveat saying so, and Caleb checked each server during the PR #136 deploy. Deploy state is still
> the single thing this file is most likely to get wrong — it was wrong for two weeks in August, and
> the §P3 status line in the tracker was wrong twice in one day — so check git and the server when a
> specific fix matters.
>
> The Aug 5 doc reset had archived a handoff that said *"None of it is on TEST or PROD"* and declared
> two operating rules pending a deploy. That deploy has since happened and those statements were
> stale for roughly two weeks. They are corrected below.
>
> **[Aug 23, second pass] The trackers were re-verified against the code at `fc5069f`.** Nine open
> items turned out to be already done, seven more were describing behaviour the code no longer has,
> and the ten items this file had been holding for `OUTSTANDING_ISSUES.md` were finally moved there.
> Details in that file's *Corrections applied*. **This file no longer owns any open item.**

---

## 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.
- **✅ [CLOSED Aug 23] Admin accept of a tournament invitation fatals and half-writes.**
  `GroupInvitationsForm.php` queried a `tournament` field that doesn't exist on the Invitation
  entity. Fixed in `59c12ec` (CF2 + D2 + CF12), which is **now on PROD**. **The operating rule is
  lifted — the admin Group Invitations accept dropdown is safe to use on tournaments again.**
  Kept here rather than deleted because that rule was in force for about three weeks and someone
  may still be avoiding the page. `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.
- **✅ [CLOSED Aug 23] Doc consolidation.** The harvest is finished: the pre-launch checklist landed as
  `OUTSTANDING_ISSUES.md` §P9 on Aug 5, and the last stranded items — the ten from the Aug 22 sessions —
  moved there on Aug 23 (§P4, §P8, §P11). `DOC_CLEANUP_CONSOLIDATION_WIP.md` is in `archive/`; its job
  is done. The three D-8 records (`GROUP_CLEANUP_BRIEF.md`, `D8_OPTIONS_COMPARISON.md`,
  `GROUP_ID_AT_ACCEPT_BRIEF.md`) survived consolidation as intended and are also in `archive/`.

---

## Where things stand — August 23, 2026

**The roster-reconciliation cluster and everything after it are deployed.** PR #124 (CF1, CF1b,
CF2 + D2, CF12, CF4, CF5, CF8) and PR #129 (D-8 = CF10 + B + E) went to PROD along with the later
PRs through `1ecee79`, and update hook `9073` ran on both TEST and PROD. The §0 deploy-blocker
section in `OUTSTANDING_ISSUES.md` is closed out.

**Invitation-only tournament registration is deployed too** — PR #136 (`fc5069f`), hook `9074`, all
three environments, Aug 23, reviewed by Caleb before the pull. Live but inert until the checkbox is
set on a tournament. See the deploy entry below and `OUTSTANDING_ISSUES.md` §P3.

**The Aug 23 module updates are deployed too** — core 11.4.5, Entity API 1.7, Message 1.9,
reCAPTCHA 3.5 (`099e992`, `4755bc1`), all three environments, no hooks, no config. See the
third-session entry below for what was deliberately left alone.

**Nothing is currently merged-but-undeployed.** The Aug 28 work is *un*merged — two commits on
`feature/tournament_roster_csv`.

> ### ⚠ One operating rule is still in force — and it got wider on Aug 22
>
> 1. ~~The admin Group Invitations page is read-only for tournament groups.~~ **LIFTED.** The D2 fix
>    is on PROD.
> 2. **Keep `schedule_visible` OFF while iterating schedule candidates — seasons as well as
>    tournaments.** ⚠ **STILL IN FORCE, and now broader than when it was written.** It guards **TS2**,
>    and since the Aug 22 export change it guards **S2** too. Nothing anywhere now reads per-game
>    `published`, so on a *visible* season or tournament a regenerate puts the candidate schedule in
>    front of players immediately. It lifts when TS2 and S2 are fixed, not when code ships.
>    ⚠ **This is the one thing in the Aug 22 work that made something worse** — the season half of it
>    was previously masked by the `published` filter. Worth knowing before the next regenerate.

---

## Next steps, in order

1. **Finish the §5 test plan** in `archive/TOURNAMENT_INVITE_ONLY_PROPOSAL.md`. The code is on PROD,
   so these are now verification rather than a gate. Not yet exercised: **4** (bookmarked
   `/register/tournament/{id}` still blocked — this is the gate, as opposed to the card), **9**
   (stale form posting `none`), **11** (in-flight cart dead end), **12** (uncheck the box, everything
   reverts — the reversibility guarantee). A UI-only implementation passes every other step and fails
   exactly those four, which is why they are the ones that matter.
2. **Set the checkbox on SLO Friendly** when the team count is genuinely final — Edit Tournament →
   *Invitation-Only Registration*. **Read the two operational gotchas in the Aug 23 deploy entry
   first**; while it is on, unregistered board members and any late captain forming a team are both
   locked out.
3. **Aug 28 — the override reminder test.** mpalmerston's override on Mens 2026 – Early Fall expires
   Aug 29, so the 15:05 run should reminder him with no setup. Check with
   `watchdog:show --type=ccsoccer`, **not** a `sql:query` — see the correction in the third-session
   entry. A miss answers the open question about `dailyOverrideReminders()` querying the wrong store;
   the item is `OUTSTANDING_ISSUES.md` §P11.
4. **Generate one throwaway tournament schedule** before the real one, and sanity-check matchups and
   field assignments. Carried from Aug 7: `getSkillGapTier()`, `getFieldTier()` and
   `assignFieldBySkill()` have still never run against real (non-3.00) skill averages.
   ⚠ **Do it with `schedule_visible` OFF** — see operating rule 2 above; a regenerate on a visible
   tournament is live to players immediately.
**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 (P6), CF7.

> ### ✅ The Aug 22 backlog is harvested — Aug 23
>
> Both Aug 22 entries below used to end with lists headed *"need entries in
> `OUTSTANDING_ISSUES.md`"*. **All ten are now in the tracker** — the schedule-export dead code in
> §P8, the 502 folded into G8 in §P4, and the accounts/overrides/PROD-data items in the new §P11.
> The lists below are left in place as the record of where each came from, marked with where it went.

---

## 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 30, 2026 — waitlist overrides became redeemable; tournament completion date guard

**One commit, `bb28595`, on `fix/waitlist_override_redemption`. Unmerged, undeployed, not run
against a site. `php -l` has NOT been run since the last round of fixes** — the desktop sign-in went
stale mid-session and blocked the lint environment. Structural checks pass (braces balanced,
routing.yml parses, the new route resolves). **Lint or `drush cr` + load `/register` before trusting
it.**

**The bug.** Season 48 had five reserved spots offered to waitlisted players and none of them could
sign up. The waitlist exists to backfill a roster when someone drops out mid-season — which is always
after `registration_close` and always after the season has been hidden. So redemption had never once
worked in the only scenario the feature was built for. The waitlist half was fine throughout; nothing
in the registration path ever asked whether the player held an override before rejecting them.

**The fix.** An override is now a key for a closed door: it lifts `registration_visible` and
`registration_close` for its holder, and nothing else — not `active`, not the `max_players` backstop,
not the registration *open* date. Age exceptions are excluded via a new `getValidGateOverride()`.
Also reorders `createSeasonRegistration()` so the override is consumed *after* the hard blocks rather
than before them: a player who hit either guard was losing their money, their override and their
held seat in one go. Adds a *Re-offer Spot* action for lapsed offers, which renews the existing
override in place and keeps queue position.

**An independent review after the fact caught two High issues in the first attempt**, both now fixed:
age overrides had become gate keys, and cancelling a waitlist entry left a live override behind. The
second was already logged in §P11 as tidiness — correctly, until this branch made it dangerous.

**Also in the commit, unrelated to overrides:** `createTournamentRegistration()` now blocks an order
for a tournament that has already happened, completing the pair `1b317f5` started at cart-add. Carts
live 48 hours, so cart-add alone never covered it. See §P3 D3 — **its "port the season guard
verbatim" instruction was wrong and has been corrected in the tracker**, because gating tournament
completion on `registration_visible`/`status` would break invitation-only registration.

Full analysis, the six patches, the review findings and a ten-step manual test plan are in
`WAITLIST_OVERRIDE_REDEMPTION_PLAN.md` (merged to `main` separately as PR #140). There is no
automated test suite in this module; steps 3, 7, 10 and 12 are the ones that catch a bad
implementation.

**Tracker updated the same day:** §P11 (waitlist-cancel closed, two new follow-ups), §P3 D3 (half
closed, premise corrected), S3 (five flag states → six), S4 (partially relieved), Security (two
untokenised waitlist routes).

---

## Aug 28, 2026 — JS cache-busting fix; tournament roster CSV exports

**Two commits on `feature/tournament_roster_csv`. Not merged, not deployed, not yet run against a
site.**

- **`be48c45`** — every JS-bearing library declared `version: 1.x`, which opts it out of Drupal's
  global cache-busting query string (`JsCollectionRenderer.php:73`), so an edited `.js` file kept its
  old URL indefinitely. Found because the team-name search shipped June 15 still had not reached
  Andrew's normal browser, while working in incognito. 21 module libraries plus the theme's `global`
  were frozen the same way. Versions removed, so `drush cr` busts JS again. ⚠ **Until this deploys,
  anyone who has not hard-refreshed may be running stale roster- and schedule-builder JS.**
- **`da6bdc0`** — two CSV download links on `/admin/ccsoccer/tournament/{id}/players` for the
  tournament director: a roster CSV (row per player) and a team-email CSV (row per team,
  semicolon-joined addresses for BCC). New `ccsoccer.tournament_roster_export` service. Both routes
  return a `Response` and so are theme-independent — the Jersey Report's browser-side CSV is why that
  matters; the reasoning is in the controller docblock.

**Next:** click both buttons on the Players page and check the roster row count against the
"N players registered" line. Then merge and deploy — code-only, no update hooks, no config.

---

## Aug 23, 2026 (third session) — contrib and core updates to all three environments

**Deployed LOCAL → TEST → PROD.** No update hooks, no config. Two commits on
`chore/module_updates_2026_08_23`, merged to `main` as `099e992` and `4755bc1`.

| Package | From → to |
|---|---|
| Drupal core | 11.4.4 → **11.4.5** |
| Entity API | 8.x-1.6 → **1.7** |
| Message | 8.x-1.8 → **1.9** |
| reCAPTCHA | 8.x-3.4 → **3.5** |

Plus 28 transitives carried by core — all patch-level, `0 installs, 0 removals`. Notably
`symfony/mailer` and `guzzlehttp/guzzle` moved, so **email and Clickatell SMS were in the blast
radius**; both verified working on PROD afterwards (see below).

**Verified on each box:** `drupal-version` = 11.4.5, `updatedb:status` = no updates required,
`config:status` = only the three known `media_library` entries.

### Deliberately NOT updated

- **WebAuthn (Passkey) 2.0.0-rc7.** This is the *only* remaining `composer audit` advisory —
  `web-auth/webauthn-lib`, **low severity, no CVE**, in `SimpleFakeCredentialGenerator` (decoy
  credentials for username-enumeration protection; weak only when configured with an empty secret).
  The fix needs `>=5.3.5`, which means bumping the Drupal module. **The available target is
  `2.1.0-beta2` — rc → beta is a move to a *less* stable label, not an upgrade**, on the module that
  controls how people log in. That trade was judged not worth taking today. **P9 #15 says
  `2.1.0-beta1`; the actual available release is beta2.** Before doing it, run
  `composer why web-auth/webauthn-lib` to confirm the module bump even reaches 5.3.5 — if it does
  not, the bump buys nothing.
- **Mailer Plus (DSM+) 1.6.2** — 2.0.2 exists but is listed *"Also available"*, not *"Recommended"*,
  so 1.6.2 is still supported. This is **P9 #20's "symfony_mailer 2.x"**; a major on the thing that
  sends every email. Own session. Standing rule still applies: do not enable the "User" override.
- **Better Exposed Filters 8.0.0-alpha1** — alpha, and 7.1.3 is current. Skip.

**None of the five was flagged as a security release by Drupal's update report** — those render red
with a "Security update" label; all of these were amber "Update available".

### Three process notes worth keeping

- **`ccsDeploy` / `ccsProdDeploy` already run `composer install`.** Running it separately afterwards
  is harmless but redundant — it reports "Nothing to install" because the alias did it. The aliases
  are more than a `git pull`.
- **The first TEST deploy silently did nothing.** The branch had been pushed but **not merged to
  `main`**, and the aliases pull `main`. The tell was in the output: the pull moved two markdown
  files and composer said "Nothing to install" — i.e. the lock file had not changed. **Read what the
  pull actually moved before assuming a deploy landed.**
- **`git status --short` before every server deploy.** PROD's untracked file collision earlier the
  same day is why; TEST and PROD were both checked clean this time.

### PROD verified on live traffic, not just by smoke test

`watchdog:show --type=ccsoccer` after the deploy caught a real player completing a tournament
registration at 15:19: token invitation accepted → added to team 49 → registration 5521 created →
confirmation email sent → SMS to the captain. **That exercises Commerce, the order-complete
subscriber, symfony/mailer and guzzle end to end on 11.4.5.** Invitations and cart adds also firing
normally through the afternoon.

**Two known-open items re-confirmed live, neither caused by this deploy:** the recursion-guard
warning fired on order 609 (benign path, registration created correctly — P8), and
`CartEventSubscriber` is still emitting three notice rows per cart add plus the
`getUserPreference()` debug lines (P8). That chatter is now the bulk of dblog volume, which matters
more since dblog is the primary diagnostic tool.

**And one open item incidentally closed — P9 #3, the vanishing CAPTCHA.** Caleb hit the PROD login
form with a stale saved password: correct CAPTCHA, wrong password, error page, **widget still
rendered**. That is the exact scenario the item was watching for. It also confirms reCAPTCHA 3.5
renders and validates against the live keys, which was the one thing LOCAL could not test (passkey
login bypasses the form entirely). **A successful username+password login was never completed**, so
the post-validation half of that path is still unexercised — though the tournament registration
below means authenticated sessions demonstrably work.

### ⚠ Correction — how to read the `ccsoccer:daily` heartbeat

**The Aug 22 entry below recommends a `sql:query` against `watchdog`. That is misleading and it
misled this session.** `watchdog` stores the message template and its arguments in **two separate
columns** — `message` holds the raw string with `@time` / `@tz` / `@mode` still in it, `variables`
holds the serialized args. **Substitution happens at display time**, so `watchdog:show` and the
dblog UI resolve placeholders and raw SQL does not.

Reading the raw column produced `ccsoccer:daily started at @time (@tz)@mode.` and looked exactly
like a logging bug. **It is not — `CcsoccerCommands::daily()` passes the args array correctly and
needs no change.** Use:

```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 watchdog:show --type=ccsoccer --count=50
```

**This is the command for the Aug 28 override check**, not the `sql:query` in the Aug 22 entry.

**The heartbeat itself is healthy** — PROD logged `15:05:04` today and `15:05:03` on Aug 22, so the
cPanel `5 15 * * *` entry is firing. There is also a one-off `14:05` run on Aug 22: that was a
manual invocation during implementation, not a second cron entry.

---

## Aug 23, 2026 (second session) — PR #136 reviewed and deployed to all three environments

**Caleb's session.** The work below had merged as **PR #136 (`fc5069f`)** while LOCAL was still on
`1ecee79`, so the first job was reading the diff before pulling. Reviewed, then deployed
LOCAL → TEST → PROD.

| Environment | State |
|---|---|
| LOCAL | `updb` (9074) + `cr`. Field storage verified. `config:status` clean |
| TEST | `site_instance` confirmed `"test"` **first**. `ccsDeploy && ccsUpdb && ccsCr`. Field storage verified |
| PROD | `ccsProdDeploy && ccsProdUpdb && ccsProdCr`. Field storage verified. `config:status` shows only the three known `media_library` entries |

One hook (`9074`), no config, on every box. **Live but inert** — `registration_invite_only` defaults
to FALSE everywhere, so nothing changes until the checkbox is ticked.

### ⚠ Two operational gotchas — read before checking the box on SLO Friendly

Both are deliberate design, not defects, and both have the same one-line workaround (untick the box
for a minute). Neither is obvious from the field description.

1. **There is no admin bypass.** The age and photo gates let admins through; this one does not, on
   purpose — the checkout pane has no matching bypass, so an admin would clear the front-door gate
   and hit a dead end one step later with a cart already created. Half a bypass is worse than none.
   **Consequence: any board member who has not yet registered for the tournament cannot register
   while the box is on.**
2. **`create` is closed too, not just free agent and pool.** Invitation-only means the team list is
   closed by definition. **Consequence: a late captain — including a pre-paid, Bakersfield-style one
   — cannot form their team while the box is on.**

The second one also names a limitation worth knowing: **the single flag cannot express "no new free
agents, but a new team is still fine."** It closes all three uninvited routes together. Fine for
this year's trigger (max_teams already reached); worth remembering if the requirement ever splits.

### Review notes on the implementation

Read in full before pulling: the proposal, `TournamentInviteService`, the rewritten pane, the entity,
the controller, the subscriber diff, `TournamentForm`, and the install hook. Conclusion was **take it
as-is**. Points worth keeping:

- **The 259-line service is a move, not a rewrite.** `getInvitedTeams()` and `getTokenInvitation()`
  lifted out of the pane essentially verbatim; the pane keeps presentation only (drop full teams,
  build the `Markup` labels). Low risk for its line count.
- **The `OrderCompleteSubscriber` +25 lines are pure comment.** The proposal said that file would not
  be touched, and behaviourally it was not — the addition is the docblock explaining why
  `createTournamentRegistration()` is deliberately **not** gated on `isInviteOnly()`. Gating it would
  charge the card and create no registration, which is the Aug 4 shape (orders 304/309). Do not
  "fix" it.
- **`ccsoccer_invitation_list` cache tag on `/register`** — without it a captain sends an invitation
  and the invitee keeps seeing a cached buttonless card. Easy to miss; it is there.
- **The dead-end notice tells two cases apart.** "No options" means either *never invited* or *your
  team filled while you were deciding* — the pane drops full teams, the service is capacity-blind, so
  it asks the service which one. Telling the second player they were never invited sends them to
  argue with the wrong person.
- **It fails open.** `isInviteOnly()` is `hasField()`-guarded, so code-without-`updb` reads every
  tournament as open and the checkbox silently does nothing. Safe direction; run them together.
- **Vestigial, not worth its own PR:** `getInvitedTeams()`'s `disabled` key is now always empty while
  `buildPaneForm()` still `array_diff`s against it; `tokenMatchesTournament()` in the pane looks
  fully dead now that token resolution moved to the service.

### Next-year alignment (the reason it was reviewed rather than just merged)

Checked specifically for unwind cost against the team-fee model. **Survives regardless:**
`TournamentInviteService` — invitation is *more* central under team-fee, not less — plus the
card/cart/pane gate structure. **Gets simpler if the deposit model is retired:** the flag's third
effect (closing `create`) stops mattering, because team creation moves out of the pane to the Team
Fee purchase. That is deletion, not rework. **Only real rework** is if both models run side by side,
in which case Tournament needs a field saying which one applies. Nothing here has to be undone first.

**Open product decision, unchanged:** does the deposit/invite model survive next year alongside
pay-for-team, or is it retired? That answer drives whether `tournament_deposit`, both deposit
forfeit/refund forms, the acknowledgment checkbox and the pane's `create` branch all go away.

### PROD deploy blocker — untracked file collision

`ccsProdDeploy` aborted before touching anything:

```
error: The following untracked working tree files would be overwritten by merge:
archive/SESSION_2026-08-22-b.md
```

**A session doc had been written directly on the PROD server during the Aug 22 work, and the same
file was later committed from LOCAL.** Git refuses to clobber an untracked file with a tracked one.
Diffed identical, removed, deploy proceeded. **This pattern will recur if session docs keep being
written on servers** — they belong in the repo.

Three things learned in the process, all worth keeping:

- **`git status --short` first, always.** Git reports these blockers **one at a time**, so clearing
  one can just surface the next.
- **`rm` is aliased to `rm -i` on that server.** An unanswered prompt silently breaks an `&&` chain —
  this cost a full retry cycle. Use `rm -f` in chains.
- **That shell has no process substitution** (`dash`, not bash). `diff <(git show ...) file` fails
  with `/dev/fd/63: No such file or directory`. Use a temp file:
  `git show origin/main:path > /tmp/x && diff /tmp/x path`.

The other untracked entries on PROD are pre-existing and were left alone: `.htaccess`,
`web/.htaccess.backup`, `temp/`, `under_construction.html`.

### Correction to a documented gotcha

**The `config:status` terminal colour error hits PROD too**, not only TEST — this file recorded it as
TEST-only. `Invalid option specified: "bold"`. **Workaround: `--format=json`**, which renders fine and
confirmed the three expected `media_library` entries. Folded into Key Facts below.

---

## Aug 23, 2026 — invitation-only tournament registration (Andrew: design and build)

> **[Deployed — see the entry above.]** This section was written when the work was an unpushed
> branch, then corrected to "merged, not deployed." Both were true when written and both were stale
> within hours. **The design notes below stand; only the status claims moved.** Status lives in
> `OUTSTANDING_ISSUES.md` §P3.

**Four commits, merged as PR #136 (`fc5069f`).** Design and the 17-step LOCAL plan are in
`archive/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 |
| `e9852a2` | Tracked the work in both registers |
| `da0e8cc` | Split the invitation-only card message by authentication state (from Andrew's LOCAL pass) |

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

**[Aug 23, still true] 18 leftovers remain in `.git/`** — `index.lock.stale`, `HEAD.lock.stale`,
`objects/maintenance.lock.stale` and loose `tmp_obj_*` blobs. They are inert and git is working
normally, but clean them from the Mac:

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

✅ `_to_delete/` is **gone** — that half of this note is done.

---

## 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`** (now in `archive/`, since the question it
was written to settle is answered)**.** 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.

### ✅ [Aug 23] These five were harvested into `OUTSTANDING_ISSUES.md`

Kept here as the record of where they came from. **Do not work from this list — work from the
tracker**, which carries the verified line numbers.

- `ContentController::exportPdf()` unreachable · `ReportController::scheduleIcal()` a routeless stub ·
  `Game.published` with no readers · the same duplication on the tournament side → **§P8,
  *Schedule-export dead code and the `published` residue***. All four re-verified in the tree on
  Aug 23; `exportPdf()` is also the method the closed **G5** finding cites, which is now noted there.
- Regs **5073 / 5074** pointing at deleted seasons 43 and 44 → **§P11**, with CF7/B-9 named as the
  family it belongs to.
- ⚠ **One consequence of this session was missed at the time and is now recorded:** removing the
  `published` filter also removed the thing that was masking **S2**. A regenerate on a *visible*
  season now shows players the candidate schedule immediately. See operating rule 2 at the top.

---

## 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 watchdog:show --type=ccsoccer --count=50
```

> ⚠ **[Corrected Aug 23]** This originally recommended a `sql:query` on the `watchdog` table.
> **Do not use that here.** `watchdog` keeps the message template and its arguments in separate
> columns, so raw SQL returns `started at @time (@tz)@mode` with the placeholders unresolved — which
> reads exactly like a logging bug and is not one. `watchdog:show` substitutes them. See the Aug 23
> third-session entry above.

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 — five items that surfaced, ✅ all harvested Aug 23

Kept as the record of where they came from. **The tracker owns them now:** the password-reset loop,
the parallel override records, the un-revoked waitlist override and the profile-form password UI are
`OUTSTANDING_ISSUES.md` **§P11**; the Game Status 502 was folded into **G8** in §P4, because it is
the symptom of the synchronous drain that item already described.

- **🟠 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. **[Updated Aug 23] Merged as PR #135
(`7694d91`) and now on PROD** — this section previously said "Branch `feature/require_photo`, LOCAL
only," which it was when written.

---

## 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`". The config side is one file**, so use
> a targeted partial import rather than a blanket `cim`. **Do not run `cex`.**
>
> **[Aug 23] The code half of this shipped; the config half was never confirmed.** LOCAL was imported
> on Aug 8. Whether the targeted partial import ever ran on **TEST and PROD was never recorded** — if
> it did not, `config:status` on those boxes reports `field.field.user.user.field_skill_level` as
> `Different` permanently. The description is inert, so this is drift hygiene rather than a behaviour
> bug. One command settles it per box:
> `drush config:get field.field.user.user.field_skill_level description`. Carried in
> `OUTSTANDING_ISSUES.md` under the PR #131 entry.
>
> ⚠ **A second config file is now in the same position:** `views.view.jersey_report_view.yml`, changed
> by `28a40d1` on Aug 22 — that is the whole of the Jersey CSV fix, and **board members keep a dead
> button on any box where it has not been imported.** Same treatment: targeted partial import.
> *(The instructions here used to point at a `§0` box in `OUTSTANDING_ISSUES.md`. That section is
> closed and the box is gone — this is the surviving copy.)*

**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.
>
> **[Aug 23] That deploy has landed**, so any PROD tournament team whose page has been viewed since
> has already recomputed. The pre-flight check above — generate one throwaway schedule and
> sanity-check matchups before the real one — **is still outstanding** and is now item 4 in
> *Next steps*.

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

**On the servers it fails with a terminal colour error — on PROD as well as TEST.**
(`Invalid option specified: "bold"`. This file previously recorded it as TEST-only; corrected
Aug 23.) It is drush's table renderer, not a config problem. **Use `--format=json`:**
```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 config:status --format=json
```
`--no-ansi` also works. Both bypass the formatter and print the real answer.

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

### registration_invite_only — what the checkbox actually closes
Tournament edit form, under *Registration Closes*. Defaults FALSE; PR #136, on PROD since Aug 23.
When ticked, only a player holding a **pending team invitation** can register. Enforced on three
surfaces — the `/register` card, `addTournamentToCart()` (the real gate), and the checkout pane
(options removed **and** re-validated server-side). "Invited" has one definition, in
`ccsoccer.tournament_invites`; do not write a second one.

**Two things it does that the field description does not say:**

1. **No admin bypass.** Age and photo gates let admins through; this one does not, deliberately —
   the pane has no matching bypass, so an admin would clear the gate and dead-end one step later
   with a cart already made. **An unregistered board member cannot register while it is on.**
2. **It closes `create` too**, not just free agent and pool — so **a late captain cannot form a
   team while it is on**, including a pre-paid one. The flag cannot express "no new free agents but
   a new team is fine"; all three uninvited routes close together.

Workaround for both: untick for a minute. **Not** the tournament equivalent of
`Season.groups_locked` — that is D17 and does not exist; this closes the front door while leaving
captains' doors open.

⚠ **Do not add a `registration_close` check to `addTournamentToCart()`.** It is the obvious next line
and it breaks the feature: invited players reach checkout through that same method via their token,
so the close date is deliberately display-only on that path. Warning comment is in the code.

⚠ **Fails open** — `isInviteOnly()` is `hasField()`-guarded, so code deployed without `updb` reads
every tournament as open and the checkbox silently does nothing. Ship code and hook together.

### 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 shell quirks — they broke a deploy on Aug 23
The cPanel shell is **`dash`, not bash**, and `rm` is aliased to `rm -i`.

- **No process substitution.** `diff <(git show origin/main:path) path` fails with
  `/dev/fd/63: No such file or directory`. Use a temp file instead.
- **`rm` prompts.** An unanswered prompt silently breaks an `&&` chain and the deploy never runs.
  **Use `rm -f`** in anything chained.

### Never write session docs directly on a server
On Aug 23 `ccsProdDeploy` aborted because `archive/SESSION_2026-08-22-b.md` existed **untracked** on
PROD and the same path arrived tracked. Git will not clobber an untracked file with a tracked one, so
the whole deploy stops. Docs belong in the repo and reach the servers through git like everything
else. If it happens again: `git status --short` first (git reports these blockers **one at a time**),
confirm the copies match, then `rm -f`.

### Reading an incoming change without pulling
To review a merged PR before touching the working tree — useful when a deploy carries an update hook:
```bash
mkdir -p .review-<n>
git show origin/main:path/to/File.php > .review-<n>/File.php
git diff main..origin/main -- path/to/big-file > .review-<n>/big-file.diff
```
Everything is reproducible from the commands, so `rm -rf .review-<n>` when done. Keeps the code pull
and the `updb` — the part with no easy undo — as separate decisions.

---

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