# CC Soccer D11 - Session Handoff
**Date:** August 5, 2026 *(Aug 4 entries retained below)*

**Note on this file:** The full narrative history through Aug 2 (bulk notification
verification deep-dive, roster reconciliation session logs, etc.) has been archived
in full, unmodified, to `archive/SESSION_2026-08-04.md`. This trims the live file to
current status + the most recent session + stable reference material, per the direction
already agreed in `DOC_CLEANUP_CONSOLIDATION_WIP.md` step 11. **Nothing was deleted** —
if you need older session detail, it's in that archive file. Review before committing.

---

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

---

## 🤝 Andrew and Caleb need to decide — ~~two~~ **one** open question

~~Both block~~ **Decision 2 blocks** work that is queued. It needs *an* answer recorded, because code is
currently being written against unstated assumptions.

> ### ✅ Decision 1 (D-8) is DECIDED — Andrew, Aug 5. Caleb still to review.
>
> **Chosen: CF10 + B + E.** Clean up the group when it empties; move the mint below the validation
> checks. **Caleb's option D — do not mint a `group_id` until an invitation is accepted — is HELD, not
> rejected.**
>
> | Document | What it is |
> |---|---|
> | **`GROUP_CLEANUP_BRIEF.md`** | **The implementation spec for what was chosen.** ~9-11 hrs, thirteen call sites, one new predicate on `GroupDissolveService`. Reviewed adversarially; six blockers folded in. |
> | **`D8_OPTIONS_COMPARISON.md`** | Why B+E beat D. Side-by-side, pros and cons, and the two arguments that decided it. **Read this first if you only read one.** |
> | `GROUP_ID_AT_ACCEPT_BRIEF.md` | Option D, specified in full. **HELD.** Kept because §4/Appendix A (all 29 `Invitation.group_id` sites), §5.7 and Appendix C are accurate regardless. |
>
> **The two arguments that decided it**, in one line each:
>
> - **Cost.** D re-keys ~20 of 29 `Invitation.group_id` sites, introduces three concurrency races, needs
>   row locking inside the Commerce order transaction, and its rollback needs a data step. B touches
>   **none** of those sites and adds **no** concurrency changes at all.
> - **Failure mode.** If B misses a call site, someone is stranded in a group of one — *today's
>   behaviour*, visible, and Dissolve fixes it. If D's locking is wrong you get a manager-less group, or
>   a player silently ungrouped **after payment**. D swaps a known tolerable failure for an unknown one
>   on the money path.
>
> **What Caleb should know:** his diagnosis was right and D is the better *model*. What killed it was
> cost that was not visible from the original write-up — the concurrency work, **not** the
> `getGroupSize()` rework he flagged, which turned out to be the smallest problem. Two adversarial
> review passes found six blockers in D, two of them inside the fixes for the first four.
> `GROUP_ID_AT_ACCEPT_BRIEF.md` Appendix C is worth his time even with D shelved — it documents where
> this part of the codebase is sharp.
>
> **Also decided by consequence:** **CF10 is reinstated** (D had superseded it — it is the only thing
> that closes route 1). **CF11 (Disband) is now optional** — route 4 clears itself under B; decide after
> four weeks of the audit query. **D-9 mostly resolves itself.**
>
> ~~**Q-B9 must be answered before that code ships**~~ — **ANSWERED Aug 5: no change needed, the premise
> was wrong.** The concern was that `CancelRegistrationForm` and `GroupInvitationsForm` have no
> `groups_locked` guard, so under B *a member cancelling* would dissolve a group after the roster is
> locked. **A member cannot cancel.** Both forms are `administrator`-only (`manage seasons` /
> `generate rosters` — no other role in `config/sync` holds either), and those two forms are the only
> code anywhere that writes `status = 'cancelled'` to a registration. `groups_locked` is enforced on
> exactly four paths, all player-facing, all in `GroupController`; `GroupDissolveService`'s own docblock
> already records that admin paths deliberately skip it. Adding a guard would be a **new restriction on
> admins**, not a gap closed. Full evidence in `GROUP_CLEANUP_BRIEF.md` §11. **One thing left:** run
> `drush role:list` on PROD to confirm active config matches `config/sync` — there is unexplained role
> drift on record from June 16. **Nothing now blocks shipping the D-8 work except testing (§10.8).**

---

### ~~Decision 1~~ *(superseded — kept for the reasoning)* — When should a season `group_id` exist? (plan decision **D-8**)

### See GROUP_ID_AT_ACCEPT_BRIEF.md for Claude's first pass at implementation ###

**The problem.** A season `group_id` is minted the moment a player clicks Invite
(`GroupController::invite()` :932-937), before the invitee has done anything. If the invitation is
declined, ignored, or deleted, the inviter is left as a permanent "group of one". They then **cannot be
invited into anyone else's group** (`invite()` :1025-1034 blocks it), **cannot be merged in by an admin**
(roster builder :311), and **cannot leave** (`leaveGroup()` refuses managers). Only an admin using
Dissolve can free them. It is not data corruption — it is a support ticket.

**Four ways in, all confirmed in code:**

| # | How | Cleared today? |
|---|---|---|
| 1 | Invite fails validation, `group_id` already saved | no — closed by CF10 if built |
| 2 | Invitee declines, or accepts a rival group | **no** — `declineInvitation()` never touches the inviter |
| 3 | Invitee never answers; inviter deletes the invitation | **no** — `deleteInvitation()` hard-deletes, leaves `group_id` |
| 4 | Group had members; all left or were removed | **no** — each path clears only the *departing* row |

**Traced Aug 4 — the rival-invitation half is already handled.** All three season accept paths
(`GroupController` :1484-1500, `RegistrationController` :~510, `OrderCompleteSubscriber` :1029-1042)
decline competing invitations for that season. So if A and C both invite B and B accepts A, C's
invitation *is* auto-declined and stops burning C's cap. **C's `group_id` still is not cleared** — that
is the whole of route 2.

#### The options

**A — CF11 only: a "Disband Group" button.** *For:* explicit, no background mutation, one new path.
*Against:* routes 2-4 still strand people; a player who never knowingly made a group has to find and
understand a button that sounds destructive.

**B — Auto-clear when a group drops to one live member.** *For:* nobody is ever stranded; the invariant
becomes clean. *Against:* needs a "and no pending invitations" carve-out or it dissolves a group out
from under someone waiting on a slow invitee; and it has **six call sites** (decline, delete-invitation,
removeMember, leaveGroup, cancel, admin-decline) — six chances to miss one, which is the duplication
shape this codebase keeps getting bitten by.

**C — Leave `group_id` alone, relax the three guards** so they ask *"are you in a group with anyone
else?"* rather than *"do you have a `group_id`?"* *For:* no new state to keep in sync, fixes the
user-visible problem directly. *Against:* leaves rows that look like groups and are not, so the next
person to write a group query re-introduces it.

**D — Caleb's proposal: do not create `group_id` until an invitation is ACCEPTED.**
*This is upstream of A/B/C and closes routes 1, 2 and 3 at the source* — no acceptance, no group, nothing
to clean up. It also makes the field mean something: *two or more people agreed to be a group*, rather
than *somebody once clicked Invite*. **Route 4 still needs one of A/B/C**, but route 4 is rare and is the
one case where a Disband button reads naturally, because the player really did have a group.
*Cost:* `Invitation.group_id` is load-bearing in seven places (both accept paths, `GroupPane`,
`OrderCompleteSubscriber`, `TeamBalancerService`, two display sites). Each would need to derive the group
from the **inviter** instead — *the group is whatever group the inviter is in, or a new one*. The awkward
part is **`getGroupSize()`**, which counts pending invitations *by `group_id`*; with no group yet it
returns 0 and the cap stops working, so it would need to count by inviter in that case.

**E — Andrew's proposal: deleting the last pending invitation clears a now-empty group.**
Composes with D rather than competing. **The Delete button already exists**
(`ccsoccer-group-manage.html.twig:177` → `deleteInvitation()`); the only gap is that it does not clear
`group_id`. This is option B with **one** trigger instead of six, on an action the player already
understands — *"cancel the invite I sent"* is discoverable in a way *"Dissolve Group"* is not.

#### Recommendation *(NOT TAKEN — see the ✅ box above)*

**D + E, with CF11 demoted to a rare fallback for route 4.** D removes most of the problem rather than
managing it; E gives self-service recovery for the rest; CF11 covers the case where a real group emptied
out. **This supersedes CF10** — if the mint moves to accept time, there is nothing left at invite time to
guard.

**What needs deciding:** whether the `getGroupSize()` rework is worth it. That is the only real cost in D.

> **[Aug 5] Both of those last two claims turned out to be wrong**, and the second one is why this
> recommendation was not taken.
>
> - *"The `getGroupSize()` rework is the only real cost in D"* — it was the **smallest** cost. `Invitation.group_id`
>   is load-bearing in **29 places**, not the seven this write-up estimated, and ~20 of them re-key.
>   The real cost was concurrency: moving the mint to accept time introduces **three** races,
>   one of which produces two manager-less groups neither player can escape.
> - *"This supersedes CF10"* — only if D ships. It did not, so **CF10 is reinstated** and is the only
>   thing that closes route 1.
>
> Full reasoning in `D8_OPTIONS_COMPARISON.md`. The route table and the four options above are still
> accurate and are why that memo could be written quickly.

### See GROUP_ID_AT_ACCEPT_BRIEF.md for Claude's first pass at implementation ###

---

### Decision 2 — What does "live registration" mean? (allowlist vs exclude-list)

**This is not academic. The module currently has three answers, and the one we just standardised on is
the minority.**

| Definition | Sites | Where |
|---|---|---|
| **Allowlist** — `status IN ('paid', 'active')` | **46** | `Season::isFull()`, `AdminController`, `GameStatusForm`, `SeasonCreditEventForm`, `TournamentRosterBuilderForm`, … |
| **`status = 'paid'` only** | **9** | `TeamBalancerService` (×3), `OrderCompleteSubscriber`, `CartEventSubscriber`, `GroupInvitationsForm` |
| **Exclude-list** — `status NOT IN ('cancelled', 'expired')` | **2** | `GroupController` :108, :376 |

**Decisions D-4 and D-13 chose the exclude-list**, and `LiveRegistrationTrait::isDeadRegistrationStatus()`
(CF4, `b46c28b`, 8 classes) implements it. **CF3 is about to add nine more sites on that convention.**
Worth knowing: D-4's stated rationale was *"the full status set isn't confidently known"* — that is
factually wrong. The enum is exactly six values. The decision may still be right; the reason given for it
was not.

#### What is actually written

`Registration.status` enumerates `pending`, `paid`, `active`, `cancelled`, `waitlist`, `expired`. In code:

- **`paid`** — the only creation status (`OrderCompleteSubscriber` :553 season, :877 tournament).
- **`cancelled`** — the only transition (`CancelRegistrationForm` :360, `TournamentCancelRegistrationForm` :281).
- **`active`, `pending`, `waitlist`, `expired` — never written to a registration by any code path.**
  (The `status = 'active'` in `ccsoccer.install` is the **Team** entity, not Registration.)

**So on today's data all three definitions agree on 100% of rows.** Every registration is `paid` or
`cancelled`. There is no observable difference right now — which is exactly why this can be decided
calmly, and why it will quietly diverge later if it is not.

#### Where they diverge — and why `pending` is the crux

**The field default is `pending`** (`Registration.php` :96). Any registration created without an explicit
status — a hand-created row, an admin entity-form save, or a future code path that forgets — lands there.

- **Under the allowlist (46 sites): that player is invisible.** Not counted toward capacity, not on
  rosters, not notified, not selectable. A registration exists and the player does not.
- **Under the exclude-list (2 sites + the trait): that player is fully live.** Counted everywhere.

**That is the real question: should a status nobody writes be treated as live or dead?**

- *Allowlist* fails **closed** — an unknown status is ignored. Conservative for money and rosters; a
  mis-created row simply does not act. But a real player can vanish with no error anywhere.
- *Exclude-list* fails **open** — an unknown status counts. A mis-created row shows up as a wrong count
  or an unexpected name on a list, which someone notices. But it will act on rows that may be junk.

**Direction of harm differs by operation:**

| Operation | Allowlist risk | Exclude-list risk |
|---|---|---|
| Capacity counting | under-counts → **over-fill a season**, turn away a paid player | over-counts → under-fill, recoverable |
| Row selection (CF4) | may find nothing → visible failure | may pick a junk row → silent wrong write |
| Notifications | real player silently gets nothing | extra recipient, harmless |
| Rosters / display | player missing from a list | stray name on a list |

#### What it means for `pending` and `waitlist`

- **`pending`** — the name implies *registered, awaiting payment*, which argues for live. But nothing
  implements that meaning, so nobody has actually decided. **If you keep the allowlist, the field default
  should probably change from `pending` to `paid`** so a forgotten status is not an invisible player.
- **`waitlist`** — vestigial on Registration; the waitlist is a **separate `ccsoccer_waitlist` entity**.
  If it were ever used it is the one case where a single definition cannot serve: a waitlisted player
  *holds a registration* (live for "do they have one") but *does not occupy a roster spot* (dead for
  capacity).
- **`expired`** — never written; D-3 already decided nothing should ever write it to an Invitation, and
  the same logic applies here.

#### The framing worth considering

"Live" is being asked for three different questions, and they may not want the same answer:

1. **Which row is this player's current registration?** (row selection — CF4)
2. **Does this player occupy a capacity slot?** (counting — CF3, and Caleb's `CapacityManagerService`)
3. **Should this player appear on a roster or list?** (display)

Today all three would use the same test. That is an accident, not a decision — and **Caleb's capacity
work is about to add a fourth caller**, which is the forcing function for settling it now.

#### What needs deciding

1. **One definition or several?** If one, which — and does CF3 continue on the exclude-list, or do CF4
   and the trait get converted to the allowlist to match the other 46 sites?
2. **If the allowlist wins:** change the field default from `pending` to `paid`, or accept invisible rows.
3. **Either way:** the 9 `status = 'paid'` sites should be converted to whichever is chosen. They are a
   third convention with no stated rationale.

---

## Aug 5, 2026 session #2 (Andrew + Claude) — §10.8 passed, and D-8 implemented

**Branch `fix/solo_group_auto_delete`.** Not `fix/D8_group_cleanup`, which is what
`GROUP_CLEANUP_BRIEF.md` §14 proposed — the brief has been corrected. Getting a branch name wrong in
these documents has already cost this project once (`fix/roster_sync` never existed and is still cited
in several files as though it did).

### The unblock

**Andrew ran `ROSTER_RECONCILIATION_PLAN.md` §10.8 on LOCAL and it passed.** All six fixes merged in
PR #124 — CF1, CF1b, CF2, CF12, CF4, CF8, CF5 — are linted and exercised, and registration 5088 is
cleaned up. **That closes the single largest risk in this project for the past week**: ~1,100 lines
that had been merged to `main` without ever being run. CF2 in particular had never been exercised at
all before this.

**It does not lift the operating rules.** Those need a PROD deploy, and nothing is on TEST or PROD.

### What was built

| Commit | Contents | State |
|---|---|---|
| `dde90b5` | **Commit 1** — `clearGroupIfOrphaned()` and `clearInviterGroupIfOrphaned()` on `GroupDissolveService`; injections into `GroupController`, `OrderCompleteSubscriber`, `RosterBuilderController`; `services.yml` | committed, linted, `drush cr` run |
| `995e286` | **Commit 2 — CF10.** Mint moved below the validation checks; the `getGroupSize()` off-by-one fix; three paste artifacts deleted | committed, linted |
| *(uncommitted)* | **Commit 3 — B + E.** Thirteen call sites, six files, +218/−4 | **written, NOT linted, NOT tested** |

All eight touched files were archived to `archive/*_2026-08-05.php` before editing, per the repo rule.

### Three judgement calls worth knowing about

1. **`createGroup()` places two players, not one.** The brief's §6.9b snippet was written for
   `mergeToGroup()`'s single placed player and said "check `createGroup()` for an equivalent." There
   isn't one — it places a manager *and* a member. Both now have their incoming pending invitations
   declined. Declining only the member's would have left the same permanent-carve-out hole the site
   exists to close, just on the other player.
2. **§6.9b adds a decline that does not exist today.** Admin Roster Builder placement now auto-declines
   the placed player's other pending invitations, matching every other accept path. This is the one
   behaviour change in commit 3 that is not pure cleanup, and it is called out in the commit message.
   Without it, §9.4 query B can never reach zero and §12 would misdiagnose that as a missed call site.
3. **§6.3's "twin" is confirmed not a call site.** `RegistrationController::acceptSeasonInvitationDirectly()`
   warns and returns without declining, leaving the invitation pending — exactly as Q-B5 recorded.

### Verified rather than assumed

There is no PHP in the authoring sandbox, so **nothing written this session could be linted there** —
Andrew linted commits 1 and 2 on LOCAL. What was checkable without PHP was checked: no direct
instantiations of the three classes that gained constructor arguments (so no other caller breaks),
neither controller is defined in `services.yml`, `services.yml` parses with argument order matching the
constructor, brace/paren/bracket balance on every edited file, and every one of the fourteen call rows
resolved to its enclosing method programmatically rather than by eye.

**The `getGroupSize()` off-by-one was confirmed real, not taken on trust** — it is
`count($accepted) + count($pending)`, both read from the database, so with nothing saved a brand-new
group genuinely returns 0 where it used to return 1. Without the explicit count the group cap would
have silently tightened by one.

### Next steps

1. **Lint commit 3's six files, `ddev drush cr`, commit.**
2. **Run `GROUP_CLEANUP_BRIEF.md` §9.2 and §9.3** — 29 cases. If time-boxed, cases **8, 9, 19, 21, 24
   and 27** carry the most weight: the P1/P3 headline case through both Accept buttons, the admin
   invitation-row decline that a wrong §6.8 placement would miss, the new Roster Builder decline, the
   tournament regression, and idempotency.
3. **Run §9.4 queries A and B and record the counts** before deploying.
4. **Add query B to `ROSTER_DATA_AUDIT.sql`.** Still not done. It is the entire safety net for this
   design — the only thing that will reveal a missed fourteenth call site.
5. **Confirm `drush role:list` on PROD** matches `config/sync`, closing the one caveat on Q-B9.
6. Then LOCAL → TEST → PROD, which is what finally lifts the operating rules.

---

## Aug 5, 2026 session #1 (Andrew + Claude) — D-8 researched, specified, and reversed

**Docs only. No code was written or changed.** Three documents added; `SESSION_HANDOFF.md` updated.

### What happened, in order

1. **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 seven** — the handoff's estimate was off by
   4×. Two independent adversarial review passes against the working tree 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, 12 open questions.**
2. **Andrew read it and pushed back** — too much change for the value, given the goal is just letting
   players manage their own groups without admin action.
3. **Wrote `D8_OPTIONS_COMPARISON.md`** — honest pros and cons of cleanup (B+E) vs accept-time mint (D).
   The numbers supported Andrew: **~9-11 hrs vs ~25-30**, none of the 29 sites touched, no concurrency
   changes, code-only rollback.
4. **Andrew chose CF10 + B + E.** `GROUP_ID_AT_ACCEPT_BRIEF.md` marked **HELD**, not deleted.
5. **Wrote `GROUP_CLEANUP_BRIEF.md`**, the implementation spec for what was chosen, and ran it through
   the same adversarial review. **Six blockers and nine majors**, folded in.

### The findings worth reading even if you skip the documents

- **`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 be shipped incrementally: the first invite after deploy would have fataled.
- **Three call sites were missed in the first drafts, all 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
  `RosterBuilderController::mergeToGroup()`/`createGroup()` **never decline the placed player's other
  pending invitations**, unlike every accept path in the module. That last one would have kept the
  cleanup's health metric from ever reaching zero, with no call site actually missing.
- **`groups_locked` is enforced on one accept path out of four** —
  `GROUP_ID_AT_ACCEPT_BRIEF.md` §5.7. Pre-existing, unrelated to which option ships, still wrong.
- **The pattern across both reviews:** pure data-flow analysis held up every time. What did not was
  concurrency, Drupal API semantics, and display code — and every missed call site was somewhere admin
  code does something the player-facing equivalent refuses to do.

### Where the work stands

> **[Superseded — see session #2 above.]** Everything in this section was written before §10.8 was run.
> §10.8 has since passed and all three D-8 commits are written; commits 1 and 2 are committed. Kept as
> the record of where things stood when the D-8 research was finished.

~~**Nothing is implemented. The prerequisite has not moved.**~~ PR #124's ~1,100 lines had never been
linted or executed at the time of writing, and `GROUP_CLEANUP_BRIEF.md` calls `GroupDissolveService`
from thirteen places. **`ROSTER_RECONCILIATION_PLAN.md` §10.8 was the first job**, ahead of any of this.

### Next steps *(as of session #1 — see session #2 for the live list)*

1. ~~**Run `ROSTER_RECONCILIATION_PLAN.md` §10.8**~~ — **DONE Aug 5, passed**, registration 5088
   cleaned up.
2. **Run `GROUP_CLEANUP_BRIEF.md` §9.4 query A** — read-only, two minutes. Sizes the stranded-group
   backlog and settles whether CF11 is worth building at all. **Still to do.**
3. **Caleb reviews `D8_OPTIONS_COMPARISON.md`**, then `GROUP_CLEANUP_BRIEF.md`. **Still to do** — 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 rather than about the code being wrong.
4. ~~**Answer Q-B9** (`groups_locked` vs dissolve) before any of that code ships.~~ **DONE Aug 5 — no
   guard needed; both forms are admin-only and there is no player-facing cancel route.** Evidence in
   `GROUP_CLEANUP_BRIEF.md` §11. Residual: `drush role:list` on PROD to confirm active config matches
   `config/sync`. The other eight Q-B questions can be answered while writing.
5. ~~Then implement: commit 1, commit 2, commit 3.~~ **DONE Aug 5** — `dde90b5`, `995e286`, and commit
   3 written but uncommitted.

### Cross-references still to update when the code ships

Listed in `GROUP_CLEANUP_BRIEF.md` Appendix A.

**[Aug 5] The plan-side ones are now done, ahead of the code** — they were describing D-8 as undecided
to anyone opening the plan, which is the document implementers are told to start from:

- ✅ plan §7 **D-8** → DECIDED, with the reasoning and knock-ons
- ✅ plan START HERE "open decisions blocking work" → D-8 struck; no decision now blocks the work
- ✅ plan §10.3 **CF10** → superseded-as-instructions banner pointing at `GROUP_CLEANUP_BRIEF.md` §5
  (which carries the `getGroupSize()` off-by-one fix CF10's own brief does not)
- ✅ plan §10.3 **CF11** → optional, deferred pending §9.4 query B, `notify` corrected to `FALSE`
- ✅ plan §7 **D-9** → mostly self-resolving
- ✅ plan §10.4 constraints + commit-8 row → unblocked
- ⬜ **`ROSTER_DATA_AUDIT.sql` → add the §9.4 query B health check.** Still to do; it is the entire
  safety net for B, so it should land with the code rather than after it.

---

## Aug 4, 2026 session (Caleb) — two paid-but-unregistered players; discovered a systemic capacity race

### What happened

Caleb reported a player (Tenaya, order 334/order_id 304) who paid for Coed 2026 - Early Fall but
had no registration at all. Root cause: `OrderCompleteSubscriber::createSeasonRegistration()`'s
capacity guard correctly fired — season 48 was at 144/144 the instant her payment completed — so
no registration was created, but the order was still charged and marked completed. The guard did
exactly what it was designed to do; the gap is that nothing surfaces this to an admin, and worse,
`sendRegistrationConfirmation()` still fires regardless (it checks cart contents, not whether a
registration was actually created), so **she received an email and checkout confirmation telling
her she was registered.**

A sweep of `commerce_order.data` for the same flag pattern found a second case: Garret McElveny,
order_id 309, season 47 (Mens), blocked the same way on 2026-07-29 (84/84 at the time). Nobody had
gone back to fix his registration even after the season cap was later raised to 112.

Both required a real order-number vs order-id mixup detour early on — **order 334 as shown in the
Commerce UI is `order_number`, not `order_id`.** `order_id = 304` for that order. Worth remembering
next time a UI-displayed order number is used in a query.

### Fixes applied (PROD)

- **Order 309 (Garret, season 47):** season had genuine room (103/112 → confirmed capacity was
  raised). Ran a one-off `drush scr` script mirroring `createSeasonRegistration()` exactly —
  created registration 5426, accepted his pending invitation (140), verified in UI (`season 47
  players` list and his own account both show him correctly).
- **Order 304 (Tenaya, season 48):** season is hard-capped at 144 (explicitly, "for many reasons" —
  not to be raised). Caleb made the call to use one of the two seats reserved for pending waitlist
  offers, accepting the risk that a waitlist offer may need to be walked back if both convert. Ran
  the equivalent script — created registration 5427. Her order had no invitation selected at
  checkout (`ccsoccer_group_selections` was NULL for season_48), so — unlike Garret — nothing was
  linked/accepted; a real pending invitation to her (id 131, from Raquel Bedell) was deliberately
  left untouched, matching what the real code path would have done. Confirmed in UI: Caleb joined
  her group and can see her as registered.
- Both orders' `commerce_order.data` got a `ccsoccer_flag_resolved` key added (flag, resolving
  registration id, timestamp) so there's an audit trail without disturbing the original failure
  flag — matches the "mark resolved" mechanism sketched in `FLAGGED_ORDERS_REPORT_PROPOSAL.md`.
- One-off scripts used: `/tmp/fix_order_309_registration.php`, `/tmp/fix_order_304_registration.php`
  (server `/tmp`, not committed — one-off data fixes, not app code).

### Systemic issues surfaced, not yet fixed

1. **No capacity check exists at add-to-cart.** `addSeasonToCart()`/`addTournamentToCart()` in
   `RegistrationController.php` guard against duplicate registration, inactive seasons, and age
   eligibility — but never check capacity. The only capacity check in the whole flow is the
   completion-time guard in `OrderCompleteSubscriber`, which fires after payment is captured.
2. **Cart-abandonment cleanup — done.** Commerce 3.x ships this natively (Commerce > Configuration
   > Orders > Order types > [type] > Edit > "Delete abandoned carts", cron + Queue API). Caleb
   enabled it (2-day expiration) on **LOCAL, TEST, and PROD** — all three environments now agree.
   **Still needs `drush cex` + commit** — `config/sync/commerce_order.commerce_order_type.default.yml`
   still shows `cart_expiration: {}` in git, so this is config drift sitting uncaptured. Not urgent
   since all three envs already match, but flagged because it's easy to forget — see Next Steps.
3. **`sendRegistrationConfirmation()` sends a false-positive "you're registered" email/receipt**
   regardless of whether a registration was actually created — it gates only on whether a
   `season_registration`/`tournament_registration` product was in the cart. This is why neither
   Garret nor Tenaya self-reported the problem — the confirmation told them everything was fine.
4. **`FLAGGED_ORDERS_REPORT_PROPOSAL.md`'s July 3 deferral ("players will self-report by email,
   that's an adequate backstop") is now demonstrably false** given #3 above and two real incidents.
   Worth revisiting whether to build the proposed admin report/notification.

### Planned fix — design agreed, not built yet

**Scope:** season capacity (definite) + tournament `max_teams` (Caleb: "if it won't introduce more
problems and keeps things aligned" — leaning yes, low incremental cost since the core logic is
shared). Tournament *player* capacity isn't an issue — seasons are what's capped.

**Three checkpoints**, all using one shared definition of "effective capacity" so they can't drift
apart from each other:
1. Page load (register page buttons / spots-remaining text)
2. Add-to-cart (reject before it's added, not after payment)
3. Right before payment is charged (checkout pane validation) — pushes the failure window down to
   seconds; if it still fires, message is "sorry, someone just grabbed the last spot"

**"Effective capacity" formula (Caleb's proposal, agreed):** live paid/active registrations +
draft-cart holds for that product, **but only carts updated within the last N days** (proposed 2,
matches the cart-expiration window). This makes correctness independent of cron ever running —
a stale cart just silently stops counting the moment it crosses the age threshold, no job has to
act for that to be true. Cron (item #2 above) only matters for DB hygiene at that point, not
correctness.

**Where it sits in the stack (discussed, not built):**
- **New service** (pattern-matching `CreditManagerService`/`WaitlistManager`/etc.) — e.g.
  `CapacityManagerService` with explicitly-named methods (`isEffectivelyFull()` /
  `getEffectiveSpotsRemaining()`). **Deliberately not touching `Season::isFull()`/
  `getSpotsRemaining()`** — those are used elsewhere (admin displays, waitlist logic) where a
  strict live-only count is wanted, and silently redefining what they return would be exactly the
  "quietly different definition of the same concept" failure shape this codebase has been bitten
  by repeatedly (see `ROSTER_RECONCILIATION_PLAN.md` §3).
- **Controller layer** — `RegistrationController::getSeasonState()` and `addSeasonToCart()`/
  `addTournamentToCart()` call the new service, consistent with existing `\Drupal::service(...)`
  usage in that file.
- **Checkout pane layer** — a new small pane in `Plugin/Commerce/CheckoutPane/`, placed at the
  `review` step (before `payment_process`), validating in `validatePaneForm()`. Confirmed this is
  the right home: checkout flow order is `player_information → group_invitations → agreements →
  credits → review (payment_information + order_summary) → payment (payment_process) → complete`;
  a pane at `review` runs its validation before the customer can advance into the step that
  actually charges Authorize.net. Matches the existing one-pane-one-job pattern (`AgreementsPane`,
  `CreditsPane`, `GroupPane`, `PlayerInfoPane`, `TournamentTeamPane` already exist there).

**Where this sits in priority (per the doc-cleanup taxonomy in `DOC_CLEANUP_CONSOLIDATION_WIP.md`
§4b):** **Recommended** — not Must (nothing's actively bleeding, both known cases are manually
resolved, no forcing function before the next registration window ~7 weeks out), not
Can/Nice-to-have (two confirmed real incidents with real money is an observed symptom, not a
style preference). Bundle as one cluster with the confirmation-email fix and the flagged-orders
report reconsideration — same root cause, likely same session.

**Effort estimate:** ~7–10 hrs for season + tournament scope; ~5–6 hrs if season-only.

### Next steps

- [ ] **Check in on cart-expiration config.** Caleb applied it manually on LOCAL, TEST, and PROD
      (2-day abandoned-cart expiration, all three envs now match) but it was never `drush cex`'d
      or committed — `config/sync/commerce_order.commerce_order_type.default.yml` still shows
      `cart_expiration: {}` in git. Not urgent since all three environments already agree, but
      it's config drift sitting there until someone exports + commits it. Flagged specifically
      because it's the kind of thing that's easy to forget.
- [ ] Build the capacity-race fix (service + 3 checkpoints), season + tournament scope — not
      urgent, whenever convenient before the next registration window
- [ ] Fix `sendRegistrationConfirmation()`'s false-positive gate
- [ ] Revisit `FLAGGED_ORDERS_REPORT_PROPOSAL.md`'s July 3 deferral decision
- [ ] Add all of the above to `DOC_CLEANUP_CONSOLIDATION_WIP.md`'s harvest (not yet done — offered,
      not confirmed)
- [ ] ~~Take a look at `fix/CF1_tournament_cancel_reg` / `fix/CF2_GroupInvitationsForm` branches
      Andrew pushed Aug 4, once he flags them ready~~ → **superseded, see the Andrew + Claude session
      below.** The live branch is now **`fix/CF8_dissolve_group`** (10 commits, supersedes both of
      those). Not ready for review yet — none of it has been linted or executed.

---

## Aug 4, 2026 session (Andrew + Claude) — roster sync: CF1, CF2, CF12, CF8, CF4, CF5

**Two sessions, morning and evening. Six code fixes written. NONE of it has been linted or executed.**
Branch **`fix/CF8_dissolve_group`**, 10 commits (supersedes `fix/CF1_tournament_cancel_reg` and
`fix/CF2_GroupInvitationsForm`; PR #123 points at the latter and covers only the morning half).

**➡ The working detail lives in `ROSTER_RECONCILIATION_PLAN.md` — read its ▶ START HERE block.**
It has the full state, per-fix status banners, and a consolidated LOCAL test checklist (§10.8).
This entry is the narrative only.

### What shipped

| Fix | Commit | What it does |
|---|---|---|
| **CF1 / CF1b** | `e5317f8`, `8156bc1` | Tournament cancel cleans `Team.players`; handles captains **and co-captains** |
| **CF2 + D2** | `59c12ec` | Admin accept/decline syncs `Team.players`; closes the live PROD fatal |
| **CF12** | `e5317f8` + `59c12ec` | Tournament `invitation_status` written; admin page stops rendering it as Pending. Closes D19 + half of D15 |
| **CF4** | `b46c28b` | `LiveRegistrationTrait::pickLiveRegistration()` — 21 call sites, 8 classes |
| **CF8** | `b22734f` | Season cancel cleans the group; new `GroupDissolveService` + `sendGroupDissolved()` |
| **CF5** | `7c9a7c5` | Roster builder reconciles against every team, not just `reg.team` |

**`ddev drush cr` is mandatory** — four constructor/service changes (`TournamentCancelRegistrationForm`,
`GroupInvitationsForm` twice, `CancelRegistrationForm`, plus the new `ccsoccer.group_dissolve`).

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

Andrew tested the morning 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, and 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 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.

### Decisions recorded (details in the plan, §7)

- **D-12** — a group dissolve declines both `pending` and `accepted` invitations.
- **D-13** — dead registration statuses are `cancelled` **and** `expired`; `waitlist` counts as live.

### Cross-references to your items above

- **Your 🔴 carried-forward "admin accept fatals"** — the fix is written (`59c12ec`), not deployed.
  **Keep the operating rule in force**: the admin Group Invitations page stays read-only for
  tournament groups until it is tested and on PROD. Note `fix/roster_sync` never existed.
- **Your planned `CapacityManagerService`** — its "effective capacity" formula counts *live paid/active
  registrations*. **That definition is already decided**: D-4 and D-13 in the plan settle on excluding
  `cancelled` and `expired` rather than allowlisting, and `LiveRegistrationTrait::isDeadRegistrationStatus()`
  now encodes it in one place used by 8 classes. Please build on that rather than introducing a third
  definition — it is exactly the drift shape you flagged when you cited §3.
- **Your finding #3, `sendRegistrationConfirmation()` false positives** — this is **S1** in
  `ORDER_FLOW_EDGE_CASES.md` (notification preferences suppressing transactional mail), same method,
  adjacent fault. Worth fixing together.

### Not fixed, recorded in the plan §11

- **Phantom captain view** — a tournament registration with no team renders as a manager with an
  Invite a Player form (`GroupController:419`), then errors "No team found for this registration".
- **Destination resolution** in `mergeToGroup()`/`createGroup()` derives the target team from
  `reg.team`, so a ghost member resolves it to NULL. Same root cause as CF5, opposite direction.

### Next steps

1. **Lint and test.** `ddev php -l`, `ddev drush cr`, then plan §10.8. **CF2 is the least-validated
   code in the set** — the reproduction never touched the admin page, so nothing about it has been
   observed working. Clean up registration 5088 first; it still carries bad state from the repro.
2. **CF9** — highest-value remaining. The inline `Team.players` appends do not dedupe, and the
   reproduction went through one of those sites; a second pass would have produced a duplicate roster
   entry. Researched but not written.
3. Then CF3, CF7, and CF10/CF11 once **D-8** is decided (Andrew's call, in the plan §7).
4. `OUTSTANDING_ISSUES.md`, `SEASON_TOURNAMENT_DRIFT_AUDIT.md` and `ADMIN_TOOLING_REVIEW.md` still
   carry July 28 status blocks that contradict all of the above. Folding into your consolidation
   harvest would settle it.

---

## Key Facts / Gotchas

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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