---
title: Enforce the roster position limit at auction bid time
type: task
status: completed
description: >-
  _validate_bid checks cap and roster space but not position limits, so a team at max_roster_kicker
  can bid on a fourth kicker and only learn it is ineligible at the award. Apply the limit at bid
  time, keeping the award check as the guard against a mid-block roster change.
base_uri: user:task/league/add-bid-time-position-limit-check.md
created_at: '2026-09-02T17:40:16.472Z'
entity_id: 06f63f11-7a56-472c-81fd-e2535afc07d7
finished_at: '2026-09-07T23:04:26.384Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[████████████████████-███████-███████████-██████████-████████]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-07T23:04:26.384Z'
visibility_analyzed_at: '2026-11-05T23:48:09.520Z'
---

A team already at `max_roster_kicker` can bid on a fourth kicker today and the bid is recorded. `_validate_bid` in `api/sockets/auction.mjs` checks cap, roster space, player identity and bid magnitude only; the position limit is applied at the award, in `_validate_team_can_acquire_player`. The manager gets no feedback until settlement, and the block runs its full clock on a bid that cannot be honored.

The operator ruled that the limit should apply at bid time. The award check stays — it re-reads the roster inside the settlement transaction and is the only guard that catches a cap or roster change made DURING a block.

## Approach

`_validate_bid` is synchronous and reads a cached capacity object that carries only `availableSpace` and `cap`, so neither the bidding team's per-position counts nor the auctioned player's position is reachable from it today. Two changes make it reachable without making the method async:

- `_calculate_team_capacities` caches the `Roster` instance it already builds, alongside the two scalars it currently keeps. `has_bench_space_for_position` then answers the position question directly, and it short-circuits on `isFull` — so it SUBSUMES the existing `availableSpace` branch rather than sitting beside it. Collapse the two into one predicate; that is what `_validate_team_can_acquire_player` already does at the award, and having the bid path and the award path ask the same question the same way is the point.
- The auctioned player's `primary_position` is cached when the nomination opens. `_validate_nomination` already loads the player row, and the player under auction does not change for the life of the block. Do NOT make `_validate_bid` async to load it — every caller would need an `await`, and the method is on the bid hot path.

Refuse with `exceeds roster limits`, matching the award and the nomination guard.

## Consequence to accept deliberately

The cached capacity refreshes per NOMINATION in live mode, not per bid. A trade or commissioner-override release mid-block therefore leaves the bid-time position check reading a stale roster, exactly as the bid-time cap check already does. That is why the award check is not being removed.

## Test

Extend `test/auction.eligibility-validation.spec.mjs`, whose bid-eligibility block already has the kicker fixtures and the recording client this needs.

- A bid from a team at `max_roster_kicker` on a fourth kicker is refused, asserted through the full `{ user_id, error }` reply.
- The acceptance control: the same roster bidding on a running back is accepted. RB is uncapped in the fixture — `max_roster_quarterback/running_back/wide_receiver/tight_end` are all 0, which `has_position_capacity` reads as unlimited — so this isolates the kicker cap from the roster generally.
- The boundary: a team one under the limit is accepted.

**This change reddens an existing spec, and that is intended.** "refuses to award a player that would exceed the position limit" asserts that the over-limit bid is ACCEPTED, and says in its own comment to assert the bid-time check here if one is ever added. Move that assertion rather than deleting the spec — the award-time refusal it drives through a real `sold()` must survive, since the award guard is staying.

Prove every new spec red against the guard it targets, and delete the CALL SITE as well as the guard body: mutating a method never shows that anything invokes it, which is the hole that let three award specs pass while `sold()`'s invocation could be removed wholesale.

## Blocked until

League 1's auction has opened and closed. This is auction runtime.
