# SPEC — `/api/props/top-graded` server selector (rank with p_win, serve without it) **Status:** built 2026-07-29. New READ endpoint. No grade/ledger/lock_line/scoring write. Push scoring untouched. Spec companion: `specs/grade-board-sort.md`. ## 1. Review Zero findings - **0.1 CORRECTED — the handler NEVER EXISTED.** Not "removed": searched every commit (`git rev-list --all`) for a `/top-graded` definition in `src/` — **zero hits**. The three axios callers (`content/cheatsheetGenerator:21`, `content/gradeOfTheDay:17`, `routes/widget:76`) and the Next proxy were written against a phantom endpoint, so those three content generators have silently received `[]` for their entire life. The contract is therefore recoverable ONLY from consumers, which is what this spec builds to. - **CONTRACT (VERIFIED, union of four consumers).** Envelope `{ props: [...] }` (all three callers do `Array.isArray(res.data?.props)`; the Next proxy returns the same). Query: `sport` (UPPERCASE `NBA|MLB|WNBA` — the proxy validates that set; absent = all sports, which `gradeOfTheDay` relies on) and `limit`. Row fields REQUIRED by `dashboard/page.tsx` `TopGrade`: `player`, `stat`, `line`, `direction` (`'over'|'under'`), `sport`, `grade`, `confidence?`. Callers additionally read `player_name || player`, `stat_type || stat`, and `game_id` (cheatsheet's `gameCount`). - **0.5 POPULATED-PATH RISK — FLAGGED.** The board's populated branch has effectively never run in prod. `dashboard/page.tsx:463` calls **`g.stat.replace(/_/g,' ')` UNGUARDED** — a row without a string `stat` THROWS and takes out the board. `g.player` is used in the key, the `/scan` URL and the `

`; `sport` must be UPPERCASE (`type Sport = 'NBA'|'MLB'|'WNBA'`, fed to `SportPill`). `confidence` is the only null-guarded field. The selector therefore emits `player`/`stat` as non-empty strings and uppercases `sport`, and DROPS any row that cannot satisfy that (a crashed board is worse than a shorter board). - **0.2 VERIFIED — the strip can run server-side, after ranking.** `utils/requestTier.resolveTierFromRequest(req)` is purpose-built for a PUBLIC endpoint (bearer token when present, else `'free'`) and **FAILS CLOSED** — any error returns the LEAST entitled tier, so a resolution failure can only withhold, never leak. `utils/snapshotGating.stripModelPrice(rows, tier)` deletes `model_odds|p_win|ev_pct|value|takeable` for unentitled tiers and stamps `model_price_locked` where a book+fair pair survives. - **0.3 VERIFIED LIVE — the server HAS p_win right now.** `GET /api/hero-prop` returns `available:true` (Brionna Jones, B, wnba), and the hero rule REQUIRES a non-null `p_win` AND a takeable price, so the snapshot cache carries both server-side. The public `/api/snapshot` shows `p_win` on 0/8 MLB + 0/25 WNBA rows only because it is stripped on the way out. - **0.4 ONE SHARED DEFINITION.** Extracted to `src/utils/gradeRanking.js` (`takeablePWin`, `descNullsLast`, `rankGrades`). `heroPropService` now imports `takeablePWin` instead of its inline copy, and the new selector imports `rankGrades`. The browser cannot import `src/` (S25 rule), so `web/src/lib/slateAdapter` keeps its mirror — a test cross-checks the two on identical fixtures, the `playerName.js` precedent. Takeable band = `config/valueEngine.isTakeable` (−160..+200), strict-null. ## 2. The order of operations (the leak surface) read snapshot cache → RANK with p_win (all tiers, server-side) → map to contract rows INCLUDING model fields → stripModelPrice(rows, tier) ← the boundary → serialize Ranking happens BEFORE the strip, so a free caller gets the SAME order an entitled caller gets, without the paid values. `Cache-Control` follows the `/api/snapshot` precedent: `private` when a bearer token is present, `public` otherwise — a CDN must never hand a paid payload to an anonymous viewer. ## 3. Rank order `grade → confidence → takeable-gated p_win (nulls LAST) → SIGNED edge (nulls LAST) → stable input order`. Grade-first because this is "top **GRADES**", not "top read" — it is INTENDED that this board and the hero can lead with different picks (the hero is p_win-first). They agree within the leading grade tier. ## 4. Acceptance criteria 1. Entitled request: ranked by takeable-gated p_win; `p_win` PRESENT in the payload. 2. Unentitled request: **byte-identical ORDER**; `p_win`/`ev_pct`/`model_odds`/`value`/ `takeable` ABSENT. Proven with POPULATED p_win, not today's nulls. 3. Nulls sort LAST server-side; untakeable chalk never tops the board. 4. Grade tier dominates every signal. 5. Thin/empty slate → `200 { props: [] }`, never 404, never filler. 6. Every emitted row satisfies the populated-render contract (string `player`+`stat`, uppercase `sport`). 7. Hero / client / server use ONE definition — cross-checked by test. 8. No grade/ledger/lock_line/scoring write. Suite green, web build exit 0. ## 5. Held edge_pct rescale or display retirement (Order B) · changing the board's columns or contract · making the board and hero lead with the same pick.