# VYNDR — System Manifest

Source of truth for every interface in the codebase as of Session 7c
(958 tests across 73 suites, zero regressions). When this drifts from
reality, update this file FIRST.

---

## 1. Architecture overview

```
                  ┌──────────────────┐
                  │  Next.js (3000)  │ ─── public web app + app/api/* proxies
                  └────────┬─────────┘
                           │ BACKEND_URL
                           ▼
   n8n cron ── POST ─► ┌──────────────────┐ ─► Supabase (RLS-enforced reads,
   PM2 pollers ──────► │  Express (3001)  │     service-role writes from API)
   Coolify healthcheck │                  │ ─► Redis (cache + locks)
                       └────────┬─────────┘
                                │
                                ▼
                ┌───────────────────────────────────┐
                │ External adapters (configured()-  │
                │   gated, rate-limited, breakered) │
                │ SharpAPI · OddsPapi · PropOdds ·   │
                │ ParlayAPI · CFBD · OpenRouter ·    │
                │ ESPN · MLB Stats API · Pinnacle    │
                └───────────────────────────────────┘
```

**Unified grading path (closed by Session 7g):**

  - `routes/scan/parlay` and `routes/analyze/{prop,batch}` →
    `analyzeViaEngine1` (`computeFeatures` → `engine1` → `gradeAdapter`).
  - `routes/grading/pipeline` (scheduled) → `gradingOrchestrator` →
    `featureCache` + `trapDetection` + `consistencyScore` +
    `probabilityEstimator` → `engine1` → (A/B-tier only) `engine2`.

The book-adapter chain (`UnifiedOddsProvider` + 9 legacy adapters +
`services/{rateLimiter,circuitBreaker}.js`) remains live but only for
the DATA REFRESH path at `/api/pipeline/refresh` — a separate concern
from grading. ARCH-1 retired the legacy GRADING path (`propAnalyzer`,
`grader.js`) in Session 7g.

---

## 2. Express API endpoints

Mounted in `src/app.js`. Auth column meanings:

- **public** — no auth middleware
- **user** — `requireAuth` (Supabase JWT bearer)
- **internal** — `requireInternal` (X-VYNDR-Internal-Key + loopback IP)
- **pipeline** — `requirePipelineSecret` (X-Pipeline-Secret)
- **stripe-sig** — `stripe.webhooks.constructEvent` signature

| Method | Path                                  | Auth        | Body limit | Handler                              |
| ------ | ------------------------------------- | ----------- | ---------- | ------------------------------------ |
| GET    | /api/health                           | public      | n/a        | `app.js` (inline)                    |
| GET    | /api/odds/nba                         | public      | 10mb       | `routes/odds.js`                     |
| GET    | /api/odds/ncaab                       | public      | 10mb       | `routes/odds.js`                     |
| GET    | /api/odds/wnba                        | public      | 10mb       | `routes/odds.js` (Session 14)        |
| GET    | /api/odds/mlb                         | public      | 10mb       | `routes/odds.js` (Session 14)        |
| GET    | /api/odds/soccer/:league              | public      | 10mb       | `routes/odds.js` (Session 7j)        |
| POST   | /api/analyze/prop                     | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s)     |
| POST   | /api/analyze/batch                    | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s)     |
| POST   | /api/scan/parlay                      | user        | 10mb       | `routes/scan.js`                     |
| GET    | /api/movements                        | public      | 10mb       | `routes/movements.js`                |
| GET    | /api/alerts                           | user        | 10mb       | `routes/alerts.js`                   |
| PATCH  | /api/alerts/:id/read                  | user        | 10mb       | `routes/alerts.js`                   |
| POST   | /api/bets/quickslip                   | user        | 10mb       | `routes/bets.js`                     |
| POST   | /api/bets/screenshot                  | user        | 10mb       | `routes/bets.js`                     |
| POST   | /api/bets/screenshot/confirm          | user        | 10mb       | `routes/bets.js`                     |
| POST   | /api/bets/sync                        | user        | 10mb       | `routes/bets.js`                     |
| PATCH  | /api/bets/:id/settle                  | user        | 10mb       | `routes/bets.js`                     |
| GET    | /api/bets                             | user        | 10mb       | `routes/bets.js`                     |
| GET    | /api/bets/performance                 | user        | 10mb       | `routes/bets.js`                     |
| POST   | /api/stripe/checkout                  | user        | 10mb       | `routes/stripe.js`                   |
| POST   | /api/stripe/webhook                   | stripe-sig  | raw bytes  | `routes/stripe.js`                   |
| POST   | /api/stripe/portal                    | user        | 10mb       | `routes/stripe.js`                   |
| GET    | /api/stripe/status                    | user        | 10mb       | `routes/stripe.js`                   |
| GET    | /api/stats/parlays-graded             | public      | 10mb       | `routes/stats.js`                    |
| GET    | /api/stats/public                     | public      | 10mb       | `routes/stats.js`                    |
| GET    | /api/stats/live                       | public      | 10mb       | `routes/stats.js`                    |
| GET    | /api/props/joint-history              | user        | 10mb       | `routes/props.js`                    |
| POST   | /api/waitlist                         | public      | 10mb       | `routes/waitlist.js`                 |
| POST   | /api/pipeline/refresh                 | pipeline    | 10mb       | `routes/pipeline.js`                 |
| GET    | /api/pipeline/status                  | public      | 10mb       | `routes/pipeline.js`                 |
| POST   | /api/share-card                       | public      | 10mb       | `routes/shareCard.js`                |
| POST   | /api/push/subscribe                   | user        | 10mb       | `routes/push.js`                     |
| DELETE | /api/push/unsubscribe                 | user        | 10mb       | `routes/push.js`                     |
| POST   | /api/grading/resolve                  | internal    | **10mb**   | `routes/grading.js`                  |
| POST   | /api/grading/pipeline                 | internal    | 10mb       | `routes/grading.js` (Session 10: off-host n8n callers; empty body iterates active sports) |
| POST   | /api/grading/correct                  | internal    | 256kb      | `routes/corrections.js`              |
| GET    | /api/widget                           | public      | 10mb       | `routes/widget.js`                   |
| OPTIONS| /api/widget                           | public      | n/a        | `routes/widget.js`                   |

### Next.js API routes (frontend `app/api/*`)

These are proxies or thin wrappers; they hit Express via `BACKEND_URL`
or the Python service via `NEXT_PUBLIC_NBA_SERVICE_URL`.

- `/api/checkout` (POST/GET) — Stripe checkout proxy
- `/api/games/[id]` and `/api/games/tonight` — list / detail
- `/api/games/[id]/props` — props for a game
- `/api/intelligence/feed` — homepage live signals
- `/api/ledger`, `/api/ledger/accuracy` — Ledger feed
- `/api/odds/soccer/[league]` — soccer odds proxy → Express `/api/odds/soccer/:league` (Session 8)
- `/api/odds/nba` — NBA odds proxy → Express `/api/odds/nba` (Session 14)
- `/api/odds/wnba` — WNBA odds proxy → Express `/api/odds/wnba` (Session 14)
- `/api/odds/mlb` — MLB odds proxy → Express `/api/odds/mlb` (Session 14)
- `/api/parlay/add-leg`, `/api/parlay/grade` — proxy to `/api/scan/parlay`
- `/api/players/search` — proxy to Python `/players/search`
- `/api/props/live`, `/api/props/most-parlayed`, `/api/props/top-graded`
- `/api/scan` — bare scan endpoint (Session 8 — accepts `Soccer` sport in addition to NBA/MLB/WNBA)
- `/api/stats/parlays-graded`, `/api/stats/public` — proxy
- `/api/user/profile`, `/api/user/scans`, `/api/user/recent-scans`
- `/api/waitlist` — proxy

### Next.js pages (Session 8 additions)

- `/soccer` — live soccer odds feed + inline prop grading. Hosts `SportSelector` + per-league match cards, scans selected props through `/api/scan` → Express `/api/analyze/prop` with `sport: 'Soccer'`. Results render in `SoccerGradeResult` (parses the engine's reasoning summary into visual signal chips: ⚽ goals/90, 📊 xG, 🏔️ altitude, 🟨 referee, 🎯 penalty taker, 🏆 WC pedigree). Free tier gets a blurred preview + upgrade CTA.
- `/upgrade/success` — Stripe checkout success landing. Reads `session_id` query param, refreshes the AuthContext so the new tier flips immediately. Stripe webhook is the source of truth; this page does not verify the session against Stripe (no secret key on the client).
- `/upgrade/cancel` — Stripe checkout cancel landing. No judgment, links back to `/#pricing` and `/scan`.

---

## 3. Environment variables

Source: `grep -rn 'process\.env\.' src/ poller/ scripts/ web/src/`.
✓ = documented in `.env.example`. ⚠ = used but optional (default falls
back). Updated this session in Section 1 of Session 7c.

### Core
| Var                       | Required | Default        | Used By                              | Doc? |
| ------------------------- | -------- | -------------- | ------------------------------------ | ---- |
| `NODE_ENV`                | no       | (none)         | various                              | ✓    |
| `PORT`                    | no       | `3001`         | `src/server.js`                      | implicit |
| `BASE_URL`                | yes      | `http://localhost:3001` | `stripeService`, `widget`   | ✓    |
| `FRONTEND_ORIGINS`        | yes      | (none)         | `app.js` (CORS)                      | ✓    |
| `VYNDR_INTERNAL_KEY`      | yes      | (none)         | `middleware/internalAuth` (S10), `routes/grading`, `routes/corrections` | ✓    |
| `SENTRY_DSN`              | no       | (none)         | `utils/sentry` (S10) — graceful skip when unset | ✓ S10 |
| `NEXT_PUBLIC_SENTRY_DSN`  | no       | (none)         | `web/components/SentryInit` (S10) — same DSN, browser-side | ✓ S10 |

### Supabase
| Var                              | Required | Used By                          | Doc? |
| -------------------------------- | -------- | -------------------------------- | ---- |
| `SUPABASE_URL`                   | yes      | both clients                     | ✓    |
| `SUPABASE_ANON_KEY`              | yes      | `getSupabaseClient`              | ✓    |
| `SUPABASE_SERVICE_ROLE_KEY`      | yes      | `getSupabaseServiceClient`       | ✓    |
| `SUPABASE_SERVICE_KEY` (legacy)  | no       | fallback only (7b)               | ✓ commented |
| `SUPABASE_DB_URL`                | yes      | `scripts/backup.sh`, `migrations`| ✓    |
| `SUPABASE_DB_PASSWORD`           | no       | legacy backup form               | ✓ commented |
| `NEXT_PUBLIC_SUPABASE_URL`       | yes      | Next.js client                   | ✓    |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY`  | yes      | Next.js client                   | ✓    |
| `NEXT_PUBLIC_SITE_URL`           | yes      | Next.js metadata                 | ✓    |

### Web ↔ Backend
| Var                          | Required | Default                  | Used By                          | Doc? |
| ---------------------------- | -------- | ------------------------ | -------------------------------- | ---- |
| `NEXT_PUBLIC_API_URL`        | yes      | `http://localhost:3001`  | `web/src/lib/api.ts`             | ✓ (added 7c) |
| `NEXT_PUBLIC_NBA_SERVICE_URL`| yes      | `http://localhost:8000`  | `web/api/players/search`         | ✓ (added 7c) |
| `BACKEND_URL`                | yes      | `http://localhost:3001`  | `web/services/odds-cache.ts`     | ✓ (added 7c) |

### Stripe + Payments
| Var                          | Doc? |
| ---------------------------- | ---- |
| `STRIPE_SECRET_KEY`          | ✓    |
| `STRIPE_WEBHOOK_SECRET`      | ✓    |
| `STRIPE_PRICE_ANALYST`       | ✓    |
| `STRIPE_PRICE_ANALYST_FOUNDER` | ✓  |
| `STRIPE_PRICE_DESK`          | ✓    |
| `STRIPE_PRICE_DESK_FOUNDER`  | ✓    |
| `FOUNDER_CODES`              | ✓    |
| `FOUNDER_CODE_EXPIRY`        | ✓    |

### Push
| Var                          | Doc? |
| ---------------------------- | ---- |
| `VAPID_PUBLIC_KEY`           | ✓    |
| `VAPID_PRIVATE_KEY`          | ✓    |
| `VAPID_SUBJECT`              | ✓    |
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY`| ✓   |

### Odds adapters
| Var                       | Doc? |
| ------------------------- | ---- |
| `SHARPAPI_KEY`            | ✓    |
| `SHARPAPI_BASE_URL`       | ✓    |
| `ODDSPAPI_KEY`            | ✓    |
| `ODDSPAPI_BASE_URL`       | ✓    |
| `PARLAYAPI_KEY`           | ✓    |
| `PARLAYAPI_BASE_URL`      | ✓    |
| `PARLAYAPI_PULL_RATE_MS`  | ✓    |
| `PROPODDS_KEY`            | ✓    |
| `PROPODDS_BASE_URL`       | ✓    |
| `CFBD_KEY`                | ✓    |
| `CFBD_BASE_URL`           | ✓    |
| `PINNACLE_API_KEY`        | ✓ commented (legacy) |
| `PINNACLE_API_BASE`       | ✓ commented (legacy) |
| `ODDS_API_KEY`            | ✓ commented (legacy) |

### Soccer / World Cup 2026 (Session 7j + 9)
| Var                       | Required | Default                                          | Used By                                                 | Doc? |
| ------------------------- | -------- | ------------------------------------------------ | ------------------------------------------------------- | ---- |
| `FOOTBALL_DATA_API_KEY`   | no       | (none)                                           | `footballDataAdapter` (TERTIARY)                        | ✓    |
| `API_FOOTBALL_KEY`        | no       | (none)                                           | `apiFootballAdapter` (PRIMARY, Session 9)               | ✓ S9 |
| `SOCCER_LEAGUES`          | no       | `WC`                                             | `poller/soccer.js`, `soccer-data-prefetch`              | ✓    |
| `WORLDCUP_API_URL`        | no       | `https://worldcup2026-api.up.railway.app/api/...` | `poller/soccer.js`                                      | ✓    |
| `RAPID_API_KEY`           | no       | (none)                                           | `footApiAdapter` (BACKUP), `tank01NbaAdapter`, `tank01MlbAdapter` | ✓ S9 |
| `FOOTAPI_HOST`            | no       | `footapi7.p.rapidapi.com`                        | `footApiAdapter`                                        | ✓ S9 |
| `TANK01_NBA_HOST`         | no       | `tank01-fantasy-stats.p.rapidapi.com`            | `tank01NbaAdapter`                                      | ✓ S9 |
| `TANK01_MLB_HOST`         | no       | `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com` | `tank01MlbAdapter`                          | ✓ S9 |

Container runtime (Session 9 finding):
- `NODE_OPTIONS=--max-old-space-size=4096` — set on the web container
  in Coolify. Without it, Next.js's V8 default ceiling (~2 GB) is hit
  in production and the container OOM-loops (44 restarts observed on
  the live host before the fix was identified).

### Pricing tiers (Session 12 — Africa tier added; Session 13 — geo-gated)
| Var                          | Required | Default | Used By                            | Doc? |
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
| `STRIPE_PRICE_AFRICA`        | no       | (none)  | `web/components/Pricing`, `stripeService` (post-DB-CHECK migration) | ✓ S12 |
| Cloudflare `CF-IPCountry`    | n/a      | (none)  | `middleware.ts` → `x-vyndr-country` → `useRegion()` | ✓ S13 |

**Blocker**: the existing migrations (001 + 011) declare `tier IN
('free','analyst','desk')` as a CHECK constraint on `users.tier` and
`user_profiles.tier`. The `africa` tier value will VIOLATE that
constraint until the constraint is updated. The frontend Pricing page
shows the tier now (UX), but the click handler short-circuits to an
honest "coming soon" message rather than triggering checkout. Required
follow-up: manual SQL to drop + re-add the CHECK across both tables
including 'africa' (cannot be done in this session per the no-migration
rule).

**Session 13 — Africa tier visibility is now driven by real IP geo**
(Cloudflare `CF-IPCountry` header), not by locale. The middleware
copies `CF-IPCountry` to `x-vyndr-country`; the root layout reads it
into `LocaleProvider`; `useRegion()` exposes `inAfrica: boolean`. The
Pricing component filters the Africa tier out of the render entirely
when `inAfrica === false`. Empty header (traffic bypassing Cloudflare)
degrades closed.

### Internationalization (Session 12)
| Var / file                   | Required | Default | Used By                            | Doc? |
| ---------------------------- | -------- | ------- | ---------------------------------- | ---- |
| `NEXT_LOCALE` cookie         | no       | en      | `web/middleware.ts`, switcher      | ✓ S12 |
| `web/src/lib/locales.ts`     | n/a      | n/a     | locale registry (10 languages)     | ✓ S12 |
| `web/src/locales/{code}.json`| n/a      | n/a     | per-locale translation dictionaries | ✓ S12 |

Locale-detection priority (left wins): URL prefix (reserved for future
`[locale]/` segment) → `NEXT_LOCALE` cookie → `Accept-Language`
header → `'en'`. Resolved locale rides on the request via the
`x-vyndr-locale` header; server components read it through
`next/headers`, client components consume it via `LocaleContext`.

**Side effect**: every Next.js page is now `ƒ (Dynamic)` instead of
`○ (Static)` because the root layout reads request headers. If FCP
regresses meaningfully, the fix is to move locale resolution
client-side (cookie-only, no `headers()` in layout) — that re-enables
static prerendering at the cost of a brief English flash on first
paint for non-English users.

### Engine 2
| Var                          | Doc? |
| ---------------------------- | ---- |
| `OPENROUTER_API_KEY`         | ✓    |
| `OPENROUTER_PRIMARY_MODEL`   | ✓    |
| `OPENROUTER_FALLBACK_MODEL`  | ✓    |
| `OPENROUTER_BASE_URL`        | ✓    |
| `OPENROUTER_REFERER`         | ✓    |
| `OPENROUTER_TITLE`           | ✓    |
| `ENGINE2_BATCH_SIZE`         | ✓    |
| `ENGINE2_ENABLED`            | ✓    |

### Redis + Python
| Var                       | Doc? |
| ------------------------- | ---- |
| `REDIS_URL`               | ✓    |
| `PYTHON_SERVICE_URL`      | ✓    |
| `NBA_SERVICE_URL`         | ✓ commented (legacy alias) |
| `NBA_STATS_URL`           | ✓ commented (legacy alias) |
| `EVOLUTION_SERVICE_URL`   | ✓ commented (legacy) |
| `VYNDR_PYTHON`            | ✓ commented (legacy alias) |

### Distribution + Analytics
| Var                       | Doc? |
| ------------------------- | ---- |
| `TELEGRAM_BOT_TOKEN`      | ✓    |
| `TELEGRAM_CHANNEL_ID`     | ✓    |
| `DISCORD_WEBHOOK_DAILY`   | ✓    |
| `DISCORD_WEBHOOK_RESULTS` | ✓    |
| `DISCORD_WEBHOOK_ALERTS`  | ✓    |
| `DISCORD_WEBHOOK_RARE`    | ✓    |
| `RESEND_API_KEY`          | ✓ (added 7c) |
| `RESEND_FROM_EMAIL`       | ✓ (added 7c) |
| `NEXT_PUBLIC_POSTHOG_KEY` | ✓ (added 7c) |
| `NEXT_PUBLIC_POSTHOG_HOST`| ✓ (added 7c) |

### Poller-set (per-process via PM2)
| Var               | Default | Doc? |
| ----------------- | ------- | ---- |
| `SPORT`           | (none)  | ✓ commented |
| `POLL_INTERVAL`   | `60000` | ✓ commented |
| `BUFFER_MS`       | `30000` | ✓ commented |
| `VYNDR_API_URL`   | `http://localhost:3001` | ✓ commented |
| `OFF_HOURS_POLL_MS` | hardcoded 5min | not env |

PM2 ecosystem (Session 7j) — four poller processes per container:
- `poller-nba`, `poller-wnba`, `poller-mlb` (box-score resolution path via `poller/poller.js`)
- `poller-soccer` (fixture indexing via `poller/soccer.js` — different
  data sources and cache shape; honors `SOCCER_LEAGUES` env)

### Backup + Ops
| Var                       | Doc? |
| ------------------------- | ---- |
| `NTFY_PORT`               | ✓    |
| `NTFY_TOPIC`              | ✓    |
| `BACKUP_RETENTION_DAYS`   | ✓    |
| `PIPELINE_SECRET`         | ✓ commented |
| `PUBLIC_SHARE_CARD_BASE`  | ✓ commented |
| `API_BASE_URL`            | ✓ commented (alias of BASE_URL) |
| `ENGINE`                  | ✓ commented (legacy) |

---

## 4. Supabase tables

Source: `grep -rn "\.from('[a-z_]*')" src/ poller/`.

### Tables created by VYNDR migrations
| Table                  | Migration | Read by                                                | Written by                              |
| ---------------------- | --------- | ------------------------------------------------------ | --------------------------------------- |
| `user_profiles`        | 011, 015  | AuthContext, stripeService mirror, accuracyTracker     | handle_new_user, stripeService          |
| `parlay_leg_frequency` | 011       | parlay analytics                                       | scan flow                               |
| `scan_history`         | 011       | user scans listing                                     | scanRoutes                              |
| `odds_cache`           | 014       | oddsService                                            | oddsService, n8n pipeline               |
| `line_history`         | 014       | lineMovementService                                    | lineMovementService                     |
| `cascade_alerts`       | 014       | cascadeService, intelligence feed                      | cascadeService                          |
| `player_stats_cache`   | 014       | propAnalyzer                                           | propAnalyzer                            |
| `grade_history`        | 014/16/18 | Ledger, resolution route, weight adjuster, accuracy    | orchestrator, resolution, engine2, corrections |
| `prop_correlations`    | 014       | correlationEngine                                      | correlationEngine                       |
| `player_id_map`        | 014→016   | populate-player-ids, oddsPapiAdapter                   | populate-player-ids                     |
| `push_subscriptions`   | 015       | webPush                                                | push routes, webPush prune              |
| `closing_lines`        | 016       | clvTracker, oddsPapiAdapter                            | oddsPapiAdapter.batchCapture            |
| `resolution_results`   | 016       | clvTracker, weightAdjuster, corrections                | resolution route                        |
| `historical_props`     | 017       | trap historical signal (future)                        | pull-parlayapi-history                  |
| `line_snapshots`       | 017       | lineMovement intel                                     | sharpApiAdapter (fire-and-forget)       |
| `ref_profiles`         | 017       | refSignals                                             | scrape-sports-reference                 |
| `coach_profiles`       | 017       | coachSignals                                           | scrape-sports-reference, seed file      |
| `game_ref_assignments` | 017       | refSignals                                             | ops manual entry                        |
| `engine1_weights`      | 018       | weightAdjuster                                         | weightAdjuster                          |
| `accuracy_tracking`    | 018       | accuracyTracker, Ledger accuracy page                  | accuracyTracker                         |
| `user_notifications`   | 013       | notifications route                                    | notification senders                    |

### Tables present in DB but NOT in VYNDR migrations (BetonBLK era)
`bets`, `daily_scan`, `discrepancy_reliability_scores`, `joint_outcomes`,
`lineup_role_profiles`, `model_predictions_extended`, `outcomes`,
`performance`, `picks`, `player_role_activations`,
`player_role_profiles`, `prediction_registry`, `scan_sessions`.

VYNDR code reads/writes only the subset still in use:
- `bets` — `routes/bets.js`, `betService.js`
- `joint_outcomes` — `routes/props.js` (joint-history)
- `model_predictions_extended` — `propAnalyzer`
- `performance` — `performanceService`
- `picks` — `parlayScanService`
- `scan_sessions` — `routes/scan.js`

### Shared tables (BetonBLK + VYNDR)
- `users` — both eras
- `waitlist` / `waitlist_signups` — created in 012 (latter is canonical)

---

## 5. Redis keys

Source: `grep -rn "cacheSet\|cacheGet\|redis\.set"`.

| Key pattern                              | TTL         | Set by                            | Read by                       |
| ---------------------------------------- | ----------- | --------------------------------- | ----------------------------- |
| `odds:{sport}:{gameId}:player_props`     | 60s (300s stale) | sharpApiAdapter              | sharpApiAdapter, orchestrator |
| `odds:{sport}:{gameId}:game`             | 60s         | sharpApiAdapter                   | sharpApiAdapter               |
| `parlayapi:hist:{sport}:{p}:{stat}:{n}`  | 24h         | parlayApiAdapter                  | trapDetection (future)        |
| `parlayapi:close:{sport}:{date}`         | 24h         | parlayApiAdapter                  | pull script                   |
| `parlayapi:checkpoint:{sport}:{season}`  | 30d         | pull-parlayapi-history            | pull-parlayapi-history        |
| `propodds:{sport}:{game}:{player}:{stat}`| 90s (300s stale) | propOddsAdapter              | propOddsAdapter               |
| `cfbd:teamstats:{team}:{year}`           | 6h          | cfbdAdapter                       | cfbdAdapter                   |
| `cfbd:usage:{player}:{team}:{year}`      | 6h          | cfbdAdapter                       | cfbdAdapter                   |
| `cfbd:talent:{team}:{year}`              | 6h          | cfbdAdapter                       | cfbdAdapter                   |
| `cfbd:lines:{team}:{year}`               | 6h          | cfbdAdapter                       | cfbdAdapter                   |
| `gamelogs:{sport}:{player}:{n}`          | 4h          | gameLogService                    | featureCache, consistency     |
| `team_stats:{sport}:{teamAbbr}`          | 24h         | teamStatsCache                    | featureCache, engine1         |
| `features:{sport}:{playerId}:{stat}:{game}` | 2m       | featureCache                      | orchestrator                  |
| `injuries:{sport}:{teamId}`              | 2h          | injuryParser                      | featureCache                  |
| `game:{gameId}:status`                   | 36h         | poller                            | poller                        |
| `game:{gameId}:resolution_lock`          | 5min (NX)   | poller                            | poller (dedupe)               |
| `game:{gameId}:live_status`              | 1h          | poller                            | frontend badges (future)      |
| `poller:{sport}:heartbeat`               | 3min        | poller                            | ops monitoring                |
| `vyndr:line_baseline:{...}`              | 24h         | lineMovementService               | lineMovementService           |
| (cascadeService player-set key)          | 24h         | cascadeService                    | cascadeService                |
| (oddsService legacy game cache)          | 5min        | oddsService                       | routes/odds                   |
| (schemeClassifier cache)                 | varies      | schemeClassifier                  | schemeClassifier              |

---

## 6. External API dependencies

| Service        | Adapter                              | Env vars                              | Rate budget       | Used by                  |
| -------------- | ------------------------------------ | ------------------------------------- | ----------------- | ------------------------ |
| SharpAPI       | `sharpApiAdapter.js`                 | `SHARPAPI_KEY`, `SHARPAPI_BASE_URL`   | 10/min            | orchestrator             |
| OddsPapi       | `oddsPapiAdapter.js`                 | `ODDSPAPI_KEY`, `ODDSPAPI_BASE_URL`   | 5/min             | poller (tip-off CLV)     |
| ParlayAPI      | `parlayApiAdapter.js`                | `PARLAYAPI_KEY`                       | 5/min             | pull script              |
| PropOdds       | `propOddsAdapter.js`                 | `PROPODDS_KEY`                        | 3/min             | trap consensus           |
| CFBD           | `cfbdAdapter.js`                     | `CFBD_KEY`                            | 10/min            | college features         |
| OpenRouter     | `openRouterAdapter.js`               | `OPENROUTER_API_KEY` + model env      | 20/min, 1000/day  | engine2                  |
| ESPN scoreboard| `poller`, `teamStatsCache`, `injuryParser` | none                            | 2/min (limiter)   | poller, refresh job      |
| MLB Stats API  | `poller` (gamePk feed)               | none                                  | 2/min             | poller (mlb final)       |
| Pinnacle (legacy) | `PinnacleAdapter.js`              | `PINNACLE_API_KEY`, `PINNACLE_API_BASE` | tunable         | UnifiedOddsProvider      |
| DraftKings (legacy) | `DraftKingsAdapter.js`           | none                                  | tunable           | UnifiedOddsProvider      |
| FanDuel/BetMGM/Caesars/PrizePicks/Covers/Rotowire (legacy) | each `*Adapter.js` | none | tunable | UnifiedOddsProvider |
| Sports-Reference HTML | `scripts/scrape-sports-reference.js` | `REF_HTML_FILE`, `COACH_HTML_FILE` (optional) | 1 req / 5s | scraper |
| Resend (email) | `web/src/services/email.ts`          | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a               | transactional email      |
| PostHog        | `web/src/lib/analytics.ts`           | `NEXT_PUBLIC_POSTHOG_KEY/HOST`        | n/a               | browser analytics        |
| football-data.org | `footballDataAdapter.js`          | `FOOTBALL_DATA_API_KEY`               | 10/min (8 enforced) | poller-soccer, prefetch (TERTIARY) |
| api-football.com | `apiFootballAdapter.js`            | `API_FOOTBALL_KEY`                    | 100/day (soft 90) | soccer cascade (PRIMARY, Session 9) |
| FootApi (RapidAPI) | `footApiAdapter.js`              | `RAPID_API_KEY`, `FOOTAPI_HOST`       | 50/day (soft 45)  | soccer cascade (BACKUP, Session 9) |
| Tank01 NBA (RapidAPI) | `tank01NbaAdapter.js`         | `RAPID_API_KEY`, `TANK01_NBA_HOST`    | 1000/mo (TTL bound) | live NBA box scores (Session 9) |
| Tank01 MLB (RapidAPI) | `tank01MlbAdapter.js`         | `RAPID_API_KEY`, `TANK01_MLB_HOST`    | 1000/mo (TTL bound) | live MLB box + batter-vs-pitcher (Session 9) |
| Stripe         | `services/stripeService.js`          | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_*` | n/a | checkout + webhook |
| The Odds API   | `services/oddsService.js`            | `ODDS_API_KEY`                        | quota tracked     | per-sport odds endpoints |
| worldcup2026 OSS | `poller/soccer.js`                 | `WORLDCUP_API_URL`                    | none (free)       | WC fixture poll          |

---

## 7. File dependency graph (entry points)

### Express entry: `src/server.js`
```
server.js
  └─ app.js
       ├─ routes/{odds,analyze,scan,movements,alerts,bets,stripe,stats,
       │   props,waitlist,pipeline,shareCard,push,grading,corrections,widget}
       └─ middleware/{auth, mission}

routes/grading.js  (POST /pipeline)
  └─ services/intelligence/gradingOrchestrator
       ├─ adapters/sharpApiAdapter
       ├─ services/intelligence/featureCache
       │     ├─ teamStatsCache · refSignals · coachSignals
       │     ├─ injuryParser · lineMovement · gameLogService
       │     └─ lineupSignals
       ├─ services/intelligence/trapDetection
       │     └─ lineMovement
       ├─ services/intelligence/consistencyScore
       ├─ services/intelligence/probabilityEstimator
       ├─ services/intelligence/engine1
       └─ services/intelligence/engine2
              └─ adapters/openRouterAdapter

routes/grading.js  (POST /resolve)
  └─ services/training/jsonlLogger
  └─ services/distribution/{webPush, telegram, discord}
  └─ services/intelligence/{clvTracker, accuracyTracker, weightAdjuster}

routes/scan.js  (POST /parlay  — legacy path, still wired)
  └─ services/parlayScanService
        └─ services/propAnalyzer
              └─ services/grader   ← legacy grading (see ARCH-1)
              └─ services/UnifiedOddsProvider
                    └─ adapters/{ESPN,Pinnacle,DraftKings,FanDuel,BetMGM,
                                 Caesars,PrizePicks,Covers,Rotowire}
                    └─ services/processing/{LineShoppingEngine,
                                            MiddlesDetector, EVCalculator}
```

### Poller entry: `poller/poller.js`
```
poller.js
  ├─ src/config/sports
  ├─ src/utils/redis (cacheGet/cacheSet/lock)
  ├─ src/utils/rateLimiter (espnLimiter, mlbLimiter)
  └─ src/services/adapters/oddsPapiAdapter (tip-off CLV capture)
```

No circular imports detected.

---

## 8. Findings

### ARCH — Architecture

- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
  **FIXED in Session 7g** — all grading routes (/api/analyze/prop,
  /api/analyze/batch, /api/scan/parlay) now grade through engine1 via
  the unified `analyzeViaEngine1` helper. /api/bets/* never used the
  legacy path. Dead files removed: `src/services/propAnalyzer.js`,
  `src/services/grader.js`, `tests/unit/grader.test.js` (-10 tests).

  UnifiedOddsProvider + the 9 legacy book adapters + the legacy
  rateLimiter/circuitBreaker stay live — they're still consumed by
  `/api/pipeline/refresh` (the data refresh path, a separate
  concern). When that route eventually migrates off them, those
  files can also retire.

  Migration story for the record:
    Session 7c — audit catalogued ARCH-1.
    Session 7d — adapter scoped + DEPRECATED banner on grader.js.
    Session 7e — adapter built + tested (gradeAdapter.js, 26 tests).
    Session 7f — computeFeatures (8 tests) + analyzeViaEngine1
                  (7 tests) + /api/scan/parlay migrated.
                  /api/analyze escape-hatched (integration test
                  asserted legacy-engine values).
    Session 7g — analyze test mocks rotated to drive the new path;
                  /api/analyze migrated; dead code removed.

  Adapter, computeFeaturesForProp, and analyzeViaEngine1 helpers all
  shipped and tested. Future migration can flip the analyze route any
  time the integration test mocks are updated to drive the new
  upstream chain.

  ORIGINAL Session 7e ARCH-1 narrative below for history.

  **PARTIAL (carried from 7e)** — Step 1 of the migration (the adapter)
  is complete and tested. Steps 2-6 partially executed.
    Step 1 ✓ — `src/utils/gradeAdapter.js` translates engine1 output
                  into the legacy shape `DemoScan` reads. 26 unit
                  tests covering grade collapse, confidence math,
                  kill-condition mapping, reasoning fallback, partial
                  input safety.
    Step 2-6 deferred — legacy `analyzeProp` and `engine1.gradeProp()`
                  have incompatible INPUT contracts: the legacy
                  analyzer takes a raw prop and self-fetches data
                  (odds, stats, opponent, spread, kill conditions);
                  engine1 takes a pre-computed feature vector and is
                  a pure grading function. To rewire any route to
                  engine1 we'd first need to extract an
                  orchestrator-lite preprocessor — that's the kind
                  of architectural change 7c/7d's no-restructure
                  rule blocks. The reasoning.summary string-level
                  parity is also currently lost (concrete numbers
                  in legacy vs abstract factor labels in engine1).
  Migration roadmap (now actionable for the next session):
    (a) Extract `gradingOrchestrator.gradeProp()` into a standalone
        `computeFeaturesForProp({player, stat_type, line, direction})`
        that lifts the player-roster + feature-fetch + trap-detect +
        consistency-score logic into a reusable callable.
    (b) Wrap engine1 + gradeAdapter behind a thin
        `analyzeViaEngine1(prop)` helper.
    (c) Rewire one route at a time — `/api/analyze/prop` first
        (lowest risk: single prop, public, well-tested), then
        `/api/analyze/batch`, then `/api/scan/parlay`, then
        `/api/bets/*`.
    (d) Remove `grader.js` + `propAnalyzer.js` + the legacy book
        adapters only after every consumer has migrated and a soak
        period.
  Session 7d's DEPRECATED banner on `src/services/grader.js` stays
  put.

- **[ARCH-2] Two circuit-breaker / rate-limiter modules.** Severity:
  Low. Status: **DOCUMENTED in Session 7e.** Both legacy modules now
  carry banners listing their three callers:
    - `src/services/UnifiedOddsProvider.js`
    - `src/services/adapters/ESPNAdapter.js`
    - `src/services/adapters/PinnacleAdapter.js`
  Full removal blocks on ARCH-1 Step 6 — the legacy book adapters
  retire together with the legacy grading path.

- **[ARCH-3] Soccer pipeline added as a parallel branch.** Severity:
  Info. Status: **SHIPPED in Session 7j.** Soccer routes off
  `computeFeaturesForProp` to `soccerFeatureExtractor` when
  `sport ∈ {'soccer','football'}`; trap detection branches on the same
  in `getTrapScore`; reasoning branches in `buildConcreteReasoning`.
  Engine1 is sport-agnostic (passes unknown feature keys through).
  Data flow: `poller/soccer.js` writes per-team `nextmatch` /
  `lastfixture` pointers; `scripts/soccer-data-prefetch.js` writes
  per-player + per-team-defense aggregates. The feature extractor
  reads ONLY from cache — no external HTTP on the user request path.
  Day-1 gap: xG fields (`xg_per_90`, `xg_delta`) are null until the
  soccerdata-Python bridge ships; engine handles the nulls gracefully.

### SEC — Security

- **[SEC-1] `/api/analyze/batch` has no auth or rate limit.** Severity:
  High. Status: **FIXED in Session 7d.** Added IP-keyed rate limit
  (`src/middleware/rateLimit.js`, 10 req/min) and mounted it on the
  `/api/analyze` router via `router.use(analyzeLimit)` — covers both
  `/prop` and `/batch`. 7 unit tests + 1 behavioral test verify the
  429 path.

- **[SEC-2] `routes/shareCard.js` leaks `err.message` via `detail:`.**
  Severity: Medium. Status: **FIXED in Session 7d.** Lines 185 and
  206 now log to `console.error('[VYNDR] shareCard ... failed:',
  err?.message)` (ops-only) and return generic error verbs to public
  callers. The validation-errors `detail:` on line 166 is intentional
  user-facing input feedback, retained.

- **[SEC-3] Several `console.log` calls in production code.** Severity:
  Low. All have `[VYNDR]` / `[redis]` / `[poller-X]` prefixes and
  represent OPERATIONAL status (server starting, poller tick summary),
  not debug. Per project convention `console.log` is acceptable for
  operational events. Documenting so future audits don't re-flag.

### DEAD — Dead code

- **[DEAD-1] No truly orphaned files.** Every adapter in
  `src/services/adapters/` has at least one consumer (legacy ones via
  `UnifiedOddsProvider`, new ones via orchestrator/poller). Removing
  any would break either `/api/scan` or `/api/grading/pipeline`.

- **[DEAD-2] `grader.js` is LIVE.** Imported by `propAnalyzer.js` →
  `parlayScanService.js` → `routes/scan.js`. Kept until ARCH-1 is
  resolved.

### DUP — Duplicates

- **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status:
  **FIXED in Session 7e.** Consolidated into `src/utils/normalize.js`
  with a `keepDigits` option (default false). `trapDetection.js`
  imports the default; `scripts/populate-player-ids.js` wraps with
  `keepDigits: true` via `normalizeRosterName`. 9 unit tests cover
  accent strip, suffix removal, digit options, idempotency, null
  input.

- **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.**
  Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
  the two files serve different purposes (math primitives vs book-level
  prop shape).

### LEGACY — Documentation / naming

- **[LEG-1] BetonBLK references.** Severity: Informational. Confined to
  `BUILD-STATE.md` historical notes, the `FOUNDER_CODES` allowlist
  (intentional grandfathering — documented in stripeService comment),
  and tests that assert the grandfathered code still validates. No
  action.

### TODO

- **[TODO-1] PrizePicks / DraftKings / Pinnacle adapter TODOs.**
  Severity: Low. Tracked in `specs/data-pipeline-books.md`. Each has a
  comment line referencing the spec. Keep until the corresponding
  feature ships.

### PERF — Performance

- **[PERF-1] `/api/analyze/*` lacks Redis cache.** Severity: Medium.
  Status: **FIXED in Session 7d.** Added `cachedAnalyze()` wrapper in
  `src/routes/analyze.js` with key
  `analyze:{sport}:{player}:{stat}:{line}:{direction}` and a 60s TTL.
  Response payload gains `_cache: 'HIT'|'MISS'` for observability.
  3 unit tests verify HIT/MISS, independent keys, and case folding on
  player name.

- **[PERF-2] `routes/scan.js` resolves parlays one prop at a time.**
  Severity: Medium. Status: **FIXED in Session 7d.** Replaced the
  sequential `for (leg of legs) await analyzeProp(leg)` with
  `Promise.allSettled(legs.map(analyzeProp))` in
  `src/services/parlayScanService.js`. The picks-table writes also
  switched from N sequential inserts to a single batched insert. A
  behavioral test asserts a 6-leg parlay completes in well under
  6 × delay wall-clock.

- **[PERF-3] Bundle analyzer not installed.** Severity: Low. Status:
  **FIXED in Session 7d.** `@next/bundle-analyzer@^16.2.9` added as a
  devDependency and wired into `web/next.config.ts`. Inert by default;
  emits `web/.next/analyze/{client,edge,nodejs}.html` when
  `ANALYZE=true npm run build` runs.

### Frontend ↔ Backend contract

All frontend API paths discovered are either:
- Direct Express paths confirmed in `src/app.js` (e.g. `/api/scan/parlay`,
  `/api/waitlist`), or
- Next.js `app/api/*/route.ts` proxies that internally fetch
  `${BACKEND_URL}/...`.

**No broken contracts found** — every fetched path maps to an existing
handler. Spot-checked: `/api/players/search` (Next → Python),
`/api/scan` (Next → Express), `/api/intelligence/feed` (Next direct DB).

#### Payments: Stripe cutover (Session 8 — COMPLETE)

The dual-provider divergence flagged in 7h is closed:

1. ✅ `web/src/app/api/checkout/route.ts` now forwards to
   `${BACKEND_URL}/api/stripe/checkout` with the user's bearer token.
   The route remaps `{ checkout_url, session_id }` → `{ url, … }` so
   the existing client field shape still works.
2. ✅ `Pricing.tsx` CTAs were converted from `<a href>` to onClick
   handlers that POST to `/api/checkout` and `window.location.assign`
   the returned Stripe URL. Loading state during redirect; error
   surfaced inline.
3. ✅ `/upgrade/success?session_id=…` and `/upgrade/cancel` pages
   shipped. Express `stripeService.js` updated to point `success_url`
   and `cancel_url` at the new frontend pages via `NEXT_PUBLIC_SITE_URL`
   (the only backend file touched in Session 8).
4. NexaPay has been PURGED (2026-07-27). VYNDR is Stripe-only — NexaPay was
   cross-project contamination copied from another venture and never a real
   VYNDR payment path. Code, route, and env references removed. Provider-side
   cleanup owed to Kev: remove `NEXAPAY_*` from Coolify env, revoke the NexaPay
   API key + webhook secret, and de-register the webhook at NexaPay if an
   account was ever configured. DB column `user_profiles.nexapay_customer_id`
   is orphaned (no reader/writer) — drop via a follow-up migration.

---

## 9. How to update this manifest

When you add a route, table, env var, or external API:

1. Add the row to the appropriate section above.
2. Re-run the discovery greps from this session's prompt to confirm
   nothing else changed implicitly.
3. If you remove a table or env var, mark it `[REMOVED Session NX]`
   instead of deleting the row outright — keeps audit trail.

Last updated: Session 7c.
