Files
vyndr/specs/STATE.md
T
builtbykev f5156dd16d Un-claim CLV on the public profile; un-fabricate player-page FORM
Two Truth-Law fixes found by auditing the product logged-out.

FIX 1 — /u/[handle] claimed a "CLV-verified record" with "closing-line
value included" while ZERO closing-line value renders there. Verified
live: GET /api/profiles/vyndr returns beat_close_pct null (gated behind
CLV_CAPTURE_RELIABLE, unset while C4 is open). Eight instances found —
two of them (the OG + portrait "CLV-VERIFIED RECORD · 30D" eyebrows)
only by the post-removal residual sweep; two more printed the claim in
exactly the no-record branch.

Copy now describes what the page shows. The gated CLV-VERIFIED badge and
the BEAT CLOSE figure are removed from the public profile, OG card and
portrait card. DISPLAY ONLY: beat_close_pct, clvCaptureReliable() and
the whole CLV data path are untouched, and the earned directional badge
stays Analyst+Desk. The claim returns when CLV genuinely renders here.

Also fixes the doubled "· VYNDR · VYNDR" title (layout's '%s · VYNDR'
template already supplies the suffix); verified on composed output by
serving the build and reading the real HTML, not on source.

FIX 2 — the player page's FORM was `70 + 4 × (count of tonight's graded
props)`. Nothing on the HTTP path ever sets stats.form, so that fallback
WAS the live number: Josh Bell's "74" is 70 + 4×1 prop, confirmed
against his live payload. MATCHUP was gradeFromForm(that number), with a
hardcoded 'B' on the no-archetype branch — both fabricated letters with
no opponent input on the path. Systemic: buildIntel is the unconditional
path for every player and sport.

FORM and MATCHUP now render "—" (kind 'plain', so no bar width or colour
is computed off a null). gradeFromForm is deleted and the prop count is
no longer passed into buildIntel. computeFormScore's hardcoded 75 now
returns undefined. Induced across MLB/NBA/WNBA: all render cleanly, and
real values (USAGE 3.6 AB/G, REST B2B) still render.

Neither form value feeds the grade — engine1 reads raw l5_avg/l20_avg
against the line and never a form key; buildIntelFields decorates the
already-graded object. Grade inputs are byte-identical.

Held (needs a per-sport headline-stat design call): a real player-level
form metric + label disambiguation.

Tests 3491 passed / 289 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 17:05:04 -04:00

878 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VYNDR — STATE OF THE WORLD
### As of `HEAD` on main (DEPLOYED + fingerprinted live), 2026-07-20. This file opens every future session. **Start with the CURRENT STATUS + OPEN ITEMS block below.**
---
# 🧭 CURRENT STATUS + OPEN ITEMS — orientation block (2026-07-20, ~03:20 UTC)
*Written to orient a fresh session. Ground truth, not optimism. Everything below
was verified live unless explicitly marked UNVERIFIED.*
## What shipped tonight (all deployed + fingerprinted)
| # | Shipped | Proof |
|---|---|---|
| 1 | **Probability layer revived** — it was DEAD in prod | `p_win`/`ev_pct`/`model_odds`/`value` on **32/32** live grades (was 0/8) |
| 2 | **Value engine Arc 1** — de-vig, EV, value triplet, hero v2 | live: book 140 · fair 125 · model 109, `value:false` correctly |
| 3 | **Grade-range work** — L20 symmetry, `refreshTeamStats` wired, consistency CV guard, `confidence_basis`, dead `mlbGrader.js` removed | suite 279/3325; D emitted on real WNBA data locally |
| 4 | **Backup durable ON-BOX** | dump read back: **643 rows in dump == 643 live**, 18 MB |
| 5 | **`model_snapshots` retention LIVE** (migration 025) | **100 rows** captured over 2 cycles, incl. **36 refusals** |
| 6 | ESPN team-stats parser fixed (`buckets is not iterable`, 0/15 → 15/15) | live refresh |
### ⚠️ Two honest qualifiers on the above
- **A STILL DOES NOT EMIT IN PRODUCTION.** The 11-step grade is now *stored*
(`grade_11`) and A/D are *arithmetically* reachable + locked by tests, but the
±1.0 opponent factor is still dead because `opp_rank_stat` is underivable from
ESPN (see open item 6). Live boards remain **B/C only**.
**→ The "A-RATED" marketing hold STANDS** (`AccuracyBadge` correctly falls
through to "MODEL · X% HIT"; `TopSignals` self-hides). Do not promote A-rated.
- **EV IS OVERCONFIDENT AND UNVALIDATED.** First captured values include
**+62.1% / +61% / +56.9%** — real markets don't offer that. Cause: `p_win`
clamps at `PROB_CEIL` 0.95 off ~10 games. **Hero v2 ranks on `ev_pct`, so it
picks the MOST overconfident read.** Do not surface EV as truth until
calibration runs on the harness.
## 🟢 OFF-BOX BACKUP — CLOSED. FULL ROUND TRIP PROVEN (2026-07-20 05:25 UTC)
**Root cause (Kev):** the vyndr-backup key was only in Hetzner's PROJECT ssh-key
store, never in the Storage Box's own `.ssh/authorized_keys`. Installed there →
the box now offers `publickey,password` (it previously offered an EMPTY auth
list, which is what "SSH not enabled on this box" looks like).
**Proven end-to-end:**
- `POST /api/internal/backup/run``ok:true`, **`offbox_ok:true`**, exit 0, 31s.
- Output confirms both env questions: **`ssh key: base64-decoded`** (the key is
correct base64, not a mangled raw paste) and destination
**`u635423@...:vyndr-backups/`** — no leading-slash bug.
- **File VERIFIED on the box, not inferred from exit 0:**
`GET /api/internal/backup/offbox` (rsync `--list-only` through the pinned host
key) returned `vyndr-20260720-051158.dump`, **833,917 bytes, 05:12:28 UTC**
byte-identical size to the local dump, timestamp from that run.
**Hardening shipped with it:**
- **Host key STATICALLY PINNED.** `ssh-keyscan` matched the out-of-band
fingerprint `SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM`;
`scripts/storagebox_known_hosts` ships to the container and the script uses
`StrictHostKeyChecking=yes` + `UserKnownHostsFile=<pin>`. `accept-new`
(trust-on-first-use) is GONE; a missing pin file REFUSES the push. Tests
assert the weakenings never return.
- **Remote dir guaranteed** — `--mkpath`, else remote `mkdir -p`. Without it a
push into a missing parent can write the dump AS the directory name: one file,
overwritten nightly, reading as "backups exist" while retaining exactly one.
- **A failed required push now PAGES** at urgent (was "low/deferred") and the run
reports `offbox_ok:false`. Exit code still tracks ON-BOX durability so a good
on-box dump never raises a false total-failure alarm.
### ✅ ROUND TRIP CLOSED — pulled back off-box and rebuilt into a live DB (05:25 UTC)
The full insurance chain is now PROVEN, not assumed: dump → validated → pushed
off-box → verified on the box → **pulled back down from the box → restored into a
live Postgres**.
- **Pulled FROM the Storage Box** (not the local copy) with the in-session key
through the pinned host key (`StrictHostKeyChecking=yes`, never bypassed):
`vyndr-20260720-051158.dump`, 833,917 bytes — byte-identical to what was pushed.
- **Restored into scratch Postgres 17** (docker): 715 archive objects,
**42 public tables**, `ledger_entries` with all **27 columns** and real rows
(spot-checked: Kelsey Mitchell points o22.5 B 105 hit).
- **ASSERTION PASSED — `ledger_entries` restored = 645, live = 645** (target was
≥ 645). `model_snapshots` also restored 100/100, so the new retention store is
covered by backups from day one.
- Scratch DB torn down; the pulled copy deleted; **both dumps still on the box**
(nothing rotated out, cron untouched).
**🔴 OPERATIONAL GOTCHA FOUND DURING THE RESTORE — record this in any DR runbook:**
the dump is written by **pg_dump 17** (Supabase is PG 17.6), so **pg_restore 16
CANNOT read it** — it fails with `unsupported version (1.16) in file header`.
The first restore attempt failed for exactly this reason. **Restore with PG 17+
tooling** (`postgres:17-alpine` works). Restoring into vanilla Postgres also logs
`errors ignored on restore: 12` — Supabase-specific roles/extensions that do not
exist locally; harmless, the data restores completely.
## Open items — with status
| Item | Status | Note |
|---|---|---|
| **Settlement: 0 pushes / 470 settled** | 🔴 OPEN, unstarted | Implausible — hits/TB land on the number regularly. Exact-number push almost certainly mis-scored as hit or miss. Corrupts every accuracy/ROI number. |
| **~28 props/day never settle** | 🔴 OPEN, unstarted | Jul 17 MLB 86 graded/57 settled; Jul 18 103/75. Cause undiagnosed. |
| **Model-version contamination** | 🟠 PERMANENT, mitigate | `ledger_entries` mixes pre/post-2026-07-19-fix grades with no marker; eras cannot be separated retroactively. **Any backtest/accuracy claim off existing ledger history MUST treat the fix boundary as a hard cutoff.** `model_snapshots` stamps `model_version`+`code_sha` so it can't recur. |
| **A-grade unreachable in prod** | 🔴 OPEN | `opp_rank_stat` null; ESPN team endpoint has no defensive metric at all. Marketing hold stands. |
| **EV overconfident** | 🟠 OPEN | Needs calibration before it drives any surface. Hero v2 already ranks on it. |
| **`edge_pct` broken scale (U-deg pt 2)** | 🔴 OPEN | 51.5% of ledger rows exceed the sane cap; worst 620. 13 frontend surfaces render it; **nothing renders `ev_pct`**; it's the free-tier hook; it's written to the append-only `edge` column every cron. |
| **CLV broken (C4)** | 🔴 OPEN | `closing_line == locked_line` on ~95% of rows. BEAT CLOSE suppressed. **CLV ledger stays PRIVATE until backtest-proven.** |
| **Consistency CV floor** | 🟠 STOPGAP | `CONSISTENCY_MIN_MEAN=4` leaves a ±1.0 dead for MLB low-count stats. Real fix = index-of-dispersion classifier; needs a backtest first. |
### 🔑 THREE KEYS TO ROTATE (all currently valid)
1. **Storage Box password** — Kev plans a fresh reset; do it before/with the SSH fix.
2. **`VYNDR_INTERNAL_KEY`** — was pasted in plaintext into a chat transcript
(2026-07-19) and is in the local `.env`. It gates snapshot triggers, backup
runs, settle passes and the newsletter send. **Rotate: Coolify env + local
`.env` together.**
3. **GitHub PAT in the `origin` remote** — plaintext in `.git/config`, leaked
since ~Session 31. Deploys run off `gitea` so it's inert, but it is a live
credential. Revoke on GitHub; the remote can simply be deleted.
## Next queued work (in order)
1. **Backtest harness** — reads `model_snapshots`. **Needs ~2 weeks of history**;
as of now it holds ONE night. Its honest first output is *"cannot validate
yet"* and that is the expected, correct result. Build the instrument now, let
data accumulate.
2. **Settlement-correctness audit** — the two bugs above. Highest-value
correctness work: everything downstream trusts hit/miss.
3. **A-grade / opponent-strength sourcing** — MLB is **solved and verified**:
`statsapi` `teams/stats?group=pitching` returns all 30 teams free with
`era/whip/avg/slg/ops/HR9`, and it's *stat-specific*, which beats what we were
fetching. NBA/WNBA need `stats.nba.com` or derived allowed-points. Implement
behind the **source-adapter pattern** (health check that PAGES on empty —
tonight's ESPN crash was invisible for exactly that reason).
4. **Metrics engine** — archetype-baselined proprietary metrics
(`specs/model-train.md` §2C). **Gated on the harness**: every metric family
ships only if it backtests better than without it.
**Governing docs:** `specs/model-train.md` (value engine + phases + north star) ·
`specs/snapshot-retention.md` (Phase 2 report + schema) ·
`specs/audit-data/grade-collapse.md` · `specs/audit-data/gate-simulation.md`.
---
# 🟢 SESSION 65 — TWO TRUTH-LAW FIXES (2026-07-20, from Chrome's logged-out audit)
Both found by auditing the product as a STRANGER sees it. Neither touches the
triplet build; push scoring untouched.
## Doc drift corrected in this session
The header of this file was stale at `7ea0af2` and this file carried **no record
of the directional-CLV session** (`dcdad60``ca9ca34`: `src/services/
directionalClv.js`, the Analyst+Desk server-gated `ClvBadge`, and removal of the
legacy C4 CLV chip from five surfaces). `BUILD-STATE.md` is stale to April 2026 —
**`specs/STATE.md` is the authoritative state file**; treat BUILD-STATE as archive.
## FIX 1 — "CLV-VERIFIED" UN-CLAIMED ON THE PUBLIC PROFILE (copy + 2 render blocks)
`/u/[handle]` was titled **"CLV-verified record"** with the subhead **"closing-line
value included"** while **zero** closing-line value renders there. Verified live,
not assumed: `GET /api/profiles/vyndr``beat_close_pct: null` (gated behind
`clvCaptureReliable()` / `CLV_CAPTURE_RELIABLE`, which is unset while C4 is open).
A verification claim with nothing verified shown is an unearned claim, and it
leaked the standing CLV hold onto a surface that had never been audited.
**EIGHT instances found — two of them ONLY by the post-removal residual sweep:**
| # | Where | Was |
|---|---|---|
| 1 | `u/[handle]/page.tsx` | `<title>` / `og:title` / `twitter:title` — "CLV-verified record" |
| 2 | `u/[handle]/page.tsx` | meta + og + twitter description — "closing-line value included" |
| 3 | `u/[handle]/opengraph-image.tsx` | no-record fallback tagline |
| 4 | `u/[handle]/portrait/route.tsx` | no-record fallback tagline |
| 5 | `PublicProfile.tsx` | house subhead — "closing-line value included" |
| 6 | `PublicProfile.tsx` | `✓ CLV-VERIFIED` badge (gated, dark, one env flag from re-claiming) |
| **7** | `opengraph-image.tsx` | **eyebrow `CLV-VERIFIED RECORD · 30D`** — missed in the first report |
| **8** | `portrait/route.tsx` | **same eyebrow** — missed in the first report |
Instances 3 + 4 printed the verification claim **exactly when there is no record
at all** (the `rec == null` branch), i.e. the claim was loudest where the data was
most absent.
**Applied:** honest copy on 15 + 78 (settled record · wins and misses · nothing
curated); the badge (6a) and the `BEAT CLOSE` figure (6b) REMOVED from the public
profile, OG card and portrait card. **DISPLAY ONLY — `beat_close_pct`,
`clvCaptureReliable()`, `closing_captures` and the whole CLV data path are
untouched**, and the earned directional badge stays Analyst+Desk (never public).
The claim returns here when CLV genuinely renders here.
**Title double-append fixed and VERIFIED ON COMPOSED OUTPUT, not source:** the page
title carried its own `· VYNDR` while `layout.tsx`'s metadata template already
appends `%s · VYNDR` → live was `…@vyndr · VYNDR · VYNDR`. Built the app, served
it, and read the real HTML: `<title>Settled record — @vyndr · VYNDR</title>`
exactly one suffix. Note `openGraph`/`twitter` titles **bypass** the template, so
they carry the brand suffix explicitly.
Both share images re-rendered locally (200 / image/png) and eyeballed: no overflow,
no clip, no residual CLV scaffold.
## FIX 2 — FORM WAS FABRICATED ON THE PLAYER PAGE (un-fabricate; steps 2+3 HELD)
Chrome saw Josh Bell as **FORM 74** (player page) and **Form 63** (generated read)
in one session. Traced both. **Neither feeds the grade** — proven, not assumed:
`engine1.gradeProp` runs at `analyzeViaEngine1.js:446`, `buildIntelFields` (which
computes form) decorates the already-graded object at `:468`; `engine1.js` never
reads a `form` key (only raw `l5_avg`/`l20_avg` **against the line**), and nothing
in `src/` reads `result.form`. Grade inputs are byte-identical before/after.
- **63 is real** — `70 + (l5_avg / l20_avg 1) × 60`, per-prop, per-stat.
- **74 was manufactured** — `70 + 4 × (count of tonight's graded props)`, capped
92. **Nothing on the HTTP path ever sets `stats.form`**, so that fallback WAS
the live value. Confirmed empirically: Bell's payload carries `activeProps`
length **1** → 70 + 4 = **74**, exact.
- It **poisoned a second tile**: `MATCHUP` was `gradeFromForm(form)` — Bell's
"B" was `gradeFromForm(74)`, from the prop count, with **no opponent input on
that path at all**; the no-archetype branch hardcoded `'B'`. Both fabricated.
- **SYSTEMIC, not Bell-specific** — `buildIntel` is the unconditional path for
every player and every sport.
**Applied (absent renders absent):** FORM and MATCHUP now emit `kind:'plain'`
with `'—'` when there is no real value — `'plain'` deliberately, so the page
draws no progress bar and computes no width/colour off a null. `gradeFromForm`
deleted; the prop count is no longer passed into `buildIntel` at all.
`computeFormScore`'s hardcoded **75** (returned whenever the baseline was missing
or zero) now returns `undefined` → the card's self-hiding intel section drops the
row. Induced live across **MLB / NBA / WNBA**: all render `—` cleanly; real values
(`USAGE 3.6 AB/G`, `REST B2B`) still render — the fix is surgical to fabrications.
**HELD (needs a per-sport headline-stat design call, deliberately NOT built):** a
real player-level form metric + label disambiguation (page-level vs per-stat). It
lands as `stats.form` / `stats.matchup` and lights these tiles straight back up.
### Audit of the same file (order item 10) — reported, NOT changed
`toNum()` (`playerIntelService.js:18`) returns **0** for absent, feeding
`classifierInput`: `k_rate` (`pa > 0 ? … : 0`), `ip_per_start`, `k9`, `saves`.
These are **classifier inputs, not display** — a 0 there can misclassify an
archetype (0% K-rate reads as elite contact). Same family as the `Number(null)
=== 0` rule. `mlbLast10Rows` renders `${st.hits ?? 0}-${st.atBats ?? 0}`, which
prints a real-looking `0-0` line if a game-log row lacks its stat object.
**Ambiguous — changing classifier inputs shifts archetypes, so it needs its own
order.** Display rows elsewhere (`mlbSeasonRows`, ESPN season rows) correctly use
`?? '—'`.
---
## 🟢 MLB opp_rank_stat LIVE — computed, CONSUMED, retained (2026-07-20 16:51 UTC)
- **Consumption path verified BEFORE wiring** (a value the grader doesn't read
is a dead end): `featureCache.teamFeatures` sets `out.opp_rank_stat`
`engine1.computeFactors` READS `features.opp_rank_stat` → fires
`weak_opponent_defense` (≥0.70) / `top_opponent_defense` (≤0.30).
- **Source:** `mlbStatsAdapter.getTeamPitchingStats` — all 30 teams in ONE free
unauthenticated statsapi call. Only fills when the ESPN path yields nothing
(always, for MLB), so WNBA is untouched.
- **CONSUMPTION PROVEN on the exact retained vector** (Josh Bell hits o0.5),
varying only `opp_rank_stat`:
| opp_rank_stat | grade | factors |
|---|---|---|
| 0.367 (as retained, mid) | **B-** | l5_hot_vs_line, l20_over_line, back_to_back |
| 0.98 (weak) | **B** | + **weak_opponent_defense** |
| 0.02 (tough) | **C** | + **top_opponent_defense**, away_vs_top5_defense |
| absent | **B-** | identical to mid — neither fires ✅ |
- **RETAINED** grade-time in `model_snapshots.features` (immutable, joinable).
### 📊 COVERAGE FIRST — then the grade read
**8 of 14 graded MLB rows carried a non-null `opp_rank_stat` (57 %); 6 null.**
The nulls are almost certainly unresolved `opponentAbbr` (`teamFeatures` returns
early without one), NOT the sample floor — but that is **undetermined without
container logs**, so it is reported as such rather than asserted.
**🔴 THE FACTOR FIRED ON ZERO ROWS THIS CYCLE.** All 8 non-null values were the
SAME mid-scale `0.367` (one opponent, neither threshold crossed:
`would_fire_weak 0`, `would_fire_tough 0`). **So this slate cannot test
A-emission at all** — the factor was present but inert. Any "A didn't emit"
reading from tonight is meaningless.
**Live MLB 11-step distribution (14 graded):** `C+ 4 · F 4 · B- 2 · C 2 · D 2`.
**No A.** Notably **F now emits (4 rows)** — the range has widened DOWNWARD, as
the restored ±1.0 makes Σδ ≤ 2.51 reachable for the first time.
**A-rated marketing stays HELD.** No thresholds changed. A genuine test needs a
slate with a weak-BAA opponent (≥0.70) so the +1.0 can actually lift a read.
### Thin-sample floor in production
No thin-sample opponent appeared on this slate, so the production null-floor path
was **not exercised live** — unit-tested only. Stated rather than implied.
## 🟢 HARNESS ARMED + CLOSING CAPTURE STARTED (2026-07-20 ~16:10 UTC)
Both verified by INDUCING the real code path — never by waiting for a slot.
**A — Backtest harness runs on OUR scheduler.** `snapshotScheduler` fires it at
`HARNESS_HOUR_UTC` (default 14), appends to `harness_results`, and
`opsWatch.harnessStaleAlarm` pages if it stops (a validator that isn't running
looks exactly like one that keeps passing). No external dependency.
**INDUCED live:** `verdict INSUFFICIENT_HISTORY, scored 35, joined 283,
snapshots 478` — the correct, passing output. `POST /api/internal/harness/run`.
**B — Closing capture (CAPTURE ONLY; no CLV metric, as ordered).**
- **C4 diagnosed:** `closing_line` is ONE overwritable field with no timestamp
and no provenance. `captureClosing` writes the current line and, on a prop
match failure, silently leaves the earlier value (= the lock) in place — so
"captured a real close" is indistinguishable from "never updated". It is
**92 % equal, not 100 %** (56 rows DID move): the defect is **provenance**.
- **`closing_captures` (migration 029)** — append-only, never overwritten, BOTH
raw side prices retained so the existing de-vig engine computes the fair
closing probability later. Sharp (`pinnacle`) vs book close kept distinct:
"beat the market" and "beat our book" are different questions.
- **JOIN drops `line` on purpose** — a close that MOVED off the graded line is
the whole point of CLV. Verified safe: all 164 identity groups have exactly
ONE line per `(sport, player_key, stat, side, game_date)`; zero ambiguity.
- **Wired into the intraday refresh** (already polls ~20 min through slate
hours) with a capture-rate alarm — a missed close is unrecoverable.
- **🔴 ODAWA IS NOT CAPTURABLE.** `odawa` exists only as a UI preference option
in onboarding/settings — no adapter, no `ALLOWED_BOOKS` entry, no feed. CLV
vs our own book cannot be measured until a real Odawa feed exists.
- **Refusals proven first (test-first):** unbound game_time, doubleheader
ambiguity, missed window, or a one-sided price → `missed_reason` and **NO
price**. Induced live: 1,076 rows correctly refused `one_sided_price`.
### ⚠️ MY OWN ERROR, CAUGHT AND REVERTED
To force a capture I widened the window to 1,200 min and **persisted 7,114
rows** — every one had a lead time of **392602 minutes**, i.e. 6.510 hours
before lock. **Those were not closes.** Leaving them in a table named
`closing_captures` would have poisoned the first CLV computation with pseudo-
closes. **All 7,114 deleted; table verified back to 0.** Verification is now
done with `?dry=1`, which proves the pricing path (wnba: 486 rows priced, 0
missed) while writing nothing. The real 45-min window currently yields 0 rows
because no game is near lock — which is the correct answer, not a failure.
## 🟢 HEAL EXECUTED (Order 2, 2026-07-20 ~10:00 UTC) — record is clean
Rollback point first: `vyndr-20260720-093821.dump` (856,890 B) **verified ON the
Storage Box**, not just exit 0.
**⚠️ THE DRY-RUN OVERTURNED MY OWN DIAGNOSIS — read this before trusting the
Order 1.5 notes above.** Across every bindable row the stored `game_date`
matched a real game (**MIS-DATED: 0**). The players I cited as proof of
mis-dating were **genuine DNPs** on their true dates (Freeman 07-18;
Kwan/Hedges/Davis 07-17 — their teams played, they did not). **The original 64
`player_dnp` voids were CORRECT and my Order 1.5 revert undid correct work.**
| Axis | Result |
|---|---|
| Settlement scorer | **578/578 re-computed identical, 0 differ** — confirmed, no heal needed |
| Dates | 527 correct · **0 mis-dated** · 170 unbindable (my dry-run's parser, NOT proven damage) |
| Grade damage | **51 rows, not 303** — the rest hit the same opponent anyway (baseball SERIES effect) |
**Writes executed (each verified against the dry-run prediction):**
- **64 re-voided** (60 mlb + 4 wnba) — by the settle pass's positive-evidence
guard, not hand-picked ids. DNP voiding re-enabled deliberately because its
disable rested on a false premise.
- **25 ledger rows quarantined** `wrong_opponent_grade` (wnba 2026-07-18,
10 hit / 15 miss). They REMAIN real public settled results; only train/validate
excludes them.
- **200 `model_snapshots` rows quarantined** — the 4 pre-fix MLB cycles for
game_date 07-20 (06:00/06:02/06:03/07:25 UTC). Superseded by the correct
07:50/07:51 captures of the same props, so nothing is lost.
- **70 rows flagged** `doubleheader_game_identified`.
**TWO EXCLUSION SCOPES (migration 027) — deliberately separate:**
- `quarantine_reason` → grade untrustworthy. Row stays a real public result but
is **excluded from the model-record denominator** (`getModelAggregate`), like
void/unrecoverable.
- `analysis_flags` → valid for settlement AND the record; excluded only from
per-game/opponent analysis. **Never filtered from aggregates** (a test asserts
this, so a future edit can't silently collapse the two).
**Corrections to my own Phase 0 numbers, found while executing:**
- The 50 WNBA "poisoned moat rows" were **NOT poisoned** — captured 03:02 UTC
(23:02 ET 07-19) for game_date 07-19, so ESPN's "today" was the right day.
- The 166 doubleheader rows came from **loose team matching** and could not be
reconciled (a strict predicate found 121). I flagged only the **70** rows the
FEED itself marks (`G2…`/`(Game1)`) — and those are attributable, not
unattributed. The wider unattributed set needs a stricter pass; it is advisory
metadata that affects no number today.
**Final state:** 360 hit · 218 miss · 64 void · 55 pending · 25 quarantined
(697 public rows). 26 pending mlb 07-20 rows left untouched to self-heal on the
next cycle, as ordered.
## 🟢 GRADING DATE BINDING FIXED — wrong-opponent bug closed (2026-07-20 07:50 UTC)
`computeFeatures.lookupTodayGame`**`lookupGameOnDate`**: it now sends
`?dates=YYYYMMDD` from the prop's BOUND game instead of taking ESPN's dateless
"today". Grading, settlement, retention and the ledger finally reference the
SAME game. **This was a MODEL-OUTPUT bug**: `opponentAbbr` feeds `opp_rank_stat`
(a ±1.0 factor) and `isHome` feeds `home_away` (+0.5).
**PROVEN against live ESPN, same instant:**
```
dateless "today" CLE→PIT NYY→LAD LAD→NYY (Jul 19 card)
bound to 2026-07-20 CLE→MIN NYY→PIT LAD→PHI (the real games)
bound to 2026-07-19 CLE→PIT NYY→LAD LAD→NYY (reproduces OLD exactly)
```
**Every opponent was wrong.** Measured at 07:5x UTC = **03:5x ET** — ESPN's
dateless scoreboard was STILL serving the previous day's card, so **the affected
window is wider than the 01:00/03:00 slots**.
- **Honest degradation:** no bound date → NO fallback lookup. Records
`no_bound_game_date`, leaves `opponentAbbr`/`isHome`/`gameId` null, and engine1
omits those factors rather than scoring a wrong matchup. Tests lock both ways.
- **Same class, also fixed:** Tank01 augmentation keyed its cache on TODAY's UTC
date; now uses the bound game date.
- **Audited:** no other dateless/"today" lookups remain in the feature path
(weather is current-conditions by venue; park/pace are static).
- Live cycles at 07:50/07:51 UTC wrote retention rows with `game_date 2026-07-20`
and `home_away` on 38/50 — games binding. MLB `opp_rank_stat` stays 0 for the
separate, known reason (ESPN's MLB team endpoint carries no defensive metric).
### 🔴 BLAST RADIUS (report-only — gates the heal)
**`ledger_entries` — grades by UTC hour (at-risk = late slots):**
| UTC hour | grades | settled results | risk |
|---|---|---|---|
| 00 | 32 | 29 | 🔴 at risk |
| 01 | 68 | 67 | 🔴 at risk |
| 03 | 152 | 103 | 🔴 at risk |
| 06 | 51 | 0 | 🔴 at risk (ESPN still on prior card at 02 ET) |
| 13/14/19/22 | 390 | 379 | ✅ same-ET-day, correct |
**At-risk: 303 grades (199 already settled with real results).** ~44 % of the
public record was graded in the affected window.
**`model_snapshots`:** 350 rows total — **50 carry `opp_rank_stat`** (the 03:02
UTC WNBA cycle) and are the poisoned moat rows; 190 carry `home_away`. Rows
captured 07:50+ are clean (post-fix).
**Recoverable vs quarantine (PLAN ONLY, nothing written):**
- **RECOVERABLE:** the correct opponent for a past date is still fetchable from
the date-pinned ESPN scoreboard, so a grade can be RE-DERIVED — but only where
the feature vector is reproducible. `model_snapshots` rows store the full
feature vector, so those are re-computable; **ledger rows do not** and would
need a full re-grade against historical inputs.
- **QUARANTINE:** any at-risk ledger grade that cannot be re-derived should be
marked computed-against-wrong-opponent and EXCLUDED from harness/validation —
never silently trusted. It stays in the public record as a real settled bet
(the result happened) but must not train or validate anything.
- **Scope note for the heal:** at-risk is defined by grading HOUR, not by proven
wrongness. Proving per-row wrongness requires re-deriving each row's opponent —
that IS the heal work.
## 🟢 game_date ROOT FIXED + 64 WRONG VOIDS REVERTED (2026-07-20 07:30 UTC)
- **ROOT (not DNP, as first diagnosed):** PropLine emits **no `commence_time`**
(grep-verified), so `ledgerService`'s `dateET(prop.game_time) ||
dateET(gradedTs)` always fell through to the **GRADE clock**. A 01:00/03:00
UTC snapshot is 21:00/23:00 ET the PREVIOUS day → tonight's props filed under
yesterday → settlement correctly found no game → Order 1's void logic
destroyed 64 real results.
- **FIX — `src/services/gameBinder.js`:** binds each prop to a scheduled game by
TEAMS across the ET window (grade date, +1, 1) and attaches the GAME'S own
time/date/id, in `snapshotService` **before** grading and the ledger write, so
ledger + retention + settlement all inherit one correct date.
**PROVEN against the live schedule at the failing hour:** a `2026-07-20T03:00Z`
grade (ET date 07-19, what the old code used) bound Steven Kwan to
**game_date 2026-07-20**, `mlb:2026-07-20:401816188`.
- **HARD CONTRACT:** unbindable → **nothing**. The grade-clock fallback is gone;
ledgerService SKIPS and counts such rows. Verified live: a prop with an
invented matchup returned `unresolved`, never a guessed date.
- **Doubleheaders** are flagged `ambiguous`, never attributed. Real case already
in data: `mlb:2026-07-11:MilwaukeeBrewers@PittsburghPirates(Game1)`.
- **Retention had the same bug** (rows dated by the snapshot clock) — fixed to
the bound game's ET date.
- **64 wrong voids REVERTED** (60 mlb + 4 wnba), scoped exactly to
`settlement_version=2 AND outcome='void' AND settlement_source='player_dnp'`.
- **LIVE PROOF of the safety fix:** the next induced settle pass returned
**`voided: 0, pending: 64`**. The old code voided all 64; the deployed code
refuses to claim DNP on an unconfirmed absence.
### 🔴 STOP-AND-REPORT — the date bug also reaches GRADING (not just settlement)
`computeFeatures.lookupTodayGame` calls the ESPN scoreboard with **no date
param** — it takes whatever ESPN calls "today". A prop graded at 23:00 ET can
therefore bind its `home_away` / `opponentAbbr` features to the WRONG game
(yesterday's finished one), which feeds `opp_rank_stat` and the home/away factor.
**Not fixed — flagged per the order's stop rule.** Blast radius:
- **Grading: AFFECTED** (feature correctness at late-ET slots — 01:00/03:00 UTC).
- **Settlement: FIXED** (this order).
- **Retention: FIXED** (this order).
- **Display/slate: NOT affected** — the board renders from the date-pinned
schedule and joins grades by player+stat, never by ledger `game_date`.
### 🔶 STILL OPEN — the reverted 64 cannot settle yet
Their `game_date` is still the OLD wrong value (Jul 17/18); the binder only
corrects rows written from now on. They will retry and age to `unrecoverable`
until their dates are corrected. **Correcting them is the first act of the heal
order** — healing against wrong dates would simply re-fail.
## 🟢 RETENTION CLOCK TICKS ON ITS OWN (2026-07-20 06:05 UTC)
Induced the REAL cron entrypoint rather than waiting for 14:00 UTC.
- **Cron capture needed NO wiring — verified in code.** `snapshotScheduler`'s
tick calls `runAll` = `snapshotService.runAllSnapshots``runSnapshot` per
sport → `gradeAndCacheSlate``onGraded` → retention. **The scheduled and
manual paths are the same function.** Nothing had been captured only because
no slot had fired since retention deployed (~02:55; slots 14/19/22/1/3).
- **PROVEN by induction:** `POST /api/internal/snapshot/all` (= `runAllSnapshots`,
the exact function the tick calls) wrote cycles at **06:03:23 mlb** and
**06:05:09 wnba**, 50 rows each, `cycle_hour_utc=6`, features 50/50.
*(The HTTP request gateway-timed-out at ~2 min; the job completed server-side —
verified in the DB, not from the response.)*
- **`archetype` FIXED — 50/50 on MLB** (was 0/100). Retention still COLLECTS at
grade time (only moment features exist) but now PERSISTS after enrichment via
`retentionService.mergeEnrichment`, which fills ONLY archetype/team/opponent
and never rewrites grade-time features or model output (test-locked).
- **🔶 `team`/`opponent` STILL NULL — upstream, not the merge.** The ENRICHED
grades themselves carry `team:null` (verified live: 0/25 with team, 25/25 with
archetype). `resolvePlayerStats` doesn't return `stats.team` for MLB, and
enrichment never sets `opponent` at all. Fixing that is an upstream change,
deliberately not made under this order.
- **WNBA archetype is null** — honest absence: WNBA stats don't resolve (Python
service offline), so there is nothing to classify from.
- **ZERO-WRITE ALARM ARMED** — `opsWatch.retentionZeroWriteAlarm` pages at
missed-snapshot severity when a slot GRADED props but retention wrote fewer
rows than the slate. `runSnapshot` now returns `retentionRows` so the tick can
evaluate it. Retention is best-effort by design, so a broken write is silent by
construction; this is the counterweight. Verified quiet on a healthy run
(gradeCount 25 → retentionRows 50).
- **INTRADAY DEFERRED (Phase 4).** `intradayRefreshService` calls
`analyzeViaEngine1` DIRECTLY — it never goes through `gradeAndCacheSlate`, so
there is no `onGraded` hook to reuse. Capturing it needs its own ctx +
snapshot_id + persist path and would ~5× row volume. Not small; not done.
- Outcome stamping still NOT implemented (depends on the settlement fix).
## 🟢 RETENTION LIVE — model_snapshots capturing (2026-07-20, migration 025 applied)
Phase 2 priority zero. **History now compounds from tonight.** Spec:
`specs/snapshot-retention.md`.
- **Why:** the ONLY model history was `ledger_entries` (640 rows / 6 game days)
and it stores NO model inputs. All ~30 other warehouse tables are empty. We
could score our own grades but could not replay a different model — the only
question a backtest answers.
- **`model_snapshots`** (migration 025, APPLIED): append-only, ONE ROW PER GRADED
PROP PER SIDE PER CYCLE. Unique on `(snapshot_id, player_key, stat, line,
side)` so a retried cycle can't duplicate. RLS on, service-role writes only.
- **VERIFIED CAPTURING (2026-07-20 03:02 UTC, 2 triggered cycles): 100 rows.**
MLB 50 (14 graded / **36 refused**), WNBA 50 (50 graded). `features`,
`grade_11`, `p_win`, `ev_pct` populated on 100% of graded rows; 1 model_version.
- **Refusals are captured — the ledger drops them entirely.** First-ever refusal
breakdown: `juiced_no_edge` 18, `rare_event_over_below_line` 13,
`insufficient_data` 5. **The MLB gate refused 36 of 50 sides (72%)** — whether
that costs edge is now measurable for the first time.
- Contract: retention is best-effort + injectable; `persist()` returns errors and
never throws. **A retention failure can never break a snapshot.**
- 🔶 **EV LOOKS OVERCONFIDENT — do not surface it as truth yet.** First captured
EVs include +62.1%, +61%, +56.9% — implausible for real markets. Cause is the
quantile estimator clamping `p_win` at 0.95 (`PROB_CEIL`) off ~10 games.
**Hero v2 already ranks by highest `ev_pct`, so it will select the MOST
overconfident read.** Calibration (C-cal) must run on the harness before EV
drives anything user-facing.
## 🔶 SETTLEMENT-CORRECTNESS AUDIT — two concrete findings (open, scheduled)
Logged from the Phase 2 history report; do NOT fix inside a retention build.
1. **ZERO pushes across 470 settled rows.** Implausible — hits/TB land exactly on
the number regularly. The exact-number push case is almost certainly
mis-scored (silently counted as hit or miss).
2. **~28 props/day never settle** (Jul 17 MLB 86 graded/57 settled; Jul 18
103/75). Cause undiagnosed.
Both are correctness bugs in the scoring that accuracy, ROI, CLV and every public
record depend on.
## 🔴 MODEL-VERSION CONTAMINATION in ledger_entries (permanent, mitigate by cutoff)
`ledger_entries` mixes pre- and post-2026-07-19-fix grades with **no version
marker**, and the eras cannot be separated retroactively. **Any backtest or
accuracy claim computed off existing ledger history must treat the fix boundary
as a hard cutoff.** `model_snapshots` stamps `model_version` + `code_sha` on every
row so this can never recur.
## 🟢 BACKUP — DURABLE ON-BOX, VERIFIED BY READ-BACK (2026-07-20, deployed)
The highest-severity open item is closed **on-box**. Off-box is a tracked follow-up.
- **Runs as CODE, not a cron somebody must install** (`src/backupScheduler.js`,
armed in `server.js`). Arming is opt-OUT: armed whenever `SUPABASE_DB_URL`
exists, `BACKUP_CRON=0` kills it, nightly `BACKUP_HOUR_UTC:BACKUP_MINUTE_UTC`
(default 03:10 UTC). The S62 design was opt-in and nobody ever opted in — that
is why the DB went unbacked for weeks. Deploy == installed now.
- **DURABLE:** `BACKUP_DIR=/app/backups` is a Coolify persistent volume, so dumps
survive redeploys. The container-ephemeral risk is closed.
- **VERIFIED END-TO-END (2026-07-20 02:31 UTC):** `POST /api/internal/backup/run`
→ exit 0 in 31s → `vyndr-20260720-023136.dump`, **821,549 bytes**, 698 archive
objects, ledger_entries present. `GET /api/internal/backup/verify` read the
rows back OUT of the archive with `pg_restore`: **643 rows in the dump vs 643
live — exact match**, DB 18 MB.
- The earlier figure of 604 was a moving target, not a discrepancy: the ledger
is append-only and 39 rows were written between the census and the dump
(2 manual regrades + the 01:00 UTC cron; newest lock 01:01:45 UTC).
604 + 39 = 643 reconciles exactly.
- **Permission bug found and fixed by the real run:** the first attempt failed
with `pg_dump: Permission denied` — the container runs as non-root `vyndr`
(uid 100/gid 101) and the mounted volume was root-owned. The Dockerfile now
creates + chowns `/app/backups`; the verify endpoint reports `uid`/`gid`/
`backup_dir_writable` so a mounted-but-unwritable volume is loud, not silent.
- **🔶 OFF-BOX IS DEFERRED, NOT DONE.** Storage Box SSH auth is not working yet,
so the rsync is gated behind `BACKUP_OFFBOX=1` (+ `BACKUP_REMOTE` +
`BACKUP_SSH_KEY`) and currently logs "DEFERRED". A failing push is a LOW
priority notice, never a failure — the durable on-box dump succeeded, and
crying wolf trains us to ignore backup alerts. **Remaining risk: a single
Hetzner-side loss takes the dumps with it.** To finish: install the generated
public key on the Storage Box, set `BACKUP_SSH_KEY` in Coolify (port 23 —
22 is SFTP-only), flip `BACKUP_OFFBOX=1`.
- **Not yet done:** a full restore into a scratch Postgres SERVER. The read-back
proves the archive CONTAINS the data; a server restore would additionally
prove schema/constraint restorability. It needs the dump off the container,
which is blocked on the same Storage Box auth.
## MODEL TRAIN — ARC 2 PREP: DEAD PROBABILITY LAYER REVIVED (2026-07-19, on main, DEPLOYED + fingerprinted)
Re-sequenced ahead of the gate work after the audit found the ranking signal did
not exist. Reports: `specs/audit-data/gate-simulation.md` (G-b/C-cal) +
`specs/audit-data/grade-collapse.md` (the big one).
- **p_win/ev_pct/kelly/model_odds/value were absent on 100% of live grades** —
`gameLogService` returns null for MLB by construction + the Python service is
offline, so `meta.gameLogs` was `[]` everywhere. EV, the Model Train's whole
ranking signal, had never been computed on a live prop; hero v2 always fell
through to its fallback; quarter-Kelly (sold on /pricing) never ran.
FIX: `featureCache.getStatRows`. **Fingerprinted live: p_win 0.523, ev_pct
-10.4, model_odds -109 — the value triplet is whole.**
- **Grade range restored ON MERIT** (rescaling permanently rejected):
`refreshTeamStats` wired into `runSnapshot` (it had ZERO callers), L20 made
symmetric (no negative branch existed), `game_count_in_7d` derived. D now emits
(WNBA 1/25, earned at p_win 0.365). **A-emission awaits the 01 UTC snapshot**
`opp_rank_stat` needs that run. MARKETING HOLD on "A-RATED" copy until then.
- **Calibration guard:** consistency CV is NBA-tuned; `cv ~ 1/sqrt(mean)` made
every MLB stat `boom_bust`. Floored at `CONSISTENCY_MIN_MEAN=4`.
- Ledger truth (576 rows): only B/C ever emitted, 9 confidence values, ceiling 63;
past -400 was -7.7% ROI (the -400 floor was the whole win); flex -161..-250 is
our BEST band (+2.2%) so its EV gate ships DISABLED. Suite 276/3286, build 0.
## MODEL TRAIN — ARC 1 (ENGINE) SHIPPED (2026-07-19, on main `7a925f4`, pushed, NOT yet deploy-fingerprinted)
Spec written retroactively: `specs/model-train.md` (arc list + what's open). Goal of the
train: make "real opportunities at takeable prices" the ENGINE, not a display filter.
Arc 1 = steps 16, backend only. **No frontend consumes the new fields yet** — the
triplet/VALUE marker/refusal copy are Design's Session-2 surfaces.
- **De-vig** (`src/utils/devig.js`, NEW): two-way multiplicative de-vig → fair prob +
fair American price per side + `overround`. One side unpriced → fair is NULL, never
faked. Also `americanToImpliedProb` / `americanToDecimal` / `impliedProbToAmerican`.
- **EV** (`devig.evPct`): `ev_pct = modelProb × decimal 1`, at the graded side's
ACTUAL price. This is the ranking signal now, replacing raw |model consensus|.
- **Takeable / value gates** (`src/config/valueEngine.js`, NEW): `TAKEABLE_ODDS_CEILING`
**160**, `TAKEABLE_ODDS_MAX` **+200**, `VALUE_EV_THRESHOLD` **2** (%) — all env-tunable.
`takeable` = price in band; `value` = takeable AND ev ≥ threshold. Strict null guards
(`Number(null)===0` would have made a missing price "takeable").
- **Hero v2** (`heroPropService`): the hero is the highest `ev_pct` among **takeable**
A/B reads — a huge model-vs-line gap on a 900 line is trivia, not an opportunity.
Falls back to most-recent-real graded read on an empty slate (unchanged).
- **Value triplet** on every graded read: `book_odds` · `fair_odds` · `model_odds`
(+ `fair_prob`, `overround`, `devig_method`). Wired inside `analyzeViaEngine1`'s
existing p_win/Kelly block; rides the `...result` spread through snapshot / hero /
scan. Documented in `BACKEND_HANDOFF.md`.
- **Test baseline: 276 suites / 3306 tests green** (was 274/3289), `next build` exit 0.
New suites: `devig.test.js`, `valueEngine.test.js` (+ `heroPropService.test.js` rewritten).
- **Knobs NOT in this arc** (asked about, confirmed absent from the codebase):
`EDGE_FLEX_WALL`, `HARD_JUICE_WALL`, `LADDER_ODDS_MAX`, `MIN_RUNG_PROBABILITY`.
The only price wall on disk is the flat `JUICE_ODDS_FLOOR` (400) from the
rare-event work below — it is **not** edge-aware, and there is no 250 wall.
## RARE-EVENT UNDER SUPPRESSION (2026-07-19, on main)
Betting-logic audit: the CONSENSUS-vs-MODEL board flooded with "doubles u0.5 · MODEL 0.2 · +edge" fake reads (juiced rare-event unders). Report: the doubles projection is REAL per-player (MLB_LOG_FIELD doubles→doubles; values varied 0.03/0.16/0.2/0.22) — NOT a flat fallback; the issue is purely structural.
- **Config-driven** (`src/config/rareEventMarkets.js`): RARE_EVENT_STATS = doubles/triples/home_runs/stolen_bases, RARE_EVENT_LINE_MAX = 0.5.
- **Grade layer** (`analyzeViaEngine1`): rare-event UNDER at ≤0.5 → always refused (grade null + suppressed); rare-event OVER at ≤0.5 → refused unless projection > line (a 0.2-over-0.5 carries the SAME |edge| as the under, so it'd just take its board rank — refusing it is what actually clears the market). OVER with a genuine projection > line still grades.
- **Board layer** (`marketBreadth.collectBreadth`): drops null-model rows — a suppressed/ungraded prop can't rank a "MODEL —" placeholder onto the board.
- Suite 274/3289 green. Fingerprint: next MLB snapshot should have no rare-event u0.5 grades.
## BACKUP + FOUNDER CHECKOUT (2026-07-18, on main)
- **Task A — DB backup (SHIPPED; Kev cron+fingerprint on box)** `c2c43cd`: Dockerfile now has pg_dump/pg_restore/rsync; `backup-db.sh` validates every dump via `pg_restore --list` (must contain ledger_entries). Runs IN the API container (SUPABASE_DB_URL is there; WSL2 can't reach Supabase). Runbook = host cron `docker exec <api> sh /app/scripts/backup-db.sh`. **Mechanism fingerprint PASSED locally** (137 rows → dump → validate → restore → 137 rows). Kev: install the host cron + run the prod restore fingerprint.
- **Task B — founder checkout SEAT-GATED (SHIPPED)** `ccb9668`: `resolveCheckoutPrice` attaches the founder price while seats remain (< FOUNDER_SEATS_TOTAL, same `countFounderSeats()` truth as the meter), flips to standard at 100; meter shows SOLD OUT. Fixes "Claim a Founder Desk" charging $44.99 vs advertised $34.99. **payment_failed grace 48h→14d** (spans Stripe retries; revoke only on real cancel). Tested seat 0/99/100/null. Needs `STRIPE_PRICE_*_FOUNDER` set in prod for founder pricing to activate.
## SECURITY + PLUMBING FOLLOW-UP (2026-07-18, on main) — code shipped, migrations authored
Chrome ops session findings (Supabase advisor + Stripe). Code items shipped + fingerprinted; DB migrations authored for Kev to apply (no DB access from the box).
- **Item 0 (SHIPPED, fingerprinted)** `3b12c6c`: founder count = real active Stripe subs (`stripeService.countFounderSeats`), not DB tier fields. Phantom 1 (a comped profile) gone → prod now `available:false` (founder price IDs unset in prod env — set `STRIPE_PRICE_*_FOUNDER` to show the real 0).
- **Items 1,3,4,5 (AUTHORED — Kev applies in Supabase)** `78c1929`: `migrations/023_security_hardening.sql` (founder_pricing_seats → security_invoker; waitlist anon-INSERT-only; function search_path) + `migrations/024_anon_revoke_discoverability.sql` (revoke anon SELECT). Fingerprint = re-run Security Advisor.
- **Item 2 (SHIPPED — Kev sets env + cron)** `5f5c004`: `scripts/backup-db.sh` + `docs/BACKUP-RUNBOOK.md`. Needs `SUPABASE_DB_URL` (direct :5432 URI) in Coolify + the cron on the Hetzner box. Fingerprint = restore a dump + count ledger_entries.
- **Item 6 (SHIPPED — Kev activates portal in Stripe)** `889e862`: portal proxy + "Manage billing" button. Dunning verified: cancel-on-exhaustion → subscription.deleted → 48h grace → `gracePeriod.checkGracePeriod` revokes to free. Nuance: 48h grace on first payment_failed < Stripe's 2-week retry window (self-correcting).
- **Item 7 (SHIPPED)** `ae3cff9`: ESPN BET → theScore Bet (PENN) in books.js + ALLOWED_BOOKS; design HANDOFF flagged for the BookChip swap.
## TRUTH-EVERYWHERE PART 2 — COMPLETE (2026-07-18, on main, all 8 items fingerprinted)
Standing law: NO FABRICATED DATA ANYWHERE (UI, marketing, demos, examples). If it renders a number, it's from the DB or it doesn't render. Live data IS the better demo.
- **Item 1 — Verb law** (`66d52a9`): one verb, READ (never SCAN), swept every user-visible surface (FAB/nav/tier/ticker/upgrade). `tests/unit/verbLaw.test.js` enforces. Ticker "slate read" text flips at the next snapshot cron.
- **Item 2 — Founder counter** (`41fc2b9`): real active-paying-founder count from Stripe-synced user_profiles, cached; HIDES when unavailable. Live: **1/100** (was fabricated 47/100).
- **Item 3/4 — Reports**: full fabricated-data inventory + pricing claim audit (LIVE/PARTIAL). Alt-ladder/Kelly/correlation are LIVE in the engine; scratch-cascade + real-time-feed are PARTIAL.
- **Item 5 — Daily hero prop** (`9b9aab4`): live RULE = largest |modelconsensus| gap, A/B only. Shows book line vs VYNDR model + real timestamp. Empty→most-recent-real; nothing→hidden. NO static Jokic fallback. `heroPropService` + `/api/hero-prop`. Live: Erica Wheeler 9.5 vs 14.2.
- **Item 6 — Desk showcase** (`cb3237c`): real alt-line ladder from the snapshot (edge guarded on small-line artifact); φ = real same-team correlation for two real legs or hidden; Kelly tile REMOVED (no odds → can't size honestly). `deskShowcaseService` + `/api/desk-showcase`. Live: Vandersloot ladder.
- **Item 7 — Accuracy → TRUTH** (`89a2977`): public 30D accuracy (`/api/accuracy` + `/api/ledger/accuracy`) now reads the CLEAN Postgres ledger aggregate (getModelAggregate/getAccuracyView, model_value>0), not the Redis outcome log. Number moved 67%→61%. **BEAT CLOSE suppressed** at source (`clvCaptureReliable()`) — see C4.
- **Item 8 — Blog** (`3b7a1f5`,`a8e383e`): wired the 5 REAL articles (content lives in `web/content/articles` — the runtime content root, NOT repo-root content/) with honest dates + markdown render + OG. Backdated 2026-03-22 orphan deleted + 301. Live: 5 articles serve.
### 🔴 C4 — CLV CAPTURE BROKEN (open, backend)
`captureClosing` re-records the LOCKED line as "closing" (closing_line == locked_line on the whole sample) → CLV is flat → BEAT CLOSE = a measured-wrong 0%. Suppressed on all public surfaces until fixed. Full write-up + fix plan: `specs/audit-data/clv-capture-broken.md`. Flip `CLV_CAPTURE_RELIABLE=1` after C4 verifies real closes.
## PHONE AUDIT PUNCH LIST — COMPLETE (2026-07-17, on main, deployed, fingerprinted)
Kev's 35-screenshot real-390px audit (signed-out + Desk signed-in). Worked top-down, fingerprinted the batch (compare "on current form" literal live in served chunk off `71e35e9`). Test baseline: 268 suites / 3239.
- **P0-1** blue-B/amber-C banished → token-derived grade badges (earlier batch).
- **P0-2** image pipeline (logos/headshots) `loading=eager` (earlier batch).
- **P0-3** player grouping/ladders across leaders/explore/ledger/breadth (`lib/playerGrouping.js`).
- **P0-4** mobile app bar collapsed to one line (ticker+heartbeat hidden <768, `MobileSyncClock`).
- **P1-5** 390px overflow containment (breadth rows stack, pitcher line wraps).
- **P1-6** (`ff0d3b1`) ONE freshness source: Slate "UPDATED" now reads the pipeline `refreshed_at` (the app-bar clock's field), not the client poll time — no more "0s ago" vs "SYNC 46:03" contradiction.
- **P1-7** (`d755b43`) flat edge board: an impossible `|edge|>40` is treated ABSENT at both data (can't rank a fake +140% over a real +8.4%) and display layers. Chips render now (P0-2). `EDGE_BOARD_SANE_MAX`.
- **P1-8** (`00537eb`) Compare verdict truth: no phantom "edge tonight" for July NBA(0) → "on current form"; cites what actually wins (scoring/boards/playmaking, not usage).
- **P2-9** (`77e8937`) leaderboard stat labels `web/src/lib/statAbbrev.js` (SB/ER/TB) + **FLAGGED** the backend grade degradation (below).
- **P2-10** (`71e35e9`) ledger read-card density (padding/margins tightened; archetype showDesc kept — it's inline, zero height cost).
### 🔴🔴 BACKEND FINDING — MLB GRADE PIPELINE DEGRADED (needs backend work, NOT frontend)
Diagnosed against live `/api/snapshot/mlb` (2026-07-17). Full write-up + do-not list: `specs/audit-data/mlb-grade-degradation.md`. Frontend guards (sane-edge cap, statAbbrev) are damage control, NOT the fix.
- **projection == 0 for 9/25** grades — the MLB feature path feeds 0 instead of refusing (S58 `insufficient_data`). This also breaks `computeEdge` → the broken edge_pct.
- **edge_pct on a broken scale** {20,60,100,140} — not a market %. Real prop edge is single-digit.
- **grade↔confidence mismatch on 25/25** — B shown at conf 55 (= the B- band per `grade_thresholds.json`); systematic one-sub-tier gap. The surfaced `confidence` is not the probability that derived the letter (likely the data-sufficiency penalty applied to display-only). This is the audit's "B at 45%" — NOT a frontend labeling or threshold bug; DO NOT re-letter/re-threshold on the frontend.
## DESIGN COMPLETION TRAIN — SUBSTANTIALLY SHIPPED (2026-07-16, on main, deployed) · ONE MASTER CHROME AUDIT PENDING
Goal: the live product matches Design's mockup at EVERY width. **HONESTY LAW: I cannot see rendered output (WSL2↔Chrome unreachable) — every visual change is "built to Design's spec, VISUALLY UNVERIFIED." Kev runs ONE master Chrome audit at the END (390px + desktop) against the running unverified list below; fixes propagate through tokens (surfaces inherit, don't fork).** Design package (3 revs) authoritative at `specs/design-reference/` (HANDOFF.md = entry point; `vyndr-system.html` desktop, `vyndr-mobile.html` 19 screens, `vyndr-landing.html`, `assets/glyphs/` 83 SVGs+MANIFEST).
SHIPPED + deployed:
- **Palette alignment** (`e0a26a8`,`c3d469e`,`ebaab00`): tokens → Design's EXACT ramp. **Grade colors corrected: B blue→WHITE #F0F0F0, C amber→GREY #B8BCC8, D #FF4757** (the old blue/amber violated DESIGN-SPEC's own "B neutral-bright, C muted"). Surfaces #0E0E14/#14141E, text #F0F0F0/#B8BCC8/#707080, borders #1E1E2A/#2A2A38. vyndrTokens + tests aligned. ShareCard/OG billboards grade-B white.
- **Glyph system** (`77d8fd6`,`771d8b0`): 44 archetypes render Design's REAL marks (83-mark set: 74 display + 9 classifier-legacy), front `lib/archetypes.js` + back `archetypeService.js` color-synced. ZERO generic placeholders left. Combat keeps its char-glyph namespace (separate test).
- **Entity layer VERIFIED already-complete** (DS0/Wave 2A/2B — the audit was stale): TeamLogo (real ESPN CDN, monogram fallback) on game cards/team hub; PlayerAvatar headshots MLB/NBA/WNBA + NFL/NHL resolver ready + soccer-monogram (blocked) + combat-no-photos; books via bookInfo/BookChip everywhere (fixed BookComparison holdout `1558710`); line-shopping surfaces (detectBestBook/MarketBreadth) self-deepen. No `$1M` copy.
- **Rev 3 matchup chips** (`f24f941`,`baf977f`,`bf8bf16`): `TeamChip` primitive (real TeamLogo at 10-12px + abbr, inherits row opacity) on StatStrip board rows, grade reveal, pitcher-vs. Game-card headers already had TeamLogo.
- **Mobile: M1a header collapse** (`a10227e`) · **M1b app-bar clock** (`7c7ab24`, Hybrid: wall clock rests, STALE reacts off refreshed_at) · **grade-reveal mobile** (`baf977f`, 74px hero) · **flat EDGE BOARD** (`8a24ac9` — Design's core mobile screen: NEW flat edge-ranked IA, `slateAdapter.flattenToEdgeBoard`+`MobileEdgeBoard`, shown <768px, cards ≥768px; threaded edge_pct through the pipeline) · **M4 structural locks** (`6bc9093`).
- **Key finding:** the 19 mobile screens were LARGELY already built (pages built to the SAME DS0-DS5/§7 contracts Design's mobile follows) → they already match structurally + stack on mobile. The genuinely-new work was the flat board (done) + narrow chip/hero polish.
REMAINING (all VISUAL, for the master audit): per-screen mobile polish of the ~17 already-built screens (blind, mostly Design-aligned) · M2 billboard crop-ratio verification (OG routes exist, colors fixed). Test baseline: 266 suites / 3214.
### 🔴 MASTER-AUDIT UNVERIFIED LIST (every visual change this train — Kev's one end-of-session audit)
1. **Palette** (biggest): grade B-white/C-grey/red-#FF4757 + full surface/text/border ramp — every surface inherits.
2. **Archetype marks + colors** — 44 archetypes render Design's real 83-mark set.
3. **Flat EDGE BOARD** at 390px — the core mobile screen (rows/opacity-ramp/chips/hero-edge%).
4. **Grade-reveal mobile** (74px hero + team chip) · **app-bar clock** (Hybrid) · **M1a header collapse**.
5. **TeamChip** on board rows / grade reveal / pitcher-vs (real logo at 10-12px).
6. **OG billboards** (ShareCard/player/u) grade-B white + text #F0F0F0.
## SHIPPED — HEARTBEAT + CADENCE + QUOTA-GUARD (2026-07-15, on main, deployed)
Three ships, each green + deployed. Standing authority is now PERMANENT full autonomy (three-gate floor: tests green, web build exit 0, post-deploy fingerprint of NEW code).
- **`7712f0a` Heartbeat honesty (fingerprint-verified live):** the "frozen snapshot / STALE 8h" was a MEASUREMENT bug, not a dead pipeline. `updated_at` = grade-LOCK time (advances 5×/day by design; grades never change in-game); the SYNC badge measured the 20-min `expected_interval_s` against it → structurally STALE. `refreshed_at` (the real 20-min intraday heartbeat) was written to Redis but NEVER serialized. FIX: full snapshot seeds `refreshed_at`, `/api/snapshot/summary` + GET `/:sport` expose it, `LiveLayer` badge measures from it. Fingerprint PROVED the pipeline was alive all along (`refreshed_at` advancing 20:20→20:40→21:00→22:00; the 22:00 full slot fired → scheduler healthy, re-armed fine after the 19:37 restart).
- **`4cd933d` + `2d413cf` Per-sport cadence (Job 1):** `src/config/sportCadence.js` (config-over-constants). REALITY-CORRECTED: mlb/nba/**wnba** all keep the full grid `14/19/22/1/3` (PropLine 9k/day = cheap; WNBA games span afternoon→late-evening, so 14 UTC/10am ET catches afternoon-game props — the earlier "drop it as waste" was wrong, corrected by observing 2 finished afternoon WNBA games on Jul 15). **Soccer alone is trimmed** to `14/19` + NO intraday — the genuine win, protecting the scarce odds-api key. Scheduler fires at HOURS_UTC and grades only `sportsForHour(h)`; only `intradaySports()` refresh.
- **`2d413cf` Quota guard (the real finding):** odds-api quota was EXHAUSTED 0/500 mid-cycle, unpaged. Root cause: `futuresService` (the only regular burner) used raw axios, BYPASSING the gateway → never hit `recordCall` (the ONLY place the WARN/BLOCK pager fires) → the counter reached 100% via silent header-sync. FIX: futures now routes through `gateway.fetch('odds-api', …)` (counted + blocked); a RESERVE floor (`ODDS_API_RESERVE`=50) refuses DISCRETIONARY calls (futures/soccer) while `remaining ≤ reserve` so they can NEVER starve MLB's essential prop-backup; `quotaTracker.syncFromHeaders` (authoritative) now ALSO fires the once-per-period alert; `POST /api/internal/quota/test-alert` test-fires the pager end-to-end. **Fingerprint caveat:** backend-internal, no unauthenticated public surface (`/api/internal/*` 401s for any path — auth runs before route-match); verify via authenticated test-alert (200=new/404=old) with the internal key.
- **odds-api 0/500 → BOTH soccer props AND futures are blocked on QUOTA, not code.** Kev's ruling: HOLD THE LINE (don't pay; zero-out-of-pocket). Soccer stays honest-empty; cadence is ready for the reset. Env: keep `API_FOOTBALL_KEY` (code reads it), delete inert `API_FOOTBALL_KEY2` (Kev handling). See memory [[odds-api-quota-soccer-blocked]]. Test baseline: 264 suites / 3195.
## SHIPPED — TRUTH-EVERYWHERE + OFFSEASON HUB TRAIN (on main, deployed)
Governed by `specs/truth-everywhere-train.md`. **The reframe that drove it: only MLB GRADED in prod (NBA/WNBA refused their whole slate — offline Python feature source, no fallback; soccer errored pre-grade). So grading, not settlement, was the first domino.** Standing deploy authority granted (green + fingerprint → merge + deploy, no ask). Waves DEPLOYED:
- **Wave 0 — NBA/WNBA GRADE (free ESPN gamelogs):** `espnStatsAdapter.getPlayerGameLog` feeds `featureCache` when the Python source is null → l5/l20 → props grade. **Verified live** (A'ja Wilson→B, Ionescu→C off real ESPN form). Endpoint `site.web.api.espn.com/apis/common/v3/sports/basketball/{nba|wnba}/athletes/{id}/gamelog`.
- **Resolver-hardening:** roster-index primary resolver (206 WNBA / 544 NBA) + `?season=` gamelog retry — the real fix for dual-league (WNBA+NCAA) players who got an empty gamelog (Collier/Brionna Jones now resolve). Broad coverage.
- **Wave 1 — NBA/WNBA SETTLE:** `defaultGetPlayerStats` routes nba/wnba to the ESPN gamelog in BOTH `outcomeService` + `ledgerService`; separate `NBA_BOX_KEY`/`NBA_COMBO` settle maps; final-honesty guard (never settle in-progress); `opsWatch` finals-gated zero-settle alarm + per-sport boot announce. accuracy:{sport}+by_tier light up automatically. **PROOF is next-day** (grades lock tonight → settle tomorrow → `accuracy:wnba`).
- **Wave 2 — NEVER-DARK HUB:** ESPN `/news` wire (`newsService` + `/api/news/:sport`) — **verified live (real headlines)**; `NewsWire`+`FuturesBoard` on `/explore` (offseason-aware, self-hide on empty, futures "TRACKED · NOT GRADED"). **KNOWN GAP: `/api/futures/:sport` returns `markets:[]` for ALL sports in prod** — code path looks correct (odds-api `/sports/{FUTURES_KEYS}/odds?markets=outrights`); needs a live odds-api probe (ODDS_API_KEY) to finish. Self-hides honestly meanwhile. `FUTURES_ENABLED=0` kill-switch; 12h TTL; 1 credit/refresh.
- **Also rode along:** `espnAthleteIndex` cross-sport headshot capture (headshot-coverage) — NBA/WNBA/NFL/NHL real headshots when in-season.
DEFERRED (locked decisions): **soccer** — grade+settle path, needs `API_FOOTBALL_KEY` (VALIDATED, Free 100/day, active to 2027 — set it in Coolify env `API_FOOTBALL_KEY`; code dormant until wired) + ESPN-soccer-free-first attempt; **MMA moneyline settle** (combat not yet in the grade/lock loop); **NFL/NHL** (dormant, in-season later); **futures debug** (odds-api probe). Test baseline now **263 suites / 3183**.
## SHIPPED — WIRING & DATA TRAIN (on main `3b1aa9f`, deployed 2026-07-13)
Governed by `specs/wiring-data-train.md` + `specs/combat-intelligence.md` + the global visual reference `specs/design-reference/vyndr-system.html` (build toward it; live wordmark kept). **All 6 waves shipped + deployed** (fingerprint-verified: /parlay, /fight, /u/vyndr live; house record 88-38/70% real). Real assets verified live (MLB/ESPN headshot CDNs, ESPN-MMA feed, Baseball Savant CSV).
- **Wave 1 — trust bugs:** billing renewal honest render (`billingDisplay.classifyRenewal` — no far-future placeholder); James Wood nameKey-collision fixed (`mlbStatsAdapter` teamHint disambiguation + streaks join-invariant); DeskShowcase "$1M terminal" → deadpan copy.
- **Wave 2 — sport-agnostic entity layer:** real player headshots threaded from ingestion (MLB MLBAM + NBA/WNBA ESPN athlete ids that were fetched-and-discarded) across slate/scan/hotlist/search/grade card; soccer = honest monogram (no free id); 8 self-authored SVG book wordmarks (`web/public/books/*.svg`, swappable for official art) + all 10 book keys resolve; team-logo abbr aliases. Storage: id on the `enriched` grade at `snapshotService` (zero new I/O).
- **Wave 3 — record by grade tier (Addition 2):** ONE shared `TierRecord` (`lib/tierRecord.js` + component) on dashboard + /u + ledger; per-tier W-L always, hit-% only at n≥20 per tier (gate stays in `getModelAggregate`).
- **Wave 4 — missing surfaces:** Outlook mode (grid never blank → yesterday receipts / tomorrow schedule); Market-Breadth median-consensus-vs-model strip (self-hides <2 books); Parlay Lab `/parlay` (slate-independent leg source); live Grade-Shift timeline (`GradeShift`).
- **Wave 5 — /u house-mode + arsenal:** house handle `vyndr` surfaces the real `user_id=NULL` public model record + per-tier calibration + 1080×1350 portrait/OG (user-handle privacy 404s stay byte-identical); Baseball Savant pitcher-arsenal (`savantAdapter`, free CSV, verified) → `PitcherArsenal` card, self-hides on absent.
- **Wave 6 — combat v1 (MMA):** ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes (sport-scoped `COMBAT_ARCHETYPES` — FINISHER color collided w/ soccer + tripped the ΔE gate, so kept separate) + odds-api ML/round-totals + style-edge verdict. NOT in the snapshot/settle loop; method/round/props + fighter photos + matchup-GRADE engine + ufcstats scraping all DEFERRED, flagged data-limited in-UI. New routes: `/fight/[id]`, `/parlay`, `/u/[handle]/portrait`.
POST-MERGE TODO: NBA/WNBA headshot coverage + combat depth need prod runtime verification; soccer headshots blocked on `API_FOOTBALL_KEY`; combat settlement + matchup-grade engine are the next combat sub-wave.
## SHIPPED — DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live)
Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as
themselves, color contract, sub-200ms, screenshot-first billboards). **All six
sessions DS0DS5 shipped: merged to main, pushed to gitea, Coolify auto-deploy
LANDED — verified live via `/pricing` DeskShowcase ("A $1M terminal") + `/u`
route serving the unified EmptyState. 238 suites / 2885 tests green,
`next build` exit 0.** Post-ship TODO: re-run the Chrome design audit against
prod v2 to confirm all 22 audit findings closed (env here can't screenshot —
WSL2↔Windows-Chrome localhost unreachable).
- **DS0 Entity Layer** (`24af247`): `teamMeta.js` (real logos+colors for 30 MLB /
30 NBA / 13 WNBA / 48 WC nations via ESPN CDNs), `TeamLogo` / `PlayerAvatar`
(team-colored monogram fallback, no gray silhouette) / `BookWordmark`, swapped
into GameCard/StatStrip/StreaksPanel/TeamHub.
- **DS1 Speed + Trust Bugs** (`1c681df`): React #418 hydration fix (mounted-flag
guard), layout-matched `Skeleton`s replace text-wall loaders, scan→ledger
persistence via `session.access_token`. **DS1 follow-up** (`cf91c04`): closed
the sb-token trust-bug CLASS — `lib/authToken.js` `currentAccessToken()` reads
the REAL Supabase session (not the OAuth-only `sb-token` key) across
profile/slip/dashboard/settings/tracker.
- **DS3 Color Contract** (`49a3323`): `lib/colorContract.js` — green = one meaning
(edge/A-tier/CTA), edge/CLV by sign, glow A/A+ only, archetype greens deduped.
Enforced by `colorContract.test.js` (fails on violation).
- **DS4 Billboards** (`45bafbc`): STREAKS row (length as mono hero), grade reveal
(sign-colored edge), CLV reframe (flat says so), `/u` public profile + OG.
- **DS2 Dashboard Slate Rebuild** (`fe294a5`): never-empty hero (falls back to
yesterday's A-tier settled receipts), one bold hero per card, pending-filler
collapse, ranked tonight-grades. `slateAdapter.js` engine + 21 tests.
- **DS5 Pricing + Motion + States** (`a18a3f3`): Desk $44.99 as the hero tier
(single green CTA, `DeskShowcase`), ticker → punctuated stillness (≥4.2s hold,
one idle motion), unified `EmptyState` (404 grammar) at TeamHub/game/ledger,
archetype glyphs propagated to streaks/ledger.
NOTE: WSL2↔Windows-Chrome localhost is unreachable in this env, so visual proof
was SSR-render + source-assertion tests + live prod fingerprint curls, not
pixel screenshots. The `design/v2-train` + per-DS worktree branches can be
pruned. Stale `origin` GitHub remote still carries the long-dead leaked PAT —
deploy runs off `gitea`, so it's inert, but rotate it when convenient.
## WHERE THINGS STAND
**Tests: 2757 / 229 suites, all green. Web build exit 0.** Baseline at the A1
board's start was 2398. Governing docs: `specs/VYNDR-NORTH-STAR.md` (v1.1) +
`specs/VOICE.md` (v1.1) + `specs/ROW-GRAMMAR.md` — read them before building
anything.
**The record is live and writing itself.** Day one (Jul 11): 24→25 MLB rows,
real locks (line/odds/book), closing capture every snapshot + every 20-min
intraday refresh, settlement each morning slot with signed CLV. No percentage
renders anywhere under n≥20 — enforced in `ledgerService.getModelAggregate`,
the single gate. WNBA/soccer rows pend honestly until Phase 4.5.
## THE THREE A1 TRAINS (all merged to main, all pushed)
| Train | Range | Carried |
|---|---|---|
| #1 | `4d2b27d → 219167e` | S0 verify · S1 promise audit (`PROMISE-AUDIT.md`: alt ladder + quarter-Kelly BUILT, analyst unlimited, "40+" claims made honest) · S2 compliance (/responsible-gambling rebuilt, /terms /privacy drafts, /methodology, 5 seed articles in `content/articles/`) · S3 affiliate (BOOK IT deep links organic-until-config-flip, best-price dots, ?ref= partner attribution) · S4 media engine (`mediaEngine` + executable VOICE lint, `/desk` arsenal + DATA BRIEF, Ghost DRAFTS-only publisher) · S5 viability (statsapi lineups → CONFIRMED/NOT-IN kills grades visibly; ESPN injury wire; Yesterday/Today/Tomorrow nav + Settle panel) · S7 newsletter (Listmonk capture + THE VYNDR REPORT assembly, env-gated) · S8 ops (settle alarm, 3-slot failure pager, quota/disk/mem, 9AM pulse — test alert delivered to the real ntfy channel) · S9 slip reader (tesseract.js OCR + per-book parsers, /slip page; synthetic-image acceptance passed) · S10 public profiles (/u/{handle}, private-by-default, no-existence-leak) · migrations 021/022 |
| #2 | `219167e → 14dc9cf` | S6 display (ROW-GRAMMAR.md + slot-order fixes, line sparklines from intraday history, last-10 ●●○ dots, CLV distribution on MODEL tab, ⌘K SearchModal + mobile search, landing LCP fixes) |
| #3 | `14dc9cf → ec5ee3d` | S11 live tracking (statsapi/ESPN live box lines → ON PACE / NEEDS N / HIT ✓ in the outcome slot, live games float up, "TRACKING — READ LOCKED PRE-GAME"; real-live acceptance: Harper 3/1.5 TB ▼8th → HIT ✓). Grades never change in-game. |
**Deploy status:** ALL LIVE as of `17fb981` (Jul 12 17:43 UTC). Fingerprints
confirmed in prod: `/api/live/mlb` serves JSON with `hasLive:true` (S11 live
tracking working — a real game was tracked at confirm time), `/api/health`
healthy, `/api/snapshot/summary``expected_interval_s:1200` (env var now set).
**P0 postmortem (`17fb981`):** the API silently served a 14h-old image
(`4d2b27d`) from Jul 11 ~20:00 to Jul 12 17:43. Root cause: the Dockerfile
never copied `content/`, and `mediaEngine.js` read `content/stark-lines.json`
with an unguarded module-load `readFileSync` → ENOENT at require time crashed
`app.js`'s eager route chain (app → routes/desk → deskService → mediaEngine)
→ boot failed → Coolify healthcheck rolled back. Every merge from S6 onward
was merged-but-not-running for 14h. Fixed: Dockerfile COPYs content/;
mediaEngine treats the file as optional garnish (loadStark try/catch → {});
`src/preflight.js` now prints `[preflight] OK|DEGRADED` at boot so a missing
file/env is legible instead of masked by a rollback. LESSON: any module-load
file read is a boot-crash risk — keep them lazy + try/catch, and the
Dockerfile must copy every dir the runtime reads.
## LIVE INFRA MAP (verified in prod)
- **VYNDR Web** — Next.js :3000, vyndr.app. **VYNDR API** — Express :3001,
api.vyndr.app (`/api/internal/*` behind `VYNDR_INTERNAL_KEY`).
- **Scheduler (in-process, API app, `SNAPSHOT_CRON=1`)** — snapshot slots
14,19,22,1,3 UTC; settle pass (outcomes + ledger, idempotent) runs FIRST
each slot; 20-min intraday odds refresh during slate hours
(`INTRADAY_REFRESH=0` kills); desk-ready ping after the day's first slot;
daily pulse 13:00 UTC; missed-cron + 3-slot-failure + quota pagers → ntfy
`vyndr-pipeline-kev2026`. Boot logs: `[snapshotScheduler] armed` +
`[settlement] armed`.
- **Data**: Redis (mastermind-cache) for all pipeline caches (SNAP_TTL 24h —
do NOT shorten; the 11h overnight gap killed morning settles once).
**Supabase = the ledger** (`ledger_entries` + `public_profiles` +
`user_profiles.partner_ref`; RLS everywhere, service-role-only writes;
migrations 001022 applied, files in `supabase/migrations/`).
- **Deploys**: gitea `git.builtbykev.com/builtbykev/vyndr` → Coolify webhook
(HMAC secret fixed Jul 11; auto-deploy proven). GitHub `origin` remote is
dead-PAT hygiene debt only.
- **Free feeds** (zero out-of-pocket law): PropLine ×3 keys (9k/day capacity;
intraday uses ≤144/day), odds-api backup (quota-gated), statsapi.mlb.com,
ESPN site API. tesseract.js OCR self-hosted (traineddata caches on first
call — needs outbound network once).
## ENV VARS STILL PENDING ON KEV'S SIDE (Coolify, API app)
1. `SNAPSHOT_EXPECTED_INTERVAL=1200`**confirmed absent** (fresh restart
still served the 18000 default). SYNC badge tracks intraday cadence once set.
2. `DESK_OWNERS=kevdevelops@gmail.com` — /desk 403s everyone until set.
3. `LISTMONK_URL` / `LISTMONK_USER` / `LISTMONK_TOKEN` / `LISTMONK_LIST_ID`
(all four together) — newsletter no-ops politely without them; box-side
setup in `docs/NEWSLETTER.md`.
4. Optional: `GHOST_URL` + `GHOST_ADMIN_API_KEY` (drafts), `PULSE_HOUR_UTC`.
**Entity placeholders** (S2, blocked on Kev's values): `[ENTITY NAME]`,
`[STATE OF FORMATION]`, `[ARBITRATION VENUE]`, `[CONTACT EMAIL]` in
`web/src/app/terms/page.tsx` (6×) + `web/src/app/privacy/page.tsx` (5×).
A test keeps unverified entity names out until replaced.
## HONEST OPEN ITEMS
- **Phase 4.5 — WNBA settlement via ESPN box scores. DUE ~Jul 24** (hard
date). Until then WNBA/soccer/NBA ledger rows pend forever; accuracy = MLB only.
- **The record needs time**: no percentage anywhere before n≥20 settles, by
design. First percentages expected ~day 34 of MLB volume.
- **Slip reader**: acceptance was a clean synthetic image; needs one real
phone screenshot (DK) to validate OCR robustness.
- **Landing LCP**: S6's fixes are static-analysis-provable but unmeasured —
run PageSpeed mobile vs the 3.8s baseline; next suspect is the
LiveHeroProp post-fetch swap.
- **Partner report endpoint** returns real numbers only for signups AFTER
migration 021 (metadata path live; OAuth signups carry no ref — known gap
in `docs/PARTNERS.md`).
- **NBA Oct / NFL Sep readiness** + soccer end-to-end: `specs/vyndr-roadmap.md`.
- **Killed permanently** (founder ruling): AI chat assistant, light mode.
## OPERATING LAWS (never relax)
Zero out-of-pocket · absent beats wrong, refusal beats hollow · n≥20 before
any percentage · nothing auto-posts anywhere, ever · grades never change
in-game (revisions are public via `revised_from_grade`) · one meaning per
color, data in mono · no exclamation points, anywhere · the pipeline is the
only source of numbers, including in marketing (`mediaEngine` lint enforces).
— STATE as of ec5ee3d · the record is writing —