# 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.