---
title: Add Batch Practice Squad Protection
type: task
status: completed
description: >-
  Allow selecting multiple practice squad players and protecting them in one action, producing a
  single league notification instead of one per player.
base_uri: user:task/league/add-batch-practice-squad-protection.md
created_at: '2026-09-08T14:33:22.319Z'
entity_id: e0d39088-5ae3-4ab6-baf4-cc4c456deb77
finished_at: '2026-09-08T18:21:26.872Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[user:repository/active/league]]
  - relates [[user:task/league/fix-processed-poach-hides-protect-menu-item.md]]
  - relates [[███████████████████████-████████-█████-████████-█████████-██████]]
started_at: '2026-09-08T16:15:48.589Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-09T00:56:44.649Z'
visibility_analyzed_at: '2026-11-05T23:48:08.478Z'
---

# Implementation Plan: Add Batch Practice Squad Protection

## Overview

- Let a team manager select several practice squad players on the team page and protect them in one action, plus a `Protect All` button on the drafted practice squad table.
- Emit exactly one league notification naming every protected player, instead of one Discord message per player.
- Impact: a manager protecting four practice squad players today fires four confirmations, four requests, and four Discord messages. This collapses each to one. `Protect All` covers the common case of protecting a whole rookie draft class.
- Ordering: this follows nothing and blocks nothing. `surface-sanctuary-poaching-periods` is Planned with a live session and its next item is poach state at roster level — the same rows and the same poach selector this work touches. Message that session before starting.

**Citation convention for this plan.** Symbols are named rather than line-numbered wherever a grep resolves them. A prior draft of this plan carried line numbers that had drifted one to four lines throughout; they were all substantively correct and all pointed slightly wrong, which cost a reviewer real time. Grep the symbol.

## Background

Protection is strictly single-player end to end today.

**Server** — `api/routes/teams/protect.mjs` handles `POST /teams/:teamId/protect` with body `{ pid, leagueId }`. It checks auth, `leagueId`, `verifyUserTeam`, `getLeague`, `current_season.is_regular_season`, `roster.has(pid)`, membership in `roster.practice`, not-already-`PSP`/`PSDP`, and no unprocessed `poaches` row. It then updates `rosters_players.slot` and inserts a `PRACTICE_PROTECTED` transaction as two independent statements with no wrapping `db.transaction`, sends an object response, and after `res.send` broadcasts one `ROSTER_TRANSACTION` and calls `sendNotifications` once.

**This route is the outlier in its own directory.** `api/routes/teams/activate.mjs` and `reserve.mjs` contain zero `db(` calls and delegate entirely to `libs-server`; `deactivate.mjs`, `release.mjs` and `add.mjs` have one each. `protect.mjs` has five. `libs-server/submit-reserve.mjs` is the canonical shape — named-parameter entry, validation by throw, slot update, transaction inserts, `sendNotifications`, and a returned `data` array.

**Client** — the only affordance is the `Designate Protected` item in `player-context-menu`, which uses the ad-hoc `showConfirmation` branch. `PROTECT_PLAYER` / `protect(pid)` in `app/core/rosters/actions.js` reaches the saga `protect`, then `api_post_protect`, then `api.post_protect`.

**Three consumers read the protect response, not one.** `app/core/rosters/reducer.js` and `app/core/players/reducer.js` both handle `POST_PROTECT_FULFILLED` object-shaped in a block shared with `ROSTER_TRANSACTION` and the activate/deactivate/reserve cases. The players reducer destructures `payload.data` and then `transaction` out of it, so an array payload throws a `TypeError` — a hard store crash. `protectNotification` in `app/core/rosters/sagas.js` is payload-agnostic and safe.

**A spec already drives this route to 200.** `test/teams.protect.spec.mjs` has two `// TODO` happy-path stubs, but its last error case (`rejects a protect one minute before the Regular Season begins`) asserts `res.status(200)`, `res.body.pid` and `res.body.slot`. Response validation runs whenever `NODE_ENV === 'test'` and there is no holdout entry for this operation, so the object schema is gated today.

**Team page** — `league-team.js` builds signed and drafted practice squad rows as `PlayerRoster` into two `DashboardPlayersTable` sections. The drafted list sorts by draft year then overall pick and slices for a two-year collapse; the unsliced sorted list stays in scope in the same render. `league-team/index.js` supplies `is_team_manager` and does essentially all derivation, while `league-team.js` is presentational. `is_team_manager` is currently passed to active rows only, not to either practice squad row set.

**Existing shared vocabulary** — `libs-shared/constants/roster-constants.mjs` already exports `practice_squad_slots` (all four), `practice_squad_protected_slots` (`PSP`, `PSDP`) and `practice_squad_unprotected_slots` (`PS`, `PSD`), already consumed isomorphically by `app/core/selectors.js`, two reserve-confirmation components, and `libs-server/submit-reserve.mjs`.

**Precedents** — `api/routes/teams/nomination-queue.mjs` rejects an empty array, rejects duplicates, bulk-validates pids in one `whereIn`, and wraps its writes in `db.transaction` using `trx` for every inner statement. `libs-server/auction-settlement.mjs` carries the `provided_trx ? run(provided_trx) : db.transaction(run)` shape for callers that own the transaction. `PUT_ROSTER_FULFILLED` in both reducers is the array-response shape to copy.

**Confirmation dialogs** — `app/views/components/confirmation/confirmation.js` switches on `info.id` across fifteen registered dialog components, falling back to a generic modal whose whole body is a single `<p>{info.description}</p>`. Every neighbouring action in `player-context-menu` uses the registered form; protect is the lone ad-hoc holdout.

### Assumptions

- No caller outside the SPA and `test/teams.protect.spec.mjs` posts to `/protect`. Checked: `grep -rn "post_protect\|/protect"` over `.mjs`/`.js`/`.md` excluding `node_modules` and `dist`; `scripts/reset-protected-designation.mjs` writes the database directly and never calls the route.
- The pause guard needs no per-route work. Checked: `api/routes/teams/index.mjs` mounts `require_league_not_paused` on `/:teamId` above every sub-router; `POST` is not in `SAFE_HTTP_METHODS` and `/protect` does not match `PAUSE_ROUTE_PATH`.
- `roster.practice` includes `PSP` and `PSDP`, so the already-protected branch is live code. Checked behaviorally, not by reading: an existing passing spec seeds a player at `PSP` and asserts that exact error.
- No cap exists on how many players may be protected, and protection is capacity-neutral. Checked: `practice_signed` spans `PS`+`PSP` and `practice_drafted` spans `PSD`+`PSDP`, `availablePracticeSpace` counts the former, drafted practice squad is explicitly unlimited, and no such column exists in `db/schema.postgres.sql`.
- Nothing depends on one `PRACTICE_PROTECTED` row per request or on `occurred_at` ordering within a batch. Checked: `walk-transactions.mjs` keys its events on `(tid, pid)` so same-timestamp rows for different players cannot collide, and these events only decorate an already-open holding.
- The client cannot import `libs-server`. Checked: `webpack/webpack.config.base.mjs` aliases only `@views`, `@pages`, `@core`, `@components`, `@styles`, `@layouts`, and no `#libs-server` specifier appears anywhere under `app/`. This is why the shared predicate must live in `libs-shared`.
- `#libs-shared/<file>.mjs` subpath imports resolve from `app/` under webpack. Checked: `app/core/selectors.js` already imports `#libs-shared/get-draft-window-config.mjs` and `#libs-shared/league-timezone.mjs` that way.
- `test/utils/error.mjs` asserts equality on `res.body.error` only, so adding a sibling field to a 400 body breaks no existing case. Checked: read the file; it asserts status, JSON, and `res.body.error.should.equal(message)`.
- `getTransactionsSinceAcquisition` returns at least one row for any practice squad player. UNCHECKED — inherited exposure from today's single-pid code, where the same `reduce` has no initial value. The plan adds an explicit guard rather than relying on it, because a batch turns one player's empty result into a whole-batch failure.
- The `db.transaction` wrapper IS testable without stubbing, via the `provided_trx` seam this design already adds — see the transaction-participation spec below. An earlier draft of this plan asserted a mid-write failure "cannot be induced from a spec without stubbing" and used that to excuse shipping the wrapper untested; the excuse was false, and the seam that refutes it was in the same draft.

## Design

### One route, no compat shim, one atomic commit

`POST /teams/:teamId/protect` changes its body from `{ pid, leagueId }` to `{ pids, leagueId }` and its response from an object to an array. No second endpoint, no single-pid fallback; the context menu posts a single-element array.

**This is a breaking wire change and the deploy order makes it visible.** `docs/guides/ship.md` records that deploy runs pm2 reload BEFORE `build && deploy:dist`, and documents a 2026-08-05 incident where exactly this class took out the SPA between the two. Every `Designate Protected` issued against the new API by an old bundle returns 400 until the bundle lands and browser caches turn over.

**RULED 2026-09-08: no shim, take the window.** The operator's direction was to keep this simple and hold a clean codebase, and to loosen the ship guidance rather than work around it — done in `docs/guides/ship.md` (league `6a1117d68`), which now states the rule by blast radius and says a transitional request-body shim is not wanted. The rest of this paragraph is the reasoning that decision confirmed.

The decision here is to accept that window rather than ship a transitional `req.body.pids ?? [req.body.pid]` shim. Three reasons: the operator chose replacement explicitly for a cleaner end state, a shim is exactly the transitional cruft `user:guideline/design-toward-clean-end-state.md` forbids, and the failure mode is a retryable 400 toast in a twelve-manager league during regular season, not data loss. **The obligation this creates is a deploy note, not a code change** — see the deploy step in the plan. If the operator would rather not take the window, the shim is two lines and one follow-up commit to remove; say so and it goes in.

### Roster mutation moves to libs-server

New module `libs-server/submit-practice-squad-protection.mjs`, modelled on `submit-reserve.mjs`:

- Named parameters `{ pids, tid, league_id, user_id, provided_trx }`.
- Validates every pid before any write and throws on the first failure, carrying both the message and the pid.
- Owns its transaction via the `provided_trx ? run(provided_trx) : db.transaction(run)` shape from `auction-settlement.mjs`, with every inner statement taking `trx`. A bare `db(...)` inside the callback would queue for a second pooled connection while holding the first and hang rather than error — the hazard recorded in `user:guideline/write/write-software.md`.
- **The module shape is pinned, not left to taste: the validation loop runs to completion BEFORE `db.transaction` is opened, and `run(trx)` contains writes only.** This is load-bearing for the ordering spec below — if validation lives inside `run`, a failure rolls the writes back and the spec's control cannot distinguish a correct implementation from a write-as-you-go one.
- Validation throws carrying `message` and `pid`. **The route must map `error.message`, never `error.toString()`.** The existing terminal catch sends `error.toString()`, which renders `Error: player is already protected` and would break the exact-equality assertion in every existing spec case.
- Returns the `data` array the route sends.

The route becomes the `activate.mjs` shape: parse, authorize, delegate, respond, then broadcast and notify. This is not an adjacent refactor — the plan triples the mutation logic in this file, and the convention question is which file that logic lands in.

### The shared predicate, correctly sized

New module `libs-shared/practice-squad-protection-eligibility.mjs` exporting `get_practice_squad_protection_ineligibility_reason({ slot, has_pending_poach_claim })`, returning `null` or one of `not_on_practice_squad`, `already_protected`, `pending_poach_claim`.

Its body is two lines over constants that already exist: `!practice_squad_slots.includes(slot)` and `practice_squad_protected_slots.includes(slot)`. It exists **only** because the client cannot reach `libs-server` and both halves must agree on which rows are protectable. It is an unremarkable shared helper, not an architectural decision.

Imported as `#libs-shared/practice-squad-protection-eligibility.mjs` from both sides. Nothing is added to `libs-shared/index.mjs`, which is a barrel (109 export lines of 208) that `user:guideline/write/write-javascript.md` rules out for new consumers.

The reason values are declared as an ALL_CAPS vocabulary object in `libs-shared/constants/`, matching `bid_types` and `admission_vote_statuses`, so a misspelling on either side fails loudly rather than falling through.

**What this does not consolidate, stated plainly:** `is_regular_season` and `isActive` remain duplicated between the selector and the route, because they are request-level and roster-level facts rather than per-player ones. The predicate covers the slot and poach half only.

### Client

- `PROTECT_PLAYER` / `protect(pid)` become `PROTECT_PLAYERS` / `protect_players({ pids })`. `post_protect_actions`, the `POST_PROTECT` triple and `api.post_protect` are unchanged.
- `POST_PROTECT_FULFILLED` moves out of the shared object-shaped block in **both** `app/core/rosters/reducer.js` and `app/core/players/reducer.js`, into array-shaped cases modelled on each file's own `PUT_ROSTER_FULFILLED`, each guarding an empty array on the first line. `ROSTER_TRANSACTION` and the activate/deactivate/reserve cases stay object-shaped, because the socket event remains per-player.
- `PlayerRoster` renders a leading checkbox cell only when `on_toggle_checked` is supplied, driven by `is_checked`. **Not `is_selected`** — the component already destructures `selected`, supplied by connect as the pid whose context menu is open, and two differently-typed `selected` props in one destructure is a greppability failure.
- `DashboardPlayersTable` takes one new prop, `header_action`, rendered in its existing dense `Toolbar` after `summary`; it derives `const show_selection = Boolean(header_action)` internally, matching the existing `showReorder` shape.
- `league-team.js` holds **one** `selected_pids` Set. The two tables' pids are disjoint by construction — `practice_signed` is `PS`/`PSP` and `practice_drafted` is `PSD`/`PSDP` — so each toolbar filters the shared set to its own rows and no union can arise. Selection clears by intersecting with the currently-eligible pids on each render, which is a mechanism the component actually has; a local `useState` cannot observe `POST_PROTECT_FULFILLED`.
- Eligibility is derived in `league-team/index.js`, not in `league-team.js`, matching the file's existing division of labor, via a new selector `get_protect_eligible_practice_squad_pids` beside `getPlayerStatus`.
- **That selector must filter `processed` poaches.** `get_poaches_for_current_league` is the raw map and retains processed rows — which is why `get_poach_players_for_current_league` deletes them by hand. Reading the raw map hides the checkbox for players the server would protect. The existing `status.eligible.protect` has this same defect; fixing it there is out of scope and gets its own task.
- New registered confirmation dialog `protect-confirmation/` under `id: 'PROTECT'`, taking `{ player_maps }`, added to the switch in `confirmation.js`. It serves both callers — the menu passes one player, the toolbar passes N — and retires the last ad-hoc `showConfirmation` in `player-context-menu`. N names inside the fallback branch's single `<p>` is precisely what the registered branch exists to avoid.
- `Protect Selected` uses `Button`'s existing `count` prop rather than string-building the number.
- `is_team_manager` must be threaded to both practice squad row sets; today it reaches active rows only.

### Notification

One `sendNotifications` call. Single-pid wording stays byte-identical to today. Multi-pid reads `Team Name (ABC) has designated 3 players as protected practice squad members: First Last (WR), First Last (RB), First Last (TE).` — the plural form applies at two or more, and names follow request-array order.

**The enumeration is capped by RENDERED LENGTH, not by player count.** Drafted practice squad is unlimited, `send-discord-message.mjs` has no length guard or chunking, and it returns `is_sent: false` on a refusal which `send-notifications.mjs` discards, so an over-long message fails invisibly. Build the enumerated string and fall back to a count-only form only above 1900 characters.

A count threshold was considered and rejected: Discord's limit is 2000 characters and a name entry runs about 28, so a twelve-player batch lands near 400 — a fifth of the ceiling. Any count cap low enough to feel safe fires on this feature's own headline case, the rookie draft class, and silently drops the enumeration for exactly the scenario the feature exists to serve.

No general name-formatting helper is introduced. Twenty-five inline `${first_name} ${last_name} (${primary_position})` builds exist across `api/` and `libs-server/`; a local `.map().join(', ')` inside the new module is right-sized. Note `format_player_name` is taken and is a matching normalizer, not a display formatter.

### Failure shape

All-or-nothing, with the 400 carrying `{ error: '<existing message string>', pid }`. The message strings stay byte-identical so every existing spec's exact-equality assertion still holds, and the client names the player from its own store. "First" means request-array order.

**`Protect All` plus all-or-nothing is a real usability edge**, since one stale poach claim fails a twelve-player batch. Mitigated rather than redesigned: the client filter uses fresh `processed`-filtered poach state, and the failure toast names the offending player. Returning per-pid partial results was considered and rejected — it doubles the response contract and the reducer branches for a case the fresh filter makes rare.

### Knowledge Base Impact

- No new domain entry document, no `ABOUT.md` edit, no new tag.
- The `@swagger` block is the endpoint's own documentation and changes with the route. `docs/api-documentation.md` does not enumerate this endpoint (checked: it lists only `GET /teams/{tid}` and the two `/lineups` routes) and no `text/league/` entity describes protection.
- One follow-up task to file, not an observation: the `processed`-poach defect in `status.eligible.protect`, which predates this work and affects the row menu.

### Scratch and orchestrator

No scratch slug and no orchestrator — this is code plus specs in a single bounded task.

## Notes

- Read `docs/guides/api.md` before the route and `docs/guides/spa.md` before `app/`, per the repo `CLAUDE.md` routing table.
- Run the suite with `yarn test:isolated`, never `yarn test:db:up && yarn test`.
- **`yarn dev:live` must not be used to exercise any write in this work.** It points a production-mode frontend at the LIVE API, so an undeployed route 404s and any outward side effect acts on production. For undeployed writes, run webpack-dev-server on a free port against your own `NODE_ENV=test` API on 8082 with `LEAGUE_DB_DATABASE=league_test_<slug>`, per `█████████████████████████████████████████`. `dev:live` remains fine for read-only render checks.
- Discord wording cannot be observed in any dev environment — `send-notifications.mjs` returns immediately unless `NODE_ENV === 'production'`. It is verified by unit-testing the message builder, never by watching a channel.
- If a happy-path spec returns an unexplained 500, it is response validation rejecting the body against the swagger schema. `LEAGUE_RESPONSE_VALIDATION_DISCOVER=1` is the discriminator.
- Two commits. The first is behavior-preserving. The second must be atomic — a commit carrying the server without the matching bundle is a deployable broken state.
- **`app/core/selectors.js` is shared with the `surface-sanctuary-poaching-periods` task**, in a disjoint region: this work adds `get_protect_eligible_practice_squad_pids`, that one edits the practice squad branch of `get_player_roster_status`. Coordinated 2026-09-08 — that session confirmed no writes in flight and no ordering preference. The agreed hedge is to commit the `selectors.js` addition as its own explicit-path commit promptly rather than holding it inside a larger change, so the other side builds on a clean file. Same in reverse.

## Plan

### Baseline and Extraction (first commit)

- [x] **Implement the two stubbed happy paths against today's single-pid contract**
  - File: `test/teams.protect.spec.mjs`
  - Purpose: establish a green baseline BEFORE any behavior changes. Both cases are `// TODO` today, so under any other ordering the first-ever green on this route is written against new code and nothing ever witnessed the old behavior.
  - Changes:
    - Fill in `rookie practice squad player` and `veteran free agent practice squad player` with the current `{ pid, leagueId }` body, asserting the 200 object body, the `PSDP`/`PSP` slot via `getRoster`, and the `PRACTICE_PROTECTED` transaction.
    - Fill in the two stubbed error cases as well — `player has a poaching claim` and `during the off-season` — and add the missing `player is not on practice squad` case, which has no coverage at all today.
    - All five must assert the exact message strings, so the extraction cannot silently reorder or drop a check. The route currently tests already-protected BEFORE querying `poaches`, and without these the extraction could invert that with CI green.
  - Verify: `yarn test:isolated --grep "protect"` passes on an otherwise unmodified tree. This is the baseline reading; record that it was green before proceeding.

- [x] **Extract the single-player logic to libs-server with no behavior change**
  - File: `libs-server/submit-practice-squad-protection.mjs` (new), `api/routes/teams/protect.mjs`
  - Purpose: put roster mutation where this repo keeps it, and make the batch step a change to one module instead of a rewrite of a router body.
  - Changes:
    - New module taking `{ pids, tid, league_id, user_id, provided_trx }`, modelled on `submit-reserve.mjs`, initially handling a single-element array.
    - Validation by throw carrying message and pid; transaction ownership via the `provided_trx ? run(provided_trx) : db.transaction(run)` shape; every inner statement uses `trx`.
    - Route delegates and keeps its own broadcast and notification tail.
  - Verify: the baseline specs from the previous step still pass unchanged. That is the behavior-preservation proof, and it only works because they landed first.

- [x] **Add the shared eligibility predicate and its reason vocabulary**
  - Files: `libs-shared/practice-squad-protection-eligibility.mjs` (new), `libs-shared/constants/` (reason vocabulary)
  - Purpose: one rule the client and server both read, since the client cannot import `libs-server`.
  - Changes:
    - Export `get_practice_squad_protection_ineligibility_reason({ slot, has_pending_poach_claim })` returning `null` or a reason, implemented over `practice_squad_slots` and `practice_squad_protected_slots` rather than re-listing slot literals.
    - Declare the three reason values as an ALL_CAPS vocabulary object beside `bid_types`.
    - Consume from the new `libs-server` module. Import via the `#libs-shared/<file>.mjs` subpath; add nothing to `libs-shared/index.mjs`.
  - Verify: a node one-liner over all four slot constants crossed with both poach values prints the expected reason for every combination including at least one `null`; and `grep -n practice-squad-protection-eligibility libs-shared/index.mjs` returns nothing.

- [x] **Commit the baseline and extraction together** — landed as `2520e7029`.
  - Purpose: this commit is behavior-preserving and independently testable, so it clears CI alone and is not entangled with the contract break.
  - Verify: `yarn test:isolated` and `yarn lint` pass; `git log --oneline -1` shows the extraction commit with no client files in it.

### Batch Contract (second commit, atomic)

- [x] **Make the module and route accept a pid array, with the swagger block in the same change**
  - Files: `libs-server/submit-practice-squad-protection.mjs`, `api/routes/teams/protect.mjs`
  - Purpose: the batch itself. Swagger is merged here rather than split — it is the same file's contract, and a split leaves the first item's verify unsatisfiable until the second lands.
  - Changes:
    - Body `{ pids, leagueId }`; 400 on missing, non-array, empty, or duplicate-containing input.
    - One `whereIn` for unprocessed poach rows and one `whereIn` for player rows, with a missing-row guard; per-pid `getTransactionsSinceAcquisition` with an empty-result guard. All reads happen before the transaction opens.
    - Validate every pid, then write: slot updates and one bulk `transactions` insert, all on `trx`.
    - 400 body carries `{ error, pid }` with message strings unchanged.
    - Array response of the existing per-player object shape.
    - One `ROSTER_TRANSACTION` broadcast per player; one `sendNotifications` with the singular form at one player, the enumerated plural at two to eight, and the count-only form above eight.
    - Guard the post-`res.send` tail so a throw there cannot re-send and produce `ERR_HTTP_HEADERS_SENT`.
    - Swagger: `pids` array in the request body, array 200 response, updated summary and examples, no duplicated `components` definition.
  - Verify: `yarn check:cluster` passes, which gates `$ref` resolution. Response-body correctness is verified by the spec items below, not here.

- [x] **Convert the existing spec that already drives a 200**
  - File: `test/teams.protect.spec.mjs`
  - Purpose: the `one minute before the Regular Season` case asserts `res.body.pid` and `res.body.slot` object-shaped and will fail the moment the route returns an array. It is a required conversion, not an optional adaptation.
  - Changes: send `pids: [player.pid]` and assert against `res.body[0]`.
  - Verify: `yarn test:isolated --grep "protect"` passes.

- [x] **Make both reducers array-shaped**
  - Files: `app/core/rosters/reducer.js`, `app/core/players/reducer.js`
  - Purpose: `players/reducer.js` destructures `transaction` out of `payload.data` and throws a `TypeError` on an array — a full store crash, and the consumer a prior draft of this plan missed entirely.
  - Changes:
    - Move `POST_PROTECT_FULFILLED` out of the shared object-shaped block in each file into its own case modelled on that file's `PUT_ROSTER_FULFILLED`.
    - Guard `if (!payload.data.length) return state` as the first line of each; the model being copied carries the same unguarded `payload.data[0]` and a thrown reducer is a store crash for a one-line fix.
  - Verify: `grep -rn "POST_PROTECT_FULFILLED" app/` shows exactly the two reducer cases plus the payload-agnostic saga watcher, and no remaining occurrence sits inside a block that reads `payload.data.pid`.

- [x] **Pluralize the action and saga**
  - Files: `app/core/rosters/actions.js`, `app/core/rosters/sagas.js`, `app/core/rosters/index.js`
  - Purpose: carry a pid array end to end.
  - Changes: `PROTECT_PLAYER` becomes `PROTECT_PLAYERS`, `protect(pid)` becomes `protect_players({ pids })`, saga and watcher renamed, `roster_sagas` updated, plural success toast added.
  - Verify: `grep -rn "PROTECT_PLAYER\b" app/ test/` returns nothing, and `grep -n "watchProtectPlayers" app/core/rosters/sagas.js` shows the renamed watcher present in the `roster_sagas` array. Do NOT rely on `test/app.action-type-registration.spec.mjs` or `test/app.api-service-verb-contract.spec.mjs` here — the first scans only for `create_api_actions('X')` pairs and this is a hand-written constant, and the second covers a service function this change does not touch, so both are green before and after.

- [x] **Add the registered protect confirmation dialog**
  - Files: `app/views/components/protect-confirmation/` (new), `app/views/components/confirmation/confirmation.js`, `app/views/components/player-context-menu/player-context-menu.js`
  - Purpose: a batch confirmation must list N player names, which is exactly what the generic fallback's single `<p>` cannot do; and protect is the last ad-hoc holdout among its own neighbours.
  - Changes:
    - New component taking `{ player_maps }`, registered under `id: 'PROTECT'` in the switch.
    - Context menu dispatches `protect_players({ pids: [pid] })` through the registered dialog.
  - Verify: against a local `NODE_ENV=test` API on 8082 (not `dev:live`), the row menu confirmation renders one name and the toolbar confirmation renders every selected name.

- [x] **Add the opt-in checkbox to the roster row**
  - File: `app/views/components/player-roster/player-roster.js`
  - Purpose: per-row selection without affecting the active, reserve, waiver or cutlist tables that share this component.
  - Changes:
    - Render a leading checkbox cell only when `on_toggle_checked` is supplied, checked from `is_checked`. Named `is_checked`, not `is_selected`, because the component already destructures a differently-typed `selected`.
    - Add a `PlayerRoster.propTypes` block — the file declares none today — covering both new props.
  - Verify: `grep -n "propTypes" app/views/components/player-roster/player-roster.js` shows the new block, and against the local 8082 API the active roster and reserve tables render with no leading cell and no column misalignment. Do NOT cite `test/app.connected-component-props.spec.mjs` as the gate — its own header records that it deliberately rejects the general undeclared-prop assertion and narrows to spelling drift against connect mappers and to `Button` props, so ownProps from a parent JSX site pass it identically before and after.

- [x] **Add the header action slot to the players table**
  - File: `app/views/components/dashboard-players-table/dashboard-players-table.js`
  - Purpose: somewhere for the buttons, and an aligned leading header cell when rows carry a checkbox.
  - Changes: one new prop `header_action` rendered in the dense `Toolbar` after `summary`; derive `show_selection` from it internally, mirroring `showReorder`; add the `propTypes` entry.
  - Verify: against the local 8082 API, header and body cells align in both practice squad tables and are unchanged in every other table on the team page.

- [x] **Add the eligibility selector**
  - File: `app/core/selectors.js`
  - Purpose: `Protect All` and the `(N)` count need the eligible SET at the parent, which `getPlayerStatus` cannot provide from inside a `createSelector`.
  - Changes:
    - Add `get_protect_eligible_practice_squad_pids` returning the pids whose ineligibility reason is `null`, consuming the shared predicate.
    - **Filter `processed` poaches.** `get_poaches_for_current_league` is the raw map and retains them, which is why `get_poach_players_for_current_league` deletes them by hand. Reading the raw map hides checkboxes for players the server would protect.
  - Verify: a spec or console check seeding one poach with `processed` set and one without shows the processed player's pid present in the returned set and the unprocessed player's pid absent.

- [x] **Wire selection state and the buttons into the team page**
  - Files: `app/views/components/league-team/league-team.js`, `app/views/components/league-team/index.js`
  - Purpose: the feature surface.
  - Changes:
    - `index.js`: consume `get_protect_eligible_practice_squad_pids`, and add `protect_players` and the confirmation action to `map_dispatch_to_props`, which currently maps only `load_team_players` — without this neither button can fire.
    - `league-team.js`: one `selected_pids` Set; each toolbar filters it to its own rows. Checkboxes render only when `is_team_manager && current_season.is_regular_season` and only on eligible rows.
    - Thread `is_team_manager` to both practice squad row sets; it currently reaches active rows only.
    - Signed table `header_action`: `Protect Selected`, using `Button`'s `count` prop, disabled at zero.
    - Drafted table `header_action`: the same plus `Protect All`, hidden when nothing is eligible. Build its pid list from the full sorted drafted list, NOT the collapse-sliced display binding — a manager with the list collapsed still means every eligible drafted player.
    - Clear selection by intersecting with currently-eligible pids on render.
    - Declare every new prop in `propTypes`.
  - Verify: against the local 8082 API, with the drafted list collapsed, `Protect All` protects players hidden by the collapse; the checkboxes do not appear when viewing another manager's team; and a failed batch leaves the selection intact rather than silently discarding it.

### Testing and Validation

- [x] **Add the multi-player happy path**
  - File: `test/teams.protect.spec.mjs`
  - Purpose: prove a batch spanning both practice squad slot types writes every row.
  - Changes: protect one `PS` and one `PSD` player in one request; assert a two-element response, both slots moved, and two `PRACTICE_PROTECTED` rows read directly. Do not reach for `checkLastTransaction` — it reads a single newest row globally and cannot see a two-row batch.
  - Verify: `yarn test:isolated --grep "protect"` passes.

- [x] **Add the validate-before-write ordering case**
  - File: `test/teams.protect.spec.mjs`
  - Purpose: prove no pid is written when any pid in the batch is invalid. Named for what it proves. A prior draft called this the rollback test and specified removing the `db.transaction` wrapper as its control, which cannot fire — with validation ahead of all writes, nothing is written either way and the check stays green in both states.
  - Changes:
    - Submit three pids with the invalid one **LAST**, assert 400 and that the body carries the failing `pid`, then re-read every one of the three via `getRoster` and assert each slot unchanged, and assert the team's `transactions` row count unchanged.
    - Invalid-last is the discriminating permutation; invalid-first passes even under a write-as-you-go implementation, which is why the earlier draft's ordering proved nothing.
  - **What this proves, stated narrowly:** the `getRoster` re-reads are wired and can fail. It does NOT prove a realistic write-as-you-go implementation would be caught, because a realistic one writes on `trx` and the rollback hides it. Do not let a later reader upgrade this claim.
  - Verify: `yarn test:isolated --grep "protect"` passes; then prove the assertions can fail by temporarily issuing the first pid's slot update on bare `db(...)` **at the top of the module function, before any transaction is opened**, and confirming this case goes red while the happy paths stay green (the control is idempotent there — the real code writes the same slot value moments later). Restore afterwards.
  - The bare-`db` and before-any-transaction wording are both load-bearing. Two earlier controls for this item were vacuous: removing the `db.transaction` wrapper (nothing is written either way under validate-before-write), and moving the update into the validation loop (it then runs on `trx` and rolls back). A bare `db(...)` issued anywhere inside `run(trx)` does not go red either — it queues for a second pooled connection while the first is held and HANGS, which reads as infrastructure flake rather than as a firing control.

- [x] **Add the transaction-participation case**
  - File: `test/teams.protect.spec.mjs` or a sibling spec
  - Purpose: this is the real control for the `db.transaction` wrapper, and it is the ONLY item that tests the thing the design most depends on — that every statement inside the module writes on `trx` rather than bare `db`. A statement written on bare `db` is not undone by an outer rollback.
  - Changes:
    - Open a transaction in the spec, call `submit_practice_squad_protection` with it as `provided_trx` for a valid multi-pid batch, then throw to abort it.
    - Catch, then assert no slot moved via `getRoster` and no `PRACTICE_PROTECTED` row exists for those pids.
  - Verify: `yarn test:isolated --grep "protect"` passes; then prove it discriminates by temporarily switching one inner statement from `trx(...)` to `db(...)` and confirming the case goes red or hangs. Either outcome is the control firing; restore afterwards.

- [x] **Add the batch-shape and pause error cases**
  - File: `test/teams.protect.spec.mjs`
  - Purpose: cover the new body contract and close a standing coverage gap.
  - Changes:
    - Missing `pids`, non-array, empty array, duplicate pids; rename the existing `missing pid` case.
    - Assert the 400 body carries `pid` alongside the unchanged `error` string.
    - One 423 case with the batch body against a paused league — no spec covers this route's pause behavior today.
    - Keep and adapt the remaining existing cases.
  - Verify: `yarn test:isolated --grep "protect"` passes with every listed case present.

- [x] **Unit-test the notification message builder**
  - File: `test/teams.protect.spec.mjs` or a sibling spec
  - Purpose: the wording cannot be observed anywhere but production, since `send-notifications.mjs` no-ops outside it.
  - Changes: assert the one-player string is byte-identical to today's sentence, the three-player string matches the enumerated form, and that a twelve-player batch STILL enumerates every name — the headline rookie-draft-class case must not silently degrade.
    - Separately assert the length fallback by building a batch whose rendered message exceeds 1900 characters, and confirm it produces the count-only form.
  - Verify: `yarn test:isolated --grep "protect"` passes; the twelve-player assertion fails if a count-based cap is reintroduced, and the length assertion fails if the fallback is removed.

- [x] **Run the full gate set and commit atomically**
  - Purpose: the contract break means server and bundle must not be separable in history.
  - Changes: none — verification and commit.
  - Verify: `yarn lint`, `yarn check:types`, `yarn check:test-collection`, `yarn check:cluster` and `yarn test:isolated` all pass; `git show --stat HEAD` shows route, module, client and specs in one commit.

- [x] **Deploy with the full path and note the window** — landed 2026-09-08. Production reached `437170259` with API and bundle in agreement; bulletin #734 resolved and #733 updated.
  - Purpose: the API reloads before the bundle ships, so old bundles hit the new contract in between.
  - Changes: none — a deploy step.
  - Verify: host manifest, both host HEADs and the served `index.html` all named `437170259`; `Protect Selected`, `Protect All` and `Designate Protected` all present in the deployed chunks against a positive and a negative control; sourcemaps 404.
  - The window was NOT the planned few minutes. A sibling ran a backend-only `yarn deploy` that moved both hosts to `437170259` while `dist/build-manifest` still named `0812a0a97`, so the new `{pids}` API served old `{pid}` bundles for roughly 25 minutes until this deploy's rsync closed it. Recorded on bulletin #733.

- [x] **Verify the client in a browser** — done 2026-09-08 against a seeded `NODE_ENV=test` API on 8082 with webpack-dev-server on a free port, never `dev:live`. All confirmed: both practice squad tables align header to body; every other table keeps its unchanged nine-cell shape; `Protect Selected` shows its count and is disabled at zero; `Protect All` appears only on the drafted table; `Protect All` with the list COLLAPSED to two visible rows opened the dialog naming all five and protected all five, including the three the collapse was hiding; the row menu `Designate Protected` opens the same registered `PROTECT` dialog with one name and singular copy; a batch rejected 400 left every player unprotected and the selection intact; checkboxes are absent on another manager's team.
  - A player with a PROCESSED poach keeps his checkbox, and one with a PENDING poach does not. This is the behaviour with no unit test, since `app/core/selectors.js` is not importable from a spec, and the pending case is the control proving the processed case is not vacuous.
  - Not covered: the out-of-season guard, which needs a clock this harness cannot move. `get_protect_eligible_practice_squad_pids` returns an empty set before anything else when `is_regular_season` is false, and the another-manager case measured here shows an empty eligible set does produce exactly the unchanged nine-cell table.

The browser verification found one real defect, fixed in `0be0549c8`: `PlayerRoster` gated the leading checkbox cell on `on_toggle_checked`, which is supplied only for an ELIGIBLE player, so an ineligible row omitted the cell and every column in it sat one checkbox-width left of the header. It is reached the moment a manager protects part of a practice squad. The fix is pushed and CI-green but undeployed, because `preflight:deploy` requires acknowledging twelve other sessions' commits — carried by [[███████████████████████-████████-█████-████████-█████████-██████]].

### Knowledge Base Updates

- [x] **File the follow-up task for the processed-poach defect in the row menu** — [[user:task/league/fix-processed-poach-hides-protect-menu-item.md]]. NOTE: the stated verify (`base relation forward ... --json`) is NOT a working oracle here — it reports `forward: 0` even for the pre-existing `relates [[user:repository/active/league]]` edge. The edge is confirmed in this entity's frontmatter instead.
  - Purpose: `status.eligible.protect` reads the unfiltered poach map and over-restricts the context menu. It predates this work and is out of scope, but leaving it only in prose makes this task un-self-describing.
  - Changes: create a task in `task/league/` for the fix and add a `relates` edge to it on this task.
  - Verify: `base relation forward user:task/league/add-batch-practice-squad-protection.md --json` shows the new edge.
