# 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 (UPDATED 2026-07-27 β NexaPay purged):** VYNDR is Stripe-only. The old note pointed at a `web/src/app/api/webhook/nexapay/route.ts` that wrote `subscription_end = now + 30 days` β that webhook was cross-project contamination and has been DELETED. 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 the Stripe provider-asserted value (consume `/api/stripe/status.current_period_end`); 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 `
` β 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 `` 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 `
`) **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). `
` 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.