---
title: Add restricted ████ ██████ ███████ history to the web client
type: task
status: completed
description: >-
  League-level page showing completed restricted ████ ██████ ███████s with every bid, its outcome,
  and a precise loss reason. Replaces the free-text reason column with a closed-set outcome code and
  gives the auction a first-class nomination row at (league, player, season) grain.
base_uri: user:task/league/add-restricted-free-agency-auction-history.md
created_at: '2026-08-02T15:28:54.620Z'
entity_id: b55e3c62-0e59-444a-bb4b-0032666c69c1
finished_at: '2026-08-02T16:43:15.554Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
started_at: '2026-08-02T15:29:05.537Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-08-02T16:43:15.554Z'
visibility_analyzed_at: '2026-11-05T23:48:17.325Z'
---

The web client has no surface for completed restricted ████ ██████ ███████s. Six seasons of bid history sit in `restricted_free_agency_bids` and are visible nowhere. This plan gives the auction a first-class row, replaces the free-text `reason` column with a closed-set `outcome` code, and adds a read-only league history page showing every bid, its outcome, and why it failed.

## Overview

- Add a league-level Restricted Free Agency history page at `/leagues/:lid/restricted-free-agency` showing completed auctions: every bid, the winner, and a precise reason for each loss.
- Replace the free-text `restricted_free_agency_bids.reason` column with a closed-set `outcome` code plus an `outcome_detail` free-text field used only for unexpected processing errors. The server writes codes; the client owns every human-readable label.
- Introduce `restricted_free_agency_nominations` as the row representing an auction at its true `(league, player, season)` grain, holding the nomination and announcement timestamps that are currently written onto whichever bid belongs to the nominating team.
- Impact: league members gain a permanent, auditable record of how each restricted ████ ██████ ███████ resolved. Managers can see whether they were outbid, lost a waiver-order tiebreak, or were matched by the original team — information that exists nowhere in the product today and is not recoverable from the current `reason` text.
- Ordering: the nomination primitive removes the structural cause of the 2026-08-02 early-processing incident (a competing bid's null `announced` coercing to epoch 0). It follows the RFA processing work already committed at `54fc7bf18` and unblocks any future work that needs auction-level state.

## Background

### Current data

`restricted_free_agency_bids` (`db/schema.postgres.sql:25708`) holds one row per bid with `pid`, `userid`, `bid`, `tid`, `year`, `player_tid`, `lid`, `succ`, `reason`, and the `submitted` / `processed` / `cancelled` / `nominated` / `announced` epoch-second integers. `restricted_free_agency_releases` holds the conditional cuts attached to each bid, keyed on `restricted_free_agency_bid_id`.

Production distribution, read 2026-08-02:

| Outcome | Rows |
| --- | --- |
| `succ = true` | 96 (2021-2025) |
| `succ = false`, `reason = 'player no longer a restricted free agent'` | 262 |
| `succ = false`, `reason = 'exceeds roster limits'` | 9 |
| `succ = false`, `reason = 'player no longer on original team roster'` | 7 |
| `succ = false`, `reason = 'player has tied winning bids'` | 2 |
| `succ` null with `cancelled` set (withdrawn) | 173 |
| 2026 live | 20 |

`nominated` and `announced` are populated on one bid per auction — 2025 has 131 bids and 20 `announced` values — and are entirely absent before 2024 (zero rows for 2021-2023), because the nomination window system postdates those seasons.

### Why the loss reasons are wrong

Every ordinary losing bid receives the same string, written by two blanket `UPDATE` statements in `scripts/process-restricted-free-agency-bids.mjs` at lines 311 and 349: `player no longer a restricted free agent`. That text does not distinguish the three cases a reader wants — outbid on amount, lost the waiver-order tiebreak at an equal amount, or matched by the original team. The specific strings (`exceeds roster limits`, `player no longer on original team roster`) originate as thrown `Error` messages in `libs-server/process-restricted-free-agency-bid.mjs` and reach the column through `reason: error.message`, so the database column's vocabulary is an accident of exception text.

### Existing surfaces

- `GET /teams/:teamId/tag/restricted-free-agency` (`api/routes/teams/restricted-free-agency.mjs:81`) filters `whereNull('processed').whereNull('cancelled')` and verifies team ownership: live bids only, own team only.
- `libs-server/get-restricted-free-agency-signings.mjs` returns winners only and has one consumer, `scripts/calculate-team-daily-ktc-value.mjs`.
- No route serves historical bids to the client.
- Client RFA code is live-auction only: `app/views/pages/league-home/league-home.js` renders in-progress notices gated on `is_before_restricted_free_agency_end`, plus the nomination schedule component.

### The pattern to mirror

The waivers report is the established shape for this exact problem: `api/routes/leagues/waivers/report.mjs`, `app/core/waivers/`, `app/views/pages/waivers/`, routed at `app/views/routes.js:94`. League transaction history (`api/routes/leagues/transactions.mjs:224`) is served without an auth check, establishing that completed league history is a public read.

### Assumptions

- An auction is uniquely identified by `(lid, pid, year)`; a player is not nominated twice in one season. Checked: production has no `(lid, pid, year)` group containing two distinct `nominated` timestamps.
- A losing bid's amount relative to the winning bid is sufficient to classify it as `outbid` versus `lost_tiebreak`. Checked: derived from the tie handling in `scripts/process-restricted-free-agency-bids.mjs:252`, which only consults waiver order when top bids are equal.
- Historical waiver order at processing time is not recoverable, because `teams.waiver_order` is a current-season mutable column. Checked: `sort_bids_by_waiver_order` reads `teams` filtered on `current_season.year`.
- Cancelled bids are excluded from the history view entirely, per operator decision.
- Completed auctions are fully disclosed to all league members, per operator decision. Sealed-bid secrecy for live auctions is preserved structurally: the history route filters on the processing timestamp, not on a permission check.
- `db/schema.postgres.sql` is the source of truth and the ratchet baseline currently sits at 559 baselined violations with a clean exit. Checked: ran `node db/adhoc/check-schema-conformance-ratchet.mjs`.

## Design

### Primitive: the nomination is the auction

New table `restricted_free_agency_nominations`, one row per `(league, player, season)`:

| Column | Type | Purpose |
| --- | --- | --- |
| `nomination_id` | `integer` identity | surrogate key |
| `league_id` | `integer not null` | league |
| `player_id` | `varchar(25) not null` | the restricted free agent |
| `season_year` | `smallint not null` | season |
| `original_team_id` | `integer not null` | team holding the player's rights |
| `nominated_at` | `timestamptz` | nomination time |
| `announced_at` | `timestamptz` | announcement time, the window anchor |
| `processed_at` | `timestamptz` | resolution time |
| `winning_bid_id` | `integer` | resolved winner, null until processed |

Unique on `(league_id, player_id, season_year)`.

Column names deliberately diverge from the codebase's `lid`/`pid`/`tid` shorthand. Those are baselined conformance violations on existing tables; reintroducing them on a new table mints fresh violations and turns the CI ratchet red, and the full-word forms also satisfy the naming guideline. `roster_asset_holding` set the precedent with `player_id`.

Timestamps are `timestamptz`, not the bigint epochs used by the neighbouring bid columns. A new time column must be `timestamptz` regardless of local convention — the ratchet keys on `table.column`, so following the legacy shape is exactly what trips it.

`restricted_free_agency_bids` gains `nomination_id` and drops `nominated`, `announced`, and `player_tid`. Window arithmetic reads `announced_at` through the nomination, so a competing bid can no longer carry a null that coerces to epoch 0 — the value is either present on the auction or the auction is not announced.

### Primitive: outcome is a code

New column `restricted_free_agency_bids.outcome`, vocabulary defined once in `libs-shared/constants/restricted-free-agency-constants.mjs`:

| Code | Meaning |
| --- | --- |
| `won` | this bid signed the player |
| `matched` | original team exercised right of first refusal |
| `outbid` | a strictly higher bid won |
| `lost_tiebreak` | tied the top bid, lost on waiver order |
| `roster_limit_violation` | bid was winning, failed the roster or cap check |
| `player_ineligible` | player left the original roster or lost restricted status |
| `processing_error` | unexpected failure; message in `outcome_detail` |

Each value names its distinguishing property rather than enumerating, per the closed-set identifier rule.

`reason` is dropped. `outcome_detail` carries free text only for `processing_error`, and no code branches on it. `succ` is retained: it is the settled boolean the roster and valuation paths already read, and `outcome` refines rather than replaces it.

### Classifier

New `libs-server/classify-restricted-free-agency-bid-outcome.mjs` — a pure function taking the winning bid and a losing bid, returning a code. `scripts/process-restricted-free-agency-bids.mjs` calls it in place of the two blanket `UPDATE ... reason: '...'` statements, which already have the winning bid, its amount, its team, and the sorted waiver order in scope.

### Read path

`GET /api/leagues/:leagueId/restricted-free-agency?year=` in a new `api/routes/leagues/restricted-free-agency.mjs`, mounted alongside `transactions` and `waivers`. Unauthenticated read like its siblings. Returns nominations for the requested season where `processed_at` is not null, each with its non-cancelled bids joined to teams and to `restricted_free_agency_releases`.

### Client

- `app/core/restricted-free-agency/` — `actions.js`, `reducer.js`, `sagas.js`, `index.js`, mirroring `app/core/waivers/`.
- `app/views/pages/restricted-free-agency/` — page, container, styl.
- `app/views/components/restricted-free-agency-auction/` — one card per auction: player, original team, winner, processed date, then bids sorted descending with amount, bidder, outcome chip, and conditional releases.
- Route registered in `app/views/routes.js`.

Outcome labels live in the component, resolved from the shared constants module. No English is read out of the database.

### Knowledge Base Impact

- `docs/api-documentation.md` and the route's swagger block: new endpoint.
- `repository/active/league/CLAUDE.md`: the section describing the `announced`-on-one-row trap documents a hazard this design removes; rewrite it to describe the nomination grain rather than deleting it, since the `Number(null)` lesson generalizes.
- `docs/glossary.md`: `reason` row retires and `outcome` is added. `scripts/update-glossary-from-coverage-report.mjs` keys rows on physical column names, so a dropped column silently freezes its row rather than reporting an error.
- `user:text/league/`: the restricted free agency subsystem document needs the outcome vocabulary and the nomination table.
- Observation candidate on this task entity: the structural fix for the announced-per-group incident.

### Working tier

No `scratch/` slug. This work produces no multi-file artifacts outside the repository.

### Orchestrator shape

Not warranted. This is one subsystem in one repository over a handful of commits, so it lives in a single task entity.

## Notes

- The schema change includes drops and is therefore inside the window where an unrelated session's `yarn export:schema` can commit the DDL ahead of the consumer sweep. Sweep code first; apply and commit in one short window.
- `player_tid` is also a column on the poaches table. Every sweep must be judged per `(table, column)`, never on the bare name.
- `test/scripts.restricted-free-agency.spec.mjs` currently gives competing bids an `announced` value they never carry in production. That fixture encodes the wrong shape and is why the suite could not catch the early-processing incident; correcting it is part of this work, not a follow-up.
- `db/adhoc/check-data-view-sql-validity.mjs` and the golden regeneration path are not implicated — restricted free agency bids are not a data-view source.
- Run the SQL-validity and conformance gates from a clean worktree at HEAD, since this is a shared checkout and a sibling's uncommitted edits are indistinguishable from local defects.

## Plan

### Outcome Vocabulary

- [x] **Define the outcome constants**
  - File: `libs-shared/constants/restricted-free-agency-constants.mjs` (create)
  - Purpose: single source of the closed-set outcome vocabulary, shared by server writer and client renderer.
  - Changes:
    - Export `RESTRICTED_FREE_AGENCY_BID_OUTCOMES` as a frozen map of the seven codes.
    - Export a helper returning whether a code represents a win.
  - Verify: `node -e "import('./libs-shared/constants/restricted-free-agency-constants.mjs').then(m => console.log(Object.keys(m.RESTRICTED_FREE_AGENCY_BID_OUTCOMES)))"` lists seven codes.

- [x] **Re-export from the constants index**
  - File: `libs-shared/constants/index.mjs`
  - Purpose: make the vocabulary reachable through the `#constants` alias used across server and client.
  - Verify: `grep -n restricted_free_agency_bid_outcomes libs-shared/constants/index.mjs` returns a hit.

- [x] **Write the outcome classifier**
  - File: `libs-server/classify-restricted-free-agency-bid-outcome.mjs` (create)
  - Purpose: derive a precise loss reason from the auction's resolved state, replacing the blanket reason string.
  - Changes:
    - Pure function with named parameters taking the winning bid, the losing bid, and the original team id.
    - Returns `matched` when the winner is the original team, `outbid` when the losing amount is strictly lower, `lost_tiebreak` when equal.
  - Verify: new unit spec below passes.

- [x] **Unit-test the classifier**
  - File: `test/libs-server.classify-restricted-free-agency-bid-outcome.spec.mjs` (create)
  - Purpose: pin the classification boundaries, which are the only place the history view's correctness is decided.
  - Changes: cases for matched, outbid, tied, and a winner equal to the bid under test.
  - Verify: `yarn test:local test/libs-server.classify-restricted-free-agency-bid-outcome.spec.mjs` green.

### Schema

- [x] **Author the DDL**
  - File: `db/adhoc/2026-08-02-restricted-free-agency-auction-history.sql` (create)
  - Purpose: introduce the nomination table and the outcome columns; retire `reason`.
  - Changes:
    - `-- STATUS: PENDING` header banner.
    - `CREATE TABLE restricted_free_agency_nominations` with the columns and unique constraint from the Design.
    - Backfill nominations from existing bids grouped by `(lid, pid, year)`, taking `max(nominated)`, `max(announced)`, `max(processed)`, and the `succ = true` bid as `winning_bid_id`, converting epoch integers with `to_timestamp`.
    - `ALTER TABLE restricted_free_agency_bids ADD COLUMN outcome varchar(32), ADD COLUMN outcome_detail text, ADD COLUMN nomination_id integer`.
    - Populate `nomination_id` from the new table.
    - Indexes on `restricted_free_agency_bids(nomination_id)` and `restricted_free_agency_nominations(league_id, season_year)`.
    - `ANALYZE` both tables.
  - Verify: file parses under `psql -f` against the `:5433` container before it goes near production.

- [x] **Run the conformance ratchet before pushing**
  - Purpose: the ratchet is CI-only, so a green local suite says nothing about it, and a red master defers every session's push to the repository.
  - Verify: `node db/adhoc/check-schema-conformance-ratchet.mjs` exits 0 with no new violations. If it reports one, rename the column — never `--rebaseline`.

### Backfill

- [x] **Classify historical outcomes**
  - File: `db/adhoc/2026-08-02-backfill-restricted-free-agency-outcomes.sql` (create)
  - Purpose: make all six seasons readable through the new vocabulary, best effort.
  - Changes:
    - `won` for `succ = true`.
    - Map the four legacy reason strings to `roster_limit_violation`, `player_ineligible`, and `lost_tiebreak`.
    - For the 262 rows carrying the blanket string, classify against the winning bid in the same nomination: `matched`, `outbid`, or `lost_tiebreak`.
    - Leave `outcome` null for cancelled and unprocessed bids.
  - Verify: `select outcome, count(*) from restricted_free_agency_bids where processed is not null group by 1` returns zero nulls among processed rows, and the `won` count equals 96.

### Processing Path

- [x] **Write outcomes at processing time**
  - File: `scripts/process-restricted-free-agency-bids.mjs`
  - Purpose: stop writing English into the outcome column.
  - Changes:
    - Replace the blanket updates at the two sites currently setting `reason: 'player no longer a restricted free agent'` with per-bid updates carrying a classifier-derived `outcome`.
    - Set `outcome: 'won'` on the winning bid alongside its existing `succ` write.
    - Replace `reason: error.message` with `outcome: 'processing_error'` and `outcome_detail: error.message`.
    - Read the announcement time through the nomination rather than off the bid row.
  - Verify: `yarn test:local test/scripts.restricted-free-agency.spec.mjs` green.

- [x] **Correct the processing spec fixtures**
  - File: `test/scripts.restricted-free-agency.spec.mjs`
  - Purpose: the fixtures currently give competing bids an `announced` value production never writes, which is why the suite could not catch the early-processing incident.
  - Changes:
    - Build competing bids without announcement fields; seed a nomination row instead.
    - Add assertions on the `outcome` written to each losing bid.
  - Verify: spec green, and confirm it fails against the pre-change processing script.

- [x] **Write nominations at nomination time**
  - File: `scripts/announce-restricted-free-agent.mjs`
  - Purpose: the nomination table becomes the writer of record for window state.
  - Changes: insert or update the nomination row on nominate and on announce, in place of the bid-row update at line 245.
  - Verify: `yarn test:local test/teams.restricted-free-agency.spec.mjs` green.

- [x] **Sweep the dropped bid columns**
  - Files: `libs-server/get-roster.mjs`, `libs-server/get-league-rosters-from-database.mjs`, `libs-server/get-top-restricted-free-agency-bids.mjs`, `libs-server/process-restricted-free-agency-bid.mjs`, `libs-server/restricted-free-agency-bids-query.mjs`, `api/routes/teams/restricted-free-agency.mjs`, `app/core/players/reducer.js`, `app/views/components/league-team/league-team.js`, `app/views/components/restricted-free-agency-confirmation/restricted-free-agency-confirmation.js`
  - Purpose: `player_tid`, `nominated`, and `announced` move to the nomination row; every reader must join.
  - Changes: replace direct column reads with nomination joins or selector reads.
  - Verify: `grep -rn "player_tid" api libs-server app | grep -i restricted` returns nothing, and the poach call sites are untouched. Full suite green.

- [x] **Apply the DDL and commit in one window**
  - Purpose: the drops remove columns committed code names, so an unrelated session's `yarn export:schema` between apply and commit ships the schema without this sweep.
  - Changes: `yarn db:exec` both adhoc files, `yarn export:schema`, commit the rewritten status banners and the schema diff together with the sweep.
  - Verify: `git status -- db/` shows only this cluster's files; `yarn test:local` green.

### Read API

- [x] **Add the history route**
  - File: `api/routes/leagues/restricted-free-agency.mjs` (create)
  - Purpose: serve completed auctions to the client.
  - Changes:
    - `GET /?year=` returning nominations where `processed_at is not null`, each with non-cancelled bids joined to `teams` for name and abbreviation, and to `restricted_free_agency_releases` for conditional cuts.
    - Default `year` to the current season; validate it is numeric.
    - Swagger block matching the house style.
  - Verify: route returns a non-empty array for a completed season and an empty array for 2026.

- [x] **Mount the route**
  - File: `api/routes/leagues/index.mjs`
  - Purpose: reachability.
  - Changes: `router.use('/:leagueId/restricted-free-agency', restricted_free_agency)` alongside the waivers mount.
  - Verify: `curl` against a local server returns 200 with JSON, not the SPA catch-all.

- [x] **Integration-test the route**
  - File: `test/leagues.restricted-free-agency.spec.mjs` (create)
  - Purpose: pin disclosure boundaries — completed auctions fully visible, live auctions structurally absent, cancelled bids excluded.
  - Changes: cases for a processed auction, an unprocessed auction, and a cancelled bid.
  - Verify: `yarn test:local test/leagues.restricted-free-agency.spec.mjs` green.

### Client

- [x] **Add the redux module**
  - Files: `app/core/restricted-free-agency/actions.js`, `reducer.js`, `sagas.js`, `index.js` (create)
  - Purpose: load and hold auction history, mirroring `app/core/waivers/`.
  - Changes: load action keyed on league and year, fetch saga, reducer holding the response list and a pending flag.
  - Verify: reducer registered in `app/core/reducers.js` and saga in `app/core/sagas.js`; every action-creator name referenced in a dispatch map exists on the actions module.

- [x] **Add the API service call**
  - Files: `app/core/api/service.js`, `app/core/api/index.js`, `app/core/api/sagas.js`
  - Purpose: wire the fetch through the existing API layer.
  - Verify: request fires with the expected URL in the browser network panel.

- [x] **Build the auction card component**
  - Files: `app/views/components/restricted-free-agency-auction/index.js`, `restricted-free-agency-auction.js`, `restricted-free-agency-auction.styl` (create)
  - Purpose: render one completed auction and its bids.
  - Changes: header with player, original team, winner, processed date; bid rows sorted descending by amount with bidder, amount, outcome chip, and conditional releases. Labels resolved from the shared constants module.
  - Verify: renders a matched auction, an outbid auction, and a tiebreak auction correctly against real production data.

- [x] **Build the page**
  - Files: `app/views/pages/restricted-free-agency/index.js`, `restricted-free-agency.js`, `restricted-free-agency.styl` (create)
  - Purpose: the history surface itself.
  - Changes: season filter, loading state, list of auction cards, empty state for a season with no completed auctions.
  - Verify: page loads for a past season and shows the expected auction count.

- [x] **Register the route and link it**
  - Files: `app/views/routes.js`, and the league navigation component carrying the waivers link
  - Purpose: reachability from the UI.
  - Verify: navigating to `/leagues/1/restricted-free-agency` renders the page; the nav link is present.

### Knowledge Base Updates

- [x] **Document the endpoint**
  - File: `docs/api-documentation.md`
  - Changes: add the history route with its parameters and disclosure semantics.

- [x] **Rewrite the announced-grain section**
  - File: `repository/active/league/CLAUDE.md`
  - Changes: replace the `restricted_free_agency_bids.announced` one-row trap description with the nomination grain, retaining the generalizable `Number(null)` lesson.

- [x] **Reconcile the glossary**
  - File: `docs/glossary.md`
  - Changes: retire the `reason` row, add `outcome` and `outcome_detail`.
  - Verify: `node scripts/update-glossary-from-coverage-report.mjs` reports no `not_found_columns` for this table.

- [x] **Update the subsystem document**
  - File: `user:text/league/` restricted free agency entity
  - Changes: outcome vocabulary, nomination table, history route.

### Testing & Validation

- [x] **Full suite from a clean worktree at HEAD**
  - Verify: `git worktree add ~/user-base/tmp/rfa-history HEAD`, symlink `node_modules`, run the full suite there; confirm green before trusting any result in the shared tree.

- [x] **Population-level backfill validation**
  - Purpose: an asset-level spot check cannot distinguish a working classifier from one that assigns a single code to everything.
  - Verify: `select year, outcome, count(*) from restricted_free_agency_bids where processed is not null group by 1,2 order by 1 desc` shows at least three distinct outcome codes in each of 2021 through 2025, and every processed row carries a non-null outcome. The failure this catches is a classifier that collapses all losses to `outbid` because the winning-bid join silently missed.

- [x] **Nomination coverage**
  - Verify: `select count(*) from restricted_free_agency_bids b left join restricted_free_agency_nominations n on b.nomination_id = n.nomination_id where b.nomination_id is null` returns only cancelled or 2026 live rows.

- [x] **Deploy and confirm**
  - Verify: `yarn deploy:all` from a clean tree, then confirm the served bundle by executing its behavior against the live route rather than grepping the source. Check all three checkouts are current, including `████████████`.
