Folds re-sequenced steps 1+2 into one change (Kev's call): same bug
family — features wired to sources that return null.
THE PROBABILITY LAYER WAS DEAD IN PRODUCTION. p_win/ev_pct/kelly/
model_odds/value were absent on 0/8 live grades because
gameLogService.getGameLogs returns null for MLB by construction and
depends on the offline Python service for NBA/WNBA, so meta.gameLogs was
[] for every sport. This was the S46 bug in a second location — that fix
gave featureCache an MLB branch (why grades still worked) but never the
estimator. featureCache.getStatRows now supplies normalized rows
([{date,[statType]:v}], most-recent-first) for every sport, feeding the
estimator AND consistency AND game_count_in_7d from one fetch.
VERIFIED on real props: p_win 25/25 WNBA, 8/8 MLB (was 0).
GRADE RANGE, ON MERIT — never by rescaling (permanent founder ruling:
minting A's without new information is a relabelled B sold as an A and
corrupts an append-only ledger).
- refreshTeamStats wired into runSnapshot — it had ZERO production
callers, so opp_rank_stat was permanently null and a +/-1.0 factor
could never fire. Test-env no-op (opsNotify precedent).
- L20 made SYMMETRIC: both branches were delta +1.0, so the season
baseline could only ever ADD. No negative path was a structural reason
D was unreachable. New l20_contradicts_* carries -1.0.
- game_count_in_7d derived from real logged dates (heavy_workload_7d).
- NOT wired, deliberately, with reasons inline: teamId (no team_id
column; getFeatures reads it top-level; factor also needs a starter-id
list) and season_type (ESPN 2 = REGULAR season; threading it raw would
fire veteran_in_playoffs in July). Dead code dressed as a fix is the
thing we are removing, not adding.
CALIBRATION GUARD (found by verifying, not assuming): consistency CV is
NBA-tuned; for a Poisson-ish stat cv ~ 1/sqrt(mean), so any stat with
mean < 4 auto-classifies boom_bust. First verification run showed 8/8 MLB
props boom_bust — a blanket -1.0 that dropped the board to all-C. Floored
at CONSISTENCY_MIN_MEAN=4 -> 'unknown' below. Absent beats wrong. MLB
low-count stats therefore still get no consistency factor: honest, not
fixed. Scale-free index-of-dispersion classifier is the open follow-up.
CONFIDENCE IS NOT A PROBABILITY: payloads carry confidence_basis:
'grade_band'. Corrected mlb-grade-degradation.md — its "25/25
grade<->confidence agreement" is a TAUTOLOGY (confidence is derived FROM
the letter, so it would report 25/25 even if every grade were wrong), not
a validation. Removed dead mlbGrader.js (referenced only by its own test)
and the stale computeFeatures comment claiming a penalty that never ran.
VERIFICATION (scripts/verify-grade-range.js, real props/logs/engine):
WNBA 25 props B 68%->32%, C 32%->64%, D 0->1 (4%); 11-step spread went
from 2 steps to 5 (C/C+/B-/D). The D is earned: Angel Reese assists o2.5,
p_win 0.365. Nothing flooded — grades got HARDER. A did not emit locally
because opp_rank_stat needs the Redis cache only prod populates (local
ceiling +3.0 vs the +4.5 A needs); reachability is proven arithmetically
and locked in tests. Prod A-emission is the outstanding fingerprint.
MARKETING HOLD: "A-RATED" (AccuracyBadge, TopSignals) is unsupported
until that fingerprint. Confirmed honest fallbacks render today —
/api/ledger/accuracy has B and C buckets only, so the badge shows
"MODEL · 63% HIT" and TopSignals self-hides. Nothing fabricated ships.
Suite 276/3286 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Completes the diagnosis. Report only; no grade logic or thresholds changed.
The grade is an integer index (GRADE_SCALE, NEUTRAL_INDEX 3) moved by a
flat sum of +/-1.0 and +/-0.5 factor deltas, then clamped and rounded.
grade_thresholds.json is NOT an input mapper in the JS path — engine1
reads it BACKWARDS, taking the letter the index already produced and
looking up that band's midpoint to manufacture `confidence`. So
confidence is a cosmetic re-encoding of the letter: zero information
beyond it, and it can never disagree with it. There is no
data-sufficiency penalty in the live path (the one CLAUDE.md describes is
in mlbGrader.js, which is dead code).
Six of thirteen factors are wired to features nothing populates —
verified: refreshTeamStats has ZERO production callers (so opp_rank_stat
is permanently null, killing a +/-1.0), teamId/season_type/
game_count_in_7d are never passed (gameContext is built as {home_away}
and nothing else), and MLB consistency starves on the same dead
gameLogService path as Finding 2. Also verified: BOTH l20 branches are
delta +1.0 — there is no negative L20 contribution at all.
Arithmetic: an A needs sum >= +4.5; the live maximum is +3.0 (+2.0 on a
back-to-back, and MLB rest_days is 0 most days). D needs <= -1.51; the
live minimum is -1.5 and Math.round(1.5)=2, so it misses by one rounding
tick. Reachable band is index 2..6 = {C-,C,C+,B-,B}, which the adapter's
FOUR_LETTER_MAP (a 3->1 collapse) renders as exactly {C,B} — the observed
output, derived from first principles. Reachable confidences {42,47,52,
57,63} match the live values {47,52,57,63} exactly; C- is truncated by
gradeSlateService keeping the higher-confidence side.
mlb-grade-degradation.md's "25/25 grade<->confidence agreement" is a
TAUTOLOGY, not a validation — confidence is derived from the letter, so
it would report 25/25 even if every grade were wrong.
Recommends feeding the starving factors (restores A/D on merit) and
explicitly REJECTS re-scaling thresholds, which would mint A's without
adding information — every "A" would be a relabelled B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Kev's call: investigate the B/C grade collapse before building. Report
only — no grade logic, thresholds, or engine code touched.
FINDING 1 — the collapse is real, live and structural. Across 604 ledger
rows and both sports the engine has emitted exactly TWO grades (B, C) and
NINE confidence values (63/57/55/52/47/45/35/25/20), ceiling 63. Still
true today on both sports. Confidence does NOT determine the letter:
conf 45 -> B while 47 and 52 -> C (non-monotonic), so the surfaced
confidence is not the quantity the letter came from. Edge scale still
broken: 311/604 rows exceed the frontend's sane cap of 40, 39 exceed 100,
worst 620.
FINDING 2 (bigger) — the entire probability layer is DEAD in production.
Live /api/snapshot/mlb: p_win, kelly, ev_pct, model_odds and value are
absent on 0/8 grades, while alt_lines (Desk-gated) IS present 8/8 —
proving nothing is tier-stripped, they are simply never computed.
Root cause: gameLogService.pythonPath returns null for MLB by
construction and the Python service is offline for NBA/WNBA, so
meta.gameLogs is [] for every sport; estimateProbability returns
p_over null; every field guarded by `if (pWin != null)` is skipped.
This is the S46 bug in a second location — that fix added an MLB branch
to featureCache.gameLogFeatures (which is why grades/projections still
work) but never to the estimator path.
Consequences: EV — the Model Train's whole ranking signal — has never
been computed on a live prop. Hero v2 matches nothing and always falls
through to the recent-read fallback (live /api/hero-prop returns
is_recent:true). Quarter-Kelly, sold on the pricing page and listed BUILT
in PROMISE-AUDIT.md, never runs. The value triplet is a duet live.
Recommend re-sequencing: revive the probability layer BEFORE G-a and
C-led (C-led would persist a column of nulls; G-a's EV_FLEX_THRESHOLD
would gate on a permanently-null value — Kev's EV_FLEX_ENFORCE=0 ruling
accidentally prevented an outage). featureCache:206-226 already has both
adapter branches and is the template.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
REPORT-FIRST per the arc order. G-a is HELD — the data changes the
recommended dials. No engine code touched.
Replayed against live ledger_entries (576 rows, 6 game days, 470 settled)
because the "30 days of stored snapshots" does not exist: snapshot Redis
keys are latest/previous only at 24h TTL, and no backtest harness exists
anywhere in the repo.
Findings that change the plan:
- The -400 floor shipped this morning was the whole win: past -400 hit
80.3% against an 86.9% breakeven = -13.29u / -7.7% ROI on 173 settled.
- Arc 2's incremental cut over the live gate is ~11 props in 6 days. The
only material change is gating the flex band behind 2x EV.
- The flex band (-161..-250) is our BEST band (+2.2% ROI, n=70) and the
takeable band is flat (-0.3%, n=209) — the opposite of the assumption
behind EDGE_FLEX_WALL. Recommend shipping the knob with enforcement
OFF until EV is persisted and measured.
- ev_pct/p_win are on NO ledger row, so the EV half of the gate cannot be
replayed at all. C-led (persist EV) is now the highest-leverage item.
- Confidence is monotonic but understates hit rate by ~20-25 points, and
the entire public ledger contains only B and C grades — zero A/A+.
That breaks hero v2 (isAB) and undermines "A-RATED" copy. Escalated.
- L-a answered: alt_lines carry NO odds and the feed has no alternate
markets. L-b is blocked on a data source, not engine work.
- C-led needs no odds backfill (locked_odds 99.1% populated).
- U-deg: the projection==0 leak is already closed (0 since 07-18).
- C4 confirmed in data (359/376 MLB closes == the lock). Stays suppressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Arc 1 (7a925f4) shipped without a spec and without a STATE.md entry — the
plan lived only in a session context that was lost. Both written from the
code on disk, not from memory.
- STATE.md: header was stale at a8e383e; now 7a925f4 (pushed, NOT yet
deploy-fingerprinted). New top section records de-vig + EV + takeable/
value gates + hero v2 + the value triplet, the real config values, and
the 274/3289 -> 276/3306 test baseline.
- specs/model-train.md (NEW, per CLAUDE.md rule #1): every knob and its
ACTUAL default (TAKEABLE_ODDS_CEILING -160, TAKEABLE_ODDS_MAX +200,
VALUE_EV_THRESHOLD 2, JUICE_ODDS_FLOOR -400, RARE_EVENT_LINE_MAX 0.5);
the gate AS BUILT (flat price-only refusal at -400 pre-feature, NOT
edge-aware, no -250 wall; the -160..+200 band never refuses a grade and
is enforced on the hero alone); the triplet + hero v2; and a checklist
of what is NOT built.
Recorded honestly rather than assumed: EDGE_FLEX_WALL, HARD_JUICE_WALL,
LADDER_ODDS_MAX and MIN_RUNG_PROBABILITY do not exist in the codebase; no
frontend reads ev_pct/fair_odds/model_odds/book_odds/suppressed_reason yet;
the value fields are ungated to free tier; a second EV implementation
(processing/EVCalculator.js) still coexists with devig.evPct.
No engine code touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
Steps 1-6 — make "real opportunities at takeable prices" the engine, not a filter.
1. DE-VIG (src/utils/devig.js): two-way multiplicative de-vig strips the vig and
returns fair prob + fair price per side + the overround. One side missing →
fair UNAVAILABLE (null), never faked. Method noted in code + the `devig_method`
field.
2. EV (devig.evPct): ev_pct = model prob × decimal − 1 at the graded side's
ACTUAL price. This is the ranking signal now, replacing raw |model−consensus|.
3. TAKEABLE gate (src/config/valueEngine.js, TAKEABLE_ODDS_CEILING −160 .. +200,
env-tunable): promoted surfaces only (hero/featured/alerts). The full board
still shows everything; Parlay Lab exempt; JUICE_ODDS_FLOOR (−400) stays the
absolute backstop underneath. Strict null-guard (Number(null)===0 would have
made a missing price "takeable").
4. VALUE flag: passes BOTH gates (takeable AND ev_pct ≥ VALUE_EV_THRESHOLD).
Grade = read quality; value = the price pays you. Shipped in payloads.
5. HERO v2 (heroPropService): highest ev_pct among takeable A/B reads — a huge
gap on a −900 line is trivia, not an opportunity.
6. VALUE TRIPLET: book_odds · fair_odds · model_odds on every read (snapshot,
hero, scan — they all spread the grade). Handoff documents the fields; the
rendering is Session-2 Design's job.
All wired in analyzeViaEngine1's existing p_win/kelly block (real quantile
probability × real book odds, or nothing). 33 new tests; suite 276/3306 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the rare-event under fix — the whitelist (doubles/triples/HR/SB)
was fragile: the same juiced-under problem exists for steals, blocks, and any
other low-frequency market, and a new stat would slip through.
The real signal is the book's own price. The doubles unders were priced -625 to
-1100 — laying 6-11x to win 1x on an ~82% event, with no value the model could
recover. So the PRIMARY guard is now stat/sport-agnostic: analyzeViaEngine1
refuses any read whose graded-side odds are past the juice floor
(JUICE_ODDS_FLOOR, default -400, env-tunable). That catches every version of
this — steals, blocks, anything — and it also keeps the public record honest
(those -800 "wins" hit ~82% of the time and would inflate the hit rate, the same
class as the projection-0 degradation).
The structural rare-event rules stay as the BACKUP for props with no odds
(list also expanded cross-sport: + steals, blocks). Normal + longshot prices
(-110, -250, +600) are preserved. 16 tests cover both layers.
Reported: the doubles projection was REAL per-player (not a fallback); the fix
is the price guard, not a bigger list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Betting-logic audit: the CONSENSUS-vs-MODEL board flooded with fake reads like
"DOUBLES u0.5 · MODEL 0.2 · +edge" — the juiced under side of rare counting-stat
markets (doubles/triples/HR/SB), which is never a takeable edge and violates the
no-unders-default doctrine.
Report finding (item 3/4): the doubles projection is REAL per-player, not a flat
fallback — 'doubles' maps to a real game-log field (MLB_LOG_FIELD doubles→
doubles) and the live values varied (0.03/0.16/0.2/0.22). So no projection-gate
refusal for fakeness; the problem is purely structural (a rare event's real
projection always sits below a 0.5 line, so the under always "wins").
Fix (config-driven — src/config/rareEventMarkets.js, tunable stat list + line
threshold):
- Grade layer (analyzeViaEngine1): a rare-event UNDER at ≤0.5 is always REFUSED
(grade null + suppressed flag/reason). A rare-event OVER at ≤0.5 is refused
UNLESS the model genuinely projects the event above the line — because a
0.2-over-0.5 carries the SAME |edge| as the suppressed under and would just
take its rank on the board. The over grades normally once projection > line.
- Board layer (marketBreadth.collectBreadth): drops null-model rows so a
suppressed/ungraded prop can't rank a "MODEL —" placeholder onto the board.
10 suppression tests + config locks; also fixed a settingsPage book assertion
left over from the ESPN→theScore swap. Suite 274/3289 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SUPABASE_DB_URL is set in Coolify on the API service, and this WSL2 box can't
reach db.<ref>.supabase.co — so the backup runs INSIDE the API container, which
has the env + Supabase network. Made that real:
- Dockerfile: install postgresql-client (pg_dump/pg_restore) + rsync + bash in
the runner image.
- backup-db.sh: added an integrity fingerprint on every run — pg_restore --list
must parse the archive AND find ledger_entries, else the run FAILS + pages
(stronger than the size check; catches a corrupt/structureless dump).
- BACKUP-RUNBOOK.md: rewritten for the container-exec reality — host cron does
`docker exec <api> sh /app/scripts/backup-db.sh` (inherits env + network +
pg_dump), or a Coolify Scheduled Task. Full restore-fingerprint steps included.
MECHANISM FINGERPRINT (run locally, docker + pg16): seeded a ledger_entries
table (137 rows) → ran backup-db.sh (dump + validate: 22 archive objects,
ledger_entries present) → pg_restore into a scratch DB → 137 rows restored,
exact match. The dump/validate/restore path is proven end-to-end; it's the same
pg_dump/pg_restore that run in the container against Supabase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1+2. Checkout price was CODE-gated (founder price only with a valid founder
code) — so "Claim a Founder Desk" would have charged the $44.99 standard
price, not the advertised $34.99. Now it's SEAT-gated: resolveCheckoutPrice()
attaches the founder price while founder seats remain (< FOUNDER_SEATS_TOTAL,
read from the SAME countFounderSeats() truth as the ClaimMeter), and flips to
standard at seat 100. createCheckoutSession uses it; the founderCode param is
kept for back-compat but no longer drives price. The meter flips to "SOLD
OUT" at capacity. When the count can't be verified we honor the advertised
founder price (never overcharge).
- Also hardened countFounderSeats to manual pagination (the for-await form
broke on non-async-iterable list mocks).
3. Tests: resolveCheckoutPrice at seat 0 → founder, seat 100 → standard, the
99/100 boundary, null-count → advertised founder price.
4. Grace: invoice.payment_failed now sets a 14-DAY grace (spans Stripe's Smart
Retry window) instead of 48h — a transient decline no longer revokes access
mid-retry. Access is revoked only when Stripe actually cancels
(customer.subscription.deleted keeps its 48h grace). Test updated.
Stripe + founders suites green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/backup-db.sh: nightly full-DB pg_dump via the direct connection string,
14-day local rotation, weekly off-box rsync copy, ntfy alert on any failure +
an undersized-dump guard (an empty dump is a silent failure). docs/BACKUP-
RUNBOOK.md: the ONE env var Kev must set (SUPABASE_DB_URL — the direct
db.<ref>.supabase.co:5432 URI, not the pooler), the cron line, the off-box
target (Hetzner Storage Box via rsync, simplest for a Hetzner box), and the
restore FINGERPRINT procedure (pg_restore into a scratch DB + count
ledger_entries — proves it's a real, restorable backup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
023_security_hardening.sql:
- Item 1 (CRITICAL, advisor lint 0010): founder_pricing_seats view → recreate
with security_invoker=on so it respects RLS instead of running as definer.
(The founder counter no longer depends on it — item 0 uses Stripe directly.)
- Item 3: waitlist write hole — drop the always-true policies, anon may INSERT
only, update/delete/read via service role.
- Item 5: pin an explicit search_path on the flagged functions (lint 0011).
024_anon_revoke_discoverability.sql:
- Item 4: revoke anon SELECT on the advisor-named tables (accuracy_tracking,
bets, cascade_alerts, closing_lines, coach_profiles, daily_scan) + a
commented broad sweep. The frontend reads data via Express (service role),
never as anon, so this is safe. REVOKE/KEEP rationale documented in the file.
These need Kev to apply (no DB access from here); fingerprint = re-run the
Security Advisor and confirm the lints clear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The billing portal is fully configured in Stripe (cancellations, plan
switching, invoice history) and the Express endpoint (POST /api/stripe/portal)
existed, but nothing in the UI linked to it. Added the Next proxy
(app/api/stripe/portal) and a "Manage billing →" button in the profile billing
section (paid tiers) that mints a portal session and redirects. Kev still
activates the hosted portal in the Stripe dashboard; this is the app-side link.
Dunning verification (item 6): cancel-on-exhaustion is correctly wired — Smart
Retries exhausting cancels the subscription → customer.subscription.deleted →
webhook sets a 48h grace → middleware/gracePeriod.checkGracePeriod downgrades
tier to free in both users + user_profiles after the grace expires. See the
report for one nuance (the 48h grace on the FIRST payment_failed is shorter than
Stripe's 2-week retry window — self-correcting via subscription.updated, but
worth a product decision).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ESPN BET is defunct — PENN/ESPN terminated the deal; PENN rebranded it to
theScore Bet (Dec 1 2025) and ESPN is now exclusive with DraftKings. Removed
the ESPN BET entries from the BookChip map (web/src/lib/books.js) and added
theScore Bet (mono TS, slug thescore) as the successor. Added 'thescore' to the
backend oddsNormalizer ALLOWED_BOOKS so the feed's lines are accepted; synced
the bookWordmark test list. The ESPN references in src/config/sports.js are
ESPN's STATS API (data provider, unrelated to the sportsbook) — left untouched.
Flagged in specs/design-reference/HANDOFF.md that the design mockups' BookChip
row still shows ESPN BET and needs the same one-swap on the next refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The counter showed 1/100 from user_profiles (founder_pricing=true AND
subscription_status='active'), but the live Stripe account has ZERO
subscriptions of any status — the "1" is a comped/manually-tiered profile, not a
paying founder. A tier/founder_pricing field on a profile can be set without
ever paying, so it is not proof of a paid seat.
Now the count is Stripe's OWN truth: stripeService.countFounderSeats() counts
ACTIVE subscriptions on a founder price. The route reads that (cached 5 min);
null or any failure → hidden, never a number. A comped profile no longer counts
→ the honest number is 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The blog showed "Posts coming soon" live: the app reads process.cwd()/content
= web/content at runtime (that's where the old orphan lived and rendered), but
the 5 articles were committed to REPO-ROOT content/articles — which the
deployed app never reads. Moved them to web/content/articles (verified
getAllPosts finds all 5 from cwd=web) and deleted the orphan file
web/content/blog/line-movement-guide.mdx (the route already 301s). Test paths
updated to web/content/articles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pricing Desk showcase hardcoded an alt-line ladder (1.5 A +7.1% / 2.5 A+
+11.4% / 4.5 C -3.8%), QUARTER-KELLY 2.4%, and PARLAY φ 0.34 — a mocked demo
selling something we weren't proving.
- deskShowcaseService reads the pre-graded snapshot for a real A/B prop's
alt-line ladder (prefers the one with the most grade variation — the most
compelling real example). Edge per rung shows only when it's a plausible
market value; the inflated (model-line)/line artifact on small lines is
guarded to "—" rather than shown as a fake +91%.
- PARLAY φ is now REAL: the model's same-team correlation (0.34, mirroring the
frontend parlayMath team constant) computed for TWO REAL same-team legs,
named. No real same-team pair on the board → the tile hides, never an
invented number.
- QUARTER-KELLY tile is REMOVED: the snapshot has no odds, so a real
quarter-Kelly % can't be computed here — a fabricated 2.4% is worse than
nothing. Kelly stays a real in-app Desk feature; the showcase just doesn't
fake it.
- DeskShowcase is now a client component fetching /api/desk-showcase; when the
board has no real ladder the whole visuals column hides (real-or-hidden, same
law as the hero). The pitch copy is unchanged.
5 service tests. Change-affected suites green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The landing hero was a static Jokic "Example" card with a name-length pick and a
hardcoded A- 73% +6.2% fallback. Now it's deterministic and live:
- heroPropService.pickHeroProp reads the pre-graded snapshot and selects the
prop with the LARGEST |projection - line| gap among A/B grades (conviction,
not noise) — the read where VYNDR disagrees most with the market, the card
that makes a stranger argue. No curation, no grading (reads cache → no API
credits). GET /api/hero-prop (backend) + repointed Next proxy.
- The card shows the disagreement EXPLICITLY: the book's line vs VYNDR's model,
side by side (model in green), with the real grade timestamp ("Graded 2:14
PM"). The EXAMPLE chip is gone.
- Empty slate → the MOST RECENT real graded read (flagged "LATEST READ", real
date). Nothing cached → { available:false } and the card HIDES. No
hand-written fallback — the Jokic card is deleted. Survives a dead night: a
live rule shows tonight's real MLB read, never a phantom July NBA card.
7 service tests lock the rule (max-gap, A/B gate, projection/line required,
empty→recent, hidden, cross-sport). colorContract updated to the new
disagreement display. Change-affected suites green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kev's call: the 30D accuracy surfaces must read TRUTH, not a cache that can't be
filtered. My earlier degraded-row exclusion only touched getModelAggregate
(Postgres); the public buckets/badge still read outcomeService (Redis outcome
log), which counts degraded projection-0 outcomes and has no field to filter on.
- /api/accuracy (AccuracyBadge) + /api/ledger/accuracy (buckets/ModelRecord)
now source from the clean Postgres ledger aggregate via new
ledgerService.getAccuracyView + accuracyBucketsFromAgg (model_value > 0
excludes degraded rows). Same response shapes → no frontend change. Redis
outcome log is now read by nothing public; it can age out or be rebuilt.
- BEAT CLOSE is a MEASURED-WRONG ZERO: captureClosing re-records the locked line
as the "closing" line, so clv is flat on the whole sample and beat_close reads
0% (comparing a number to itself). Full write-up: specs/audit-data/
clv-capture-broken.md (the fix belongs to C4). Until then, beat_close_pct +
clv_distribution are SUPPRESSED at the source (getModelAggregate, gated by
clvCaptureReliable() / CLV_CAPTURE_RELIABLE=1). Every public surface already
renders BEAT CLOSE only when non-null, so they all hide it now — no wrong zero
anywhere. HIT RATE (real) is unaffected.
Suite 271/3261 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test locked the old draft/unwired state; item 8 intentionally published
the 5 articles to /blog with real dates. Updated the assertion to the new
published shape (title + real date + status: published). This was a
tests-before-commit miss on the item-8 push (3b7a1f5) — fixed forward.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live /blog showed "How to Read Line Movement Like a Sharp" dated 2026-03-22
— an orphaned, uncommitted file that predates the product. Meanwhile 5
genuinely-real articles sat in content/articles/, unwired.
- blog.ts now reads content/articles (not the untracked content/blog). Maps
`excerpt` → description, skips `status: draft`, reads explicit `slug`.
- Added honest dates (2026-07-17, the real publish day) + flipped the 5
articles to `status: published`. Real content: how-vyndr-grades-a-prop,
why-our-misses-are-public, what-clv-is, how-streaks-lie, the-vyndr-originals.
- Retired the orphan: /blog/line-movement-guide 301s to /blog (next.config).
- Added a minimal, dependency-free, XSS-safe markdown renderer so headers/bold
render as HTML instead of literal "##". Each article keeps title/date/
read-time + the existing OG/JSON-LD metadata (affiliate-review ready). Richer
media (images/charts) is the separate design train.
Web build exit 0 (SSGs all 5 slugs); backend suite unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ClaimMeter rendered a fabricated "47 / 100 CLAIMED" (a hardcoded default; the
comment even said "Cosmetic conversion driver… Static here"). Now:
- GET /api/founders/count counts ONLY real paying founders — user_profiles
where founder_pricing = true AND subscription_status = 'active' (the
Stripe-webhook-synced mirror, so we never hammer the Stripe API). Cached 5
min in Redis on top of that.
- If the source is unavailable (Supabase unconfigured, query error, column not
migrated, client throws) the endpoint returns { available: false } and the
ClaimMeter renders NOTHING — counter and progress bar both hidden. We never
fall back to a number.
- A low real count is shown honestly (0 → "0 / 100"); the truth is the feature.
Next proxy at app/api/founders/count. 6 route tests cover real count, low
count, error/unconfigured/throw → hidden, and cache-hit. Suite 271/3260 green,
web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The product argued with itself: FAB/nav said "Scan", Free tier "5 scans",
ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every
user-visible surface to READ:
- BottomTabBar FAB + Nav link: 'Scan' → 'Read'
- Pricing free tier: '5 scans to try the model' → '5 reads …'
- StatStrip: 'Awaiting next scan' → 'Awaiting next read'
- Ticker badge + snapshotService event: tag 'SCAN' → 'READ',
'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so
cached ticker items dedupe cleanly through the rollover)
- upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads
Internal untouched (not user-visible): /api/scan routes, scan_count column,
scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN
color-map key.
tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible
scan/scanned/scans copy across web/src + src/services (skips comments). Suite
270/3254 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Work-order #4 closed. First post-deploy snapshot (2026-07-17 14:00:53 UTC)
re-graded with the fix. Before → after:
- projection==0: 9/25 → 0/25 (the nine now refuse)
- grade<->confidence: mismatch → 25/25 agree
- edge_pct: {20,60,100,140} cluster → 7 continuous values, all-positive projections
The lone remaining edge=100 is a REAL projection (Abreu hits, line 0.5, proj 1.0
over = 100% by (model-line)/line), not the old proj=0 degeneracy. Verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/validate-grade-fix.js checks the live MLB snapshot for the three
degradation signatures (projection=0, edge=100 cluster, grade/conf disagreement)
— run after the next 14:00 UTC regrade to fingerprint the fix. The finding doc
now records root causes, fixes (commits 888d103/9fc4edf), the blast-radius SQL
(box can't reach Supabase directly), and the shared-path note for NBA/WNBA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The degraded grades (projection=0 → model_value=0) are already settled in the
append-only ledger and must NOT be deleted (Data Semantics law). But their
hit/miss is noise, not model skill — they never had a real projection. So
getModelAggregate now filters `.gt('model_value', 0)` on both the settled and
pending queries: the rows stay in ledger_entries, but leave the public hit_pct /
CLV / per-tier record. `.gt` also drops NULL model_value. Post-fix no such row
can be written (projection<=0 refuses), so this only sheds the historical set.
This is the functional form of the "marking" the work order asked for — the
degraded locks are effectively marked as non-counting without mutating history.
Test builder mocks gained `.gt`; a lock asserts the filter is applied to both
queries. Suite 269/3253 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #1 board item — three grading bugs the phone audit surfaced, all in the
live Node grade path (engine1 + analyzeViaEngine1), fixed at the source.
1. PROJECTION=0 NOW REFUSES. projectionFor returned l5_avg even when it was 0
(finite, so the `== null` gate passed it) — 9/25 live grades graded on a
zero projection, producing a degenerate edge and a hollow grade. Now a
non-positive reference is not a projection: projectionFor skips it and falls
through to the next POSITIVE reference (l5 -> l20 -> per_90 -> xg); when none
is positive it returns null and the read REFUSES (insufficient_data). The
gate also gained an explicit `> 0` guard so the invariant is structural — a
grade can never be emitted with a non-positive projection. Fewer graded
props, honest.
2. EDGE_PCT. The formula was already (model - line) / line signed by direction
— Kev's intended semantics. The broken {20,60,100,140} cluster was the
proj=0 degeneracy ((line - 0)/line = 100%); with #1 those refuse, so the
fabricated 100s vanish and real edges flow. The main-line edge now reuses
the VALIDATED projection (edgePctFor accepts an optional ref) so edge and
the persisted projection can never diverge. Frontend |edge|>40 guard stays
as a safety net.
3. LETTER == THRESHOLD_TABLE(CONFIDENCE). engine1's hand-rolled
GRADE_TO_CONFIDENCE drifted a full sub-tier low (B -> 0.55, which the
canonical grade_thresholds.json calls B-) — the "B at 45%" the audit caught.
Now confidence is DERIVED from each grade's band MIDPOINT in
grade_thresholds.json (one source of truth, shared with the Python engine),
so applying the threshold table to any grade's displayed confidence resolves
back to the same letter. Proven for all 11 grades.
Regression locks: tests/unit/mlbGradeDegradation.test.js (14 tests). Backend
suite 269/3253 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: read cards were huge, only 1-2 fit per screen. Compressed the
vertical spacing — article padding 16->12, header margin 8->5, name 15->14px,
ladder-rungs margin 10->8, book/date line 12->8.
Kept the archetype showDesc: it renders INLINE (same row as the badge), so it
adds zero vertical height — dropping it wouldn't help density and would break
the ds5 design lock ("the badge shows its one-line meaning where it leads").
Locked the density in vyndrParityQA (P2-10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DISPLAY FIX (shipped): the league leaderboard rendered raw snake_case
("stolen_bases U0.5", "earned_runs U2.5"). New canonical short-label lib
web/src/lib/statAbbrev.js (one source, CommonJS + unit-tested) maps stat_type
to SB/ER/TB/HR/K/PTS/… and ExploreHub routes through it. Unknown ids upper-case
their words so raw snake_case can never leak again.
FLAG (reported, NOT silently changed — per the audit's instruction): the "B at
45% confidence" is a BACKEND grading issue, diagnosed against live snapshot:
- 25/25 grades mismatch their own confidence vs grade_thresholds.json (B shown
at conf 55 = the B- band; a systematic one-sub-tier gap on every prop). The
surfaced `confidence` is not the probability that derived the letter (likely
the data-sufficiency penalty applied to display-only).
- 9/25 have projection=0 — the MLB feature path feeds 0 instead of refusing
(S58 insufficient_data), which also produces the P1-7 broken edge_pct.
Full write-up + do-not list: specs/audit-data/mlb-grade-degradation.md. NOT
re-lettering or shifting thresholds on the frontend — that would hide the bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: the Compare verdict read "the edge tonight tilts his way" for
Jokić vs Wembanyama — an NBA claim in July, when NBA has 0 games. The page is
sample/form data with no game resolution, so "tonight" can never be verified.
Reframed to "on current form" (the rows ARE L10 form) — always honest, in or
out of season.
Also fixed the cited dimensions: the verdict claimed "usage", but in the sample
Jokić's Usage% (29.1) is LOWER than Wemby's (31.0) — he wins scoring, boards,
and playmaking, not usage. Copy now matches the data.
Locks both P1-7 and P1-8 in vyndrParityQA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit called the board 'mostly-empty'. Two causes, both now addressed:
1. Dead images (P0-2, already fixed) → the matchup/team chips render logos now.
2. Degraded edge data. Live snapshot edge_pct is on a broken scale (distinct
values 20/60/100/140 — not a market %), with projection=0 and confidence
35-55%. A real prop-market edge is single-digit, never past ~40%. Leading
the board with '+140%' fabricates a signal (Data Semantics Rule).
Fix: an edge whose |value| > 40 is treated as ABSENT at BOTH layers — the
data layer (flattenToEdgeBoard nulls it, so it can't RANK a fake +140% above a
real +8.4%) and the display (EdgeCell shows '—'). Board falls through to the
grade-rank tiebreak when edges are unreliable. Real edges (≤40) are untouched.
The root cause — edge_pct/projection/confidence degradation — is a BACKEND
grading issue (same family as the P2-9 '45% B' flag), reported separately; this
is the honest frontend guard, not a fix for the data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: the same screen showed green 'SIGNAL LIVE · UPDATED 0s ago' AND
amber 'SYNC 46:03' — two components reading different fields. The Slate's
'UPDATED Xs ago' measured the CLIENT poll time (always ~0s, since it refetches
every 60s), while the app-bar clock honestly measured the pipeline's data age
(refreshed_at). '0s ago' claimed a freshness the data didn't have.
Now the Slate captures snap.refreshed_at from the snapshot it already fetches
(newest across sports) and 'UPDATED' shows THAT — the same source of truth as
the clock. The clock still owns the amber/STALE reaction; the strip just states
the honest data age. No more contradiction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: CONSENSUS VS MODEL bled off the right edge ('2 BO…', 'MO…') and
STARTING-pitcher lines truncated ('2.64 E…'). The M4 lock only hid the DOCUMENT
scroll (html/body overflow-x) — content still clipped inside cards. Now contained:
- .breadth-row stacks (flex-direction:column) at <430px, each field on its own
line with overflow-wrap:anywhere — no bleed.
- the game-card starting-pitcher inner spans wrap + shrink (flexWrap + minWidth:0)
so name/ERA/archetype flow onto a second line instead of clipping.
- STRENGTHENED the lock: vyndrParityQA now asserts the CONTAINMENT patterns
(breadth-row stacks, pitcher spans wrap), not just document overflow.
MLB stat pills: the game-lines grid already scrolls-within-card (<640 M1); if
the audit still shows pill clipping elsewhere, it's a follow-up targeted pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: at 390px we still rendered the full desktop 3-row header (nav +
TOP MOVES ticker + SYNC line) eating ~20% of the viewport, and its height
clipped page titles under it (MY READS tabs, HEAD TO HEAD). Implemented Design's
mobile app bar <768px:
- New MobileSyncClock (extracted from HeartbeatBar) lives in the Nav's right
cluster — wall clock rests, amber/STALE reacts off the shared freshness tier.
- <768px: the ticker row (.nav-ticker) AND the whole heartbeat bar are hidden;
only the nav row shows (logo + clock + search). main padding-top → 62px and
the Slate sticky tabs → top:60px, so nothing clips under the bar.
- Locked in vyndrParityQA (P0-4): ticker+heartbeat hidden, nav clock shown,
paddings collapsed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes P0-3 across all three surfaces:
- CONSENSUS VS MODEL (MarketBreadth): dedupe by player+market so Ben Williamson's
alt-line variants show as ONE consensus row (no per-market cap — a consensus
table just shouldn't repeat a player).
- LEDGER cards: group by player+market via groupIntoLadders — Alec Bohm's
strikeout ladder (U1.6/U1.3/O1.5) is now ONE card with the rungs nested (each
its own side/line + tier-colored grade), not three separate cards.
- playerGrouping reads player OR player_name (ledger rows use player_name) —
regression-tested so the ledger doesn't silently empty.
The Alt Line Ladder shape is what /pricing already demos; the record now uses it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: leaderboard flooded with 9 consecutive identical 'stolen_bases U0.5
45% B' rows. New shared lib/playerGrouping (dedupeLeaders + groupIntoLadders,
name-key aware, 7 unit tests): ONE row per (player, market family) keeping the
best-ranked, then a per-market cap (4) so no single prop type floods the board.
Applied to ExploreHub. Ledger cards + Consensus grouping follow in P0-3b/c using
the same lib.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: 78 ESPN <img> at naturalWidth:0, ZERO espncdn/mlbstatic requests
ever fire — the browser never attempts the fetch. CSP was NOT the cause (img-src
already allows a.espncdn.com + img.mlbstatic.com, confirmed in the live header).
Root cause = native loading="lazy" on the tiny entity <img>s never triggering on
device (the audit's suspect b). TeamLogo + PlayerAvatar now load="eager"
(+ decoding="async") — they're 10-40px core-visual entities, so eager is correct
and cheap, and it forces the fetch on parse regardless of size/viewport.
Browser-request confirmation is the audit's Chrome domain (I can't observe
network from server curls); eager semantics guarantee the fetch once the src
(which 200s server-side) is in the DOM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit found LEDGER READ CARDS rendering B badges with BLUE borders + C
with AMBER right now in prod — GradePill (components/GradeCard.tsx) hardcoded the
OLD palette (rgba(74,158,255) blue-B, rgba(255,179,71) amber-C) for bg/border
while the text used the migrated token. Migrated bg/border to color-mix on the
grade token, so B renders neutral-white and C grey (matching the board).
- globals.css .grade-*-bg → token-derived color-mix (was raw blue/amber rgba).
- DELETED glow from .grade-glow-b/c/d (glow is A-tier ONLY, by law) — B/C/D keep
their token color, no text-shadow.
- Purged the last dead grade-blue #4A9EFF fallbacks (SoccerGradeResult, the
intelligence INFO dot).
- REGRESSION LOCK: vyndrParityQA fails if #4a9eff / rgba(74,158,255) reappears
anywhere in web/src, if GradePill hardcodes blue/amber rgba, or if grade-glow
B/C/D grow a text-shadow again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design colors every LIVE indicator green (active = green; the live-dots and
LIVE·Q3 labels are all green). The slate header's 'N LIVE' count was red
(--live #ff3b3b) — contradicts the one-meaning-per-color contract (red = miss/
error, which live isn't). Aligned to --g-a.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PitcherArsenal's meta line showed team + 'vs OPP' as plain text; Rev 3 anchors
the pitcher's team and opponent with TeamChips (real logo / monogram). Combat
FightCard intentionally keeps fighter monograms (no photos — likeness rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Structural mobile rules become failing tests (M4 'test-lock what's lockable'):
the flat EDGE BOARD shows <768px and game cards are desktop-only; the board
renders TeamChips + tier GradeBadge + sign-colored hero edge% + the ranked
opacity ramp; document never scrolls sideways at 390px. Source assertions —
they lock the RULES, not the pixels (that's the master audit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The one genuinely-new mobile screen. Design's mobile BOARD is a FLAT edge-ranked
list (all graded props across every game on one list, sorted by edge) — not the
desktop's game-grouped cards. Implemented to the drawing with REAL snapshot data:
- slateAdapter.flattenToEdgeBoard(cards) — pure transform of the assembled
GameCardData[] (grade→game join already done) into ranked rows, edge desc.
STRICT null edge sorts LAST (never 0-coerced to the top — Data Semantics Rule).
Threaded edge_pct through buildPlayerStripsFromProps (was dropped). 6 unit tests.
- MobileEdgeBoard component — Design's exact screen-01 rows: rank (green #1),
player + prop, matchup sub-line with TeamChips + live-dot, tier grade chip,
and the edge% as the one bold mono hero (green +, red −). Ranked opacity ramp
(1 → .55) + green inset border on the top reads. Breadth strip EDGES/AVG CLV/
GAMES — CLV honest '—' (per-slate CLV isn't computed; never fabricated).
- Slate: <768px renders the flat board, ≥768px keeps game cards (same data,
toggled by width). Ungraded slate still shows game cards on phones (no blank).
Built to Design's screen-01 drawing, VISUALLY UNVERIFIED at 390px — the core
mobile screen, top of the master-audit list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GradeResultCard already carried Design's §7 grade-reveal structure (hero +
identity + 3-col grid + intel box) — the screen was built to the same contract
Design's mobile follows. Applied the Rev 3 gaps: team context now a TeamChip
(real logo/monogram), and the mobile grade hero → 74px (Design's exact mobile
size; M1a had 80). Desktop hero unchanged (116px).
Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design Rev 3 anchors every matchup/context abbr with a 10-12px team tile. New
reusable TeamChip renders the real TeamLogo (licensed ESPN logo where it
resolves, team-colored monogram otherwise — the resolver is already built) at
that size + the abbr, sitting inside the row so it inherits the ranked opacity
ramp. First placement: StatStrip's player/team context (name → team-chip →
archetype). ROW-GRAMMAR identity-run test updated to the chip marker.
Remaining Rev 3 placements to thread TeamChip into (reusable, mechanical):
parlay legs, grade-shift header, pitcher "vs", /u recent-settled, other
matchup context lines. Game-card headers already carry TeamLogo (TeamLink).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>