# Tournament Invitation-Only Registration — Proposal

**Written:** August 23, 2026 · **Revised:** August 23, 2026 (Andrew's responses folded in)
**Written against:** `main` @ `1ecee79`
**Trigger:** SLO Friendly 2026 (Sept 5, ~2 weeks out). `max_teams` is reached, several teams still have
pending invitations, and registration must stay open for those invitees — but not for new free agents.

**Status: approved, not built.** Implementation is scheduled for the next session. Nothing in this
document claims anything has been fixed — the tracker owns that.

---

## Revision note — what changed in this draft

Reviewed against Caleb's Aug 22 commits (`8dc00a3` … `1ecee79`, merged and deployed to TEST and PROD).

**None of the seven files this proposal touches changed.** Verified file-by-file against `7694d91`:
`RegistrationController.php`, `TournamentTeamPane.php`, `Tournament.php`, `TournamentForm.php`,
`ccsoccer.services.yml`, `OrderCompleteSubscriber.php`, `ccsoccer.routing.yml` — all unchanged.
**Every line-number citation below still holds.**

### Andrew's decisions, Aug 23

| # | Question | Answer |
|---|---|---|
| 1 | Deploy sequencing | **Moot** — Caleb pushed the pipeline to PROD. Start on a clean branch off `main` |
| 2 | Closed-card wording | **Approved as drafted.** Implement it |
| 3 | Fix F1 (capacity check on the register card) at the same time? | **No.** See §7.3 — deliberate, and the reasoning is worth keeping |
| 4 | Close the CCSoccer pool as well as free agents? | **Yes, close both** |
| 5 | F2 — `addTournamentToCart()` checks neither the close date nor capacity | **Add a past-tournament guard.** Scope and the one thing it must *not* check are in §3.3 item 5 |
| 6 | The in-flight cart problem (old §3.5) | **Not a problem.** Carts expire in 48 hours. §3.5 is rewritten and shrunk |

### One correction I owe you

The first draft said *"9071 is the next free update-hook number (highest today is 9070)."*
**That was wrong when I wrote it.** The hooks in `ccsoccer.install` are **not in numerical order** —
`9071`, `9072` and `9073` sit at lines 4243–4396, *above* `9070` at line 4933 — and I read the tail of
the file rather than sorting. `9072` already existed at `7694d91`; Caleb's `f7b0fb3` added `9073`.

**The next free number is `9074`.** Corrected in §3.3. Worth a habit: sort, don't tail.

```bash
grep -o "^function ccsoccer_update_[0-9]*" ccsoccer.install | grep -o "[0-9]*$" | sort -n | tail -1
```

### Two observations, not asks

- **Both trackers are stale as of the Aug 22 PROD deploys.** `SESSION_HANDOFF.md`'s "Where things
  stand — August 5" section still reads *"None of it is on TEST or PROD"* and still declares the two
  operating rules in force; `OUTSTANDING_ISSUES.md` §0 has not been touched since `a7968a6` (Aug 8).
  The Aug 22 session entries lower down say "deployed to TEST and PROD", and you've confirmed it.
  Nothing here depends on it — flagging it because the top of the handoff is what the next reader
  sees first.
- **The Aug 22 sessions logged ten new open items** as "need entries in `OUTSTANDING_ISSUES.md`",
  and that hasn't happened yet. This proposal will add an eleventh when work starts.

---

## 1. What the code does today

There are exactly **two** callers of `RegistrationController::addTournamentToCart()` — verified by
grep across `*.php`, `*.twig` and `*.yml` in the module and the custom theme:

| Caller | Line | Who arrives this way |
|---|---|---|
| The "Register for Tournament" button on the `/register` card | `RegistrationController:1003` | Anyone browsing `/register` |
| The invite-token branch of `available()` | `RegistrationController:162` | Anyone clicking `?invite=TOKEN` — the emailed magic link **and** the "Register & Join" button in the Pending Invitations banner (`:1551`), which links to the same URL |

That is a genuinely clean chokepoint: **one method decides whether a tournament ever enters a cart**,
and the invited and uninvited arrivals are already separable at that point.

### Six findings

**F1 — The `/register` card has no capacity check at all.**
`getTournamentState()` (`:849`) looks at exactly three things: an existing non-cancelled registration,
`registration_open`, and `registration_close`. `Tournament::isRegistrationOpen()` — which *does*
include `isFull()` (`Tournament.php:509`) — is never called on the public register page. It is used
only on the admin tournament view (`TournamentController:200`) and in the season equivalents.
**This is why the card in the screenshot still reads "Registration Open" with a live
"Register for Tournament" button, even though the tournament is full.** Not a caching artifact.

**→ Ruled: leave it.** See §7.3. This is now a documented design position, not an oversight.

**F2 — `addTournamentToCart()` does not check the registration-close date, does not check capacity,
and does not check whether the tournament has already happened.**
Its guards (`:1136–1156`) are: already-registered → redirect; `registration_visible` false → block;
`status` in `[completed, cancelled]` → block; age; photo. Nothing about dates, capacity, or whether
Sept 5 is in the past.

**→ Ruled: add a past-tournament guard only.** §3.3 item 5, including the one check it must *not*
grow.

**F3 — `CartEventSubscriber` has a capacity check for seasons only.**
`onCartEntityAdd()` (`:92–125`) blocks a full *season* at cart-add. The `tournament_registration`
branch does nothing but duplicate detection. No second line of defence here.

**F4 — The checkout pane already knows exactly who is invited, and already suppresses one option.**
`TournamentTeamPane::getInvitedTeams()` (`:506`) resolves pending team invitations three ways —
session token, `invitee` uid, `invitee_email` — filters to teams in *this* tournament, dedupes by
team, and drops teams whose confirmed roster is full. `validatePaneForm()` re-resolves server-side
(`:239–248`) so a forged `join:<id>` value cannot buy a join. Separately, the **`create` option is
already removed when the tournament is full** (`:142`) and re-validated at submit (`:209`) — which is
where the notice in the screenshot comes from (`:96–100`).

So the pane already has the identity gate we need and already knows how to close an option on
capacity. **`none` and `ccsoccer_pool` (`:145–146`) are simply unconditional.** They are the only two
options an uninvited player can reach, and nothing anywhere else stops that player registering.

**F5 — `registration_visible = FALSE` is not the lever.** It removes the tournament from the register
page query *and* makes `addTournamentToCart()` hard-refuse (`:1137`) — including the token path.
Turning it off blocks the pending invitees too.

**F6 — There is no tournament equivalent of the season's `groups_locked`.** Seasons have a boolean
that blocks invite, accept and leave (`Season.php:179`, enforced at `GroupController:895`, `:1466`,
`:1775`, `:2046`). Tournaments have nothing. That gap is already tracked as **D17** in
`OUTSTANDING_ISSUES.md`. This proposal is *adjacent* to D17 but deliberately not the same thing —
see §3.6.

---

## 2. What "invitation-only" has to mean

Four surfaces have to agree, or a player finds a door that is still open:

| Surface | Today | Required |
|---|---|---|
| `/register` card | "Registration Open" + button | "Registration is invitation only" + no button (unless the viewer holds a pending invite) |
| `addTournamentToCart()` | lets anyone in | invited only |
| `TournamentTeamPane` options | `join` (if invited) · `create` (if not full) · `none` · `ccsoccer_pool` | `join` only |
| `TournamentTeamPane` validation | rejects forged `join`, rejects `create` when full | must also reject `none` and `ccsoccer_pool` |

---

## 3. The design

### 3.1 The field

Add one base field to the Tournament entity, mirroring the existing visibility-flag pattern:

```php
// src/Entity/Tournament.php — after registration_close (weight 4).
$fields['registration_invite_only'] = BaseFieldDefinition::create('boolean')
  ->setLabel(t('Invitation-Only Registration'))
  ->setDescription(t('When checked, only players holding a pending team invitation may register.
    Free agents and CCSoccer-pool registrations are closed. Use this once the team count is final
    so captains can still fill their rosters.'))
  ->setDefaultValue(FALSE)
  ->setDisplayOptions('form', [
    'type' => 'boolean_checkbox',
    'weight' => 4.5,
    'settings' => ['display_label' => TRUE],
  ])
  ->setDisplayConfigurable('form', TRUE)
  ->setDisplayConfigurable('view', TRUE);
```

Weight `4.5` puts it directly under "Registration Closes", where the registration-timing controls
live, rather than down with the display flags. Float weights are already used in this file
(`15.1`, `15.2`, `15.5`), so this is in convention.

**Verified:** there is no `core.entity_form_display.tournament.*` in `config/sync`, so the entity uses
the default form display and the checkbox appears with **no form code and no config export**.

Plus a helper on the entity, `hasField()`-guarded so nothing fatals between the code deploy and
`updb`:

```php
public function isInviteOnly() {
  return $this->hasField('registration_invite_only')
    && (bool) $this->get('registration_invite_only')->value;
}
```

`TournamentForm::populateFromClone()` must also reset it to `FALSE` alongside the other flags
(`TournamentForm.php:67–74`) — a cloned tournament must not inherit last year's lockdown.

### 3.2 One shared resolver — do not copy the invite lookup

The pane's invite resolution is a **security boundary**: it is what stops a forged `join:<team_id>`
buying a fee-waived roster spot. If `addTournamentToCart()` grows its own copy of "is this player
invited", there are then two definitions of invited-ness that can drift apart.

That is not hypothetical in this codebase. `SESSION_HANDOFF.md` (Aug 4) records the split-brain roster
that came from **two Accept buttons routing through different controllers**, both broken in different
ways. The fix belongs in one place.

```yaml
# ccsoccer.services.yml
ccsoccer.tournament_invites:
  class: Drupal\ccsoccer\Service\TournamentInviteService
  arguments: ['@entity_type.manager', '@request_stack']
```

```php
// src/Service/TournamentInviteService.php
/**
 * Pending team invitations this user holds for this tournament.
 *
 * @return array  team_id => invitation_id, ascending invitation id per team.
 */
public function getPendingTeamInvitations($tournament, AccountInterface $user): array

/** Convenience: does this user hold any pending invite for this tournament? */
public function hasPendingTeamInvitation($tournament, AccountInterface $user): bool
```

The body is `TournamentTeamPane::getInvitedTeams()` lines 506–570 moved verbatim — the three-source
union, the tournament filter, the ksort dedupe, and the token-ownership check from
`getTokenInvitation()` (`:351`). The pane keeps only the presentation half: the full-team filter
(`:582`) and the `Markup` labels (`:589`).

**One deliberate difference between the two callers.** The pane drops teams whose confirmed roster is
already full, because showing an unselectable radio is a dead end. The *gate* must not: a player
invited to a team that is momentarily full should still reach checkout and get the existing
"Sorry, this team is now full" message inside the flow, rather than being told at the front door that
they were never invited. So the gate asks `hasPendingTeamInvitation()` (no capacity filter); the pane
asks `getPendingTeamInvitations()` and filters.

### 3.3 Change list — seven files, two commits

**Commit 1 — the past-tournament guard (F2).** Independent bug fix; lands first so it can be reasoned
about on its own.

**Commit 2 — invitation-only registration.** Everything else.

| # | File | Change |
|---|---|---|
| 1 | `src/Entity/Tournament.php` | New base field + `isInviteOnly()` |
| 2 | `ccsoccer.install` | **`ccsoccer_update_9074()`** — `installFieldStorageDefinition()`, guarded by `getFieldStorageDefinition()` exactly as `ccsoccer_update_9002()` does. **9074, not 9071** — see the correction at the top |
| 3 | `src/Service/TournamentInviteService.php` **(new)** + `ccsoccer.services.yml` | The shared resolver |
| 4 | `src/Controller/RegistrationController.php` | `getTournamentState()`: new `'invite_only'` status, set **after** the is-registered check and after the date checks, and only when the viewer holds no pending invite — an invitee must keep seeing a normal Register button. `buildTournamentCard()`: a new branch using the `status-box status-closed` styling, **no button**, wording in §3.4. `addTournamentToCart()`: after the `status` guard (`:1143–1148`) and before the age check, block when `isInviteOnly()` and `!hasPendingTeamInvitation()` |
| 5 | `src/Controller/RegistrationController.php` **(commit 1)** | **Past-tournament guard** — see below |
| 6 | `src/Plugin/Commerce/CheckoutPane/TournamentTeamPane.php` | `buildActionOptions()` takes the invite-only flag and omits **both** `none` and `ccsoccer_pool`. `validatePaneForm()` rejects both server-side (mirrors the `create` re-check at `:209`). `buildPaneForm()` renders a dead-end notice when invite-only and the player has no invites — §3.5 |
| 7 | `src/Form/TournamentForm.php` | Reset the flag on clone |

Not touched: `OrderCompleteSubscriber`, `GroupController`, `CartEventSubscriber`, the Team entity,
the roster builder, the scheduler.

#### Item 5 in full — the past-tournament guard

```php
// In addTournamentToCart(), alongside the existing status guard.
//
// Belt-and-braces for a tournament whose 'status' was never moved to
// 'completed'. Uses end_date so multi-day tournaments stay open on their
// final day; falls back to start_date if end_date is somehow empty.
$end = !$tournament->get('end_date')->isEmpty()
  ? $tournament->get('end_date')->value
  : $tournament->get('start_date')->value;
if ($end) {
  $end_date = new \DateTime($end);
  $end_date->setTime(23, 59, 59);
  if (new \DateTime() > $end_date) {
    $this->messenger()->addError($this->t('@tournament has already taken place.', [
      '@tournament' => $tournament->label(),
    ]));
    return $this->redirect('ccsoccer.register');
  }
}
```

> ### ⚠ This guard must NOT grow a `registration_close` check
>
> It is the obvious next line to add, and it would break the entire feature.
>
> `registration_close` is how the **card** is closed to browsers. Invited players reach checkout
> through `available():162`, which calls this same method — so a close-date gate here would lock out
> exactly the captains' invitees this change exists to let in. The close date is deliberately
> display-only on this path.
>
> **This comment belongs in the code**, not only in this document. A future reader looking at
> `addTournamentToCart()` will see a date check that ignores the field literally named
> "Registration Closes" and will want to fix it.

Both fields are `datetime_type: date` and `setRequired(TRUE)`, so `end_date` is present in practice;
the fallback costs one line. Nice side effect: **after Sept 5 the tournament closes itself**,
checkbox or no checkbox.

Not applied to `addSeasonToCart()` — seasons run for weeks and the equivalent question is different.
Worth its own item in the tracker.

### 3.4 Who sees what afterwards

**The closed-card wording** — revised Aug 23 after Andrew saw it on LOCAL. The original single
message pointed everyone at "Pending Invitations above" and was wrong for **both** audiences:

- **Anonymous visitors have no banner at all** — `available():189` only builds it for logged-in users,
  so the card told them to check something that was not on their screen.
- **For a logged-in user it can never mean this tournament.** The banner matches on `invitee_email`;
  the card matches on `invitee` uid **or** `invitee_email`. So any invitation the banner would list
  here also satisfies `hasPendingTeamInvitation()`, which sends `getTournamentState()` down the
  open-card path with a live Register button instead. A logged-in player looking at the
  invitation-only card provably holds no invitation for it.

The message now varies by authentication state, because the useful next step does.

**Anonymous:**

> **Registration is invitation only.**
> If your captain invited you, use the link in your email, or log in to check for a pending invitation.
>
> **[ Log in ]**

Logging in genuinely changes the outcome — hold an invitation and this card becomes a Register button
with the banner above it — so the card offers the door rather than just naming it. The link's
destination is `/register`, **not** `add_tournament`: sending them at the cart would run them into the
gate and greet them with an error.

**Logged in:**

> **Registration is invitation only.**
> If your captain invited you, use the link in your email.

The only honest instruction left. The likeliest reason a logged-in player is here is that the captain
invited a different address than the one on their account — see the limitation in `OUTSTANDING_ISSUES.md`.

The gate's own error message in `addTournamentToCart()` lost the same pointer for the same reason.
That route is `_user_is_logged_in`, so it needs no anonymous variant.

| Player | `/register` card | Checkout pane |
|---|---|---|
| Holds a pending invite (link, or matched by uid/email) | "Registration Open" + **Register for Tournament** | **Join *TeamName*** only. Preselected if it is their only invite (`:89–94`) |
| Invited to two teams | same | one radio per team, no other options |
| No invite, not registered | Invitation-only card, no button | unreachable — the gate stopped them |
| Already registered | "✓ You Are Registered" / Manage Team | n/a |
| Invited *after* they registered | unchanged — accepts from My Registrations / manage-group via `acceptTeamInvitationDirectly()` (`:395`) | n/a |

Captains are unaffected: they keep inviting, nudging and filling rosters exactly as now.

### 3.5 Carts already in flight — resolved, not an issue

The first draft treated this as the main edge case, on the assumption that Commerce carts persist
indefinitely. **They do not on this site.** Confirmed in
`config/sync/commerce_order.commerce_order_type.default.yml`, exported by Caleb in `9c6ec92`:

```yaml
third_party_settings:
  commerce_cart:
    cart_expiration:
      number: 2
      unit: day
      anonymous_only: false
```

`commerce_cart_cron()` queues expired carts through the `CartExpiration` queue worker, and PROD cron
runs every 5 minutes (`f7b0fb3`). So an abandoned cart is gone within roughly 48 hours, for
authenticated users too.

**Consequence:** the affected population is only "carts touched in the last 48 hours", it drains on
its own, and with two weeks to the tournament it is self-correcting. Andrew's call — accept it. No
pre-flight cart census, no data cleanup, no drush command.

**What still needs building** is the one case that is not about *time*: an uninvited player who has
the tournament in a live cart and returns to `player_information` within the 48 hours. With `none`
and `ccsoccer_pool` gone and no invite, the radios have zero options and `#required` is TRUE — they
would hit *"Team Selection field is required"* with nothing to select. That is a trap, so
`buildPaneForm()` renders a short notice ("Registration for this tournament is now invitation-only…")
plus a link to `/cart` to remove the item, and validation blocks Continue. Small, and it also covers
a stale form posted from a tab left open across the flip.

**Deliberately not gated at order-complete.** A player parked at `review`/`payment` with `action`
already saved as `none` can still complete. Blocking there would charge the card and create no
registration — precisely the Aug 4 incident (orders 304/309): the capacity guard fired correctly, the
payment went through anyway, and the confirmation email told two players they were registered when
they were not. Within a 48-hour window, letting one or two through is strictly better than repeating
that shape two weeks out.

### 3.6 What deliberately does not change

- **Captains keep inviting.** No tournament-level invite lock. That is **D17**, and it is a
  *different* switch — this one closes the front door while leaving captains' doors open, which is
  the whole point. When you later want to freeze rosters before scheduling, D17 is the one to build.
- **The order-complete path is not gated.** §3.5, and the reason matters more than the rule.
- **Players invited after registering** keep accepting through My Registrations. They already paid.
- **Seasons.** Untouched.
- **`isRegistrationOpen()`** stays as-is and stays unused on the register card. §7.3.

---

## 4. Deploy

**Clean branch off `main` @ `1ecee79`.** The pipeline is on PROD, so there is nothing to sequence
around and no operating rules to work under.

Suggested: `feature/tournament_invite_only`.

| Step | Command | Why |
|---|---|---|
| Schema | `drush updb` | `ccsoccer_update_9074()` installs the new base field |
| Container | `drush cr` | New service definition |
| Config | **none** | No form-display config for `tournament`, so the checkbox needs no export. Confirm with `drush config:status` — expect only the three known `media_library` entries |

Do **not** run `cex`. Repo convention.

---

## 5. LOCAL test plan

Set up: SLO Friendly on LOCAL at `max_teams`; one team with a pending invitation to a test account
that has no registration; one test account with no invitation at all. Watch the flood limits —
`invite()` is 10 per 5 minutes per user.

**Before flipping the checkbox** (regression — nothing should change):

1. Uninvited player → `/register` → card shows Register → checkout offers free agent + CCSoccer pool.
2. Invited player, magic link → checkout with **Join *Team*** preselected → completes → on roster,
   invitation `accepted`.

**After flipping the checkbox:**

3. Uninvited player → `/register` → card reads invitation-only, **no button**, both sentences present.
4. Same player hits `/register/tournament/603` directly (bookmark / guessed URL) → blocked, redirected
   to `/register`.
5. Invited player, magic link → still reaches checkout → only **Join *Team*** → completes → on roster,
   invitation `accepted`, no deposit charged.
6. Invited player already logged in and registered → Pending Invitations banner "Accept Invitation" →
   `acceptTeamInvitationDirectly()` → joins. Unchanged.
7. Invited player with **two** pending invites → two radios, nothing else, no preselection.
8. **Invited player lands on `/register` before clicking their link** → sees the invitation-only card
   *and* the Pending Invitations banner. Confirms the second sentence of the wording is true.
9. **Stale form:** open the pane as an invited player, flip the checkbox in another window, post
   `action=none` from the stale form → rejected server-side.
10. **Forged join:** post `action=join:<a team you were not invited to>` → rejected by the `:245`
    check. Confirms the extraction to the service did not weaken it.
11. **In-flight cart:** as an uninvited player add to cart, flip the checkbox, return to
    `player_information` → notice + cart link, Continue blocked, no fatal, no "field is required"
    with zero options.
12. **Turn the checkbox back off** → step 1 behaves exactly as before. The switch has to be reversible.
13. Clone the tournament → checkbox is `FALSE` on the clone.
14. Season registration for Mens 2026 – Early Fall → untouched.

**Past-tournament guard (commit 1), independent of the checkbox:**

15. Set `end_date` to yesterday, leave `status` as `registration_open` → `/register/tournament/603`
    → "has already taken place", redirected.
16. Set `end_date` to today → still registerable. Confirms the `23:59:59` boundary.
17. Invited player, magic link, `end_date` yesterday → also blocked. Correct: the tournament is over,
    invitation or not.

Steps 4, 9 and 11 are the load-bearing ones. 4 is what the whole change is for; 9 and 11 are where a
UI-only implementation quietly fails.

---

## 6. The zero-code stopgap — no longer recommended

The first draft offered one: set `registration_close` to a past date, which closes the card while
leaving the invite path open (because of F2). **With implementation starting next session, skip it.**

Recording why it is being dropped rather than deleting it, because the mechanism is now a permanent
property of the system: the invite path bypassing the close date is exactly what §3.3's warning box
protects. The stopgap worked by exploiting the thing we are about to write a comment to defend.

---

## 7. Questions — resolved

**7.1 Deploy sequencing.** Resolved: clean branch, everything is on PROD.

**7.2 Closed-card wording.** Approved as drafted; see §3.4.

**7.3 Fix F1 at the same time? — No, and the reasoning is worth keeping.**

Andrew's position: *"For the tournament, we cap the number of teams. Teams can have up to 16 players
each. So the captains' invitations will limit the number of players. There should not be a capacity
check."*

That is a coherent design position, not a deferral, and it is worth writing down because F1 will look
like a bug to the next reader. **Player capacity is enforced per team, by the captain's roster limit
(`max_roster_size`, default 16, `Team::isFull()`), not per tournament.** `max_teams` caps *teams*;
`8 × 16` is a derived ceiling, not one the register page should be policing. Adding a
tournament-level capacity check to the card would either duplicate that limit or contradict it.

The invitation-only checkbox is the intended control, applied by hand when the team count is final.

**7.4 Close the pool as well as free agents? — Yes, both.**

Andrew: *"We want to stop extra players from registering, and limit them to only captain invites."*
Both `none` and `ccsoccer_pool` are removed from the pane and rejected in validation.

---

## 8. The idea worth its own item — a TD invite into the free-agent pool

Andrew: *"It would be interesting if the tournament director or admin could send an invite that would
place a player in the free agent pool. But I think the admin can just masquerade as the captain and
send the invitation that way."*

**The masquerade route works, but it does something different from what you described**, and the gap
is worth knowing before you rely on it on tournament weekend.

`drupal/masquerade ^2.0` is installed. Masquerading as a captain and inviting a player produces a
normal team invitation: the player registers and lands **on that captain's team** —
`OrderCompleteSubscriber:770` (`token_accept`) appends them to `Team.players` and sets
`invitation_status = accepted`. It does not set `ccsoccer_pool`, and there is no code path anywhere
that creates a pool registration from an invitation. Pool is only ever set by the player choosing
"Play on a CCSoccer Team" at checkout (`TournamentTeamPane:332`) — the option this change removes.

**The two-step admin path that does work today:**

1. Masquerade as a captain → invite → player registers onto that team.
2. Tournament Roster Builder → drag the player from the team to **Pool**
   (`TournamentRosterBuilderController:215` sets `ccsoccer_pool = TRUE`).

⚠ **Two caveats on step 2.** It is a real detour, not a formality:

- **T4 (HIGH, open in `OUTSTANDING_ISSUES.md`):** the pool flag is write-only *in the Roster Builder* —
  dragging a pool player back to Unassigned bounces them straight to Pool, because
  `ccsoccer_pool` is never cleared on that path (`TournamentRosterBuilderController:212–216`,
  commented "pool status is a persistent marker") while the service method it bypasses *does* clear
  it. **The escape hatch is elsewhere and it is perfectly usable:** the Tournament **Players** page
  renders a ✕ next to every Pool badge (`TournamentController:1119`), CSRF-tokened and
  confirm-prompted, hitting `removePoolFlag`. So: get in via the Roster Builder, get out via the
  Players page. Two different screens for one flag — which is the actual shape of T4.
- The player receives an invitation email **from the captain**, naming that captain's team, and then
  gets moved off it. If they look at their My Registrations page in between, the two will disagree.

**Recommendation: log it as a feature request, don't build it now.** The clean version is an
admin-originated invitation whose accept branch sets `ccsoccer_pool = TRUE` instead of appending to a
roster — a new invitation kind, a fourth branch in `createTournamentRegistration()`, and its own email
copy. That is real work and it is not urgent two weeks out. The masquerade + roster-builder path
covers the one-off case this year, with T4 as the thing to watch.

---

## 9. Implementation order for the next session

1. Branch `feature/tournament_invite_only` off `main` @ `1ecee79`. Confirm the working tree is clean
   apart from `roster_audit_LOCAL_2026-08-06.txt` and this file.
2. **Commit 1** — past-tournament guard in `addTournamentToCart()`, including the "must not grow a
   `registration_close` check" comment. Test steps 15–17.
3. `TournamentInviteService` + service definition. Repoint `TournamentTeamPane::getInvitedTeams()` at
   it, changing nothing else. **Run test step 10 here** — before any behaviour changes — to prove the
   extraction did not weaken the forged-join gate.
4. `registration_invite_only` base field + `isInviteOnly()` + `ccsoccer_update_9074()` + clone reset.
   `drush updb`, confirm the checkbox renders on the tournament edit form.
5. Gate in `addTournamentToCart()`. Test step 4.
6. Pane: option removal, validation, dead-end notice. Test steps 9 and 11.
7. Card: `getTournamentState()` + `buildTournamentCard()`. Test steps 3 and 8.
8. Full pass, steps 1–17. **Step 12 last** — the switch has to be reversible.
9. Add the tracker entry to `OUTSTANDING_ISSUES.md` and a session entry to `SESSION_HANDOFF.md`.

Open before step 4: nothing. Everything needed is decided.

---

## Appendix — files read

`src/Controller/RegistrationController.php` · `src/Plugin/Commerce/CheckoutPane/TournamentTeamPane.php` ·
`src/Entity/Tournament.php` · `src/Entity/Team.php` · `src/Entity/Season.php` ·
`src/EventSubscriber/OrderCompleteSubscriber.php` · `src/EventSubscriber/CartEventSubscriber.php` ·
`src/Controller/GroupController.php` (invite/accept guards) · `src/Controller/TournamentController.php` ·
`src/Controller/TournamentRosterBuilderController.php` · `src/Form/TournamentForm.php` ·
`src/Service/CommerceProductService.php` · `ccsoccer.routing.yml` · `ccsoccer.services.yml` ·
`ccsoccer.install` · `config/sync/commerce_order.commerce_order_type.default.yml` · plus
`SESSION_HANDOFF.md` (through the Aug 22 sessions), `OUTSTANDING_ISSUES.md` §0 and
`INVITATION_FLOW_ANALYSIS.md` for context.

Per the repo rule: this document describes how the code works and what should be built.
**It does not claim anything has been fixed.** Once work starts, status lives in
`OUTSTANDING_ISSUES.md`.
