# VYNDR — WIRING & DATA TRAIN · STEP 0 MAP
### Branch `wiring/data-train`. The map before the build. Governing law: the $1M/mo feeling is PRODUCED, never claimed. Honesty guardrail: real asset where genuinely sourceable; honest fallback (monogram/text/absent) where not. Never fabricate or scrape a real person's face/data to fill a hole.

Legend: **✅ wire-able now** (data already in the system) · **🔧 sourceable free** (real source exists, needs a new adapter, zero-out-of-pocket) · **⛔ blocked** (no honest source today → keep fallback) · **🆕 net-new**.

---

## STEP 1 — TWO TRUST BUGS

### 1a. Billing "RENEWS 6/9/2036" — ✅ fixable
- **Render:** `web/src/app/profile/page.tsx:128` renders `subscription_end` verbatim from the Supabase `user_profiles` row. Honest render, bad data — no hardcoded 2036 in source.
- **Root cause:** the live web payment path is **NexaPay, not Stripe**. `web/src/app/api/webhook/nexapay/route.ts:41-50` writes `subscription_end = now + 30 days` (a synthetic guess). The 2036 value is a **manually-seeded/comped founder-account row** the UI trusts blindly. The *real* Stripe `current_period_end` exists at `GET /api/stripe/status` (`stripeService.js:278`) but the UI never consumes it.
- **Task:** point the "Renews" stat at a provider-asserted value (consume `/api/stripe/status.current_period_end`, or have the NexaPay webhook persist its real next-bill timestamp instead of now+30); correct/clear the stale 2036 row. Guard: never render a `subscription_end` the provider didn't assert → fall back to `—`.

### 1b. James Wood → "Chicago Cubs" / builds vs AL East — ✅ fixable (nameKey collision)
- **Root cause:** `mlbStatsAdapter.js:192` `people.find(p => nameKey(p.fullName) === targetKey)` returns the **first** exact-nameKey match with **no namesake disambiguation**. A second "James Wood" in the statsapi season list (a Cubs-affiliate namesake) wins → wrong `currentTeam`. The S59 substring guard only covered *fuzzy* mismatches; it never covered two players sharing an exact name — **not a regression**, an uncovered case.
- **Propagation:** wrong team enters `snapshotService.js:315` `teamByPlayer` + rosterlogs opponents → `streaksService.js:229` `team` + opponents → `streakLens.js:79-82` "built vs" / tonight's-opponent. The S59 slate JOIN INVARIANT guards only Slate cards; the **streaks/rosterlogs path bypasses it**.
- **Task:** add an optional `teamHint` to `searchPlayer`/`getPlayerStats`/`resolveStats`; on multiple exact-nameKey matches pick the one whose `currentTeam` matches the hint, else return null (never guess — S59 doctrine). In `snapshotService.js:311` pass the prop's game team as the hint and drop `stats.team`/rawLog if it disagrees with the prop's game participants.
- **Test:** join-invariant — a resolved player's `team` must equal / be a participant of the prop's game; a rosterlogs entry's `team` must match the graded prop's team → omit on mismatch, never tag a foreign team.

---

## STEP 2 — ENTITY LAYER, FULLY FED

**Root-cause verdict:** team **logos already render live**. Player **headshots break at a data-threading gap, not a helper bug** — the MLBAM id is fetched then severed before it reaches the browser.

| Surface | Asset | Status | Task |
|---|---|---|---|
| Game-card team logos | ESPN logo CDN | ✅ live (`vyndr/GameCard.tsx:121` `TeamLogo`) | none; add any missing abbrs to `teamMeta.js` |
| MLB slate headshots | `img.mlbstatic.com/…/people/{id}/headshot` | ✅ id fetched (`playerIntelService.js:141`) but **severed at `snapshotService.js:331-336`** | thread `playerId` → enriched grade → `slateAdapter.buildPlayerStripsFromProps` → `StatStrip.tsx:381` `playerId={ps.playerId}` |
| MLB scan-search headshots | same | ✅ id already on `/api/players/search` `p.id` | pass `playerId:p.id` at `scan/page.tsx:566` (+ `SearchModal.tsx`) |
| MLB hot-list headshots | same | ✅ id via `rosterlogs` | `HotListPanel.tsx:68` uses raw silhouette `<img>` → swap for `PlayerAvatar` (branded monogram on null) |
| NBA/WNBA headshots | cdn.nba.com / ESPN athlete id | ⛔ **no real id in data** (search returns synthetic `sport-i-key`; `resolvePlayerStats` NBA branch emits none) | source a real id first, or keep honest monogram. NBA/WNBA don't settle yet → low ROI now |
| Soccer headshots | — | ⛔ no central CDN (by design) | honest monogram |
| Ledger book wordmarks | `BookWordmark` (brand-color text) | ✅ built but **never imported**; renders raw lowercase `row.book` at `ledger/page.tsx:368` | import `BookWordmark`; add 6 missing keys to `books.js` (`fanatics/bet365/hardrockbet/betrivers/pointsbet/pinnacle` fall to gray) |

- **Sportsbook wordmarks:** the work order asks for **local SVGs**. Current `BookWordmark` is brand-color *text*. Decision below (D3) — text-wordmark ships today; SVGs are an upgrade.
- **Headshot normalization:** `PlayerAvatar` already enforces size/round treatment; extend to guarantee consistent crop when a real photo loads. `onError`→monogram is first-class (no broken-image icon ever).

**Wire-able now: MLB headshots (3 surfaces) + ledger wordmarks. Blocked: NBA/WNBA headshots (no id source).**

---

## STEP 3 — OUTLOOK MODE (never-empty slate) — ✅ wire-able now

- DS2's `heroFallbackState`/`buildHeroReceipts` (`slateAdapter.js:467-495`) covers ONLY the "Top grades tonight" hero (`dashboard/page.tsx:314-418`) — never blank there. **Gap:** the `<Slate>` game grid (`Slate.tsx:1001-1013` `emptyStateCopy`) AND the dashboard's separate "Today's games" section (`page.tsx:421-439`) each independently fall to a dead-end "NO SLATE" card.
- The `"No games available…"` string at `Slate.tsx:609` is the **network `fetchError`** state, distinct from empty-slate.
- **Fill data already fetched:** yesterday's settled receipts (`/api/ledger/model`, already loaded), tomorrow's date-pinned schedule (`scheduleService`), ticker items, month-aware `emptyStateCopy`. Only the wiring into the grid branch is new.
- **Task:** extend the DS2 fallback below the hero — when `games.length===0`, render receipts / tomorrow's schedule preview inside the grid slot in both `Slate.tsx:1001` and `dashboard/page.tsx:424`, not a CTA card.

---

## STEP 4 — MISSING SURFACES

| Surface | Status | Verdict |
|---|---|---|
| **Live grade-shift timeline** | ✅ wire-able now | backend done (`intradayRefreshService` revisions + `revised_from_grade` + line history); reaches UI as per-row chip (`StatStrip.tsx:171`) + `LineSparkline`. Missing only a **timeline VIEW** assembled from already-emitted movement/history data. No new backend. |
| **Market-breadth / consensus-vs-model** | ✅ wire-able now | per-prop `books[]` (`Slate.tsx:373`) + `model_value` + snapshot line-deltas already present. Missing only a component that computes median-book-vs-model. `DeskShowcase.tsx:89` currently *advertises* it as copy — must become real or the claim goes. |
| **Parlay Lab builder** | ✅ math done, 🆕 UI | `parlayService`/`/api/parlay/grade`/`ParlayContext` all live; combined grade + correlation + payout render in `ParlayPanel`. "Empty tray" because the **only leg source is the "+" on live slate rows** and there's **no `/parlay` page**. Task: build a Parlay Lab page with a prop search/browse leg source independent of the slate. |
| **Pitcher arsenal** (mix/velo/usage/whiff%) | 🔧 sourceable free / 🆕 feed | **NOT in statsapi** — this is Statcast / Baseball Savant (free, public). Needs a genuinely new Savant adapter, not wiring. |
| **/u public profile** | ✅ route live, ⛔ no record | route + aggregate machinery work; `/u/vyndr` 200s to an honest not-found because **no `public_profiles` row is claimed+published** AND `getModelAggregate({userId})` needs **≥20 settled rows** under that user. Structural note: it scopes to a *user's own* ledger — the **house model record is `user_id=NULL`** and isn't surfaceable as a user profile without a "house profile" option. |

**Wire-able now:** grade-shift timeline, consensus strip, Parlay Lab UI. **New free feed:** pitcher arsenal (Savant). **Data-gated:** /u real record (needs a published house profile — see D2).

---

## STEP 5 — COMBAT (net-new, largest lift, sequenced last)

- **Verdict: genuinely net-new.** Only inert placeholders exist (`sports.js` `mma:{active:false,comingSoon}`, a share-card color, a dormant ESPN URL in `ESPNAdapter.js`, Pinnacle id 22). No pipeline/route/service/archetype/grade path. **No combat spec exists in the repo** — it must be authored as Step 5's first artifact.
- **PropLine has no combat** → combat cannot ride the abundant-props path; it's **odds-api-only**, which changes the quota story.
- **Free source option space (report-only, no pick):**
  - **ESPN MMA** (`site.api.espn.com/…/mma/ufc/scoreboard|summary`) — free JSON, no auth, **same family VYNDR already uses**; fight cards/results/method/round + some bio. Thinner stat depth than ufcstats. The URL already sits dormant in `ESPNAdapter.js`.
  - **ufcstats.com** — richest tale-of-the-tape + striking/grappling granularity, but **HTML scraping → needs a parser dep (cheerio) + fragile**; UFC-only.
  - **The Odds API `mma_mixed_martial_arts`** — **already paid** (`ODDS_API_KEY`); realistically **moneyline + round totals** only. Method-of-victory/round/props are **thin-to-absent** on the standard feed → data-constrained, not just build-constrained.
  - Wikipedia/Wikidata — bio backstop only.
- **Honesty caveats:** never ingest fighter photos (likeness/rights — same rule as headshots); scraping must degrade to *absent*, never fabricated 0; if only ML+round-total are free, the UI must not imply a full method/prop board.
- **Biggest architectural departure:** combat's unit is a **matchup, not a per-player prop** → the current `analyzeViaEngine1` per-prop feature-vector doesn't fit; a **style-matchup grade engine** is closer to a new engine than a config add. Settlement needs a new ESPN-MMA result path (like the pending WNBA box-score work).
- **Cheapest honest v1:** ESPN-MMA (free) + odds-api ML/round-totals (paid, existing) + a net-new style-blend archetype set + a matchup-grade path; method/round/props flagged data-limited.

---

## FOUNDER DECISIONS (LOCKED — 2026-07-13)
- **Global visual reference:** `specs/design-reference/vyndr-system.html` (the Claude Design "Vyndr System" mockup, + `support.js`). Build EVERY surface toward it — layouts, the missing surfaces, color treatments, component designs. Mockup surfaces confirmed present: edge board, hero grade reveal, VYNDR intelligence panel, player identity block, archetype glyphs (**74 marks · 8 sports** — an expansion target), pitcher identity (arsenal/whiff/velo), correlation builder + parlay slip, live grade-shift (grade-by-game-clock, history·last-24), public record /u (CLV-verified, portrait 1080×1350 + OG 1200×630), fighter tale-of-the-tape (FIGHTER A/VERDICT/FIGHTER B, moneyline/KO/decision, style archetypes), pricing tier, empty/error 404-bar. **ONE exception: keep the CURRENT live-site VYNDR wordmark/name, NOT the mockup's.** Live wordmark wins; everything else references the mockup.
- **D1 — OVERRIDE → sport-agnostic entity layer NOW.** Don't defer NBA/WNBA to monograms. Capture a stable athlete id per player **at ingestion for every league** (ESPN cross-sport athlete id is the likely single key covering NBA/WNBA/NFL/NHL/soccer; MLB keeps its MLBAM thread-through). ONE headshot resolver keyed by `(league, id)`. Monograms remain the honest fallback ONLY where an id genuinely can't resolve. If a league needs a new ingestion step for the id, build it. Report any league with NO free id source.
- **D2 — default:** /u house-mode reading the `user_id=NULL` public aggregate.
- **D3 — OVERRIDE → bundle the ~8 real book SVGs in the SAME wave (Wave 2).** Text wordmarks are only the stopgap; the SVGs are the real fix and are cheap.
- **D4 — default:** Baseball Savant adapter for pitcher arsenal (free).
- **D5 — default:** author the combat spec + ship the honest free v1 (ESPN-MMA + odds-api ML/round-totals; method/round/props flagged data-limited); defer the full matchup-grade engine to its own sub-wave; confirm before any scraping dep.

### ADDITION 1 — ASSET STORAGE (ingestion-time resolve)
Resolve id→URL **once at ingestion**; store the id + resolved-or-404 status in our data so we don't re-check per render. Browser caches the actual image via CDN cache headers (no per-load re-fetch, no per-render re-resolve). Self-hosting/proxying images is a LATER optimization — not now.

### ADDITION 2 — RECORD BY GRADE TIER (non-negotiable, core not cosmetic)
The record must be tracked + displayed **PER GRADE TIER** (A+ went X-Y, A X-Y, B X-Y, C X-Y — each its own hit-rate), everywhere the record shows (dashboard, /u, ledger). A single blended "67%" hides the proof: higher grade wins more often — the tier calibration IS the credibility. Sport/bet-type slices are a plus; per-tier is required. Extend `ledgerService.getModelAggregate` (and any schema/query it needs) to return per-tier buckets; render them on every record surface. `A+` stands alone; then A/B/C/D/F by first letter (mirror the existing `outcomeService` bucketing).

### ADDITION 3 — DESIGN FILE IS THE GLOBAL REFERENCE
Weave in essentially everything from the mockup across all surfaces (see reference note above). Wordmark is the sole exception.

## ORIGINAL DECISION DEFAULTS (superseded where noted above)

- **D1 — NBA/WNBA headshots:** no real id source in the data today, and those sports don't settle yet. **Default: ship MLB headshots now; keep honest branded monograms for NBA/WNBA until an id source is added.** (Alt: invest in sourcing cdn.nba.com/ESPN ids now.)
- **D2 — /u real record:** the CLV-verified profile can't show a house record as a *user* profile (house = `user_id=NULL`). **Default: add a "house/model" profile mode that reads the public `user_id=NULL` aggregate** (the partner-pitch weapon shows the real 55-19), keeping user profiles gated at n≥20. (Alt: claim+publish a house account and let it accrue 20 settled reads — slower, but no code path added.)
- **D3 — Book wordmarks:** **Default: ship the existing brand-color text `BookWordmark` now (kills lowercase "betmgm"), bundle local SVGs as a follow-up wave** (SVGs are the work order's ask but a bigger drop-in). (Alt: source + bundle the ~8 SVGs before shipping Step 2.)
- **D4 — Pitcher arsenal:** Baseball Savant is free → **Default: build the Savant adapter (zero-out-of-pocket).** Flag any field Savant doesn't cover as absent.
- **D5 — Combat scope:** **Default: author the combat spec + ship the free honest v1 (ESPN-MMA + odds-api ML/round-totals), flagging method/round/props as data-limited; defer the full matchup-grade engine to its own sub-wave.** Confirm before adding any scraping dep (ufcstats/cheerio).

---

## WAVE 2 BUILD PLAN (LOCKED — de-risk findings)
The `(league,id)`→URL resolver (`web/src/lib/playerHeadshot.ts` `getHeadshotUrl`) + `PlayerAvatar` (headshot-or-monogram via `<img onError>`) **already exist and are correct**. PropLine props carry NO id → the id can ONLY come from the **stats resolve the snapshot already runs per player** (`snapshotService.js:311`) = Addition 1's intent exactly. **Store `playerId`+`espnId` on the `enriched` grade objects at `snapshotService.js:331-336`** (beside `archetype`/`team`) → flows free to `grades:{sport}` → `buildPlayerStripsFromProps` → `StatStrip` `playerId`. Zero new Redis key, zero new I/O.

| League | Verdict | Wiring |
|---|---|---|
| MLB | ✅ now | MLBAM `res.id` already captured (into rosterlogs only) — add to `enriched`; thread to strip/scan/hotlist |
| NBA/WNBA | 🔧 free now | `espnStatsAdapter.getSeasonAverages` **resolves the ESPN athlete id at `:89` and discards it** — return it as `espnId`; surface via `resolvePlayerStats` nba/wnba branch; thread `espnId`→`getHeadshotUrl`; runtime-verify ESPN coverage |
| NFL/NHL | dormant | not in `ACTIVE_SPORTS` (+ off-season); add `nfl`/`nhl` to `ESPN_SPORT_PATH` (`playerHeadshot.ts:39`); inherits ESPN id when ingested |
| Soccer | ⛔ blocked | no live resolve branch; API-Football has `player.id` + `media.api-sports.io` CDN but is key-gated (`API_FOOTBALL_KEY`) + unwired → **honest monogram** until a soccer resolve branch + key land. **This is the one league to report as no-free-id-today.** |

**404-status (Addition 1):** id resolve is free; a true resolved-or-404 status needs an image HEAD probe (extra I/O). `<img onError>` already degrades per-render + the browser CDN-caches → **ship id-only first**; add a bounded HEAD probe in the `mapLimit` loop only if the one-frame monogram→photo flash is objectionable. Book SVGs (D3) + ledger `BookWordmark` import ride this wave too.

## WAVE SEQUENCE (ship on green, per step; toward the mockup)
1. **Wave 1 — Step 1 trust bugs** (billing renewal date + Wood nameKey-collision + streaks join-invariant test) + the quick DeskShowcase "A $1M terminal" → on-voice deadpan copy fix. Quick, independent, no mockup dep.
2. **Wave 2 — Step 2 entity layer, SPORT-AGNOSTIC (the eyeball gate).** (a) ingestion-time athlete-id capture per league (ESPN cross-sport id + MLB MLBAM) with resolved-or-404 status stored (Addition 1); (b) ONE `(league,id)`→headshot resolver; (c) thread id → grade → strip → every surface (slate strips, scan search, hot list, grade cards); (d) ledger book wordmarks — import `BookWordmark` + add missing keys + **bundle the ~8 real book SVGs**; (e) team-logo coverage gaps. **STOP + REPORT for founder eyeball of real logos/headshots across ALL sports before the rest builds on it.** Report any league with no free id source.
3. **Wave 3 — Record-by-grade-tier (Addition 2, core)** — extend `getModelAggregate` to per-tier buckets + render on dashboard, ledger, /u. Sets up /u + the credibility story.
4. **Wave 4 — Step 3 outlook mode** (never-empty grid → receipts/tomorrow) + Step 4 wire-able-now surfaces toward the mockup: live grade-shift timeline (grade-by-game-clock, history·last-24), market-breadth/consensus strip, Correlation Builder / Parlay Lab page (parlay slip + correlation flag).
5. **Wave 5 — /u house-mode profile** (D2, reads `user_id=NULL` aggregate; portrait + OG crops from the mockup) + **pitcher identity card** fed by the **Baseball Savant arsenal adapter** (D4, new free feed).
6. **Wave 6 — Step 5 combat** (author the combat spec FIRST → free honest v1: ESPN-MMA + odds-api ML/round-totals + style-blend archetype set + tale-of-the-tape card from the mockup; method/round/props flagged data-limited; matchup-grade engine its own sub-wave; confirm before any scraping dep).

Global rule for every wave: build the surface toward `specs/design-reference/vyndr-system.html`; keep the live wordmark. Ship each wave on green (tests + `next build` exit 0); nothing to main without the founder's word.
