# CC Soccer D11 — Session Handoff

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

---

## What this file is

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

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

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

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

---

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

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

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

---

## Where things stand — August 5, 2026

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

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

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

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

---

## Next steps, in order

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

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

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

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

---

## Open questions for Andrew and Caleb

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

---

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

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

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

---

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

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

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

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

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

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

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

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

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

---

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

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

### Findings worth keeping

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

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

---

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

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

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

### Findings worth keeping

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

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

---

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

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

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

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

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

---

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

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

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

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

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

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

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

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

---

## Key Facts / Gotchas

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

# Code-only deploy (what the current cluster needs — no update hooks, no config)
ccsDeploy && ccsCr
ccsProdDeploy && ccsProdCr

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

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

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