72a14dc4cd
New READ endpoint. No grade, ledger row, lock_line, or scoring write. Push
scoring untouched.
REVIEW ZERO CORRECTED THE PREMISE: the handler NEVER EXISTED in any commit
(searched git rev-list --all for a /top-graded definition in src/ — zero hits).
Not "removed" — the three axios callers (cheatsheetGenerator, gradeOfTheDay,
widget) and the Next proxy were written against a phantom endpoint, so those
three content generators have silently received [] for their entire life.
Contract recovered from the four consumers, not guessed: {props:[...]},
?sport=UPPERCASE (absent = all sports, which gradeOfTheDay relies on) + ?limit,
rows carrying player/stat/line/direction/sport/grade/confidence? plus the
player_name/stat_type aliases and game_id.
POPULATED-PATH RISK FOUND: the board's populated branch had never run in prod,
and dashboard/page.tsx:463 calls g.stat.replace(/_/g,' ') UNGUARDED (g.player
also feeds the row key, /scan URL and heading; sport must be UPPERCASE for
SportPill). toRow requires non-empty string player+stat and a finite line,
uppercases sport, and DROPS unrenderable rows — a shorter board beats a broken
one.
THE LEAK BOUNDARY (why this is server-side): the browser cannot rank on p_win
for all tiers because stripModelPrice deliberately withholds it from unentitled
tiers. Order of operations is
read cache -> RANK with p_win (every tier) -> map rows incl. model fields
-> stripModelPrice(rows, tier) -> serialize
so a free caller receives the paid RANKING without the paid VALUES. Tier comes
from resolveTierFromRequest, which FAILS CLOSED to 'free'. Cache-Control is
private under a bearer token, public otherwise (the /api/snapshot precedent).
ONE SHARED DEFINITION, no drift: new src/utils/gradeRanking.js
(takeablePWin/descNullsLast/rankGrades). heroPropService now imports
takeablePWin instead of its inline copy (behaviour unchanged — it was that
logic verbatim); the selector imports rankGrades; web/src/lib/slateAdapter
keeps its mirror (the browser cannot import src/, S25) and a test cross-checks
the two on identical fixtures (playerName.js precedent). Board is grade-first
("top GRADES"), hero is p_win-first ("top read") — they differ BY DESIGN and
agree within the leading tier.
HONEST LIMIT: the Next proxy (cachedBackendJson) sends no Authorization header
and caches under a shared key, so via the dashboard every viewer gets the
free-tier payload — correct order, no paid values. That is the SAFE behaviour;
forwarding auth into a shared cache is exactly how a paid payload leaks to
anonymous viewers. Per-tier delivery through the proxy needs a tier-keyed cache
and is not done here.
Verified on real prod snapshot data (anonymous path): MLB 8 props, WNBA 10,
0 paid-field leaks, render-contract safe on every row, sport uppercase.
Floor: 311 suites / 3882 tests green (18 new — leak test uses POPULATED p_win,
not today's nulls: entitled gets p_win and it drove the order, unentitled gets
a byte-identical order with all five MODEL_FIELDS absent and no trace in
JSON.stringify, while book/fair market facts survive). Web build exit 0.
Dashboard visual is auth-gated -> tagged for the Chrome audit, not faked.
Held: edge_pct rescale/retirement (Order B); board columns/contract unchanged;
tier-keyed proxy caching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
83 lines
5.1 KiB
Markdown
83 lines
5.1 KiB
Markdown
# 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 `<h3>`; `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.
|