b818626870cf3abbfb99c48433a15626aa110ce5
465 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6415751f2e |
Grading binds opponent features to the REAL game, not ESPN's "today"
Order 1.6 Phase 1. This is a MODEL-OUTPUT fix, not bookkeeping. computeFeatures.lookupTodayGame called the ESPN scoreboard with NO date param and took whatever ESPN calls "today". Renamed to lookupGameOnDate and now sends ?dates=YYYYMMDD from the prop's BOUND game — the same game the ledger, retention and settlement use, so all four finally agree. PROVEN against live ESPN (before/after, 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) Every opponent was wrong. opponentAbbr feeds opp_rank_stat (a +/-1.0 factor) and isHome feeds home_away (+0.5), so late-slot grades were scored against the wrong matchup. Note the window is WIDER than the 01:00/03:00 UTC slots: this ran at 07:5x UTC = 03:5x ET and ESPN's dateless scoreboard was STILL returning the previous day's card. HONEST DEGRADATION: with no bound game date the grader does NOT fall back to a dateless lookup — it records 'no_bound_game_date' and leaves opponentAbbr/isHome/gameId null, so engine1 simply omits the opponent and home/away factors rather than scoring a wrong matchup. Tests assert both directions. Same class of bug fixed alongside: the Tank01 augmentation used TODAY's UTC date for its cache key; it now uses the bound game date. gradeSlateService threads game_date/game_time/home_team/away_team into the grader so the binding reaches computeFeatures at all. Audited the rest of the feature path for dateless/"today" lookups — none remain (weather is current-conditions by venue, park/pace are static). Suite 282/3383 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
8e17de4010 | STATE: game_date root fixed, 64 voids reverted, grading blast radius flagged | ||
|
|
1bcdd8b305 |
Fix the ROOT: bind props to their real game, never date by the grade clock
Order 1.5 Phase 1. PropLine emits NO commence_time (grep-verified: zero hits in proplineAdapter), so ledgerService's `dateET(prop.game_time) || dateET(gradedTs)` always fell through to the GRADE timestamp — and a 01:00/03:00 UTC snapshot is 21:00/23:00 ET the PREVIOUS day. Tonight's props were filed under yesterday, settlement correctly found no game there, and Order 1's void logic turned that into 64 destroyed results. gameBinder.attachGameTimes() now matches every prop to a scheduled game by TEAMS across the plausible ET window (grade date, +1, -1) and attaches the GAME'S OWN time/date/id. It runs in snapshotService before grading and before the ledger write, so ledger, retention and settlement all inherit the correct date from one place. HARD CONTRACT: an unbindable prop returns NOTHING. ledgerService no longer has a grade-clock fallback — a row with no real game time is SKIPPED and counted, because a mis-dated row is fabricated data and the ledger holds real values or nothing. A slate that binds nothing pages. DOUBLEHEADERS are reported, never guessed: two games with the same teams on one date mark the binding `ambiguous` so settlement can decline rather than attribute a prop to the wrong game. (Real example already in the data: mlb:2026-07-11:MilwaukeeBrewers@PittsburghPirates(Game1).) Also fixes retention, which had the SAME bug from last night — I had dated model_snapshots rows with the snapshot clock. Rows now take the ET date of the bound game_time. Suite 282/3381 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
270c4db47a |
STOP voiding on player-absence — it destroyed real results
Correctness fix to code I shipped minutes ago. The induced live settle pass voided 64 rows as 'player_dnp' and a large share of them are WRONG: the Jul 18 set is everyday starters (Freeman, Bellinger, Tucker, Chisholm, Conforto). They played. ROOT CAUSE — and my Phase 0 diagnosis was wrong. It is not DNP. The ledger row's game_date is WRONG. ledgerService derives game_date from the GRADE timestamp when the feed carries no game_time, and a 01:00/03:00 UTC snapshot is 21:00/23:00 ET the PREVIOUS day, so rows get labelled with the previous ET date. Verified against fresh season logs (cache disabled, so not staleness; found:true, so not name resolution): Freddie Freeman played Jul 17 and Jul 19 (x2, doubleheader) — NOT Jul 18 Steven Kwan played Jul 18 (x2) and Jul 19 — NOT Jul 17 Settlement was correct to find no game on the labelled date. My void logic then converted a data-labelling bug into destroyed results. FIX: never void on player-absence alone. Voiding now requires POSITIVE evidence — the games themselves postponed/cancelled. Absence returns 'unknown' (reason player_absent_unconfirmed), so the row retries and ages out to 'unrecoverable' at the cap. We cannot distinguish "did not play" from "mislabelled date", so we must not claim DNP. Both terminal states are excluded from the record denominator either way. Window-decay remains genuinely fixed (full season log vs a rolling window), and terminal states still prevent immortal rows. NOT DONE HERE: the 64 wrong voids are still in the table, and the game_date derivation is still wrong at the source. Both are reported for the table — no healing in this order. Suite green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
d4a6170ffa |
Settlement fix forward: date-targeted resolution + terminal states
Order 1 of 2. Push scoring UNTOUCHED — it is correct. No healing here.
PHASE 1 — DATE-TARGETED FETCH replaces the rolling window for settlement.
settleSource.resolveOutcome() resolves the SPECIFIC DATE and, when the
player is absent, reads GAME STATE to learn what the absence MEANS:
game final + player has a line -> SETTLE (a partial game is a real
result, never a void)
game final + player absent -> VOID (confirmed DNP)
postponed / cancelled -> VOID
scheduled / in progress / SUSPENDED -> PENDING (a suspended game resumes;
voiding it would destroy a real bet)
player played, stat missing -> unknown, NEVER void a real appearance
This is FREE for MLB: mlbStatsAdapter.getPlayerGameLog already returned the
full season log and getPlayerStats was discarding it with .slice(-10).
Settlement now reads fullLog — same request, same cache — which removes
window-decay entirely (the verified failure was a Jul 12 game outside a
last10 starting Jul 6). Projections keep using last10, unchanged.
PHASE 2 — TERMINAL STATES (migration 026 applied). outcome CHECK widened to
hit/miss/push/void/unrecoverable; added settle_attempts, settlement_source,
settlement_version, model_version. A row that cannot be resolved after
SETTLE_ATTEMPT_CAP (4) date-targeted attempts becomes 'unrecoverable'
rather than pending forever. CRITICAL: getModelAggregate now EXCLUDES void
and unrecoverable from the settled selection — it used
.not('outcome','is',null), so without this a void would have counted as a
settled row and silently moved the public record. Verified in the record
calc, not just the settle path.
PHASE 3 — SETTLEMENT-RATE ALARM. zeroSettleAlarm only caught a TOTAL zero
while ~30% of a slate failed quietly (Jul 17: 57/86). opsWatch
.settlementRateAlarm pages when resolved/attempted falls below
SETTLE_RATE_FLOOR (0.8). Voids count as RESOLVED — a void is a legitimate
terminal state — so healthy voiding never pages. Third silent-failure
surface of the night, now closed.
PHASE 4 — VERSION STAMPING. src/config/modelEras.js defines the cutoff
ONCE (2026-07-19T22:50:00Z); migration 026 backfilled pre-cutoff rows as
'pre-retention-unknown' (naming the uncertainty, not implying knowledge);
new rows carry model_version.
Regression caught pre-deploy: getScheduleFn was not injectable, so the
ledger suite hit the real network and HUNG. Now injectable via opts and a
no-op under NODE_ENV=test. The "no row -> pending" test was updated to the
new behaviour deliberately: a missing row on a FINAL game now voids.
Suite 281/3373 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
e46c88e364 | STATE: retention clock proven ticking via induced cron entrypoint | ||
|
|
5a5e37e32e |
Retention: fill enrichment fields + page on a zero-write slot
PHASE 1 — cron capture needed NO wiring. Verified in code: the scheduler tick calls runAll = snapshotService.runAllSnapshots, which loops runSnapshot per sport, which already carries the onGraded -> retention hook. The scheduled path and the manual path are the SAME function. The reason no cron cycle had been captured is simply that no slot has fired since retention deployed (slots are 14/19/22/1/3 UTC; retention landed ~02:55). Induced proof follows the deploy. PHASE 2 — archetype/team/opponent were permanently null because retention persisted at GRADE time, before enrichment attaches them. Retention still COLLECTS at grade time (the only moment the feature vector exists) but now PERSISTS after enrichment, merging those three fields via retentionService.mergeEnrichment. The merge is pure and fills ONLY those three fields — features and every model output are grade-time values and must never be rewritten by enrichment; a test asserts that. Unmatched rows (refusals not in the enriched slate) keep nulls rather than guesses. The empty-slate early return now persists too: a refusal-only slate is still history worth keeping. PHASE 3 — ZERO-WRITE ALARM. opsWatch.retentionZeroWriteAlarm pages at missed-snapshot severity when a slot GRADED props but retention wrote fewer rows than the slate (or nothing). runSnapshot now returns retentionRows so the scheduler can evaluate it. Retention is best-effort by design so it can never break a snapshot — which means a broken write is silent by construction. This is the counterweight. A slot that graded nothing never false-pages; an absent count reads as NOTHING and still pages, distinct from a reported 0. Suite 280/3349 green, build exit 0. Outcome stamping deliberately NOT implemented (depends on the settlement fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
c2f6041406 |
STATE: off-box round trip CLOSED — 645 restored from the box copy
Phase 3 complete. The insurance chain is proven end to end rather than assumed: dump -> validated -> pushed off-box -> verified on the box -> pulled back down -> rebuilt into a live database. Pulled vyndr-20260720-051158.dump FROM the Storage Box (not the local copy) with the in-session key through the pinned host key, never bypassing StrictHostKeyChecking. Restored into scratch Postgres 17: 715 archive objects, 42 public tables, ledger_entries with all 27 columns and real spot-checked rows. ASSERTION PASSED: ledger_entries restored 645 == live 645 (target >= 645). model_snapshots restored 100/100, so the retention store shipped yesterday is covered by backups from day one. Records the operational gotcha the restore surfaced: the dump is written by pg_dump 17 (Supabase 17.6) and pg_restore 16 CANNOT read it — 'unsupported version (1.16) in file header'. The first attempt failed on exactly this. Any DR runbook must use PG17+ tooling. Restoring into vanilla Postgres also logs 12 ignored errors (Supabase roles/extensions absent locally) which are harmless. Scratch DB torn down, pulled copy deleted, both dumps still on the box, nightly cron untouched. No private key material echoed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
491e636b7f |
STATE: off-box backup working + verified on the box
Records off-box as WORKING with the root cause (key was only in Hetzner's project store, never in the box's authorized_keys — the box previously offered an EMPTY auth list) and the proof: offbox_ok:true, and the file independently VERIFIED on the box via rsync --list-only through the pinned host key (vyndr-20260720-051158.dump, 833,917 bytes, 05:12:28 UTC, byte-identical to the local dump). Env truth captured from the run output: key is correctly base64-decoded, destination has no leading-slash bug. Hardening recorded: host key statically pinned (accept-new gone, missing pin refuses the push), remote dir guaranteed, failed required push now pages at urgent with offbox_ok:false while the exit code still tracks on-box durability. Flags the ONE outstanding acceptance item honestly: the round-trip restore is NOT done, because the dev box cannot authenticate to the Storage Box (the authorized key is Kev's, not the in-session keypair) and the container has no Postgres server. Lists both unblocks and the assertion target (>= 645). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
2bfae804da |
Verify off-box presence by reading the remote dir back
Exit 0 from the backup script is deliberately tied to ON-BOX durability, so it is not proof the off-box copy landed. GET /api/internal/backup/offbox runs rsync --list-only against BACKUP_REMOTE using the SAME pinned known_hosts as the push (checking never disabled) and returns the dumps actually present, with size and timestamp — so off-box presence is a verified fact rather than an inference from an exit code. Needed because the dev box cannot authenticate to the Storage Box: the authorized key installed there is Kev's ~/vyndr-backup-key, not the keypair generated in-session, so independent verification has to run from the container that does hold working credentials. Suite 280/3338 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
c4c9b97604 |
Off-box backup: pin the host key, guarantee the remote dir, page on failure
PHASE 1 — HOST KEY STATICALLY PINNED. ssh-keyscan -p 23 returned an ED25519 key whose fingerprint EQUALS the out-of-band value SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM, so it is safe to pin. scripts/storagebox_known_hosts now carries that verified line and ships to the container (Dockerfile already COPYs scripts/). backup-db.sh uses StrictHostKeyChecking=yes + UserKnownHostsFile=<pin> instead of accept-new, which was trust-on-first-use and would have accepted an impostor on the very first run. A missing pin file REFUSES the push rather than silently falling back. Never weakened to accept-new/=no//dev/null — a test asserts that on executable lines. PHASE 1b — REMOTE DIR GUARANTEED. The box has only .ssh/, and rsyncing a file into a missing parent either fails or silently writes the dump AS the directory name — one file, overwritten nightly, reading as "backups exist" while retaining exactly one. Uses rsync --mkpath when available, else an explicit remote mkdir -p ahead of the push. PHASE 2b — FAILED OFF-BOX PUSH IS NOW LOUD. Off-box is required, so the failed-push path pages at "urgent" (was "low"/deferred) and the script emits a machine-readable OFFBOX_OK=1/0/deferred that POST /api/internal/backup/run surfaces as a distinct offbox_ok field. Exit code deliberately still reflects ON-BOX durability — a good on-box dump must not raise a false total-failure alarm. Surfacing the truth, not manufacturing a failure. No key material is echoed anywhere; only the PUBLIC host key is committed. Suite 280/3338 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
7e150f9342 | STATE.md: pin header + point to the orientation block | ||
|
|
7ea0af2081 |
STATE.md: CURRENT STATUS + OPEN ITEMS orientation block
Top-of-file ground-truth block for orienting a fresh session. Records what shipped tonight (probability layer revived 32/32, value engine arc 1, grade-range work, backup durable on-box at 643/643, model_snapshots retention live with 100 rows incl 36 refusals, ESPN parser fix) WITH two honest qualifiers rather than a clean win: A still does not emit in production so the A-RATED marketing hold stands, and EV is overconfident (+62%/+61%/+56.9% captured, p_win clamps at 0.95) while hero v2 already ranks on it. OFF-BOX BACKUP recorded as NOT WORKING and deferred — never succeeded once, every dump lives only on the Hetzner volume. Documents the two real blockers fixed (missing base64 decode; container had rsync but no ssh binary) and the remaining one: the Storage Box offers an EMPTY auth-method list, which is an account refusing all auth rather than a wrong key. Explicitly marks as UNVERIFIED that no Chrome/UI diagnostic was run — no data on the SSH-support toggle, external reachability, project-vs-box key scope, or any Hetzner outage — and lists those as untested hypotheses in likelihood order rather than implying they were checked. The full scratch-Postgres restore proof is recorded as still OWED. Open items with status: settlement zero-pushes bug, ~28 props/day unsettled, permanent model-version contamination (with the hard-cutoff rule), A-grade unreachable, EV overconfidence, edge_pct broken scale, C4 CLV, consistency CV stopgap. Plus the three live credentials to rotate: Storage Box password, VYNDR_INTERNAL_KEY (pasted in a transcript), and the GitHub PAT still in .git/config. Next queued: backtest harness (needs ~2wk history, currently holds one night), settlement audit, opponent-strength sourcing (MLB solved via statsapi pitching splits; NBA/WNBA open) behind the source-adapter pattern, then the metrics engine gated on the harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
971f641d12 |
STATE: retention live + settlement findings + version contamination
Records model_snapshots as LIVE and verified capturing (100 rows over 2 cycles: MLB 14 graded/36 refused, WNBA 50 graded; features, grade_11, p_win, ev_pct on 100% of graded rows). First-ever refusal visibility: juiced_no_edge 18, rare_event_over_below_ line 13, insufficient_data 5 — the MLB gate refused 36 of 50 sides (72%), now measurable for the first time. Flags EV as OVERCONFIDENT and not fit to surface: first captured values include +62.1%/+61%/+56.9%, which real markets do not offer. Cause is the estimator clamping p_win at PROB_CEIL 0.95 off ~10 games. Hero v2 already ranks on ev_pct, so it will pick the MOST overconfident read — calibration must gate this before EV drives anything user-facing. Logs the two settlement-correctness findings Kev asked to track (zero pushes across 470 settled rows; ~28 props/day never settling) and the ledger model-version contamination, with the rule that any backtest off existing history must treat the 2026-07-19 fix boundary as a hard cutoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
14f47af74b |
Dockerfile: install openssh-client — rsync cannot exec ssh without it
The off-box push failed with 'rsync: Failed to exec ssh: No such file or directory (2)'. The container had rsync and pg_dump from S62 but no ssh binary, and rsync shells out to ssh for every remote transport. The dump itself succeeded, so this failed AFTER a good backup and reads like a network/auth problem when it is a missing package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
d3ffa1b8c2 |
Retention: model_snapshots live + base64 SSH key support
RETENTION (Phase 2, priority zero). History starts compounding tonight. migration 025 model_snapshots — APPLIED to prod. Append-only, one row per graded prop PER SIDE PER CYCLE, with a unique index on (snapshot_id, player_key, stat, line, side) so a retried cycle cannot duplicate. RLS on, service-role writes only. What it captures that the ledger never did: - features jsonb — the model's INPUTS. Without these a backtest can only grade our own homework; with them any future model can be replayed against the exact conditions this one faced. - REFUSALS (refused + refusal_reason). The ledger drops them, so a gate refusing props that would have WON is invisible — unmeasurable lost edge. Captured via a new onGraded hook in gradeSlateService that fires with BOTH sides before any filtering. - grade_11, the pre-collapse grade. The 4-letter map throws away the entire live C-/C/C+/B- range. - model_version + code_sha on every row. ledger_entries mixes pre/post-fix grades with no marker and cannot be separated retroactively. - p_win / ev_pct / fair_odds / takeable / value — none of which any permanent store held. Wiring: analyzeViaEngine1 attaches _features/_grade_11 (underscore = internal); gradeSlateService fires onGraded then STRIPS them so they never reach a cache or API payload; snapshotService builds rows and persists best-effort. Retention reuses the LEDGER's dateET/gameIdFor helpers so rows share the ledger's natural key exactly — otherwise the settle pass could never join outcomes onto them. Rows are written BEFORE the empty- slate early return: a slate that refused everything is exactly the case worth recording. CONTRACT HELD: retention is injectable and every path is caught. persist() returns errors, never throws; a missing Supabase client is SKIPPED, not an error. A retention failure can never break a snapshot. BACKUP: backup-db.sh now accepts BACKUP_SSH_KEY as base64 (recommended — survives env-var newline mangling, which is how injected SSH keys usually break silently) OR raw PEM, detected by decoding and looking for the PEM header. Verified both forms detect correctly against a real generated key. Suite 279/3325 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
04a09ec1b2 |
Phase 2 (a) report + (b) retention design — REPORT-FIRST, nothing built
(a) WHAT REPLAYABLE HISTORY EXISTS — the headline is confirmed and worse than "6 days". ledger_entries is the ONLY store of model history in the database: 640 public rows, 6 distinct game days (Jul 11/12/16/17/18/19 — 13/14/15 are missing entirely), 2 sports, 215 players, 470 settled, 465 settled WITH odds. Every other candidate is 0 rows: grade_history, line_snapshots, historical_props, closing_lines, resolution_results, accuracy_tracking, model_predictions_extended, engine1_weights, prediction_registry and ~30 more. A data warehouse was designed and never filled. Redis holds no history either (latest/previous at 24h TTL; the outcomes log carries no odds/confidence/projection). The blocking gap is not the day count, it is that NO MODEL INPUTS ARE STORED ANYWHERE. No feature vectors, so we can score the grades we emitted but cannot ask whether a different model would have done better — which is the only question a harness exists to answer, and the exact gate the metrics-engine north star requires. Also missing: p_win/ev_pct/ fair_odds (born tonight, on no column), grade_11 (only the 4-letter collapse is stored, so the entire live C-/C/C+/B- range is unrecoverable), and any model_version, so pre- and post-fix rows are already silently mixed in one table. CLV remains unusable (C4). Settlement gaps surfaced too: Jul 17 MLB 86 graded/57 settled, Jul 18 103/75, and 0 pushes across 470 settled rows — both feed the settlement-correctness audit. Verdict: we cannot meaningfully backtest yet. Retention is priority zero; every night without it is history we can never recover. (b) DESIGN PROPOSAL — model_snapshots in Postgres (not Redis, which is what lost us history twice). One append-only row per graded prop PER CYCLE, capturing market values, model output, outcome (stamped later by the settle pass), and critically a `features` JSONB — the counterfactual enabler. Carries model_version + code_sha so eras never mix, grade_11 so resolution is not thrown away, and refused/refusal_reason because refusals are training data the ledger currently discards entirely. Written from snapshotService (the existing chokepoint), best-effort so a retention failure can never break a snapshot. Volume: ~800 rows/day ~ 292k/year, ~300-600MB/yr of features, which would exceed the Supabase free tier alone — so the proposal keeps full features 90 days and scalars forever. Three open questions for Kev before building: the 90-day policy, whether to backfill the 640 existing rows as scalars-only with explicit null features, and confirming we store refusals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
8aafceaa3b |
STATE.md: pin header to cc8e478
|
||
|
|
cc8e47884d | STATE.md: backup is durable on-box and verified by read-back (643==643) | ||
|
|
97e4dc72d5 |
Backup: chown /app/backups in image + report uid/writability
The real backup run failed: pg_dump could not write to /app/backups — 'Permission denied'. Cause: the container runs as the non-root 'vyndr' user (Dockerfile USER vyndr) and the Coolify-mounted volume is root-owned, so the mount is present but unwritable. - Dockerfile now creates AND chowns /app/backups to vyndr alongside the existing /app/data + /app/.pm2 line. Docker seeds ownership into a NAMED volume on first creation, so this fixes it for a fresh volume; a host bind-mount still needs a host-side chown, which is why the next change exists. - GET /api/internal/backup/verify now reports process uid/gid, backup_dir_writable and the access errno, so the exact chown target is observable instead of guessed. A mounted-but-unwritable volume reads as 'configured' everywhere else — this makes it loud. Suite 278/3310 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
ef7f17610f |
Backup: durable on-box volume, off-box DEFERRED, and a real read-back check
BACKUP_DIR is now a persistent volume (/app/backups), so the dump already survives redeploys — the container-ephemeral risk that made this urgent is closed. Storage Box SSH auth is not sorted yet, so the off-box push is explicitly DEFERRED rather than failing: - gated on BACKUP_OFFBOX=1 (plus BACKUP_REMOTE and BACKUP_SSH_KEY); until then the script logs "off-box push DEFERRED" and exits clean. - if an enabled push DOES fail, it is a LOW-priority "deferred" notice, not a failure — the durable on-box dump succeeded, and calling that an incident would train us to ignore backup alerts. Adds the read-back check, because a backup nobody has read is a hope: countRowsInDump() runs `pg_restore --data-only --table=X -f -` and counts the rows between `FROM stdin;` and the terminating `\.`, proving the archive CONTAINS the data rather than merely parsing. Needs no Postgres server, so it runs inside the API container. Validated against a real pg_dump from a scratch Postgres: counted exactly 604 rows. GET /api/internal/backup/verify exposes it (newest dump in BACKUP_DIR, size, table, rows_in_dump). Unit tests inject spawn/fs so CI needs neither docker nor pg_restore. Suite 278/3310 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
13ca070096 |
Spec: metrics-engine north star + sourcing scope report (no code)
NORTH STAR (design philosophy, not built): VYNDR measures players by MODERN FUNCTION, not legacy label — the principle already under the archetype system, from Rashad Phillips' Basketball Position Metric. The rule: every proprietary metric is baselined against the player's functional ARCHETYPE's CURRENT-SEASON behavior, never the position's inherited standard. The edge is that the market often prices today's players against yesterday's baselines, so archetype-vs-position baseline disagreement is a repeatable mispricing. Generalizes across sports. Moat = proprietary metrics x current-game calibration x our private outcome data. Metrics ship as VALIDATED FAMILIES: hypothesis, flagged build, backtest, ship-or-delete with the negative result written down. Nothing is real until the harness proves it predicts better. SOURCING SCOPE (report, no code): MLB opponent strength IS derivable from statsapi, verified live — one free call returns all 30 teams' pitching splits (era/whip/avg/slg/ops/homeRuns/strikeOuts/HR9), which beats the ESPN field we were reaching for because it is STAT-SPECIFIC, exactly what opp_rank_stat wants. NBA/WNBA cannot use ESPN (its team endpoint carries only a team's own stats, no defensive rating or pace); options are stats.nba.com dashboards, deriving allowed-points from scoreboard finals we already fetch, or API-Sports. API-Sports is a fallback tier at best — 100/day will not survive per-team-per-day. ESPN stays last, always behind an adapter. Proposed the SOURCE-ADAPTER pattern: one interface per feed, config-driven primary+fallback per (sport x capability), normalized output so vendor quirks stay in adapters, fallback announced rather than silent, sources with zero callers deleted rather than left as corpses, and a health check that PAGES when a source returns empty or broken — where EMPTY IS A FAILURE. Tonight's crash (captured 0 / errored 15) and the months-null opp_rank_stat are both exactly what that check exists to catch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
40aba37f83 |
Backup: env-injected SSH key, nightly off-box push, triggerable run
Closing the backup for real. Three changes, each fixing something that
would have made the Storage Box target fail or silently rot.
1. SSH KEY COMES FROM ENV, not from the container. Generating a keypair
inside the API container was the obvious move and it is wrong: the
container filesystem is ephemeral, so the key dies on the next
redeploy and the off-box push starts failing silently. backup-db.sh
now reads BACKUP_SSH_KEY (a Coolify secret), writes it to a 0600 temp
file per run, and removes it on exit via trap.
2. PORT 23, verified live. Hetzner Storage Box runs full OpenSSH on 23;
port 22 answers with mod_sftp (SFTP only). Banner-checked both against
u635423.your-storagebox.de. rsync now uses
-e "ssh -p ${BACKUP_SSH_PORT:-23} ... -i <key>"; the old invocation had
no -e at all and would have gone to 22.
3. OFF-BOX PUSH IS NIGHTLY, not Sundays-only. A weekly push meant up to
six days of dumps existed ONLY inside an ephemeral container, which is
the same as not existing. Alert copy updated to say exactly that when
the push fails or is skipped.
Also adds POST /api/internal/backup/run (internal-key gated) so a real
backup can be TRIGGERED and OBSERVED — it returns exit code, duration,
output tail, and whether the remote + ssh key are configured. The backup
can only run where SUPABASE_DB_URL and the Supabase route live (this
container), and there was no way to fire or inspect it without a shell.
Connectivity established this session: Storage Box reachable from the dev
box on 22/23; Supabase :5432 NOT reachable from WSL2 (so the dump must
run in-container, as designed); docker IS available locally, so the
restore-verify can run against a scratch Postgres using the real dump.
Suite 278/3305 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
26b276fbfb |
Fix the ESPN team-stats parser + report: opponent rank is still underivable
Ran the manual regrade with the internal key (thanks). Results are mixed
and the honest half matters more.
CONFIRMED WORKING — the probability layer is fully alive in production.
After POST /api/internal/snapshot/{mlb,wnba}: p_win, ev_pct, model_odds
and value are present on 32/32 live grades (mlb 7/7, wnba 25/25), up from
0/8 before. That fix is done.
NOT WORKING — the grade-range half did not land, and I am not going to
claim it did. The live distribution is unchanged (wnba B17/C8 before AND
after; mlb B4/C3), no A, no D, same four confidence values. Diagnosis:
matchup_grade is 0/25 on the live board, i.e. opp_rank_stat is still
null, so engine1's +/-1.0 opponent factor still never fires and the
ceiling is still +3.0 against the +4.5 an A requires.
Two distinct causes, both verified against the live ESPN feed:
1. refreshTeamStats CRASHED on every team — "buckets is not iterable",
captured 0 / errored 15. ESPN's current shape is results.stats =
an OBJECT with categories[], not an array. The old parser did for...of
on it. This was invisible until S63 gave the function its first
production caller. FIXED here (now captured 15 / errored 0) with a
regression test covering the current shape, the legacy array shape,
and empty payloads.
2. Even parsed correctly, the endpoint does not carry a
defensive-strength metric at all: defensive_rating, opponent_ppg,
pace and opponent_fg_pct all normalize to null — it returns only a
team's OWN stats. So defensive_rank_normalized cannot be computed and
opp_rank_stat remains underivable from this source. A test documents
the gap and will fail if that ever changes.
Consequence: A STILL DOES NOT EMIT, so the A-RATED marketing hold STAYS.
Reviving the opponent factor needs a different derivation (opponent
points allowed from scoreboard/schedule, or a different ESPN endpoint) —
logged as the concrete next item, not hand-waved as done.
Suite 278/3305 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
|
||
|
|
b742230d94 |
Phase 1: ship the backup cron as CODE + a manual regrade trigger
FOUNDATION-FIRST re-order, phase 1 (tooling + safety). BACKUP (highest-severity open item) — INSTALLED, not re-proven. src/backupScheduler.js runs scripts/backup-db.sh nightly from inside the API container, armed at boot in server.js. The container already has SUPABASE_DB_URL, pg_dump and the Supabase route, so deploy == installed: no host crontab, no Coolify click. Arming is deliberately opt-OUT (armed whenever SUPABASE_DB_URL exists; BACKUP_CRON=0 kills it) because the S62 design was opt-in and nobody ever opted in — the DB went unbacked every night for weeks. A failed run pages high-priority ntfy; silence is the danger with backups. Durability is the one part still needing a human: the container FS is ephemeral, so a dump dies on redeploy unless BACKUP_REMOTE (off-box rsync) or BACKUP_DIR (persistent volume) is set. The scheduler detects that and pages a WARNING at boot rather than letting an undurable backup read as "backed up". Runbook rewritten to lead with the code path. MANUAL REGRADE TRIGGER — scripts/run-snapshot.js, runnable via docker exec with no VYNDR_INTERNAL_KEY and no new HTTP surface. Runs the SAME snapshotService.runSnapshot the cron runs (including the team-stats refresh that powers opp_rank_stat), supports `all` and `--settle`, and prints the grade/confidence distribution plus p_win/ev_pct presence — which is the thing you actually want when verifying a grading change. ACCESS BLOCKER, logged honestly in specs/model-train.md: there is no VYNDR_INTERNAL_KEY in the local .env and SSH to the box times out from WSL2, so I can neither curl the internal endpoints (which already exist from S45) nor docker exec. The trigger is built and correct but only Kev can run it until a key or SSH access exists. This is the highest-leverage unblock for phases 2 and 3, which both need on-demand regrade+settle to verify anything. Also logged the standing cautions: CLV ledger stays private until backtest-proven; "self-improving model" is unsupported marketing until the loop closes; the engine is MLB/WNBA-calibrated and NFL/NBA/soccer need their own calibration before the hub grades them (scaling gate). Suite 277/3300 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
bf8ecb45ad |
Track U-deg pt2 (edge_pct scale) + the dispersion classifier as open items
Not new work — logging so neither gets lost. U-deg part 2: edge_pct is on a broken scale and it is the number users actually see. Live: edge_pct 100 on a single-digit-edge prop; ledger-wide 311/604 rows (51.5%) exceed the frontend's sane cap of 40, 39 exceed 100, worst 620. Mapped the consumers, and the split is the whole problem: 13 frontend files + deskShowcase/contentTemplate/parlayScan/tierGating read the BROKEN edge_pct, and ledgerService:199 persists it to the column of an append-only table right now. NOTHING on the frontend reads ev_pct; only heroPropService does. Noted that S-b (rank board on EV) is the real remedy and should be done as one piece with the scale fix, and that EDGE_BOARD_SANE_MAX is damage control that nulls half the board. Dispersion classifier: MIN_MEAN=4 is the honest stopgap; it leaves a +/-1.0 dead for MLB low-count stats. The scale-free fix is variance/mean vs the Poisson baseline of 1.0. Logged with its explicit validation bar — backtest harness first (still does not exist), replay settled outcomes, show no tier degradation, report before flipping, env-gate it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
58ce1c3e56 | STATE.md: header to deployed+fingerprinted HEAD | ||
|
|
a80868c4eb |
S63 fingerprint: probability layer verified live (p_win/ev_pct/model_odds)
POST /api/analyze/prop on prod returns p_win 0.523, ev_pct -10.4, model_odds -109, confidence_basis grade_band, value false — every one of which was absent on 100% of grades before this change. The value triplet is whole (book -140 / fair -125 / model -109) and correctly refuses to call a -140 price value when the model gives it 52.3%. A-emission still pending the 01:00 UTC snapshot (opp_rank_stat populates only when refreshTeamStats runs in a snapshot). MARKETING HOLD on A-RATED copy stays until that passes. edge_pct scale remains broken (U-deg pt 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA |
||
|
|
1a94ef5fcf |
Revive the dead probability layer + restore grade range ON MERIT
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
|
||
|
|
416639efe4 |
Grade collapse: mechanism traced — A is mathematically unreachable
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
|
||
|
|
3fe840ab83 |
Diagnose grade collapse + find the DEAD probability layer (report only)
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 |
||
|
|
669479097c |
Model Train G-b/C-cal: gate simulation + calibration report (docs only)
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 |
||
|
|
18bf3ecb51 |
Reconcile the record: STATE.md header + Model Train arc 1 spec (docs only)
Arc 1 ( |
||
|
|
7a925f43eb |
Model Train arc 1 (engine): de-vig + EV + takeable/value gates + hero v2 + triplet
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> |
||
|
|
348a82b4a0 |
Generalize the no-edge guard: suppress by the BOOK'S PRICE, not a stat whitelist
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> |
||
|
|
f72f063e6f |
Suppress rare-event 0.5 unders (juiced, no-edge) — config-driven grade + board fix
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> |
||
|
|
b8b954bb96 |
STATE.md: backup + founder-checkout tasks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c2c43cdc92 |
Task A — make the container backup-capable + validated dump + mechanism fingerprint
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> |
||
|
|
ccb9668f0c |
Task B — founder checkout is SEAT-GATED; payment-decline grace spans retries
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>
|
||
|
|
39c07a03b9 |
STATE.md: security + plumbing follow-up (items 0-7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f5c004416 |
Item 2 — nightly pg_dump backup script + runbook (Supabase free tier has none)
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> |
||
|
|
78c19291c9 |
Items 1,3,4,5 — security migrations (author; apply in Supabase, then re-run advisor)
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> |
||
|
|
889e8621b4 |
Item 6 — activate the Stripe billing portal link from account settings
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> |
||
|
|
ae3cff9dbd |
Item 7 — book roster: ESPN BET → theScore Bet (PENN)
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> |
||
|
|
3b12c6ca98 |
Item 0 — founder count = REAL active Stripe subscriptions (kills the phantom 1)
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> |
||
|
|
b354d1d088 |
STATE.md: Truth-Everywhere Part 2 complete (all 8 items fingerprinted) + C4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a8e383e7e7 |
Item 8 fix: articles live in web/content (the runtime content root), orphan deleted
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> |
||
|
|
cb3237cdce |
Item 6 — Desk showcase renders REAL data (or hides), kills the mocked ladder
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> |
||
|
|
9b9aab4262 |
Item 5 — daily hero prop is a live RULE (biggest model-vs-market disagreement)
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>
|
||
|
|
89a2977f57 |
Item 7 — public accuracy reads the CLEAN ledger; BEAT CLOSE hidden until C4
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> |