# VYNDR — Claude Code Project Context

---

# 🔷 PRODUCT IDENTITY — READ FIRST, EVERY SESSION

**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO**, and picks
accurately. It reads and pulls the market apart — a student of the game that is
also an aggregator.

**Market edge is a BYPRODUCT of a good prediction. It is NEVER the success
criterion.**

> **SUCCESS = the forecast is honest about its own confidence AND still ranks.**
> Calibration (does 60% mean 60%?) *and* resolution (do higher forecasts actually
> hit more often?). Both, or it isn't working.

**No edge or CLV term belongs in a pass/fail gate.** CLV and market-relative edge
are *diagnostics we report*, never thresholds a model must clear to ship. A model
that forecasts honestly and ranks correctly is working even in a week the market
moved against it; a model tuned to beat a closing line has been fitted to the
market instead of to the game.

## PER-SPORT DOCTRINE
*(Rashad Phillips, "Basketball Position Metric," 2022 — classify players by **what
they do**, not by position labels.)*

**Each sport is its OWN model** — its own variables, archetypes, conditions,
calibration and honest ceiling. The **only** thing shared across sports is the
**Bayesian inference math**. Never one model fit to all sports; never a sport
stubbed in on another sport's template and counted as covered.

## TRUTH LAW
- **No fabricated data anywhere.** If it renders a number, it comes from the
  database or it doesn't render. `Number(null) === 0` is the classic breach.
- **Honest-absent beats invented.** An empty state is a valid answer.
- **Label limitations in-band** — e.g. "market consensus, **not sharp**",
  "RECORD BUILDING", "MODEL · LEARNING".
- **Provisional results stay provisional until re-run.** A measurement taken
  against an instrument that has since changed is not a result; it is a result
  *pending*.
- **Verify by inducing the real code path on demand** — never wait on a cron slot
  to find out whether something works. Documented ≠ verified.

---

## What This Is
Sports betting intelligence SaaS. Real software product.
Three tiers: Free (5 scans), Analyst ($19.99 / $14.99 founder), Desk ($49.99 / $34.99 founder).

## Tech Stack
- Backend: Node.js / Express
- Database: Supabase (PostgreSQL)
- Frontend: React Native (built in Cursor)
- Data: The Odds API ($30/mo), nba_api (free, Python wrapper)
- Caching: Redis — 15min for odds, 24hr for season averages, 1hr for recent games
- Payments: Stripe

## Critical Rules — Non-Negotiable

### 1. NO CODE WITHOUT A SPEC
Every feature requires a spec file in `specs/` before any code is written.
Spec must include: endpoints, data shapes, acceptance criteria, test plan.
Get approval before building.

### 2. WSL2 HEREDOC RULE
WSL2 corrupts heredoc for files over 10 lines.
ALWAYS use Python file-writing: `python3` with triple-quoted strings.
This applies to every file creation operation.

### 3. 5 QUALITY GATES (all must pass before any feature is marked complete)
1. Unit tests pass
2. Integration tests pass
3. Acceptance criteria met (from spec)
4. PR description written
5. CLAUDE.md updated if anything new learned

### 4. BUILD-STATE.md
Update after every session. What shipped, what's next, any blockers.

### 5. BLOCKERS.md
If you hit something you cannot resolve: log it. Don't guess. Don't skip.

## Folder Structure
```
vyndr/
├── src/
│   ├── routes/        # Express route handlers
│   ├── models/        # Supabase data models
│   ├── services/      # Business logic (prop analysis, odds normalization)
│   ├── middleware/     # Auth, rate limiting, scan counting
│   └── utils/         # Helpers, formatters, validators
├── tests/             # Unit + integration tests
├── docs/              # API docs, architecture notes
├── specs/             # Feature specs (write BEFORE code)
├── build-briefs/      # Session summaries
├── CLAUDE.md          # This file
├── ROADMAP.md         # Feature roadmap with phases
├── BUILD-STATE.md     # Current build status
├── BLOCKERS.md        # Unresolved blockers
└── DECISIONS.md       # Architecture decisions log
```

## All-Day Intelligence Layer (Session 23)
Free/cheap content that keeps the platform alive when odds-api props are
empty. NONE of these spend odds-api credits:
- `/api/schedule/:sport` — cache-aside ESPN scoreboard (`scheduleService`),
  self-heals on cache miss. Per-game `hasOdds`/`hasGameLines` flags peek at
  other caches without fetching.
- `/api/gamelines/:sport` — Tank01 book-by-book lines (RAPID_API_KEY quota).
- `/api/streaks/:sport` + `/api/hotlist/:sport` — PURE engines
  (`streaksService`, `hotListService`) computed from cached game logs. NO
  API calls. Logs loaded by `rosterLogs.js` (prefetch blob, else Redis SCAN
  over `gamelogs:{sport}:{player}:{count}`). Empty roster = valid empty state.
- `?stat=` filters narrow streaks/hotlist; categories in `config/statFilters.js`
  (mirror `web/src/config/statFilters.ts`). Discovery: `/api/stats/filters/:sport`.
- Dead providers: set `status: 'dead'` in `config/providers.js` to drop a
  provider from fallback chains + configured list (ParlayAPI host is dead).

## Provider Strategy (Session 30)
Player props now have abundance, not rationing.
- **Player props** — PRIMARY: PropLine (`proplineAdapter`, 3 keys
  `PROPLINE_API_KEY_1/2/3`, 3,000 req/day FREE, rotates per-key; registry
  `propline` priority 1). BACKUP: The Odds API (`ODDS_API_KEY`, 500/month,
  priority 2, conserve). `getOdds()` tries PropLine first when keys present,
  falls back to odds-api; the response + cache carry a `provider` field.
  PropLine is The-Odds-API-compatible → reuses `utils/oddsNormalizer`.
  MLB market keys (`batter_hits`, `pitcher_strikeouts`, …) were added to
  `MARKET_MAP` — without them MLB props normalize to zero.
- **MLB stats** — `mlbStatsAdapter` → statsapi.mlb.com. FREE, no auth,
  unlimited. Game logs, season averages, BvP, probable pitchers. Does NOT
  use the gateway (no quota). Registry `mlb-stats` (`noAuth: true`).
- **Game enrichment** — `scheduleService.getGameSummary(sport, eventId)` →
  ESPN summary (injuries, ESPN Bet odds, ATS, leaders, box score). Free.
- **Game-level odds** — Tank01 (unchanged). Tank01 PLAYER PROPS = empty,
  do not wire.

## Grades Content Pipeline (Session 32)
Closes the content pipeline: `contentTemplateService` reads a `grades:{sport}`
cache "when present" but nothing wrote it, so slate/POTD never reached
`dataLevel: 'full'`.
- **Writer** — `gradeSlateService.gradeAndCacheSlate(sport, props, opts)`
  dedupes props to unique player+stat+line (cap 25, concurrency 5), grades
  BOTH sides via `analyzeViaEngine1` (engine1 is direction-aware — keeps the
  higher-confidence side), sorts by confidence desc, writes
  `grades:{sport}` = `{ grades, updated_at, source }` (TTL 2h). The legacy
  grade shape already matches `normalizeGrade` — no remap needed.
- **Trigger** — fire-and-forget inside `oddsService.recordDownstream` (runs
  on a fresh odds fetch / cache MISS, NOT on cache hits). Does NOT hold the
  odds HTTP response. Gated by `shouldGradeSlate()`: ON by default, OFF when
  `NODE_ENV==='test'` (its feature-compute fan-out would pollute call-count
  assertions), override with `GRADE_SLATE_ON_FETCH=1`/`0`. `0` is the
  operator kill-switch if the per-prop feature-compute cost needs shedding.

## NFL/NHL Props (Session 32)
NFL + NHL wired end-to-end. **the-odds-api keys are `americanfootball_nfl`
and `icehockey_nhl`** (full-name prefix like `basketball_nba`) — NOT
`football_nfl`/`hockey_nhl` (those are PropLine's keys in `proplineAdapter`).
`oddsService.SPORT_KEYS` + `SPORT_MARKETS` carry both; `proplineAdapter.MARKETS`
filled. NHL keys (`player_shots_on_goal`, `goalie_saves`) added to
`MARKET_MAP` so NHL props don't silently normalize to zero in-season.

## Public Route Rate Limiting (Session 32)
`middleware/rateLimit` (`createRateLimit`, in-memory per-IP, independent
bucket per call site) now mounts via `router.use` at the top of every public
cached router: odds + parlay = 30/min, schedule/gamelines/streaks/hotlist/
content/lines/books = 60/min. `/api/analyze` keeps its own 10/min.

## Frontend ↔ Backend Wiring (Session 25 — non-obvious)
A new Express route under `/api/*` is NOT reachable from the browser until
a matching **Next.js proxy route** exists at `web/src/app/api/.../route.ts`
that forwards to `${BACKEND_URL}/api/...`. The browser hits the Next origin,
not Express directly. This bit us: schedule/gamelines/streaks/hotlist
endpoints worked on Express but 404'd in the UI for two sessions. When
adding a backend endpoint the frontend calls, ALWAYS add the proxy too
(pattern: `web/src/app/api/odds/nba/route.ts`).

Tank01 betting-odds real shape: sportsbooks are TOP-LEVEL keys on each
game object (`{ awayTeam, homeTeam, bet365:{...} }`), not a `sportsBooks`
array. Filter `NON_BOOK_KEYS` to extract books (see `gameLines.js`).

## VYNDR 2.0 Design System (Session 33 — Phase A+B)
Multi-session frontend conversion of the claude.ai/design "VYNDR 2.0" handoff
(NOT a backend change). Foundation shipped; pages/mobile/systems are Sessions
34+. Source of truth = the prototype's `vyndr.css` + `VYNDR_HANDOFF.md`.
- **Tokens** live in `web/src/app/globals.css` `:root`. The NEW canonical set
  is the short names: grades `--g-ap/--g-a/--g-b/--g-c/--g-d`, sports
  `--s-nba/--s-mlb/--s-wnba/--s-soccer`, `--amber`, `--live/--hit/--miss`,
  `--scan-op` (0.04), `--glitch` (1), `--sans` (Inter), `--mono` (JetBrains
  Mono). The legacy alias block (`--grade-a`, `--nba`, `--accent`, …) is KEPT —
  do not delete it until every consumer is migrated.
- **Two hard brand rules:** (1) JetBrains Mono (`var(--mono)` / `.mono`) for ALL
  data — odds, %, timestamps, book names, stat lines, grades; Inter
  (`var(--sans)`) for everything else. (2) Glitch animations apply ONLY to
  chrome (wordmark, headers-on-hover, dividers, loaders, living layer). DATA
  NEVER GLITCHES.
- **Glitch keyframes** (§4) are appended to globals.css after the legacy block —
  later-wins where names collide, so the appended VYNDR-2.0 definitions are
  authoritative. Entrance keyframes floor at the visible state (`fade-in` from
  opacity .6) so a paused frame is never invisible.
- **Shared components**: `@/components/vyndr/*` (Wordmark, GradeBadge,
  SportBadge, TerminalInput, SectionHead, VBtn, Card, Sparkline, Ticker), helpers
  in `web/src/lib/vyndrTokens.js`. The NEW Wordmark is `.wm` markup at
  `@/components/vyndr/Wordmark`; the legacy `@/components/Wordmark` (`.wordmark`)
  is still used by Nav until pages convert. `vyndrTokens.js` is CommonJS so it's
  importable by `.tsx` (allowJs) AND requireable by the plain-JS Jest suite —
  keep helper logic there so it stays genuinely unit-testable.
- **a11y layer** (§10): `<html data-contrast|data-text|data-cb|data-font|
  data-motion>` overrides in globals.css. Wiring the toggles to these attrs is
  Phase G (Session 38).

## VYNDR 2.0 App Shell (Session 34 — Phase C)
The frame every page sits in. Frontend-only.
- **Routing config** = `web/src/lib/routes.js` (CommonJS so it's unit-testable):
  `GATED_ROUTES`, `OPEN_ROUTES`, `HASH_ALIASES`, `isGatedRoute()`,
  `resolveHashAlias()`. GATED is deliberately narrow — only personal surfaces
  (ledger/tracker/account/profile/settings/notifications/invite). dashboard +
  scan stay OPEN (the free-scan funnel); gating them would be a monetization
  regression.
- **Auth gate = CLIENT-side** (`components/AuthGate.tsx`, mounted around `<main>`
  in layout). Our Supabase session lives in localStorage, not an httpOnly cookie,
  and `middleware.ts` is locale-only — a server middleware can't read it. The
  gate uses `useAuth().loading/user` + `isGatedRoute()` and redirects to
  `/login?next=<path>` (the param `/login` already consumes). Do NOT try to move
  this to middleware without first adding `@supabase/auth-helpers-nextjs` +
  cookie sessions (a separate, larger change).
- **Hash deep-links**: `components/vyndr/HashRedirect.tsx` translates
  `#scan`/`#terminal`/… → real Next routes once on mount. We keep file-based
  routing; hashes are just redirect aliases for old share links / PWA shortcuts.
- **Nav** (`components/Nav.tsx`): uses the new `@/components/vyndr` Wordmark
  (`.wm`), mono uppercase links, active = `--g-a`, a More dropdown, and a
  **Ticker** under the bar. The fixed header is 60px nav + 32px ticker, so layout
  `main` paddingTop is **96** (not 64) — keep that in sync if the header height
  changes.
- **Footer** (`components/Footer.tsx`) is mounted GLOBALLY in the layout (not
  per-page). System voice + "BUILT BY KEVON BUTLER · DETROIT".
- **404** (`app/not-found.tsx`) is the north star — scanlines, crt-sweep, glitch
  wordmark, amber 404. Interactive CTAs live in client `NotFoundActions` so the
  page stays a server component (keeps its metadata export).
- **RouteStub** (`components/vyndr/RouteStub.tsx`) backs not-yet-built routes
  (terminal/compare/invite/help/about/notifications). Never stub over a route
  that already has real content.

## VYNDR 2.0 Core Screens (Session 35 — Phase D)
- **Grade Result Card** = `@/components/vyndr/GradeResultCard` (the product's
  core moment) + `ProcessingGrade` (the factor-ignite reveal that precedes it).
  Feed them the §7 contract via `lib/gradeAdapter.js` (`mapScanToGradeResult`).
  The adapter is CommonJS (unit-testable) and **tier-gates content**: free =
  3-signal teaser, no kill conditions, no alt ladder; analyst = full signals +
  kill conditions; desk = + alt ladder. Keep that gating — the card itself shows
  whatever it's given, so the giveaway-prevention lives in the adapter.
  GradeResultCard returns the inferred JS-object type loosely, so cast
  `mapScanToGradeResult(...) as GradeResultData` at call sites (the JS adapter
  has no TS types).
- **Scan grade flow** (`app/scan/page.tsx`) now renders ProcessingGrade→
  GradeResultCard. The existing search/limit/parlay logic, `markReadComplete`
  reads tracking, and `noopener noreferrer` sportsbook deep-links were preserved
  — don't drop those when iterating.
- **GameCard** = `@/components/vyndr/GameCard` (Bloomberg best/worst line cells:
  best = `rgba(0,212,160,.13)` + green left border, worst = subtle red). Built +
  tested but NOT yet swapped into the live `dashboard`/`Slate.tsx` — that
  data-mapping swap is a pending task; don't assume the dashboard uses it yet.
- **Terminal** (`app/terminal/page.tsx`) is a real page now (server component,
  sample intel data) — no longer a RouteStub. Real-data wiring is later.
- **ClaimMeter** = `@/components/vyndr/ClaimMeter` (founder-seat scarcity), on the
  landing under the Hero.

## VYNDR 2.0 Remaining Screens (Session 36 — Phase E)
- **Dashboard lines** — `lib/slateAdapter.js` (`parseAmericanOdds`,
  `detectBestLines`, `mapScheduleToGameCards`) is the testable best/worst-line
  engine. The LEGACY `components/GameCard.tsx` (used by the live Slate, with
  inline grading) was RESKINNED to render its game-lines grid via
  `detectBestLines` (best = green tint + green left border, worst = subtle red)
  + SportBadge. IMPORTANT: the live Slate still uses the legacy GameCard, NOT
  `vyndr/GameCard` — a full swap needs inline grading ported into the new
  component first (slateAdapter + vyndr/GameCard are ready for it).
- **Real pages** (were RouteStubs): `compare`, `invite`, `help`, `about`. Only
  `/notifications` is still a RouteStub (keep the Session-34 stub test in sync if
  you convert it).
- **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"),
  `pricing` (+ ClaimMeter). `account` redirects to `/profile`.

## VYNDR 2.0 Mobile Parity (Session 37 — Phase F)
- **Bottom tab bar** = `components/BottomTabBar.tsx`: 5 tabs (Slate/Terminal/
  Scan/Ledger/More), Scan is the prominent raised grade-green action, More opens
  an integrated bottom sheet. Shown for ALL users (anon included — it's the only
  mobile nav); hidden on auth flows + landing via `HIDE_ON`, and hidden ≥768px
  via the `.mobile-tab-bar` rule in globals.css. The Nav hamburger is retired on
  mobile (the tab bar owns nav); the Nav's mobile panel is now dead code.
- **Mobile CSS** lives in the "MOBILE PARITY" section of globals.css: `main`
  bottom-padding clears the 64px bar + safe-area <768px; `.grade-hero` (the
  GradeResultCard letter) → 80px <640px; `.terminal-grid` stacks <768px;
  `.game-lines-grid` horizontal-scroll. Class hooks: `grade-hero`, `terminal-grid`.
- **PWA**: manifest has shortcuts (Slate/Scan/Terminal) + categories
  [sports,finance,productivity]; layout viewport sets `viewportFit: 'cover'`.
- BUILD GOTCHA: don't use `as const` on heterogeneous config arrays (TABS) — it
  makes each entry a distinct literal type and optional props fail type-check.
  Use a shared interface. And the build worker exits code 1 on type errors —
  check the build EXIT CODE, not just a `| tail` of its output.

## VYNDR 2.0 Systems (Session 38 — Phase G)
Testable CommonJS modules in `lib/` + thin React glue:
- **`lib/parlayMath.js`** — frontend correlation model (player 0.62 / team 0.34 /
  league 0.06 / cross-sport 0), `parlayGrade` penalty, grade→odds. Backend
  parlayService (S28) still owns server combined odds.
- **`lib/oddsFormat.js`** — `fmtOdds(value, format)`. CRITICAL: only signed-int
  strings + integer numbers are odds; totals/lines/spreads pass through UNCHANGED
  (`parseMoneyline`). This is intentionally stricter than the prototype's parseAm
  (which would mis-convert 228.5) — keep it that way.
- **`lib/prefs.js`** — `applyPrefs` sets `<html data-*>` (the S33 CSS layer keys
  off these), load/save to `localStorage('vyndr_prefs')`.
- **`lib/liveTick.js`** — single tick store; never auto-starts (SSR/test-safe),
  `start()` runs the unref'd 1s interval, `tick()` emits a fresh state object.
- **`lib/checkout.js`** — `checkoutUrl(plan)`.
- **`components/vyndr/LiveLayer.tsx`** (`useLive`/`LiveNumber`/`HeartbeatBar`) +
  **`GlobalHosts.tsx`** (mounted in layout — applies prefs, registers
  `window.__prefs`/`__goPaywall`/`__checkout`, hosts Prefs + Paywall modals).
- Header is now 124px tall (nav 60 + ticker 32 + heartbeat 30): layout `main`
  paddingTop = 124, Slate sticky `top` = 122. Keep them in sync if header changes.
- GOTCHA: don't return a `Set.delete`-based unsub directly from `useEffect`
  (returns boolean ≠ valid cleanup) — wrap as `() => { unsub(); }`.

## VYNDR 2.0 Conversion COMPLETE (Session 39 — Phase H QA)
The 7-session design conversion (33–39) is done and parity-verified against §13.
- Parity invariants are locked by `tests/unit/vyndrParityQA.test.js` — if you
  later add a glitch class to a data component, a raw `#00ffb8`/sport hex, a dead
  `onClick={}`, or break the gated-route list, that suite fails. Keep it green.
- INTENTIONAL hex (do NOT "fix" to tokens): `var(--token, #fallback)` fallbacks,
  Next metadata `themeColor`, and the bespoke intel-surface/red-tint text shades
  (#e8fff4/#bdf5e2/#ff8a8a/#ff8b7a/#ffb0a4/#ffd9a8/#04140f) ported from the
  prototype — no token equivalent.
- The GradeResultCard hero LETTER is intentionally `var(--sans)` (display),
  matching the prototype; all grade DATA rows + the GradeBadge chip are mono.
- DEFERRED (never in 33–39 scope): a true ⌘K command palette (Nav `›` Query
  currently links to /scan).
- TEST DE-FLAKE: `soccerFeatureExtractorCascade` gets `jest.setTimeout(20000)` —
  it falls through to live adapters on cache miss and flaked at Jest's 5s default
  under full-suite load (same family as the S32 pipeline test).

## P0 Audit Fixes (Session 41 — non-obvious)
- **THREE stat_type whitelists must stay in sync.** A prop's `stat_type` is
  gated in `src/routes/analyze.js` (`/prop` + `/batch`), `src/routes/scan.js`
  (parlay legs), AND `src/services/python/utils/validation.py`. Adding a sport's
  stats to one without the others silently 400s. The S41 MLB bug was exactly
  this: Python had the MLB set; both Node gates didn't. `mlbGrader.js` is DEAD
  CODE (required nowhere) — the live MLB grade path is the generic engine1
  feature pipeline, which keys off the Python validator's exact stat names
  (`rbi`/`runs`/`innings_pitched`, NOT `rbis`/`runs_scored`/`outs_recorded`).
- **`tests/integration/analyze.test.js` sits exactly at the 10-req/min IP rate
  limit** (`createRateLimit max:10`, no reset hook). Adding any HTTP test there
  429s the later cases. Put new analyze-route HTTP tests in a SEPARATE file
  (jest isolates module state per file → fresh limiter): see
  `tests/integration/analyzeMlbStats.test.js`.
- **Fonts are self-hosted via `next/font/google`** (layout.tsx): Inter→
  `--font-sans`, JetBrains_Mono→`--font-mono`, IBM_Plex_Mono→`--font-ibm`, all
  set on `<html className>`. globals.css `:root` maps `--sans`/`--mono`/
  `--ibm-mono` onto them. CRITICAL: next/font OBFUSCATES family names, so literal
  `'JetBrains Mono'`/`'IBM Plex Mono'` in CSS or inline `fontFamily` no longer
  resolve — always reference the variable. The old `fonts.googleapis.com` CDN
  `<link>` is GONE (it 503'd in prod). ShareCard canvas still uses literal names
  (canvas can't read CSS vars) — knowingly left.
- **Redirect routes** use server-component `redirect()` from `next/navigation`
  (`/settings`→`/profile`, `/report`→`/blog`). `/settings/security` is a REAL
  MFA enrollment page — do NOT clobber it into a redirect.
- **Profile tier** reads from `useAuth().tier` (the nav's source), not the
  `/api/user/profile` fetch, so the two can't disagree.

## Player Intelligence System (Session 42 — non-obvious)
Built from the Claude Design "VYNDR Player Intelligence" bundle.
- **Archetypes (41, NOT 45)** — 15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer. The
  canonical registry is `src/services/archetypeService.js` (`ARCHETYPES` keyed by
  UPPERCASE name; classify/getArchetype). The frontend visual map (color + glyph
  SVG + desc) is duplicated in `web/src/lib/archetypes.js` because the browser
  can't import the backend; a test asserts the colors MATCH. Colors are unique
  WITHIN a sport but REUSED across sports (a TWO-WAY archetype is purple in NBA +
  WNBA) — don't "dedupe" them. `classify(sport, stats)` is a feature-scoring
  classifier → `{ primary, secondary|null, blend: [{archetype, weight}] }`.
- **StatStrip rule** — player name appears ONCE; stats are horizontal mono runs.
  Never stack the name per stat. `components/vyndr/StatStrip.tsx` (compact +
  expanded). `vyndr/GameCard` prefers `playerStrips` (grouped) over legacy
  per-prop rows; both kept for back-comat.
- **Stats API** — `src/routes/stats.js` ALREADY existed (filters/public/live);
  Session 42 ADDED `/player/:name`, `/leaders`, `/game/:id` (don't recreate the
  file). Aggregation logic is in `src/services/playerIntelService.js` (testable;
  inject `cacheGet`). The name param is sanitized there (strip non-name chars,
  cap 60). Reads `grades:{sport}` cache for a player's props. Frontend needs the
  Next proxy (`app/api/stats/player|leaders/route.ts`) — Express isn't reachable
  from the browser directly (same rule as S25).
- **Player links** — always via `web/src/lib/playerHref.js` → `/player/:name?sport=`.
- **GradeResultCard / gradeAdapter** — new card sections (archetypeBlend, propDNA,
  statContext, vyndrIntel) are OPTIONAL + self-hiding; `gradeAdapter.buildIntelFields`
  only populates them when the engine supplies `archetype`/`season_avg`/`form`/etc.
  They light up once the Session-43 data pipeline feeds them — no empty boxes now.
- **Settings** — `/settings` is now a REAL page (replaced the S41 redirect). It
  LINKS to `/settings/security` (the real MFA page) — never overwrite that. Danger
  zone delete is gated on `deleteText === 'DELETE'`; there's NO backend deletion
  endpoint yet, so the confirmed action surfaces an honest "email support" message
  rather than faking success.
- **Components added to the barrel**: ArchetypeBadge, ArchetypeBlend, StatStrip,
  BookChip (`@/components/vyndr`). Book brand map = `web/src/lib/books.js`.

## Data Pipeline Wiring (Session 43 — non-obvious)
- **Dropdown z-index P0** — the `<nav>` uses `backdrop-filter`, which creates a
  stacking context. The living-layer bars (`Ticker`, `HeartbeatBar`) render as
  siblings AFTER it, so dropdowns that overflow below the 60px nav got covered.
  The nav carries `position:relative; zIndex:2` to float above them — don't
  remove it, and keep dropdown menus at `zIndex:100`.
- **MLB real stats** — `mlbStatsAdapter` is id-keyed; Session 43 added
  `searchPlayer(name)` (resolves via the cached season player list) +
  `getPlayerStats(name)`. `playerIntelService.resolvePlayerStats(name, sport)`
  normalizes the raw statsapi.mlb.com object into the archetype classifier's
  input shape + display rows. `getPlayerIntel` classifies from REAL stats now.
  Adapters are injectable (`opts.mlbAdapter`/`opts.resolveStats`) — tests never
  hit the network. NBA/WNBA uses `nbaStatsClient` (Python service, usually
  offline in prod → degrades to `found:false`, NOT an error).
- **Grade-card intel** — `analyzeViaEngine1.buildIntelFields(features)` computes
  stat-context + form/usage/matchup/rest from the EXISTING feature vector (no
  extra I/O) and `Object.assign`s them onto the legacy result. `gradeAdapter`
  maps them into the card. Archetype is deliberately NOT attached at grade time
  (the per-prop feature vector has no multi-stat season line) — that strip waits
  for the Session-44 pipeline. Keep grade-time I/O at zero (slate grades in tight
  loops; this is why archetype isn't fetched per-prop).
- **slateAdapter** now emits `playerStrips` (`groupPropsByPlayer`) + `pitchers`
  (`mapPitchers`) on every card. The LIVE slate still uses the legacy
  `components/GameCard` (which ignores those + renders `BookChip` in its line
  grid); the full swap to `vyndr/GameCard` is still pending (needs inline grading
  ported). `mapPitchers` returns undefined for non-MLB / no probables.
- **depthChartService** (`getLineup`/`getDepthChart`/`getCascadeProjection`) +
  `/api/stats/lineup|depth|cascade` are the foundation; all graceful + injectable.
  `matchesTeam` must guard empty names (`t.includes('')` is always true) — and
  the schedule `find` checks `g.home`/`g.away`, not the game object.

## VYNDR Original Archetypes + Make-It-Visible (Session 44 — non-obvious)
- **Archetype names are VYNDR Originals** (TORCH/BOMBER/ALPHA/…), NOT the old
  descriptive labels. The registry keys in `archetypeService.js` + the map keys
  in `lib/archetypes.js` are the new names; each carries `legacyName`/`legacy`
  (the old label, NEVER displayed). `getArchetype()` + `archetypeInfo()` +
  `badgeStyle()` resolve legacy names → the canonical VYNDR name (defends stale
  cached data). The `classify()` scorer objects use the new keys too — if you
  edit a threshold, use the new key. Full mapping is in `BACKEND_HANDOFF.md`.
- **MLB power merge:** there's ONE power archetype (BOMBER) — it fires for any
  high-HR bat (incl. high-K sluggers like Judge → BOMBER). The old POWER PULL
  slot became WHIFF (strikeout-artist pitcher). Don't re-split power.
- **`BACKEND_HANDOFF.md`** (repo root) is the canonical frontend↔backend data
  contract — update it in the same commit when an endpoint/component shape changes.
- **Grade-card intel gotcha:** the engine→tierGating→/api/scan chain already
  preserves the intel fields (everything spreads `...result`/`...data`). The bug
  was `scan/page.tsx` passing a hardcoded field subset to `mapScanToGradeResult`
  — it must forward season_avg/last10_avg/form/usage/matchup_grade/rest/archetype
  /archetype_blend/prop_dna (and `ScanResponse` must type them) or the card
  sections stay hidden.
- **Stale games:** `slateAdapter.isRelevantGame(game, now)` drops completed games
  >24h old; `Slate.filteredGames` applies it. Schedule TTL is 60s.
- **GameCard swap is DEFERRED** (Kev's call): the live Slate keeps the legacy
  on-demand "Read" card as a bridge until the snapshot pipeline lands. The
  on-demand grade model is being retired for a pre-graded snapshot model; the
  vyndr/GameCard (playerStrips/pitchers) is built for that and swaps in then.
  Don't swap it before the grades cache is populated (cards would be blank).

## Snapshot Pipeline (Session 45 — the product model)
The on-demand "Read" grade flow is RETIRED. Grades are produced by a scheduled
snapshot, locked to the line, and read from cache.
- **`snapshotService.runSnapshot(sport)`** orchestrates EXISTING services (don't
  rebuild): getOdds → gradeAndCacheSlate (captured via an injected cacheSet, so
  we re-write an ENRICHED envelope) → classify archetype per player
  (resolvePlayerStats + archetypeService) → attach `gradedAt {line,odds,timestamp}`
  → `computeLineDeltas` vs previous → write `snapshot:{sport}:latest|previous` +
  `grades:{sport}` → `generateTickerEvents` → `ticker:items`. ALL deps injectable
  → unit-tested with zero network. `runAllSnapshots()` is the cron entrypoint.
- **Redis keys:** `snapshot:{sport}:latest` (current locked snapshot:
  {grades, deltas}), `:previous` (for the next delta), `grades:{sport}` (enriched,
  read by GameCard/Explore/leaders), `ticker:items` (capped 50 array).
- **Trigger is INTERNAL-ONLY:** `POST /api/internal/snapshot/:sport|/all` behind
  `requireInternalAuth` (header `x-internal-key` == `VYNDR_INTERNAL_KEY`). `/all`
  is registered BEFORE `/:sport` or Express captures "all" as a sport.
- **Cron:** `src/snapshotScheduler.js`, gated `SNAPSHOT_CRON=1`, UTC hours
  14,19,22,1,3, armed in server.js (NOT app.js — app.js is imported by tests).
  No new dependency. For multi-replica, use an external n8n cron hitting the
  internal endpoint instead.
- **Read path:** `GET /api/snapshot/:sport` (public, cache-only, never triggers a
  snapshot → can't drain PropLine). `GET /api/ticker` (public, merges
  `TICKER_MANUAL` env pins).
- **GameCard swap:** the live Slate renders `vyndr/GameCard` (legacy GameCard kept
  for TYPES only — `import type`). It overlays snapshot grades onto each game's
  odds-derived props via `slateAdapter.buildPlayerStripsFromProps` (player name
  once + archetype + locked grade + line-delta sub-line). Ungraded → "Awaiting
  next scan", NO Read button. `StatStrip` renders the gradedAt/delta sub-line when
  a prop carries `gradedAt`/`delta`/`awaiting` (snapshot mode), else the inline chip.
- **Ticker** (`vyndr/Ticker`) polls `/api/ticker` every 30s; the passed items are
  the initial + graceful fallback (never blanks on fetch failure).
- **NBA/WNBA stats:** `espnStatsAdapter` is the FREE fallback when the Python
  nba_api service is offline. `parseAthleteStats` is DEFENSIVE (null on any shape
  it doesn't recognize → found:false). Its live ESPN shape may need prod tuning.
- **Env:** PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1,
  SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array).

## Grade Intel + Name Norm + Pitchers (Session 46 — non-obvious)
- **Grade-card intel root cause:** `buildIntelFields` reads l5_avg/l20_avg/
  opp_rank_stat/rest_days from the feature vector. `gameLogService.getGameLogs`
  is NBA/WNBA-ONLY (offline Python service) → MLB props had NO recent/season
  averages → intel always empty. FIX: `featureCache.gameLogFeatures` has an MLB
  branch using `mlbStatsAdapter.getPlayerStats` + the pure `mlbGameLogFeatures`
  (MLB stat_type→game-log field via `MLB_LOG_FIELD`). If you add an MLB stat
  type, add it to `MLB_LOG_FIELD` or its intel won't compute. `buildIntelFields`
  also takes `{ playerStats, projection }` fallbacks — don't remove the resilience.
- **Player name normalization:** `src/utils/playerName.js` is the source of truth
  (frontend copy at `web/src/lib/playerName.js` — keep them identical; a test
  cross-checks). `normalizeName(raw)→{display,key}`: display strips periods +
  de-dots suffix (keeps accents); key accent-folds + suffix-strips for comparison.
  Used in snapshotService grouping, slateAdapter grade-index + player-strip
  merge, and playerIntelService. `sanitizePlayerName` now returns the de-dotted
  display ("A.J. Ewing"→"AJ Ewing") — tests that asserted the dotted form were
  updated.
- **MLB pitchers:** the ESPN `/api/schedule` has NO probable pitchers. They come
  from `GET /api/schedule/:sport/pitchers` (MLB) → `probablePitchers` service →
  `mlbStatsAdapter.getScheduleWithPitchers`. The Slate matches them to games by
  team (full name OR mascot via `slateAdapter.buildPitcherMap`/`pitchersForGameTeams`).
  ERA is best-effort (season stats per pitcher id, cached).

## Name Norm + Intel + Ticker (Session 47 — non-obvious)
- **Name normalization is now complete:** `playerName.js` (both copies, kept
  identical) strips parenthetical team tags ("(STL)"), de-dots, suffix-strips,
  accent-folds the key, AND resolves first-name nicknames via the `NICKNAMES`
  table ("matt"→"matthew"). `nameKey` does the nickname resolution; `normalizeName`
  does display/key. To add a nickname, edit BOTH copies. The SLATE displays the
  normalized de-dotted name (`buildPlayerStripsFromProps` → `normalizeName().display`),
  not raw PropLine — that's why "AJ Ewing" shows, not "A.J. Ewing".
- **MLB VYNDR INTELLIGENCE fields** come from `mlbGameLogFeatures`: `rest_days`
  (gap-1 between the two latest game dates; 0 = B2B), `ab_per_game` (usage). If a
  field is missing from the card, check that `mlbGameLogFeatures` produced it and
  `buildIntelFields` has a branch (usage reads usage_rate→minutes→ab_per_game;
  matchup reads opp_rank_stat→bvp_advantage).
- **Ticker SCAN dedup:** `pushTickerItems` keeps one SCAN per sport (via the
  `sport` field on the event, or parsed from the text prefix for legacy items).
  MOVE/GRADE are time-specific and never deduped.
- **BOMBER threshold is prorated for mid-season** (`hr>=15` strong / `hr>=10`
  moderate). If you re-tune archetype thresholds, remember season totals are
  partial mid-season — don't use full-season cutoffs.

## Normalization Chokepoints (Session 48 — non-obvious)
- **The snapshot is the normalization SOURCE.** `snapshotService.runSnapshot`
  normalizes every grade's player to `normalizeName().display` AND dedupes to one
  grade per `nameKey|stat_type` (highest confidence) before writing
  `grades:{sport}` + `snapshot:{sport}:latest`. So GameCard overlay, Explore,
  leaders, and profile activeProps ALL inherit clean, merged names — don't add
  per-consumer normalization, fix it here.
- **Three paths that needed it (all fixed):** snapshot grades (above);
  `buildPlayerStripsFromProps` dedupes a player's props by stat (graded > awaiting);
  scan `tonightsPlayers` groups by `nameKey`. If a NEW surface lists players,
  group by `nameKey` + display `normalizeName().display`.
- **TWO intel renderers — don't confuse them:** the GRADE CARD (scan) uses
  `analyzeViaEngine1.buildIntelFields(features)`; the PLAYER PROFILE uses
  `playerIntelService.buildIntel(stats)`. The "+0%"/"—" bug was the PROFILE's
  defaults — fixed by `resolvePlayerStats` attaching real `usage` (AB/G) + `rest`
  and `buildIntel` reading them. The grade card already worked (the full feature
  merge carries ab_per_game/rest_days; `FEATURE_NAMES` is meta bookkeeping only,
  NOT a whitelist that filters the vector).

## Onboarding Flow (Session 49 — non-obvious)
- **Preferences live in Supabase `user_metadata.preferences`** (NO table/migration).
  `src/routes/preferences.js` GET/POST behind `requireAuth`, read/written via the
  service client's `auth.admin.getUserById`/`updateUserById`. POST is a PARTIAL
  merge (only body keys change) + sanitized (valid sports only, ≤12 books, clamped
  limit). Shape: `{ sports[], books[], weekly_limit, onboarding_complete }`.
- **Redirect reads FRESH from the API, not session metadata.** The dashboard
  fetches `/api/preferences` on mount and redirects new+incomplete users to
  `/onboarding`. Don't rely on `session.user.user_metadata` for this — it's stale
  after a backend write (admin API doesn't refresh the client session).
- **Existing users are exempt** via a `created_at >= ONBOARDING_CUTOFF`
  (2026-06-19) check. The redirect effect also early-returns while `authLoading`
  (else it bounces unauthenticated users to onboarding instead of login).
- **Dashboard personalization:** Slate `initialTab = prefs.sports[0]`; preferred
  books highlight in the card lines grid via `lib/books.js isPreferredBook`
  (matches DK/draftkings/DraftKings), threaded dashboard → Slate → vyndr/GameCard.
- **Settings** has a PREFERENCES section (GET to load, POST to save) — the same
  sports/books/limit the onboarding collects.
- **Name micro-fix:** `playerName.js` `collapseInitials` merges "J C" → "JC"
  (display + key) so space-separated initials dedupe.

## Name Edge Cases + Audit Polish (Session 54 — non-obvious)
- `normalizeName` now also **strips hyphens** to spaces ("Jung-hoo" → "Jung hoo")
  in display+key; `nameKey` **strips single-letter MIDDLE tokens** ("Josh H Smith"
  → "josh smith") — keeps first (may be a collapsed initial like "jc") + last, so
  real middle names ("Juan Carlos Smith") and first initials are NOT dropped.
  `richie: 'richard'` added to NICKNAMES. Both copies kept identical.
- **Accent-keep dedup** (`snapshotService`): the variant-grade collapse keeps the
  highest-CONFIDENCE grade but the richest DISPLAY (prefers accented "José" over
  "Jose", then longer). That's why prop rows now match the accented pitcher line.
  `hasAccent` uses a charCode>127 check (NOT a regex with literal control bytes).
- **Team Hub names** are normalized in `teamService.getTeamHub` (the source), not
  the page — every `/api/team/:abbr` consumer gets `normalizeName().display`.

## Parlay Lab (Session 50 — non-obvious)
- **Correlation model** lives in `src/services/parlayService.js` (ADDED to the
  S28 categorical matrix — both coexist). `correlationScore(l1,l2)` is numeric +
  GAME-aware (0.7 same-player/game, 0.4 same-team/game, 0.2 same-game, 0.0 diff-
  game). `combinedGrade` penalizes the leg-grade avg by `avgCorrelation*0.5`;
  `estimatedPayout` = Πfair-odds × (1−avgCorr) discount. `gradeParlay` is the
  bundle the route returns. Leg shape: `{ player, team, game, stat, grade }`.
- **POST /api/parlay/grade** (`src/routes/parlay.js`) — public, 2–6 legs, returns
  `{ combined, correlation, payout, legs }`. The Next proxy was pointed at the
  WRONG upstream (`/api/scan/parlay`); it now forwards to `/api/parlay/grade`.
- **ParlayContext auto-grades** the slip (debounced 250ms) via that endpoint
  whenever legs change (≥2). `combined`/`correlation`/`payout` are live on the
  context — don't call the endpoint from components, read the context.
- **Leg cap is tier-aware via the context.** `maxLegs` defaults 6; `ParlayPanel`
  sets it from `useAuth().tier` (free 2 / analyst 4 / desk 6). `addLeg` reads a
  ref so it stays a stable callback. The "+" buttons no-op at the cap.
- **"+" wiring:** `StatStrip` takes `onAddLeg`/`isLegActive`; `vyndr/GameCard`
  provides them via `useParlay` (it owns the game id + team). `legKey` =
  `player|stat|line|direction` is the dedupe key (exported from the context).
- **ParlayPanel** (mounted in layout, REPLACED `ParlayTray` which called a
  then-missing `/grade` endpoint). Floating badge bottom-right when closed; free
  tier blurs the payout with a `window.__goPaywall` upsell.

## Team Hub (Session 51 — non-obvious)
- **`teamService.getTeamHub(sport, abbr)`** is the single payload builder for
  `/team/:abbr`. MLB is the real path: `mlbStatsAdapter.resolveTeam` (abbr→id via
  the cached `/teams` list) → `getTeamRoster` → per-player `getSeasonAverages(id)`
  (bounded concurrency 8, reuses `playerIntelService._internals` mappers) →
  archetype (the snapshot grade's locked archetype, else `classify`) → graded
  props from `grades:{sport}`. The whole hub is cached 15 min (`teamhub:{sport}:
  {abbr}`); each player's season stats cache 6h. NBA/WNBA have no free roster
  feed → it returns a partial roster built from tonight's graded players + a note.
  All deps injectable for tests.
- **To add a sport's roster**, add a real roster source in `getTeamHub` (the MLB
  branch is the template); abbr→id mapping is fetched live from statsapi (no
  hardcoded team table to maintain).
- **`GET /api/team/:abbr`** (public, cached) 404s an unknown MLB team. Browser
  must use the Next proxy `app/api/team/[abbr]`.
- **Page split:** `team/[abbr]/page.tsx` is a server wrapper (for
  `generateMetadata`) rendering the `TeamHub` client component (sort/filter/parlay
  are interactive). Player names → `playerHref`; team abbrs on game cards →
  `vyndr/GameCard`'s `TeamLink` (stops propagation from the open-game handler).
  The roster "+" reuses the Parlay Lab (`useParlay`/`legKey`).

## Infra Verification (Session 52 — non-obvious)
- **`GET /api/internal/snapshot/status`** (internal-key gated) is the post-deploy
  health probe: `{ cron_armed, cron_hours_utc, last_snapshot, redis_keys,
  ticker_count }`. Use it to confirm the snapshot pipeline is alive without
  shelling into the container.
- **Scheduler logs both states** on start: armed (`[snapshotScheduler] armed —
  SNAPSHOT_CRON=…`) and disarmed. If you see neither in container logs,
  `startSnapshotScheduler()` isn't being called.
- **`SNAPSHOT_DEBUG=1`** turns on a per-run `[deltas]` log in `computeLineDeltas`
  (off by default — it's a hot path). Confirms a previous snapshot exists to diff.
- **Redis persistence is NOT app-controlled.** ioredis connects via `REDIS_URL`;
  AOF/RDB is a server-side Coolify config. Snapshot keys disappearing on restart
  = the Redis instance has persistence off, not a code bug.
- **Push-to-Book is a TEASER only** — "BOOK IT ⟶" (StatStrip) + "PUSH-TO-BOOK ·
  COMING SOON" (GradeResultCard) advertise an unbuilt feature. No backend.

## Social Preview / OG Image (Session 53 — non-obvious)
- The OG/Twitter image is **dynamically generated** by the file-based convention
  `web/src/app/opengraph-image.tsx` (Twitter re-exports it). Do NOT re-add an
  `images: ['/og-image.png']` to the `layout.tsx` metadata — that emits a second,
  conflicting `og:image` tag. The legacy static `/og-image.png` still exists in
  `public/` but is unreferenced.
- It uses **Node runtime, NOT `runtime = 'edge'`** — this is a self-hosted
  `output: 'standalone'` build; edge runtime isn't available off Vercel and breaks
  `next/og` here. Don't copy the edge example from Next docs.
- Social copy is intentionally jargon-free (no "Bayesian"/"kill conditions"/"xG
  regression"). "kill conditions" is still legit IN-APP product copy (help/pricing/
  scan) — only the social meta was cleaned.

## Self-Learning Loop + Real-Time Layer (Session 55 — non-obvious)
- **`outcomeService.settleSnapshot(sport, deps)`** is the self-learning loop:
  reads `snapshot:{sport}:latest`, settles each locked grade vs the REAL result
  from `mlbStatsAdapter.getPlayerStats().last10` (game-log row whose `date`
  matches the graded date — UTC **or** ET, to cover late-game rollover), records
  hit/miss/push, and writes `outcomes:{sport}:log` + `accuracy:{sport}` +
  `accuracy:overall`. **Idempotent** — dedupe key is `nameKey|stat|line|side|date`;
  re-running never double-counts. Presence of a game-log row ⇒ the game is FINAL
  (no separate status check). NBA/WNBA have no free settled-result feed → they
  stay `pending` (never throw). If you add an MLB stat_type, add it to
  `MLB_LOG_FIELD` in outcomeService (a LOCAL copy — settlement is decoupled from
  featureCache's map on purpose) or its props won't settle.
- **Accuracy pct EXCLUDES pushes** (`hits/(hits+misses)`); `sample` counts pushes.
  Grade buckets: `A+` stands alone, then first-letter (`A-`/`A`→A, `B±`/`B`→B).
  30-day trailing window keyed off each outcome's game `date`.
- **The cron settles BEFORE it grades** (`snapshotScheduler` tick): settle
  yesterday's now-completed games, then grade today's fresh slate. Trigger
  manually via internal `POST /api/internal/outcomes/all` (same key as snapshot).
- **`GET /api/accuracy`** (public, cached) + **`GET /api/ledger/accuracy`**
  (buckets — the pre-existing Next `ledger/accuracy` proxy finally has a writer).
  Browser reaches them via `web/src/app/api/accuracy/route.ts` (new proxy) — same
  S25 rule.
- **`AccuracyBadge`** (`@/components/vyndr`) is HONEST: `< MIN_SAMPLE` (8 settled)
  → "MODEL · LEARNING" (amber), else "A-RATED · X% HIT · 30D" (green). Pass
  `sport=` for a sport-specific record. Self-hides only when the fetch fails/empty.
- **Settled outcomes overlay the live slate:** `GET /api/snapshot/:sport` merges
  `outcomes:{sport}:log` onto grades (`outcome:{result,actual}`), threaded through
  `slateAdapter.buildPlayerStripsFromProps` → `StatStrip.OutcomeChip` (`✓ HIT (2)`
  / `✕ MISS`). A settled prop hides its "+parlay" / BOOK-IT chips (the bet is over).
- **Real-time Slate:** `fetchSlate(tab, silent=true)` polls every 60s WITHOUT the
  skeleton flash and NEVER wipes a good view on a transient empty result (guard:
  `if (silent && allGames.length===0) return`). The "SIGNAL LIVE · UPDATED Xs ago"
  strip uses `lastRefreshed` + a 15s `nowTick`. Ticker has an anchored LIVE badge
  that flashes on a new head event.
- **Founder pricing:** `stripeService` `FOUNDER_CODE_EXPIRY` default is now
  `2026-12-31` (was `2026-06-30`, which had lapsed and disabled every founder code
  + the ClaimMeter pitch). That expiry lapsing — NOT a tier-limit change — was the
  cause of the "4 stripe test failures." Operators override via `FOUNDER_CODE_EXPIRY`.

## Data Audit + Pipeline Resilience (Session 56 — non-obvious)
- **`specs/propline-audit.md` + `specs/vyndr-roadmap.md` are the source of truth**
  for data coverage + the session plan. Re-run `node scripts/propline-audit.js`
  (writes raw JSON to stdout; live sections need network) to refresh.
- **A market only fully works if it's wired at FOUR layers:** requested in
  `proplineAdapter.MARKETS`, mapped in `oddsNormalizer.MARKET_MAP`, whitelisted in
  all three grade gates (`routes/analyze.js`, `routes/scan.js`,
  `python/utils/validation.py`), AND (for MLB) present in `MLB_LOG_FIELD` in BOTH
  `featureCache` and `outcomeService`. Miss the map → silent zero; miss the log
  field → no features + no settlement. `batter_rbis` was mapped to `rbis` while
  everything else keyed on `rbi` — a 4-layer desync that silently killed RBI
  props. It's `rbi` now; a normalizer test locks it.
- **The streaks/hotlist path uses its own `rbis` key** built from raw MLB stats —
  independent of the odds normalizer. Don't "unify" them; the split is intentional.
- **MLB and WNBA both settle end-to-end.** CORRECTED 2026-07-26 (was "MLB-only"):
  `outcomeService.SPORTS` includes wnba, which settles via ESPN box scores
  (`espnStatsAdapter` box-field map) — verified: 376 WNBA rows settled, settle
  logic spot-checked correct. NBA/soccer still do not settle (no free settled
  feed wired). So `accuracy` reflects MLB + WNBA, not MLB only.
- **`src/utils/opsNotify.js`** pushes pipeline alerts to ntfy (`vyndr-pipeline-
  kev2026`). It NEVER throws and is auto-disabled under `NODE_ENV==='test'` /
  `PIPELINE_ALERTS=0` (inject `fetchImpl` to test it). `snapshotService` alerts on
  success/stale/failure; `snapshotScheduler` has a per-minute missed-cron watchdog
  (`isSnapshotOverdue`, exposed as `overdue` on the status probe).
- **Snapshot retry rule:** retry-once ONLY on a thrown/null odds response (a
  transient provider blip). A successful-but-empty slate is NOT retried — that's a
  legit off-hours empty slate, and retrying would waste quota + add 60s latency.

## Phase 0 — Kill the Lies (Session 57 — non-obvious)
Work-order Phase 0 (Jul 10 live audit): fabricated UI deleted/rewired. The
overhaul work order lives in `specs/phase-0-kill-the-lies.md` (+ the full
phased plan in the Session-57 conversation / BUILD-STATE Next section).
- **ESPN schedule is date-pinned now.** `fetchScheduleFromEspn(sport, date)`
  sends `?dates=YYYYMMDD` AND filters events to the requested ET date (undated
  events dropped). Without this, off-season ESPN returns the NEAREST slate —
  the Jun 13 NYK@SA Finals game rendered as "tonight" all July. Don't remove
  the defensive filter even though the param "should" be enough.
- **`liveTick` carries NO display data** — state is `{ tick }` only (1s
  re-render pulse). Real header numbers come from `GET /api/snapshot/summary`
  (public, cache-only; registered BEFORE `/:sport` in `routes/snapshot.js`).
  `vyndrSystems.test.js` fails if fabricated counters reappear.
- **Ticker hides below 4 real items** (spec §3) and publishes `--ticker-h`
  (32px/0px) on `<html>`. Layout main paddingTop = `calc(92px +
  var(--ticker-h, 0px))`, Slate sticky top = `calc(90px + ...)`. If you change
  header heights, update BOTH calc bases (they replace the old 124/122
  constants). No hardcoded fallback ticker items — real exhaust or nothing.
- **/terminal redirects to /dashboard.** The old page's layouts (VVI cards,
  injury wire, leaders) are preserved UNROUTED in
  `web/src/components/intel/TerminalTemplates.tsx` as §12 content-engine
  templates — every constant in there is SAMPLE data; never route it as-is.
  BottomTabBar's 5 tabs are now Slate/Explore/Scan/Ledger/More.
- **Empty slates use `web/src/lib/emptyState.js`** (month-aware per-sport
  copy). It's CommonJS + unit-tested; add sports there, not inline.

## Phase 1 — Truth Infrastructure (Session 58 — non-obvious)
- **DATA SEMANTICS RULE (system-wide):** VYNDR never generates lines/odds —
  market values are REAL book numbers captured at a timestamp; only
  `model_value`/grade/edge/confidence are model output, always labeled MODEL
  in the UI. Any path that would fill in a market value must show an absent
  state. `Number(null) === 0` is the classic fabrication bug — use
  `numOrNull` (ledgerService) style strict parsing.
- **`ledger_entries`** (migration 019, APPLIED): user_id NULL rows = the
  PUBLIC model record (pipeline-only — never write anon scans there!).
  Dedupe = `UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line,
  side, game_id)`; upserts use `ignoreDuplicates: true` so re-runs never
  overwrite the original lock. RLS: clients read own rows + public rows;
  ALL writes via service role. player_key = `nameKey()`.
- **Write paths:** pipeline → `snapshotService` calls
  `ledgerService.recordPipelineGrades` + `captureClosing` (best-effort,
  never breaks the snapshot; no-op without SUPABASE env). User scans →
  the NEXT `/api/scan` route (`writeLedgerEntry`), authed users only.
- **Closing/CLV:** captureClosing overwrites today's unsettled rows'
  closing_line/odds on EVERY snapshot — last write before game start is the
  close. CLV is SIGNED BY SIDE: over = locked − closing (positive = market
  moved toward the grade = 'beat'); under = inverse. Settlement
  (`settleLedger`) runs in the scheduler's pre-grade settle pass and via
  POST /api/internal/ledger/settle; idempotent (`.is('outcome', null)` guard).
- **Insufficient data (work-order 1.5):** `analyzeViaEngine1` REFUSES
  (grade null + `insufficient_data: true`) when `projectionFor` finds no
  model reference (l5→l20→`{stat}_per_90`→xG-for-goals). gradeSlateService
  filters refusals out of the slate; parlay legs degrade to the F-stub; the
  scan route writes nothing and doesn't burn a scan. If you add a sport,
  make sure its feature extractor emits a projection field or every prop
  refuses.
- **n≥20 rule:** `getModelAggregate` returns `hit_pct`/`beat_close_pct` as
  NULL under 20 settles; every surface (ledger MODEL tab, `ModelRecord`)
  renders "RECORD BUILDING" instead. Never render a % on a small sample.
- **SYNC thresholds** come from `SNAPSHOT_EXPECTED_INTERVAL` (s; default
  18000) via /api/snapshot/summary `expected_interval_s`. Phase 2.5 drops
  the value to the intraday cadence — no UI change needed.

## Player Keys + Slate Join + Mobile Nav (Session 59 — non-obvious)
- **`mlbStatsAdapter.searchPlayer` resolves via `nameKey`** (canonical,
  accent-folded). NEVER reintroduce substring matching — it returned the
  WRONG player's id on a near-miss (the audit's mismatched last-10 bug).
  Fallback is unique last-name+first-initial or null: a missing profile
  beats another player's log.
- **The slate JOIN INVARIANT** lives in `buildPlayerStripsFromProps(…,
  gameTeams)`: a graded prop whose player's real team (snapshot `team`,
  from the stats resolve) isn't a game participant is DROPPED. Props with
  no team info are kept (can't verify ≠ wrong). Tests in
  slateAdapterStrips lock it.
- **grades:{sport} is written with SNAP_TTL (6h), NOT 2h** — the 2h TTL
  expired between 5h-apart cron runs and blanked /team + Explore mid-day.
- **Sport tabs are THE filter** (?sport= URL param, history.replaceState).
  The dashboard has NO second tablist — legacy sections subscribe via
  Slate's `onTabChange`. Don't re-add a duplicate tab row.
- **`lib/pipelineSchedule.js` mirrors SNAPSHOT_HOURS_UTC** (14,19,22,1,3)
  for "Grades post ~X ET" waiting states; a test cross-checks it against
  snapshotScheduler.HOURS_UTC. Change the cron → change both.
- **BottomTabBar shows on '/'** — hiding it there left anon phones with
  ZERO navigation (desktop links hide <768, hamburger retired). HIDE_ON is
  auth flows only.
- **GameCard collapse**: 6 graded props max (sorted A+→F), "ALL N READS →"
  expands in place; GAME LINES <640px = `.gl-summary` best-line row +
  expander revealing `.gl-full.gl-expanded`.

## Night-2 Board (Session 60 — non-obvious)
- **SNAP_TTL is 24h ON PURPOSE** (was 6h): the overnight cron gap is 11h;
  a shorter TTL silently kills the morning accuracy settle. AUTONOMY.md is
  the zero-touch trace — update it when the loop's shape changes.
- **The snapshot pipeline FEEDS the aggregator**: `mergeRosterLogs` writes
  `rosterlogs:{sport}` (72h TTL, cap 300) from the same stats resolve that
  classifies archetypes. Streaks/hot-lists starve if you remove it — the
  external prefetch (n8n/tank01) was never armed.
- **THE LENS RULE**: no raw streak renders alone — `streakLens.applyLens`
  attaches built-vs / tonight's matchup (+opposing SP ERA for MLB batters)
  / difficulty / one-line read. Absent context = say less, never invent.
  Streaks are FREE for every tier; hot lists keep the free top-3 gate.
- **Phase 2.5 lives in `intradayRefreshService`**: signed delta vs the
  GRADED side; STEAM never re-grades; ≥1.0 against re-grades THAT prop at
  the real current line; a dropped grade sets `revised_from_grade` ONCE
  (the original letter is forever). `applyRevision` only touches unsettled
  public rows. Scheduler skips full-snapshot slots; INTRADAY_REFRESH=0
  kills it. After deploy set SNAPSHOT_EXPECTED_INTERVAL=1200.
- **/api/players/search is the canonical resolver** (nameKey fuzzy; MLB =
  statsapi list, others = cached names). The Python service is a fallback
  only. Never reintroduce raw-substring matching.
- **Landing renders instantly for anon**: `hasStoredSession()` sync
  localStorage gate — do NOT re-block the marketing render on
  `useAuth().loading`; that was the 3–4s LOADING THE SLATE bug.
- **QA.20–22 in vyndrParityQA** lock the night's color semantics (STEAM
  amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
  schedule-derived waiting copy. Keep them green.

## Affiliate + Partner Plumbing (A1 S3 — non-obvious)
- **Every sportsbook link goes through `web/src/lib/bookLinks.js`**
  (`buildBookLink` → `{url, tracking}`; unknown book → null). The affiliate
  layer is `web/src/lib/affiliateConfig.js` — ALL books `enabled:false` (no
  program approved); flipping a book + filling params produces tracked URLs
  with NO component changes. Empty param values are SKIPPED (a half-filled
  config degrades to organic, never a fabricated id). Anchors MUST render
  `rel={BOOK_LINK_REL}` ("sponsored noopener noreferrer") — tests assert it.
- **Best-price dot honesty rule:** `slateAdapter.detectBestBook(rows, side,
  refLine)` returns a book ONLY when ≥2 books post the SAME line for the side
  and prices differ — never compare odds across different lines, never mark a
  lone price "best". The per-book rows reach the browser as `lines[]` on each
  grouped prop; `Slate.groupByGame` now threads them as `books` on PropRowProp
  (pickLine still picks the single displayed line).
- **Partner attribution:** `?ref=CODE` → `vyndr_ref` cookie (90d, FIRST-touch,
  `lib/partnerRef.js` + `PartnerRefCapture` in layout) → `signUp` metadata
  `partner_ref` → internal `GET /api/partners/report/:code`. The
  `user_profiles.partner_ref` column does NOT exist yet — the endpoint returns
  zeros + note until the TODO migration in `docs/PARTNERS.md` runs (metadata
  lands in auth.users.raw_user_meta_data, which PostgREST can't query). OAuth
  signups carry no metadata (known gap). Partner code == Stripe promotion code,
  verbatim (docs/PARTNERS.md §3).
- **Worktree build gotcha:** `web/node_modules` isn't shared into git worktrees
  and Turbopack REJECTS a symlink pointing outside the project root — use
  `cp -al` (hardlink copy) from the main repo's web/node_modules.
## Newsletter — THE VYNDR REPORT (Session S7, a1 — non-obvious)
- **`newsletterService`** is the whole layer: `buildDailyReport(sports, deps)`
  (deterministic VOICE v1.1 template over snapshot grades + streak lens +
  `getModelAggregate`; every dep injectable), `subscribe(email)` and
  `sendDailyReport(opts)` (Listmonk, self-hosted, zero out-of-pocket). ALL
  Listmonk paths are env-gated on `LISTMONK_URL/USER/TOKEN/LIST_ID` — any
  missing → `{ ok:false, reason:'not configured' }`, and the capture UI shows
  "Signups open soon". Auth is the API-user scheme `Authorization: token
  user:token`, NOT basic auth.
- **Double opt-in is Listmonk's job:** subscribe sends
  `preconfirm_subscriptions:false`; the list itself must be created as
  double-opt-in (runbook: `docs/NEWSLETTER.md`). The email template embeds the
  LITERAL `{{ UnsubscribeURL }}` — Listmonk substitutes per-recipient; never
  "fix" it. A 409 (already subscribed) is returned as ok — no enumeration.
- **The send is deliberately unscheduled.** Only `POST
  /api/internal/newsletter/send` (internal key) triggers it; an empty report
  (0 signals AND 0 streaks) refuses to send. The record line renders a
  percentage ONLY when `hit_pct != null` (n≥20 gate upstream in
  `getModelAggregate`), else "RECORD BUILDING · N pending".
- **VOICE lint is executable:** `tests/unit/newsletterService.test.js` fails on
  any `!`, banned vocabulary (word-boundary regex), or hype emoji in
  subject/html/text. The RG disclaimer says "No outcome is promised" — the word
  "guarantee" trips the lint even in a disclaimer, keep it out.
- **Capture placement:** the signup "done" card auto-redirects after 1.5s
  (`setTimeout(router.replace)`) — useless for a form. The free-signup success
  surface is `/welcome`. Landing capture sits below FAQ. Subscribe route is
  10/min IP-limited → keep `tests/integration/newsletterRoute.test.js` under 10
  subscribe requests (same lesson as analyze.test.js).
## Ops Self-Watch (Session 8, A1 board — non-obvious)
- **Pure alarm logic lives in `src/services/opsWatch.js`** (failure tracker,
  zero-settle signal, quota daily check, pulse assembly) + `src/services/
  systemHealth.js` (statfs/os, injectable). `snapshotScheduler` only WIRES
  them — keep new alarm logic in opsWatch so it stays unit-testable.
- **Zero-settle signal = the ledger settle's own return values** (Postgres
  `game_date < today AND outcome IS NULL`), NOT `snapshot:{sport}:previous` —
  the snapshot key expires in exactly the failure mode the alarm exists to
  catch (the S60 SNAP_TTL bug). Scoped to `SETTLEABLE_SPORTS` (mlb);
  NBA/WNBA rows legitimately stay pending and must not page daily. Morning
  slot only (`morningHourUtc` = first configured hour ≥ 06 UTC).
- **Failure pager pages ONCE per losing streak** (exactly at count 3), not
  once per failing slot; `status:'skipped', reason:'no grades'` is NOT a
  failure (props arrived, grader refused) and resets the counter.
- **Daily pulse** fires at `PULSE_HOUR_UTC` (default 13 = 9 AM EDT), one
  notification, dual dedupe (in-process date + Redis `ops:pulse:{date}`).
  Missing data renders 'n/a' — a pulse never fabricates a zero (Data
  Semantics Rule applies to ops copy). All alert copy: no exclamation
  points, ever (VOICE v1.1) — tests lint for it.
- **Single-suite jest runs of scheduler tests can hang at exit** when redis
  is down (ioredis reconnect timer keeps the process alive) — PRE-EXISTING
  at baseline, full-suite runs are unaffected. Don't chase it as a leak in
  new code; inject cacheGet/cacheSet in tests to avoid creating a client.
- Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone
  setup) = `docs/OPS-RUNBOOK.md`.

## Slip Reader (Session 9, A1 board — non-obvious)
- **`src/services/slipReader.js`** — PURE per-book OCR-text layout parsers
  (DK/FD/MGM/Caesars) + `detectBook` + `parseSlipText(text, bookHint)`. The
  NEVER-GUESS contract: every field carries a confidence; below
  `CONFIDENCE_THRESHOLD` (0.6) the field is NULL + the leg `needs_review:true`.
  Values are USER-SLIP values (`source:'user_slip'`) — never written to any
  market cache. Stat labels normalize via `STAT_ALIASES` → the scan-route
  vocabulary (a unit test cross-checks every alias against VALID_STAT_TYPES);
  players via `playerName` normalizeName/nameKey.
- **Caesars head split is alias-anchored:** "Pete Alonso Home Runs Over 0.5"
  is regex-ambiguous (greedy name eats "Home" → stat "runs" = WRONG-stat
  fabrication). `splitPlayerStat` matches the LONGEST known stat alias the
  head ENDS with; no alias → stat null AND player low-confidence (boundary
  unknowable). BetMGM legs carry "@ -115" — don't pre-filter its lines with
  the game-row `@` heuristic; the Over/Under grammar is the filter.
- **OCR = tesseract.js v7 (Apache-2.0), lazy-required** in `recognizeImage`
  so the parsers load without WASM. ~46MB on disk (core WASM); eng
  traineddata (~11MB) downloads ONCE at first recognize and caches at
  `TESSERACT_CACHE_PATH` (default `.tesseract-cache/`) — first prod OCR call
  needs outbound network, then it's fully offline.
- **`POST /api/slips/parse`** (requireAuth): base64/data-URL image ≤4MB
  decoded OR raw `text` (paste path, also what hermetic route tests use).
  Daily quota `slips:{user}:{YYYY-MM-DD}` — free 1/day, paid 10/day; Redis
  counter with in-memory mirror (max of both enforces). Validation runs
  BEFORE the quota is burned. Injectable via `router.__internals.setDeps`.
  Next proxy: `web/src/app/api/slips/parse/route.ts` (S25 rule).
- **`/slip` page** (added to GATED_ROUTES): upload/paste → editable leg rows
  (nulled fields amber; editing clears the flag) → each leg graded via the
  EXISTING `POST /api/scan` (refusals render "NO GRADE — insufficient data")
  → `useParlay().addLeg` per graded leg → share card is copy-text (no OG
  plumbing). Sport selector is MLB/NBA/WNBA (ParlayLeg's sport union).
- **Vision-model upgrade is POST-REVENUE** (specs/vyndr-roadmap.md) — the
  route contract is the stable interface; only the extraction engine swaps.
## Public Ledger Profiles v1 (Session 10, A1 board — non-obvious)
- **`public_profiles` (migration 022) is PRIVATE BY DEFAULT** — `published`
  DEFAULT FALSE, one explicit toggle in Settings, service-role writes only.
  `GET /api/profiles/:handle` returns the SAME 404 body for unknown AND
  unpublished handles (no existence leak) — keep them byte-identical; a
  test diffs the two responses. A malformed handle 404s WITHOUT a DB query.
- **`getModelAggregate({ userId })`** swaps `.is('user_id', null)` for
  `.eq('user_id', uid)` on BOTH the settled and pending queries — the same
  30d window + n≥20 gate over one user's ledger. The no-userId default is
  the public model record and must stay untouched.
- **The user's public entries are SETTLED rows only** (`.not('outcome',
  'is', null)`, newest 50, same columns as /api/ledger) — pending reads are
  not public until they settle, and nothing is curated: misses included.
- **`/u` is in OPEN_ROUTES on purpose** — the share surface must load
  anonymous; the privacy gate is the API's 404, never the router.
- **Jest from a worktree gotcha:** the repo config's `testPathIgnorePatterns`
  includes `/.claude/`, which matches EVERY path inside
  `.claude/worktrees/...` → "No tests found". Run with
  `npx jest --testPathIgnorePatterns "/node_modules/"` from a worktree
  (CLI replaces the config array).

## Row Grammar + Display Layer (Session S6, A1 board — non-obvious)
- **`specs/ROW-GRAMMAR.md` is LAW**, locked by `tests/unit/rowGrammar.test.js`
  (source-order assertions on StatStrip). Prop-row slot order: identity →
  viability → stat+line → market context (best dot, movement chip) → model
  output (revision strike, grade) → outcome → actions → provenance. If you
  reorder a StatStrip element, amend the spec + test in the same commit or
  the suite fails. Red = settled-negative ONLY (miss/dead/stale/faded); the
  line sparkline is green(toward)/amber(against)/dim(flat), never red.
- **Sparkline history is captured by `intradayRefreshService.trackHistory`**
  — {t, line} on each grade, seeded with the LOCKED line, deduped when flat
  (a point means the line MOVED), capped 24, persisted inside the snapshot
  write-back (no new Redis keys). `StatStrip.LineSparkline` needs ≥3 points.
  `Number(null) === 0` struck again here — all three S6 numeric paths
  (trackHistory, last10Dots line, clvBucketIndex) use strict null guards.
- **`last10_dots` is attached at READ time** in `routes/snapshot.js` from
  `rosterlogs:{sport}` via `services/last10Dots` (reuses streaksService
  accessors — new MLB accessors runs/doubles/triples/outs live THERE, one
  source of truth). Dots score vs the LOCKED line (gradedAt.line), not the
  current line.
- **`clv_distribution` lives in `getModelAggregate`** behind the SAME n≥20
  gate as hit_pct (null below) — never re-derive the gate in a consumer.
  Buckets clamp outliers into the edges ([-2,-1) … (1,2]).
- **Global search** — `window.__search` + ⌘K registered in GlobalHosts;
  SearchModal fans out to `/api/players/search` per sport (MLB/NBA/WNBA) +
  static `web/src/lib/teams.js` (soccer deliberately ABSENT — no canonical
  registry; add it with the soccer team hub, not before). BottomTabBar keeps
  the Explore tab; Search is the first More-sheet item.
- **LCP mechanics:** `fade-up` now floors at opacity .6 — a 0-start entrance
  animation makes the hero h1 a LATE LCP candidate (invisible content isn't
  contentful). IBM Plex Mono is `preload:false` (decorative face; its 4
  weight files competed with Inter + CSS on slow 4G). Measure before/after
  via PageSpeed post-deploy — not runnable from the box.

## Live Tracking (Session S11, A1 board — non-obvious)
- **GRADES NEVER CHANGE IN-GAME.** `liveTrackingService` + `/api/live/:sport`
  produce per-player CURRENT box values; `web/src/lib/liveProgress.js` turns
  them into PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome slot
  (`StatStrip.LiveTracker`). Spec = `specs/LIVE-TRACKING.md`; slot-6 + color
  amendments are locked in `rowGrammar.test.js` — an in-progress prop is
  NEVER red (green = hit/on-pace/holding, amber = needs/line-passed).
- **THREE MLB stat maps now exist on purpose** — do NOT merge them:
  `featureCache.MLB_LOG_FIELD` (features), `outcomeService.MLB_LOG_FIELD`
  (settlement, game-log rows pick ONE group by position), and
  `liveTrackingService.LIVE_BOX_FIELD` (live boxscore carries BOTH
  stats.batting AND stats.pitching per player → each stat names its group,
  and innings_pitched parses in THIRDS: '5.2' = 5⅔ — parseFloat is wrong).
  Adding an MLB stat_type = wire all three.
- **Absent beats zero, live edition:** a player not yet in the game has
  EMPTY `stats.batting`/`stats.pitching` objects (MLB) or `didNotPlay`/empty
  stats row (ESPN WNBA) → he is OMITTED from the live index. Never coerce to 0.
- **Quota shape:** `live:{sport}:{date}` TTL 90s is the shared window — 1
  schedule (`hydrate=linescore`, so inning progress costs no extra call) + 1
  boxscore per live game per 90s TOTAL across all users; zero boxscore calls
  when nothing is live. The Slate polls `/api/live/{sport}` every 60s ONLY
  while live mlb/wnba games are on screen. The route never fetches for
  unwired sports (short-circuits before the service).
- **The strip join needs `statType`** — `buildPlayerStripsFromProps` now puts
  the canonical lowercase stat key on every strip prop (`stat` is the short
  display label like 'TB' and can't be joined on). attachLiveProgress only
  touches graded, unsettled, non-dead props, and computes state vs the
  LOCKED line (`gradedAt.line`). Under semantics are HOLDS-IF: an under is
  never 'hit' until the settle pass; exceeded → amber LINE PASSED, not red.
- **Actions are suppressed while live** (`!p.live` on ParlayBtn/BookIt —
  the pre-game market for a locked line closed at first pitch); the label
  "TRACKING — READ LOCKED PRE-GAME" renders once per live card (GameCard,
  dim — it's meta, not a caution signal).

## Probability Layer + Grade Range (Session 63 — non-obvious)
- **`gameLogService.getGameLogs` is a TRAP: it returns null for MLB by
  construction** (`pythonPath` `default: return null`) and depends on the Python
  service, which is OFFLINE in prod. Anything wired to it is dead. S46 fixed this
  for FEATURES (`featureCache.gameLogFeatures` MLB branch) but NOT for the
  estimator — so `meta.gameLogs` was `[]` for every sport and `p_win`, `ev_pct`,
  `kelly`, `model_odds`, `value` were absent on 100% of live grades for months.
  **`featureCache.getStatRows(player, sport, statType)` is now the one true source
  of normalized per-game rows** (`[{date, [statType]: v}]`, MOST-RECENT-FIRST —
  the estimator treats `slice(0,5)` as the recency window). Use it; never add a
  new caller of `gameLogService` directly.
- **Hero v2 requires a finite `ev_pct`** — when EV was dead it matched nothing and
  fell through to the recent-read fallback silently (`is_recent:true` was the
  tell). A "working" endpoint returning data is not proof the intended rule ran.
- **`confidence` is NOT a probability.** engine1 picks a letter from an additive
  factor index, then reads that letter's band MIDPOINT out of
  `grade_thresholds.json` to make the number — so it carries zero information
  beyond the letter and can never disagree with it. Payloads carry
  `confidence_basis: 'grade_band'`. The real signal is `p_win`. Corollary:
  mlb-grade-degradation.md's "25/25 grade<->confidence agreement" is a TAUTOLOGY,
  not a validation (corrected in that file) — never cite it as grade quality.
- **`grade_thresholds.json` is NOT an input mapper in the JS path** — only the
  Python side compares scores to it. In JS it is a confidence lookup table read
  BACKWARDS from the already-chosen letter.
- **The grade is an integer index** (`GRADE_SCALE`, `NEUTRAL_INDEX` 3) moved by
  flat +/-1.0 and +/-0.5 deltas. A needs sum >= +4.5, D needs <= -1.51. Six
  factors were wired to features nothing populated, pinning the live range to
  {C,B} — only TWO letters ever emitted across 604 ledger rows.
  **`refreshTeamStats` had ZERO production callers**, so `opp_rank_stat` (a +/-1.0)
  was permanently null; it is now called in `runSnapshot` (test-env no-op, the
  opsNotify precedent). L20 was asymmetric (both branches +1.0 = no downside path)
  and is now symmetric.
- **Consistency CV is scale-dependent — this is a live landmine.** The thresholds
  are NBA-tuned (points ~20/gm). For a Poisson-ish stat `cv ~ 1/sqrt(mean)`, so
  ANY stat with mean < 4 auto-classifies `boom_bust` (real: Alonso hits mean 0.60
  -> cv 1.17). Reviving consistency without a guard stamps a blanket -1.0 on
  nearly every MLB prop. Floored at `CONSISTENCY_MIN_MEAN` (4) -> `unknown` below.
  The scale-free fix is an index-of-dispersion classifier (open item).
- **NEVER rescale thresholds to make A's appear** (founder ruling, permanent).
  Minting A's without new information is a relabelled B sold as an A and it
  corrupts an append-only ledger. Fix the grade on MERIT or don't claim the scale.
- **A-RATED copy is on hold** until a prod fingerprint shows real A grades.
  `/api/ledger/accuracy` currently returns B and C buckets only, so AccuracyBadge
  correctly falls through to "MODEL · X% HIT" and TopSignals self-hides.
- `scripts/verify-grade-range.js` replays live-board props through the real engine
  on free feeds. It UNDERSTATES range locally (no Redis -> no `opp_rank_stat`).
  Redis runs degraded locally, so the script must `process.exit(0)` — otherwise a
  reconnect timer holds the process open and piped output is lost to SIGTERM.

## hits-v1 — a REFUTED challenger, and why it stays (Session 76 — non-obvious)
- **`specs/hits-v1-binomial.md` is the record.** hits-v1 models hits as a
  binomial over the player's EMPIRICAL at-bat distribution (P(0) stated directly,
  since 84% of hits rows trade at 0.5). It FIRES at 99.4% on the live board and
  it DOES NOT WORK: point-in-time replay, hits-only, direction-aligned, n=242 —
  resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired bootstrap
  (same rows) puts hits-v1 − ladder at −0.022, CI95 excluding 0. NOT PROMOTED.
- **Two explanations are now ELIMINATED for hits, which is the useful part.**
  The family was wrong (swapping it made things slightly worse) AND the mean was
  not the constraint (hits-v1 moved the line-0.5 mean 0.554→0.581 against a 0.598
  base rate — closer — while resolution FELL). What remains is per-prop
  DISCRIMINATION: the ladder's inputs don't separate hitters. Don't build another
  projection variant for hits; diagnose what the champion's `p_win` reads first.
- **"Honest ceiling" needs its control checked before you claim it.** The spec's
  own pre-registered fallback ("hits may be genuinely low-resolution for anyone")
  was REFUTED by the champion scoring 0.276 on the identical 189 rows. A ceiling
  claim is only honest if no instrument on the same rows beats it — check that
  BEFORE writing the branch, not after.
- **Backtest ≠ verdict.** The replay truncates each player's game log strictly
  BEFORE the row's `game_date` and reuses the row's stored grade-time
  `combined_multiplier` (both live on real ledger rows) — without that truncation
  it would be scoring predictions with the answer in hand. The verdict of record
  is still the forward accrual, so hits-v1 stays wired, writing
  `proj_hits_p_over`/`proj_hits_meta` only. Never served.
- **Paired bootstrap, not two independent SEs.** Challengers score the SAME rows;
  comparing independent standard errors overstates uncertainty and would have
  read a reliable −0.022 regression as noise. `scripts/hits-v1-holdout.js` has
  the seeded implementation — reuse it for the next challenger.
- **The takeable axis paid off measurably:** 94 of 159 live hits props are
  OUTSIDE the promotion band and 93 were modelled anyway. Scope by
  `isTakeableMarket` (book identity); record `isWithinPromotionBand` and never
  let it gate a model — a price-shape rule would have deleted 59% of the board.
- **Local `.env` has a transposed Supabase ref** (`zmdnczhtdxcddszxttub`; real is
  `zmdnczhtdxcddsxzttub`), so local scripts hitting Supabase need an explicit
  `SUPABASE_URL=` override. Prod + the MCP connection are fine.

## Settlement outage + challenger scoreboard (Session 77 — non-obvious)
- **`.in('id', [...])` IS A URL, NOT A QUERY.** PostgREST puts filters in the
  URL: 500 UUIDs = an 18,499-char request that the fetch layer rejects with
  `TypeError: fetch failed`. This silently killed `settleLedger` for two days
  (2026-08-01/02) — it refetched rows by id, destructured `const { data: rows }`
  with NO error binding, so rows was null, the loop never ran, and it returned
  `{settled:0,voided:0,unrecoverable:0,pending:0}`, byte-identical to a healthy
  "nothing to settle". 1,444 rows sat with `settle_attempts=0`. NEVER send an
  unbounded id list; `ID_FILTER_CHUNK` (100) is the guard, and settleLedger now
  selects every column it needs in ONE query.
- **It was VOLUME-TRIGGERED, which is why it hid.** Daily volume ran 20–260 rows
  for weeks; 2026-08-01 was the first day past `SETTLE_FETCH_LIMIT` (500). If a
  pipeline "works for weeks then stops", suspect a threshold that volume just
  crossed, not a code change.
- **The ops alarm was blind to it BY CONSTRUCTION.** The zero-settle watchdog
  reads settleLedger's own return values, so `pending: 0` told it the backlog was
  empty. An alarm that trusts the return value of the thing it watches cannot see
  that thing fail silently — the signal must come from OUTSIDE (a direct
  `game_date < today AND outcome IS NULL` count).
- **Settled n went 493 → 1,741 the moment it was fixed.** Anything reading
  "n-blocked" across MULTIPLE independent challengers at once is a pipeline
  symptom, not a sampling fact. Count settled rows before believing it.
- **`specs/challenger-scoreboard.md` is the board.** Nothing promoted: arch-v1
  Δ0.0000 CI[−0.005,+0.005] on 1,741 (and it MOVED 76% of rows by 2.5pp mean —
  active movement carrying zero information), contact-v1 +0.0008 inconclusive,
  proj-v1.1 ladder −0.0301 CI excluding zero = reliably WORSE. matchup/tb-v1/
  hits-v1 genuinely pending (rows dated 08-02+, settle after ET midnight).
- **arch-v1/contact-v1 need no replay** — they wrote p_win at grade time into
  their own columns, so scoring them is a TRUE prospective holdout. Only a
  challenger that did not exist at grade time (hits-v1) needs a point-in-time
  replay. Don't conflate the two kinds of evidence.
- **Score a nudge on the rows it MOVED**, not on all rows — otherwise the
  unmoved rows are the champion measured against itself and dilute any real
  effect toward zero. `scripts/challenger-scoreboard.js` does both slices.

## The 429 is odds-api, NOT PropLine (Session 77 — non-obvious)
- **`specs/odds-429-diagnosis.md`.** MEASURED: PropLine 5/3,000 daily (0.17%);
  odds-api 478/500 MONTHLY, `allowed:false` (tracker blocks at 95%). One snapshot
  = ONE PropLine call per sport (all markets comma-joined) — there is no
  per-prop/per-book fan-out and no request-pattern problem to optimize.
- **The 429 text is the BACKUP's.** `oddsService.getOdds` falls through silently
  when PropLine returns null/empty, then odds-api's quota gate throws
  `429 "Odds data temporarily unavailable"`. So an EMPTY PropLine slate is
  indistinguishable from an outage, and the error names the wrong provider. Open
  order — don't read a 429 as "PropLine exhausted" without checking
  `GET /api/internal/quota`.
- **BOOK BREADTH INVARIANT: we never discard books.** All books are KEPT and
  SHOWN (`DISPLAY_BOOKS = MODEL ∪ REFERENCE ∪ DFS`). The ONLY selectivity is that
  DFS pick'em is excluded from PRICING/consensus (`EXCLUDED_FROM_PRICING`) — a
  fixed-payout shaded number is not a market price. Never "clean up" breadth.

## Champion decomposition — the edge is a hit-rate counter (Session 78 — non-obvious)
- **`specs/champion-input-diagnosis.md`.** `probabilityEstimator` IS the champion
  and it is five lines: `base` = empirical frequency of (stat > THIS line) over
  the game log, blended 0.6/0.4 with the last-5 frequency, then oppAdj(±0.03) +
  homeAdj(±0.015) + a cv>0.40 pull toward 0.50, then clamp [0.10, 0.95].
- **EXACT ANALYTIC ABLATION (no refit):** every adjustment is closed-form from
  stored features and the consistency step is linear (`f(x)=0.9x+0.05` ⟹
  `f(a+b)=f(a)+0.9b`), so layers subtract algebraically out of the stored p_win.
  Result: **removing ALL THREE adjustments changes resolution by nothing on every
  stat**, and on rbi/runs it IMPROVES it (rbi home/away removal +0.0053, CI
  excludes zero = mildly HARMFUL). ~100% of the edge is base+recency.
- **POOLED RESOLUTION IS INFLATED — do not quote 0.46.** Per stat the champion is
  0.196 (hits) to 0.499 (rbi); pooling stats with different base rates adds
  correlation because p_win tracks the base rate across stats. Paired DIFFERENCES
  (the scoreboard) stay valid; the absolute level does not. Always per-stat.
- **THE CLAMP IS THE BIGGEST LOSS, not a missing feature.** 358/1,741 settled rows
  (20.6%) sit ON the boundary, so the model emits a CONSTANT there and cannot rank
  within a fifth of the book. And `0.900` hides home_runs-under truly 99.5%
  (−9.5pt under-confident) next to hits-under truly 51.9% (+38.1pt over-confident).
  `PROB_CEIL=0.95` makes the 99.5% case inexpressible. Global over-prediction
  +3.5pt (total_bases +7.6). Fixable with NO new data.
- **`opportunity_drift` is the ONE real missing-weighting lead** — residual corr
  +0.156 (hits) and +0.145 (total_bases), i.e. it REPEATS across independent
  stats. Discipline: 14 features × 5 stats = 70 tests, so 3–4 CI-excludes-zero
  results are expected BY CHANCE; a single hit (weather on TB) is noise. And we
  already compute it — arch-v1's opportunity axis uses it and extracts NOTHING
  (delta +0.0001). Wrong implementation, not a missing feature: opportunity must
  scale the RATE, not nudge the probability.
- **ARCHETYPE VERDICT: unmeasurable, not refuted.** Only 2 of 41 archetypes
  (BOMBER, GHOST) reach n≥40 settled rows; all mean residuals straddle zero. That
  is "we have not measured it", NOT "archetypes carry no signal". Don't act
  either way. (Their uniformly negative residuals are the global over-prediction,
  not an archetype effect.)
- **Why every challenger has failed:** the ladder/hits-v1 REPLACE the frequency
  question with a fitted distribution; arch-v1's env axis adds park/weather the
  champion ignores. Asking "how often has he cleared THIS number" directly is the
  thing that works — improve its inputs, never substitute it.
- **`model_snapshots.outcome` is NULL on all 22,032 rows.** The retention table
  built for exactly this kind of replay was never settled, so ablations must join
  outcomes from `ledger_entries` on (player_key, stat, line, side, game_date).

## Forward-model reality check (Session 79 — non-obvious)
- **`specs/forward-model-reality-assessment.md`.** THE OBJECTIVE is a FORWARD
  matchup projection (hitter profile × pitcher stuff × park/conditions, read
  through archetype), not a market-edge number. Every component that needs
  exists AND is loaded in prod — and ALL of it sits DOWNSTREAM of the grade.
- **The served grade sees NONE of it.** `probabilityEstimator` reads exactly
  three features (`opp_rank_stat`, `home_away`, `l10_stddev`/`l20_avg`) plus the
  game log. statcast rows, arsenal, park, weather, platoon and archetype are all
  loaded in `snapshotService` AFTER grading and written to CHALLENGER columns.
  `mlbContext` (platoon/handedness) has ZERO consumers — dead code.
- **STATCAST NIGHTLY REFRESH IS UNREACHABLE CODE.** `snapshotScheduler.tick()`
  returns at `if (!HOURS_UTC.includes(h)) return` (14,19,22,1,3); the statcast
  block then tests `h === STATCAST_HOUR_UTC` (default **11**), which that guard
  can never admit. Data frozen at its 2026-07-21 backfill; its failure alert is
  inside the same dead branch so it can't warn. Same shape as the settlement
  outage — guarded-out code that reports nothing. Set STATCAST_HOUR_UTC to one of
  HOURS_UTC or move the block above the guard.
- **Inputs are HAVE, not missing** — `statcast_aggregates` 1,354 rows (750
  pitchers / 604 batters): exit velo, launch, barrel, hard-hit, whiff, chase,
  pitch_mix, GB/FB, arm angle, and **bats/throws complete on all 1,354**. Gaps
  are team DEFENSE (only a coarse `opp_rank_stat`) and catcher framing/umpire.
  PARTIAL: batter GB/FB land in the `metrics` JSONB not the typed columns;
  lineup-slot tables (`player_role_profiles`, `lineup_role_profiles`) are 0 rows.
- **The card is designed for the forward read; the engine never fills it.** The
  factor vocabulary the "SIGNAL BREAKDOWN" renders is entirely counter-restating
  (`l5_hot_vs_line`, `l20_over_line`, `back_to_back`, `home_game`) with several
  structurally-NBA labels (`ref_foul_high`, `coach_pace_delta`,
  `opp_3plus_starters_out`). No signal names a pitcher, pitch type, handedness or
  park. Surface needs FEEDING, not redesigning.
- **What the prior verdicts do and don't say.** Resolution = corr(forecast,
  outcome) was never a market/edge test — the metric was right, the QUESTION was
  narrow ("does challenger out-rank champion?"). proj-v1.1 and hits-v1 remain
  correctly refuted AS DISTRIBUTION SWAPS on thin inputs; neither tested a
  matchup-fed projection. arch-v1 IS market-relative by construction and is the
  one component genuinely measured on the wrong axis — re-test its axes as
  forward inputs. "AT CEILING" (runs/walks) is provisional: measured only against
  features the champion already reads.

## The skill engine + feature registry (Session 80 — non-obvious)
- **`specs/skill-engine-architecture.md`.** `src/services/model/` is the forward
  engine: `featureRegistry.js` (CANDIDATE/PROVEN/DEAD per feature PER SPORT) and
  `skillProjection.js` (PA outcome tree: K/BB via log5 odds-ratio vs league, then
  archetype-weighted contact quality → Binomial(PA, p_hit) mixed over a PA
  distribution). Challenger-only; champion untouched.
- **THE GATE IS STRUCTURAL, not a habit.** `liveFeatures()` returns PROVEN only,
  and the registry ships with exactly ONE proven feature (the incumbent counter).
  A test asserts that with only PROVEN allowed, `projectSkill` returns NULL — an
  unproven model cannot reach a user by accident. `promote()` requires n>=200,
  positive lift, CI excluding zero, and has NO override argument.
- **STAGE A RESULT: skill-v1 LOSES, not promoted.** 570 rows, 91.9% pitcher
  coverage, resolution 0.0499 vs champion 0.166, delta −0.116 CI [−0.189,−0.043].
  Also NOT selective — its top-8 most confident picks hit 50% (lift −0.065).
- **UNITS: `statcast_aggregates` stores PERCENTAGES (0–100), not fractions.**
  `k_pct: 29.6` means 29.6%. Feeding raw rows in made `bip = 1−29.6−17.1` negative
  and refused 568/576 rows. ALWAYS convert via `skillProjection.fromStatcastRow`
  (the one chokepoint); it nulls out-of-range values rather than clamping, and
  leaves mph/degrees fields alone.
- **`ledger_entries.team`/`opponent` are NULL on ~all rows** — do NOT join a
  matchup on them. The first Stage A run resolved a pitcher for 1 of 570 rows and
  would have reported a verdict on a batter-only model. Resolve the opponent from
  the player's own statsapi game log (`getPlayerGameLog` → `{date, opponent}`),
  which is authoritative and point-in-time safe → 91.9% coverage.
- **Archetype = FEATURE SELECTOR, not a nudge.** `ARCHETYPE_MAP` weights decide
  which skill inputs drive a hitter (BOMBER barrel 0.50 / gb_speed 0; GHOST
  barrel 0.05 / gb_speed 0.60). Locked by test: same hitter read through two
  archetypes moves >0.15. Weights are DOCUMENTED, not fitted — fitting on 1,741
  rows is curve-fitting; the registry exists so they get measured.
- **total_bases is deliberately REFUSED by skillProjection.** A deterministic
  bases-per-hit multiplier made P(TB>=2) exactly equal P(hits>=1) — a relabelled
  hits curve carrying no new information. TB needs tb-v1's compound per-hit bases
  distribution; refusing beats shipping a relabel.
- **STATCAST REFRESH WAS UNREACHABLE CODE** (fixed): it sat inside `tick()` below
  `if (!HOURS_UTC.includes(h)) return` (14,19,22,1,3) while testing `h === 11`.
  Never ran once; data 13 days stale; BOTH its alerts were in the same dead
  branch. Now its own `statcastTick`. The old test only checked the string
  existed — the new one asserts it is not behind the snapshot-hours guard.

## The validation gate + the stat that was wrong (Session 81 — non-obvious)
- **`statModel.js` and `correlateValidator.js` NEVER EXISTED** in this repo. The
  spec's only prior form was `src/services/python/blueprints/unconventional.py`
  (Flask, in the OFFLINE python service, scoring NBA factors against an empty
  warehouse), and `tests/unit/supplementSystems.test.js` INLINES its own
  `validateFactor` (line 368; only `fs`/`path` are required). So those tests
  passed for months with no implementation to connect — that is the real reason
  every challenger was measured ungated.
- **`src/services/model/correlateValidator.js` is the gate now** — n>=500,
  |r|>=0.15, p<0.05, Bonferroni. The p-value is EXACT (t-transform via a
  regularized incomplete beta, Lentz CF) and unit-verified against known values;
  scipy isn't available in Node so don't reach for an approximation. Pairs with
  an unknown side are DROPPED — zero-filling a correlation invents a point at
  the origin.
- **HITS IS A CLEAN NEGATIVE — stop modelling it.** Gate run at n=570,
  Bonferroni-8: EVERY skill feature fails, max marginal |r| = 0.062 vs a 0.15
  bar. Not a power problem — an effect-size problem. And the value engine loses
  head-to-head (0.0499 vs 0.166, CI [−0.189,−0.043]). At the 0.5 hits line there
  is very little for skill inputs to know.
- **TOTAL BASES IS WHERE THE SIGNAL IS, and it is n-blocked.** Same features:
  `hard_hit_pct` marginal r = **0.153** (above threshold), `exit_velo` 0.124,
  raw r 0.196/0.167 — refused ONLY because n=295 < 500. Needs ~205 more settled
  rows. This is what the physics predicts: contact quality drives EXTRA BASES,
  not whether a grounder finds a hole.
- **The gate reports r and p even when underpowered** (`underpowered: true`,
  `rows_needed`). "Not enough data yet" and "nothing here" need OPPOSITE
  decisions — collapsing them into a bare refusal hid the best signal on the board.
- **Feature verdicts are PER STAT** (`recordStatVerdict` / `statusForStat` /
  `candidateFeaturesForStat`). Marking these DEAD sport-wide on hits evidence
  would have killed the features most alive on TB. Per-sport doctrine one level
  deeper: physics differ per stat.
- **Next is the compound TB projection** — `skillProjection` still REFUSES
  total_bases (a deterministic bases-per-hit made P(TB>=2) == P(hits>=1)). Build
  the per-hit extra-base distribution off launch/barrel (tb-v1's shape, fed by
  skill inputs), accrue to n>=500, re-run this gate. Do NOT lower the bar.

## Point-in-time skill validation + TB solo/interactions (Session 82 — non-obvious)
- **`statcast_aggregates` KEEPS NO HISTORY** — upserted in place on
  (sport,season,source_id,role), ONE as-of date, prior versions destroyed. The
  first skill backtest was honest only BY ACCIDENT: the nightly refresh was
  unreachable code so profiles sat frozen at 2026-07-21, BEFORE the settled
  window. Fixing that cron refreshed them to today and made point-in-time
  validation impossible from that table. **`statcast_history` (new) retains a
  dated snapshot per refresh** — query `where as_of_date < game_date order by
  as_of_date desc limit 1`. Retention is best-effort and must NEVER fail the
  refresh (unit-tested). Until it accrues a window, ALL skill-feature results are
  CONTAMINATED/DIRECTIONAL, never gate verdicts.
- **TB solo pass: NOTHING passes.** n=383, Bonferroni-12 (α=0.00417).
  `hard_hit_pct` is closest at marginal r=0.135, p=0.0080 — fails BOTH the 0.15
  effect bar and corrected α. **It DRIFTED DOWN from 0.153 (n=295) → 0.135
  (n=383)**: an estimate regressing as noise averages out, not an effect firming.
  Don't keep quoting the older better number.
- **Interactions: none pass.** Only `barrel × power_archetype` has incremental
  (partial, controlling for both components) exceeding its parts — −0.101 vs
  0.019 at n=260. A lead, not a finding.
- **INTERACTION-PROXY TRAP:** the archetype conditioner was first
  `barrel_pct/LEAGUE.barrel_pct` — a monotone transform of its own component — so
  the "interaction" was barrel² measuring NONLINEARITY, and it produced the run's
  only positive result (−0.132). A Gauss-Jordan pivot test does NOT catch this
  (the columns differ by a scale factor); use a **scale-free pairwise correlation
  check** on control columns. Real archetype labels come from
  `model_snapshots.archetype` (260 labelled TB rows: 140 BOMBER / 120 other).
- **Interactions must be scored by PARTIAL correlation** vs the counter residual,
  controlling for both components — raw correlation can't distinguish
  PASSES-AND-ADDS from PASSES-BUT-REDUNDANT.
- **TB is at PARITY with the counter** (0.2718 vs 0.2647, CI [−0.065,+0.079],
  inconclusive) where HITS lost by 0.116 with CI excluding zero. Same engine,
  same day — the stat choice was the whole story. Parity under contamination is
  NOT a win; nothing promoted.
- **`skillProjection` now models total_bases** as a compound convolution (per-PA
  0/1/2/3/4 bases; barrel→HR share, exit velo→2B/3B share). The old
  deterministic bases-per-hit made P(TB>=2) EXACTLY P(hits>=1); non-degeneracy is
  locked by test.

## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
- monetization-system (scan-5 pitch, tier conversion)
