# CC Soccer D11 — Session Handoff

**Date:** September 20, 2026 · a Sep 7 entry was added to the session log below (refunds — see the
deploy banner). The Sep 6 revision added an Aug 28 entry; everything else is unchanged from the
August 28 verification pass described below.
The pre-Aug-28 version was archived in full, unmodified, to
`archive/SESSION_HANDOFF_2026_08_28.md`. **Nothing was deleted.**

> ### Deploy state — read this first
>
> **⚠ ~~[Sep 7] The Sep 7 refund work is uncommitted~~ — [Corrected Sep 20] it is merged to `main`
> (`f90f72d`), and its deploy state was NOT checked; check the servers. When it goes out it NEEDS
> `drush updb` on every box — hook `9075` ADDS A COLUMN (`deposit_forfeit_amount` on
> `ccsoccer_registration`).**
> Code without the hook breaks the Captain Deposits page and silently drops writes to the new field.
> No config, so **no `cim`**. Full per-environment table in the Sep 7 entry below.
>
> **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.**~~ **[Corrected Sep 7] Both merged** — PR #139 (`ab5d702`) and
> PR #141 (`e41a18e`), plus PR #142 (`9913815`). **Their deploy state was not verified on Sep 7**;
> check the servers rather than trusting this line. See both entries below.
>
> **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.

---

## Where things stand — August 23, 2026

**The Sep 7 refund work is uncommitted.** Eight files in the working tree, `php -l` clean, never run
against a running site. See the Sep 7 entry below.

## Next steps, in order

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.


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

---

## Sep 20, 2026 — the schedule generator was repeating matchups

**Three commits on `fix/round_robin_circle_method`, branched from `main` at `f90f72d`. Unmerged,
undeployed, and never rendered in a browser.**

Andrew spotted Newcastle v Everton in consecutive weeks in Mens 2026 Early Fall. It was worse than
one repeat: **5 pairs met twice, 5 never met at all**, and there was a second back-to-back nobody had
noticed. That season is 8 teams over 7 weeks — 28 slots against exactly 28 possible pairings, so a
flawless schedule existed. `selectMatchupsForWeek()` chose greedily with no backtracking and could
strand the last two teams on a pairing they had already played. It reported success because its only
coverage warnings compare slot count to matchup count, and 28 == 28 satisfies neither branch.

- **`3c7188a`** — a **Schedule Analysis** section on the schedule builder. Read-only; works on
  schedules that already exist.
- **`added59`** — the **circle method** replaces the greedy selection. ~10% correct before, 100% now.
- **`8fc2ad6`** — three fixes from an adversarial review of the first two, the worst being that the
  analysis section could never open itself once the generator stopped producing defects.

**Early Fall is not retroactively fixed** — generation only. Three Oct 15 games still need swapping
by hand; the swaps are in `OUTSTANDING_ISSUES.md` §P2.

**Verification was extracted methods executed under PHP 8.4**, because there is still no test
harness. Nothing has been rendered or run against a site. **Generate a Late Fall schedule and read
the analysis section before trusting either commit.**

One judgement call worth revisiting: `STREAK_ALERT_THRESHOLD = 4` opens the panel in roughly 64% of
8-team/7-week seasons because two time slots make short runs common. `5` would be quieter. It is a
named constant in `ScheduleBuilderForm` precisely so this is a one-line change.

---

## Sep 7, 2026 — a partial refund was a one-way door (uncommitted)

**Eight files in the working tree on `main`, nothing committed, nothing deployed, no update hook run.**
`php -l` passes on all of them. **Nothing has been exercised against a running site** — no LOCAL test, no
`drush updb`, no click-through. Three commit messages are drafted in `COMMIT_1_trait.txt`,
`COMMIT_2_cancel_forms.txt` and `COMMIT_3_deposits.txt` at the repo root; `COMMIT_MSG.txt` is a superseded
single-commit version. **All four are scratch — delete them.** Two stale `.git/index.lock*` files also
need removing by hand (the sandbox can move files but not delete them).

**The bug Andrew hit.** A tournament captain's order carries two products — the $200 team deposit and
their own $40 entrance fee — on one card charge. Refunding the $40 worked. Going back for the $200 was
impossible: the Refund operation had vanished from the order's Payments tab.

**Root cause, four lines.** Both cancellation forms overrode the payment state immediately after the
gateway had already set it correctly. `AcceptJs::refundPayment()` ends by writing `partially_refunded`
while part of the payment is outstanding and `refunded` only once all of it is back;
`CancelRegistrationForm:600` and `TournamentCancelRegistrationForm:459` then re-saved as `refunded`
regardless. `PaymentGatewayBase::canRefundPayment()` allows a refund only from `completed` or
`partially_refunded`, so the operation's access went FALSE and `PaymentListBuilder` filtered the link
away. The direct operation URL is gated by the same check, so the money was unreachable from every
direction. **No money was ever misstated** — `refunded_amount` was already correct, and
`Order::getTotalPaid()` reads the `completed` *timestamp* via `isCompleted()`, not the state.

**Worth knowing: the Captain Deposits page had a second, unrelated one-way door.** Its Actions column
keyed off `deposit_refund_status`, a single value set to `refunded` by the first refund of any size.
Refunding $40 of a $200 deposit closed the deposit permanently, independent of the payment bug. A deposit
now carries a running balance, with a new `partially_refunded` status keeping the row open and a new
`deposit_forfeit_amount` field so a forfeit after a partial refund keeps only the remainder.

**A third thing found on the way.** `TournamentDepositRefundForm` used to catch a gateway failure, advise
"process manually", then mark the deposit refunded and email the captain a confirmation for money that
had never moved. It now charges first and records nothing on failure, with an explicit "Record only"
checkbox for a refund already issued in the Authorize.net dashboard.

### ⚠ Deploy — this one needs `updb`, and the hook ADDS A FIELD

**Caleb: this is not a code-only deploy.** `ccsoccer_update_9075` makes two entity-definition changes to
`ccsoccer_registration`:

1. **Adds a new base field — `deposit_forfeit_amount`** (`decimal`, precision 10, scale 2, nullable).
   `installFieldStorageDefinition()`, so it **adds a real column** to the `ccsoccer_registration` table.
   Existing rows get NULL, which is what the Captain Deposits report keys its legacy fallback on.
2. **Adds `partially_refunded` to `deposit_refund_status`'s `allowed_values`** via
   `updateFieldStorageDefinition()`. `list_string` stores a varchar, so **no column changes** for this
   half — it is housekeeping so the installed definition matches the code.

**No config files were touched, so `cim` is NOT part of this deploy.** Use the code + `updb` + `cr`
chain, not the four-step one. That also keeps this deploy clear of the unresolved `cim` hazard in
`OUTSTANDING_ISSUES.md` P9 (hook 9071 / `field_sms_body` uuid mismatch) — do not add a `cim` here.

**⬜ NOT RUN ANYWHERE YET. The table below is the plan, not a record.**

| Environment | Run | Then verify |
|---|---|---|
| LOCAL | `ddev drush updb -y && ddev drush cr` | Column exists; `partially_refunded` in allowed values; `/admin/ccsoccer/reports/tournament-deposits` loads; `config:status` unchanged |
| TEST | `site_instance` confirmed `"test"` **first**, then `ccsDeploy && ccsUpdb && ccsCr` | Same three checks. This is also where the Authorize.net half gets exercised — see the testing note above |
| PROD | `ccsProdDeploy && ccsProdUpdb && ccsProdCr` | Same three checks, plus `config:status` still showing only the three known `media_library` entries |

```bash
# 1. the new column
drush sqlq "SHOW COLUMNS FROM ccsoccer_registration LIKE 'deposit_forfeit_amount'"

# 2. the new status value
drush ev "print_r(\Drupal::service('entity_field.manager')->getBaseFieldDefinitions('ccsoccer_registration')['deposit_refund_status']->getSetting('allowed_values'));"

# 3. no lingering definition mismatch
drush updatedb:status && drush config:status
```

**Why the order matters, not just the fact of it.** `ReportController::tournamentDeposits()` selects
`deposit_forfeit_amount` in a raw `$query->fields('r', […])`, so between deploying the code and running
`updb` **the Captain Deposits page throws** — and that page is where every Refund and Forfeit link
lives. Worse in the same window: `SqlContentEntityStorage::getTableMapping()` resolves against the
last-*installed* definitions rather than the code, so the forfeit form's write to the new field is
**silently dropped** — and since the forfeit form no longer writes `deposit_refund_amount`, that
forfeit's amount would be recorded nowhere at all. Deploy and `updb` as one step on every box.

**Rollback:** code-only revert is safe. The column can stay — nothing else reads it, and leaving it
avoids a destructive `uninstallFieldStorageDefinition()`. Only drop it if the change is being abandoned
for good.

### Testing — most of it works on LOCAL despite the manual gateway

Commerce's Manual gateway implements `SupportsRefundsInterface` with **byte-identical** partial/full state
logic to AcceptJs, does not override `canRefundPayment()`, and makes no network call. Neither cancel form
checks for manual — both call `refundPayment()` on whatever gateway the order carries. **So the core fix
is fully reproducible on LOCAL.** The regression assertion: cancel a tournament registration with a $60
Commerce refund on a $240 manual payment, then
`drush sqlq "SELECT state, refunded_amount__number FROM commerce_payment WHERE order_id = N"` must read
`partially_refunded | 60.00` and the Refund operation must still be on the Payments tab. Pre-fix it read
`refunded | 60.00`.

**What LOCAL cannot reach:** `TournamentDepositRefundForm::submitForm()` short-circuits on `$is_manual`,
so the whole new gateway branch — the payment lookup, the abort-on-decline path, and the "Record only"
checkbox, which is not even rendered under manual — needs TEST with the Authorize.net sandbox. Budget a
next-day retest there: authnet only refunds *settled* transactions, and the sandbox settles in daily
batches, so a same-day attempt will likely return "the referenced transaction does not meet the criteria
for issuing a credit."

### Process note — the review earned its keep

Two independent review passes ran before anything was called done. They confirmed the diagnosis end to
end, and found **six real defects in the fix itself.** The worst was self-inflicted: an order-total
fallback for orders with no line item named "deposit" made the cap $240 on the very order that started
this, so the $40 entrance fee became refundable *as deposit* — re-creating the confusion the change
existed to remove. Both deposit forms now refuse rather than guess. Also caught: a payment-selection rule
that could pick a Manual payment and report it as an Authorize.net refund; `catch (\Exception)` that would
not catch the `\Error` AcceptJs raises on a payment with no payment method; and a `#min`/`#max` pair that
would have blocked the "No refund" path entirely, since `#states` hides an element in the browser without
exempting it from server-side validation. **On money code, do not skip this step.**

**Register updated.** `OUTSTANDING_ISSUES.md` Sep 7 edits: D5 (P3), two new P5 items, B-7 / B-8 / B-14 (P10),
K-3 plus a new route-permission item (Security), two additions to Known-broken, and
`DEPOSIT_REFUNDS_HANDOFF.md` reclassified as superseded.


---

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

---

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