diff --git a/specs/wiring-data-train.md b/specs/wiring-data-train.md
new file mode 100644
index 0000000..9ecaf2a
--- /dev/null
+++ b/specs/wiring-data-train.md
@@ -0,0 +1,99 @@
+# 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 `
` β 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.
+
+---
+
+## DECISIONS FOR THE FOUNDER (my honest defaults in **bold**)
+
+- **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).
+
+---
+
+## PROPOSED WAVE SEQUENCE (ship on green, per step)
+1. **Wave 1 β Step 1 trust bugs** (billing date + Wood nameKey collision + join-invariant test). Quick, independent.
+2. **Wave 2 β Step 2 entity layer** (MLB headshot thread-through on 3 surfaces + ledger wordmark import + books.js keys). **Eyeball gate: report when this lands so real logos/headshots can be verified before the rest builds on it.**
+3. **Wave 3 β Step 3 outlook mode** (never-empty grid) + Step 4 wire-able-now surfaces (grade-shift timeline, consensus strip, Parlay Lab page).
+4. **Wave 4 β Step 4 new feed** (pitcher arsenal via Savant) + /u house-profile mode (pending D2).
+5. **Wave 5 β Step 5 combat** (spec first, then free v1 pending D5).
+
+Also queued (from the work order preamble): remove the literal "A $1M terminal" DeskShowcase headline β on-voice deadpan (it claims premium instead of showing it).