Files
vyndr/specs/top-graded-selector.md
builtbykev 72a14dc4cd Build /api/props/top-graded server selector: rank with p_win, serve without it
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
2026-07-29 22:05:30 -04:00

83 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.