---
title: Move a practice squad player to reserve in one transaction
type: task
status: completed
description: >-
  Allow a reserve-eligible unprotected practice squad player to be placed on injured reserve in a
  single API call, writing an activation plus a reserve transaction row so no active roster slot is
  required.
base_uri: user:task/league/add-practice-squad-to-reserve-activation.md
created_at: '2026-08-05T17:04:08.976Z'
entity_id: bcdb2f50-6bab-4df8-94dd-ac7c50155a16
finished_at: '2026-08-05T17:38:49.288Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
started_at: '2026-08-05T17:10:56.180Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-08-05T17:38:49.288Z'
visibility_analyzed_at: '2026-11-05T23:48:15.443Z'
---

Allow a practice squad player to be moved directly to injured reserve in a single API call, without first occupying an active roster slot. The move writes two transaction rows — `ROSTER_ACTIVATE` then the reserve type — so no new transaction type is introduced and the player's activation is recorded honestly.

## Overview

Today a practice squad player reaches injured reserve only in two steps: `POST /teams/:tid/activate` (which requires an open bench slot) followed by `POST /teams/:tid/reserve`. A team with a full active roster cannot make the move at all, even though the end state consumes no bench slot and no cap.

The one exception is a carve-out in `libs-server/submit-reserve.mjs` that lets a practice squad player go to reserve when an unprocessed poach claim exists. That path already performs exactly this move — it just never wrote an activation row. This plan generalizes the carve-out to every reserve-eligible unprotected practice squad player and gives the pathway its missing activation.

## Background

### The move is free in roster accounting

- `availableCap` counts only `active` (`libs-shared/roster.mjs:96-99`), and `isSlotActive` excludes reserve slots — so practice squad and reserve both cost zero cap.
- `roster_players_for_position_limits` (`libs-shared/roster.mjs:186`) is active plus signed practice squad; reserve is excluded. The move relaxes position limits rather than tightening them.
- The move frees a practice squad slot (`practice_signed`) and consumes a reserve slot. Short-term reserve is capped by `has_open_reserve_short_term_slot()` (`libs-shared/roster.mjs:366`); long-term and COV are uncapped.

### Why the poach carve-out is the same feature

`libs-server/process-poach.mjs:35-40` refuses a player who is not in `PS`/`PSD`, so today's carve-out is precisely "move him out of the practice squad to reserve" and it defeats the pending poach as a side effect. The poach requirement is the arbitrary part; the move itself is what is being generalized.

### Current gates in `submit-reserve.mjs`

| Line | Gate |
| ---- | ---- |
| `:159-161` | player already on that reserve slot |
| `:163-169` | `PSP`/`PSDP` refused — "protected players are not reserve eligible" |
| `:171-185` | `PS`/`PSD` refused unless an unprocessed `poaches` row exists |
| `:187-248` | `isReserveCovEligible` / `isReserveEligible` injury gate |
| `:250-267` | rostered at least one week unless acquired by trade |
| `:269-274` | not a locked starter |
| `:350-355` | `has_open_reserve_short_term_slot()` |

### Reserve to practice squad is not currently blocked server-side

`libs-server/submit-deactivate.mjs` has no reserve-slot guard. Its 48-hour cutoff is gated on `isActive` (`:60-66`), which is false for a reserve player, so the check is skipped entirely. A player signed straight to the active roster (`ROSTER_ADD`) and then placed on injured reserve carries no `ROSTER_ACTIVATE` row, passes the previously-activated check at `:79-85`, and can be deactivated to the practice squad through a direct API call. The client blocks most of this (`app/core/selectors.js:1458-1506`) but the server does not.

The two-row design closes this for every player who reaches reserve through the new path — the `ROSTER_ACTIVATE` row makes `submit-deactivate.mjs:79-85` reject them permanently. The pre-existing hole for directly-signed players still needs an explicit server guard.

## Design

### One choke point

All logic lands in `libs-server/submit-reserve.mjs`. Both `POST /teams/:tid/reserve` and the `reserve_pid` branch of `POST /teams/:tid/activate` already funnel through it, so no new endpoint, no new route field, and no new transaction type.

### Server behavior

1. **Protected practice squad players stay refused.** Keep the throw at `:163-169`, reworded to `protected practice squad players can not be activated`.
2. **Drop the poach-claim requirement** at `:171-185`. An unprotected practice squad player (`PS`/`PSD`) may move to any of the three reserve slots — `RESERVE_SHORT_TERM`, `RESERVE_LONG_TERM`, `COV` — subject to the unchanged `isReserveEligible` / `isReserveCovEligible` injury gate.
3. **No bench-space check and no intermediate write.** The existing single `rosters_players` update at `:363` moves the slot straight from practice squad to reserve. The player never occupies a bench slot, which is the point of the feature.
4. **Activation ledger row.** When the player's pre-move slot is a practice squad slot, insert a `ROSTER_ACTIVATE` transaction before the reserve transaction. Read `player_salary` from `getLastTransaction` once and compute the timestamp once, then use both for the two inserts; insertion order carries the ledger ordering. This matches how the existing `activate_pid` branch at `:311-334` already writes two rows in the same second.
5. **Clear pending poaches** for that pid and league, mirroring `libs-server/submit-activate.mjs:78-89` (`is_successful: 0`, `processed: timestamp`, reason `player is not on a practice squad`). Those claims would fail at processing anyway; clearing them is the honest state.
6. **Broadcast payload unchanged.** The activation is ledger-only. Pushing a second entry with `slot: BENCH` would flicker the player onto the bench in every connected client, so `data` still carries exactly one roster-slot entry.
7. **Notification names the activation** — `... has activated <player> and placed him on Reserve (IR)`.

Rules deliberately kept for the practice squad path: the rostered-at-least-one-week check (`:250-267`), `has_open_reserve_short_term_slot()` (`:350-355`), and the full injury gate. A practice squad player earns no exemption from any of them.

### Reserve players can not return to the practice squad

Add an explicit guard to `libs-server/submit-deactivate.mjs`, after the existing practice squad check at `:42-45`: throw `reserve players can not be placed on the practice squad` when the player's current slot is in `reserve_slots`. This closes the pre-existing server-side hole and makes the server agree with what the client already enforces.

### Client behavior

`app/core/selectors.js:1322-1356` — delete the `practiceSquadReserveEligible` gate from all three reserve branches (short-term, long-term, COV). `status.protected` at `:1317` already excludes `PSP`/`PSDP`, so protected players need no new gate.

`app/views/components/player-context-menu/player-context-menu.js:348-369` — the reserve menu items read those flags and need no gating change, but the practice squad case needs a distinct label so the consequence is legible. When the player's slot is `PS` or `PSD`, the menu items read `Activate & Reserve (IR)` / `Activate & Reserve (IR LT)` / `Activate & Reserve (COV)` rather than the plain `Reserve` labels.

A confirmation dialog must state the two consequences before the move: the player is being **activated** and will no longer be practice squad eligible, so he can not be returned to the practice squad afterward. Reuse the existing reserve confirmation component and branch its copy on the practice squad slot.

### Assumptions carried

- **The offseason widens.** `isReserveEligible` treats any game designation as qualifying while `current_season.week` is 0 (`libs-shared/is-reserve-eligible.mjs:143-148`), and long-term reserve is uncapped, so between seasons a team can move QUESTIONABLE practice squad players to long-term reserve and free practice squad slots. The poach requirement is what blocks that today. Per operator direction this eligibility is left as-is and will be cleaned up separately.
- **COV is included for completeness** on the same terms as the other two reserve slots.

### Knowledge base impact

- `api/routes/teams/reserve.mjs:20-24` — the swagger description states the practice squad and poach rules in prose; rewrite for the new rule.
- `api/routes/teams/reserve.mjs:118-121` — the `practiceSquadNoPoach` error example is removed; replace with the protected-player error.
- No user-base `text/league/` entity documents reserve eligibility, and the repo `CLAUDE.md` § Poaching System does not describe this rule. No new tag, no orchestrator, no scratch slug — this is a single bounded change well under the orchestrator threshold.

## Plan

Ordered for implementation. Server first, then the deactivate guard, then client, then tests and docs.

- [x] 1. `libs-server/submit-reserve.mjs`

- Reword the `PSP`/`PSDP` throw to `protected practice squad players can not be activated`.
- Delete the `PS`/`PSD` poach-claim block at `:171-185`.
- Capture `const is_practice_squad_activation = practice_squad_unprotected_slots.includes(rosterPlayer.slot)` before any slot mutation, importing `practice_squad_unprotected_slots` from `#constants`.
- Hoist the reserve player's `getLastTransaction` read and the `timestamp` above the transaction inserts so both rows share them.
- When `is_practice_squad_activation`, insert the `ROSTER_ACTIVATE` transaction and clear unprocessed `poaches` rows for the pid and league, both before the reserve transaction insert.
- Extend the notification message with the activation clause when `is_practice_squad_activation`.
- Leave `data` shape untouched.

- [x] 2. `libs-server/submit-deactivate.mjs`

- Import `reserve_slots` from `#constants`.
- After the existing practice squad check, throw `reserve players can not be placed on the practice squad` when the player's current roster slot is in `reserve_slots`.

- [x] 3. `app/core/selectors.js`

- Remove the `isPracticeSquad` / `practiceSquadReserveEligible` computation at `:1322-1330` and the `practiceSquadReserveEligible` conjunct from the three reserve branches at `:1332-1356`.
- Verify `get_poaches_for_current_league` is still referenced elsewhere in the file before removing the import.

- [x] 4. `app/views/components/player-context-menu/player-context-menu.js`

- Branch the three reserve menu item labels on a practice squad slot, rendering `Activate & Reserve (IR)`, `Activate & Reserve (IR LT)`, `Activate & Reserve (COV)`.
- Confirm each referenced action creator exists on the actions module by name before wiring — a typo in `mapDispatchToProps` fails silently here.

- [x] 5. Reserve confirmation dialog

- Locate the confirmation component the reserve handlers open and branch its body copy on a practice squad slot, stating that the player is being activated and will no longer be practice squad eligible.

- [x] 6. `api/routes/teams/reserve.mjs`

- Rewrite the swagger `description` reserve-eligibility bullets for the new rule.
- Replace the `practiceSquadNoPoach` error example with the protected-player error.

- [x] 7. Tests

- `test/teams.reserve.spec.mjs` — invert the two practice squad rejection assertions. Add success cases for `PS` and `PSD` into short-term, long-term, and COV, each asserting two transaction rows (`ROSTER_ACTIVATE` then the reserve type), the final `rosters_players.slot`, and that no bench slot was consumed at any point.
- Add assertions that `PSP`/`PSDP` still throw, and that an unprocessed poach claim is marked `is_successful: 0` by the move.
- Add a case asserting a practice squad player who is not reserve eligible still throws.
- `test/teams.deactivate.spec.mjs` — add a case that a player on each reserve slot is refused deactivation to the practice squad.
- Confirm `test/teams.activate.reserve.spec.mjs` stays green, since the combo route branch calls the same function.

- [x] 8. Verification

- Run the suite against the container with its own database, per `CLAUDE.md`: `docker exec -u postgres league-test-pg psql -U league_test -d league_test -c 'CREATE DATABASE league_test_psquad_reserve OWNER league_test;'` then a direct mocha invocation with `LEAGUE_DB_PORT=5433 LEAGUE_DB_DATABASE=league_test_psquad_reserve`. Confirm the database exists before launching mocha.
- Run a control at HEAD in a throwaway worktree if the failure count is non-trivial in files this change does not touch.
- `yarn lint` and `yarn prettier`.
- This change touches `app/` and `libs-shared` is unmodified, so the deploy is `yarn deploy:all`, not a backend-only `yarn deploy`.

## Outcome

Shipped in `f2f3ff7f9` (league repo, `master`). Full suite green at **3203 passing / 0 failing** against an isolated database, against a control run at HEAD of 3197 passing — the delta is exactly the six tests this change adds.

Three deviations from the plan as written:

- **The client reads a selector flag, not the raw slot.** `status.reserve.is_practice_squad_activation` is set in `app/core/selectors.js` and consumed by the menu; the confirmation dialogs derive it from `player_map.get('slot')` directly, since they do not receive `status`. This keeps the menu from re-deriving domain logic.
- **The COV success test needs a trade acquisition.** COV is refused in the offseason, so its test runs in season — where the rostered-a-week rule applies and only a trade bypasses it. The helper takes `roster_status` and `acquisition_transaction` parameters for this.
- **`test/trade-veto.spec.mjs` needed a fixture fix.** Its `get_roster_player` picked `rows[0]` with no `ORDER BY` and excluded only `K`, so it could hand the trade a DST the receiving team had no position slot for. That is latent order dependence on the shared player pool, and adding six tests elsewhere was enough to trip it. Now excludes `K` and `DST` and orders by `pid`.

Two pre-existing defects found and deliberately left alone, neither in scope:

- `status.reserve` initializes a key named `covid` while every reader uses `cov` (`app/core/selectors.js`). Harmless today — both are falsy before assignment — but the declared shape does not match the used one.
- `yarn lint` reports 22 problems across `scratch/`, `private/`, and `api/routes/projections.mjs`. All pre-existing; none in the files this change touches.

**Not yet deployed.** This touches `app/`, so it needs `yarn deploy:all`, not a backend-only `yarn deploy`.
