9c5b968351a84bda4c9b1c357187b89a19db550d
457 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a7d6cf8e36 |
Raise the grade cap 25 -> 500 on measured cost; refusals are correct
PART 1 (read-only, measured on a live prod slate, n=80) OVERTURNS THE
PREMISE. The refusal rate is not a data problem -- it is 98% correct
behaviour. The cap is the entire problem, and it is worse than "25 of 546".
Composition: GRADED 44 (55.0%) | POLICY-SUPPRESSION 35 (43.8%) |
FETCHABLE-GAP 1 (1.3%) | FALSE-THRESHOLD 0 | ARCHETYPE-GAP 0 |
GENUINE-ABSENCE 0.
THE FIFTH BUCKET the order did not anticipate: all 35 "refusals" are
rare_event_over_below_line -- the 2026-07-19 betting-logic audit
deliberately refusing 0.5-line rare events, setting the SAME
insufficient_data flag as a real data gap, which is why they read as one.
They are entirely doubles (18) and stolen_bases (17), while hits (19/19),
rbi (19/19) and total_bases (5/5) grade at ~100%. Had we "fixed" this we
would have re-introduced exactly the bets a previous audit removed, and the
count would have looked like progress.
THE CAP: 585 unique gradeable props, cap 25 -> 560 discarded (95.7%).
Traced to Session 32 (
|
||
|
|
d18a19f6aa |
Part 1 diagnostic: read-only refusal categoriser (25-cap + 72% refusal)
READ-ONLY. Runs the REAL grade path over a REAL slate and categorises every refusal; writes nothing. Reproduces gradeSlateService.dedupeProps exactly (MODEL_BOOKS, first-row-wins) and calls analyzeViaEngine1 the same way, so it measures what the pipeline does rather than a re-implementation. Adds a FIFTH bucket the order did not anticipate, and it is likely to change how the 72% is read: (e) POLICY-SUPPRESSION. The 2026-07-19 betting-logic audit deliberately refuses rare-event 0.5 markets (doubles/ triples/HR/SB) on the juiced under, plus any over-juiced price -- and it sets the SAME insufficient_data flag as a genuine data gap. Counting those as a data problem would send us hunting for data that is not missing, and "fixing" them would re-introduce bets we removed on purpose. Separates (b) FETCHABLE-GAP from (d) GENUINE-ABSENCE by asking the stats layer directly whether the player has ANY game log, rather than assuming: no log -> genuine absence, keep refusing; a log that exists while the grade path found no projection -> a wiring gap with something to fix. Also measures per-grade latency (mean/median/p90/max, serial and at concurrency) so Part 2 can decide the cap on cost rather than on taste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
6c97f59546 |
WNBA truth correction + THE p_win FLIP (live, rollback armed)
PART A -- WNBA TRUTH CORRECTION (no behaviour change).
WNBA does not "abstain" and is not "anti-predictive". The -0.12 that
produced those words was NBA-template machinery run on WNBA data -- WNBA
has never had its own archetypes, variables, conditions or calibration,
which is precisely the "sport stubbed in on another sport's template"
CLAUDE.md forbids. That is an UNBUILT MODEL'S EXPECTED FAILURE, not a
verdict on the sport; reading it as a verdict would quietly retire a sport
we never actually attempted. Its own build is QUEUED, after MLB.
The guard CODE is unchanged -- FORECAST_RANKED_SPORTS = {'mlb'} and the
inheritance test are correct live safety either way. Only the meaning is
corrected, and generalised into the doctrine-as-a-gate: a sport ranks on
p_win ONLY once its OWN model is built and shown to predict (calibration
AND resolution on its own holdout). Others are held out as NOT-BUILT,
never as failed. Re-labelled across gradeRanking, snapshot route, tests,
MASTER-PLAN and the challenger report.
PART B -- THE FLIP, gated on a full-slate re-run.
The re-run found something better than a bigger sample. An induced
snapshot graded 7 props: gradeAndCacheSlate runs with DEFAULT_LIMIT = 25
and ~72% of those refuse for insufficient_data, while 546 props are
gradeable. So 8 props IS the board, structurally -- not a small sample of
it. Logged as its own finding; the cap is a separate order.
For a statistically meaningful delta I used 11 real historical boards
(n=328, board sizes 14-57): 79.9% of rows move, mean 5.16 places per
board, TOP READ CHANGES ON 9 OF 11 BOARDS. The re-ordering holds at real
board size. Query committed.
FLIPPED:
- rankGrades drops its edge key (safe for every sport: removes a
non-predictive tiebreak without putting p_win in front).
- selectTopGrades leads on forecast_rank, edge key removed.
- flattenToEdgeBoard sorts on forecastRank, not edge -- this board had
edge as its PRIMARY key, so the whole mobile board was ordered by a
quantity measured not to predict.
- forecast_rank threaded onto strip props.
Sports whose model is not built supply no forecast_rank, so their boards
fall through to the unchanged grade chain -- the fallback is the guard.
ROLLBACK ARMED: boards sort by forecast_rank WHEN PRESENT, so
FORECAST_RANK=0 reverts every surface on the next response -- no deploy,
no client release.
Edge is still computed, stored, carried and displayed as a labelled
diagnostic. Retired from ranking, not deleted.
Eight superseded tests updated to strictly stronger INVERSE properties --
they now fail if edge is ever re-introduced as a ranking key, which the
originals could not detect.
Gates: 4,045 tests / 323 suites green; next build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
ef4ac60b81 |
Per-sport rank guard + edge diagnostic-only display + delta report
DELTA MEASURED on live prod grades (live ordering unchanged): MLB 7/8 props move (87.5%), mean 2.5 places, TOP READ CHANGES (corey seager hits 1.5 under -> jake burger hits 0.5 over). WNBA 25/25 move, mean 4.1, max 12. This is a large re-ordering, not a tweak. Caveat recorded rather than buried: MLB had only 8 graded props at measurement time. The percentages are real; the sample is one small slate. Re-run before the flip -- it is one call. PER-SPORT DOCTRINE ENFORCED IN CODE. WNBA moves the most and must NOT adopt this: its p_win is anti-predictive, so ranking that board by p_win would sort it by a signal measured to point the WRONG WAY -- worse than the incumbent, not better. A comment would not have stopped a future flip from going global, so FORECAST_RANKED_SPORTS = Set(['mlb']) gates the forecast_rank stamp, with tests asserting no sport inherits MLB's result. A sport joins only by passing its own holdout. EDGE IS NOW DIAGNOSTIC-ONLY IN DISPLAY. MobileEdgeBoard.EdgeCell rendered green (--g-a) for positive edge and red (--miss) for negative. Two things were wrong: green/red IS a quality claim on a quantity that does not predict, and ROW-GRAMMAR reserves red for settled-negative ONLY -- a negative diagnostic is not a settled loss. Now neutral mono with a diagnostic tooltip; header reads "MKT GAP · DIAGNOSTIC". The number is still shown -- no display went blank. DeskShowcase neutralised likewise. PINNACLE LOGGED, NOT ENSHRINED. Per the order, "market-not-sharp" is PENDING-RECOVERY rather than a confirmed permanent limitation. The single question for PropLine is in BLOCKERS.md with its evidence, and MASTER-PLAN now carries the pending status instead of the permanent claim. Live sorts remain byte-identical: selectTopGrades, flattenToEdgeBoard and topGradedService all still call the incumbent. Gates: 4,041 tests / 323 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
86d123945c |
Rank on p_win: challenger instrument + retire edge from decisions
MEASURED BASIS (n=200 settled MLB rows): corr(p_win, outcome) = +0.26; corr(edge, outcome) = -0.010 incumbent ruler / -0.022 consensus ruler. Subtracting the market destroys the signal under BOTH rulers, so a quantity that does not predict must not rank, gate or decide. CHALLENGER-FIRST -- live ordering is byte-identical. rankGrades (the incumbent, grade-first with edge as its 4th key) is untouched and tested as untouched. NEW: rankByForecast -- takeable-gated p_win -> grade -> confidence -> stable order, with NO edge term anywhere. p_win LEADS and the letter follows, deliberately: the letter measured r ~ 0.005 and is inverted (B 52.4% < C 56.9%) while p_win measures +0.26, so leading with the letter would sort by the weaker signal and use the stronger one only to break ties. Recorded in the code: isotonic calibration is a MONOTONE transform, so ranking on raw vs calibrated p_win gives the SAME ORDER. Calibration matters when p_win is displayed or thresholded; it cannot change a ranking. Nothing here needs the calibrated value. rankingDelta + GET /api/internal/ranking-delta measure how far the board would move before any flip. The endpoint reports p_win coverage alongside the delta -- if p_win is absent the challenger degrades to grade order and the delta UNDERSTATES, which is worth saying rather than reporting a clean zero. forecast_rank is stamped on snapshot grades BEFORE stripModelPrice, so every tier gets the correct order without the paid values (the topGradedService precedent -- an ordinal can travel where the magnitude cannot). Additive only: nothing sorts by it yet. RETIRED AS DECISIONS (not rankings, so done now): - altLineScanner.compareToBookImplied no longer returns value_detected: edge > 0. Edge is still COMPUTED and returned -- losing the record would be worse than mis-using it -- but the verdict is an honest null with value_basis: 'retired:edge_does_not_predict'. - scanAltLines no longer filters to edge>0 or calls the survivor "optimal". The whole ladder is returned ranked and labelled 'price_gap_diagnostic_unvalidated'. The module has ZERO callers (verified) -- unwired like mlbGrader.js, left in place and made honest. An honest asymmetry recorded there: ranking props AGAINST EACH OTHER must not use edge, but choosing between RUNGS OF THE SAME PROP is inherently price-relative -- ranking rungs by model probability alone would always pick the lowest line, since P(over 0.5) > P(over 2.5) by construction. So the gap stays the rung key, explicitly labelled unvalidated. Two superseded tests updated to stronger properties. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
7140e62b65 |
MLB re-run vs consensus ruler: premise dissolved, isotonic DECIDED
MEASURE-ONLY. No promotion, no flip, no tier spend. Live path
byte-identical: CURRENT_RULER_VERSION still v1_first_book, model still
consumes MODEL_BOOKS only.
MANDATE 1'S PREMISE DOES NOT HOLD. The p_win calibration is
RULER-INDEPENDENT, confirmed two ways: estimateProbability takes
{gameLogs, line, statType, features} and never sees a market price, and
the calibration fits p_win against OUTCOMES. Reliability and resolution
are both p_win-vs-outcome measures, so fair_prob cannot enter either.
There is nothing to re-fit -- the ruler changes edge, CLV and takeable,
not calibration.
I RETRACT MY OWN LABEL. I declared the MLB isotonic result PROVISIONAL
"because it was measured against the bent ruler". That over-applied the
ruler caveat to a measurement the ruler never touched. The result was
never contaminated; it moves PROVISIONAL -> DECIDED, not by re-running but
because the gate I attached does not apply.
RAN THE GENUINELY RULER-DEPENDENT QUESTION INSTEAD -- does a median
consensus rescue EDGE? Timing held constant (both rulers at close; a
lock-time reconstruction joins only 43 rows, and mixing lock-incumbent
with close-consensus would confound WHEN with WHAT).
n=200 MLB settled rows: mean |ruler gap| 0.0085. corr(edge_v1, outcome)
-0.0101; corr(edge_v2, outcome) -0.0220; corr(p_win, outcome) +0.2598.
THE HEADLINE: p_win predicts outcomes at +0.26 while p_win minus the
market predicts nothing under EITHER ruler. Subtracting the market price
destroys the signal -- a direct empirical vindication of the identity now
at the top of CLAUDE.md. Market edge is not merely a poor criterion here;
it is a strictly worse instrument than the raw forecast.
CALIBRATION REFRESH (ruler-independent, but n grew 119 -> 250):
time-forward holdout n=125, reliability 0.0846 (was 0.0939), resolution
0.190 (was 0.123). Both hold and both improved on a fresh later window
the earlier fit never saw. Independent replication.
THE LIMITATION THAT BLOCKS A FULL VERDICT: closing_captures holds only
MODEL books -- exchange quotes were never stored, because normalizeProps
discarded them until yesterday. Mean 1.97 books in the historical join. So
this tested a US-books-median ruler, not the exchange-inclusive consensus
whose live delta showed p90 +10 points. That ruler is UNTESTABLE on
existing data at any n. Per Mandate 4's third outcome: inconclusive, not
forced.
SEPARATE FINDING -- LIVE FEED REGRESSION: pinnacle MLB captures went 4,022
-> 0 on 2026-07-31 and have not returned, while every other book continued
(103,940 captures in the prior 10 days). This also corrects an Order Zero
claim of mine: "no sharp anchor exists in our feed" was accurate for the
day measured but wrong generally -- pinnacle was there until 07-30 with
17,090 two-sided captures. line_type='sharp' is a label in closingCapture
via SHARP_BOOKS, not a separate provider. We had a sharp anchor and lost
it two days ago; not caused by anything in this session.
Both queries committed: scripts/ruler-comparison.sql,
scripts/pwin-timeforward.sql.
Gates: 4,028 tests / 322 suites green; next build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
c79528abae |
Order Zero: tier-reality report + widening fingerprint
PHASE 1 resolved on our real keys, and a bad source was discarded on the way: a fetched rendering of PropLine's docs "tier matrix" claimed /odds/closing is 403 on free and that /odds returns prices nulled on free. Both are contradicted by direct observation (200-redacted, and 6,196 two-sided PRICED groups on MLB). Not cited. The report uses only the machine-readable OpenAPI contract and the verbatim detail bodies our keys received. Verdict: every one of the six endpoints behaves exactly as the Free tier's published contract says. error:"upgrade_required" with an explicit required_tier is unambiguous -- NOT a key-permission problem, NOT a plan problem. $9/mo Hobby buys /results + /odds/closing (the CLV instrument) + /movement (steam across 18 books); $19/mo Pro adds the 90-day settlement export. Priced and evidenced; not recommended here -- it is a decision. PHASE 2 fingerprint on the SERVED feed: 5 books -> 13, props rendered 546 -> 2,780 (5.1x), mean 4.22 books/prop. The unflattering half, stated up front: of 2,234 newly-visible props only 698 (31.2%) carry a real non-DFS market price; 1,536 (68.8%) are DFS-only pick'em rows. The honest headline is not "80% of the slate unlocked" -- the board is 5x fuller, about a third of the new depth is real market data, and the rest is pick'em inventory now shown but tagged. PHASE 3 verified: 546 gradeable props, unchanged. CURRENT_RULER_VERSION still v1_first_book. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
68c5b65427 |
Thread book_role through the odds route grouping
The route regroups flat props into lines[] and was dropping the role tag, so the widened feed reached the browser untagged. That is not cosmetic: on a live prop, PrizePicks prices both sides at even money (+100/+100) while BetMGM has +450/-750. Rendered side by side without a tag, the pick'em row reads as a dramatically better price when it is a different product entirely -- exactly the confusion the three-way split exists to prevent. Consumers gate on book_role !== 'dfs' before treating a row as a market price. The ?book= filter now accepts any DISPLAY book, since shopping a real book against an exchange is the point of the widening. Grading still only ever consumes MODEL_BOOKS. One superseded integration test updated to a stronger pair: an unknown book still 400s, and a newly-visible one no longer does. Gates: 4,028 tests / 322 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
f0543b57a4 |
Product identity + widen books for DISPLAY, model input byte-identical
IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL: it projects what a player will DO and picks accurately. Market edge is a BYPRODUCT of a good prediction, never the success criterion. Success = the forecast is honest about its own confidence AND still ranks -- calibration and resolution, both. No edge/CLV term belongs in a pass/fail gate; they are diagnostics we report, not thresholds a model must clear. A model tuned to beat a closing line has been fitted to the market instead of to the game. Per-sport doctrine (Phillips 2022, classify by what players DO not by position): each sport is its own model -- own variables, archetypes, conditions, calibration, honest ceiling. Shared across sports: ONLY the Bayesian inference math. Truth Law: no fabricated data; honest-absent over invented; label limitations in-band; provisional stays provisional until re-run; documented is not verified. PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our own accord and 64.8% of the MLB slate was invisible to users. Every prop carries book_role (both/takeable/reference/dfs/offshore) so the display layer can say WHAT a price is -- a fixed-payout DFS number and a two-way sportsbook price are not interchangeable objects. Unknown books are still dropped. PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits MODEL_BOOKS (the legacy allow-list, character for character) from DISPLAY_BOOKS. Both model paths re-filter before they pick a line: gradeSlateService.dedupeProps (before first-row-wins AND before the limit) and intradayRefreshService.indexOddsProps (which RE-GRADES at the current line -- without the gate, widening would have silently moved locked lines onto books the model has never been calibrated against). A test asserts the graded set is byte-identical through the widening. CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the MLB calibration is re-run on the consensus ruler and v2 is promoted. HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it does NOT fix the model. WNBA still abstains -- a model problem, not a coverage problem; it is better covered than MLB. MLB isotonic still provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and polymarket are 0% on both sports, so no sharp anchor exists in our feed. Two superseded tests updated to stronger properties rather than deleted: roleOf now names the KIND of book, and the normalizer test asserts the display set widens WHILE the model set does not. Gates: 4,027 tests / 322 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
1372e6bcf7 |
Order Zero Phases 1-3: keyed verification, ruler_version boundary, report
PHASE 1 (measured on the live prod feed with the real key): - WNBA is NOT thin at the feed -- 4.21 books/prop vs MLB's 3.61. It was allow-list-starved exactly as MLB was. This removes one candidate explanation for its anti-predictive result; it does not explain it, and WNBA stays abstaining. - We cannot see 64.8% of the MLB slate at all (zero admitted books). - Exchanges are real (smarkets 27%, novig 22%, kalshi 15% on MLB) but pinnacle, matchbook and polymarket measured 0% on BOTH sports. There is no sharp anchor for player props. The consensus is a MARKET consensus, not a SHARP one -- recorded as a permanent limitation, not a milestone. - DFS is the trap, quantified: prizepicks covers 82% of MLB props, the highest in the feed. Admitting it "for breadth" would have looked like the biggest available win. Permanently excluded. - Endpoints: /context WORKS and is FREE (umpire, roof, pitcher handedness, lineup confirmation -- richer than what we hand-built). /odds/closing and /movement are REDACTED (full structure, zero prices). /results and /exports/resolved-props are 403. - The $19/mo question is answered: soccer IS graded, ~15 competitions in 30 days (MLS 41k, Liga MX 15k, Brasileirao 12k, UCL/Europa/Conference). Our "soccer grades into a void" is a Pro-tier problem, not a data problem. NBA is absent because it is July -- seasonal, not inferable either way. PHASE 2 delta, corrected: MLB mean +1.50 pts, median 0, p90 +10.0, 17.0% of comparable props move >=5 pts, one-directional (the incumbent prices the over below the exchange-inclusive consensus). WNBA symmetric and tight. The median prop does not move -- the change is a right-skewed minority. That the rulers DIFFER is established; that the new one is BETTER is not, and that is the re-run. PHASE 2 item 6: ledger_entries.ruler_version applied to prod, 1,384 existing rows backfilled to v1_first_book (a statement of fact -- every row to date was produced by the first-book rule). ledgerService stamps CURRENT_RULER_VERSION on new rows. Never pool edge or CLV across it. Repo migration numbering lags prod; 025_ledger_ruler_version.sql records the DDL for review. PHASE 3: MLB isotonic p_win remains PROVISIONAL -- calibrated against v1_first_book, does not promote until re-run on the consensus ruler. NOT LIVE, deliberately: ALLOWED_BOOKS unchanged, served slate byte-identical, CURRENT_RULER_VERSION still v1_first_book, no live path calls consensusRuler. Gates: 4,022 tests passed / 322 suites; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
c38db1ad65 |
Fix: the incumbent ruler respects the allow-list (correcting my own model)
My first delta run modelled the incumbent as first-row-wins over the RAW feed and reported that an EXCLUDED book was "the market" on 69% of MLB prop-lines, with prizepicks alone at 47%. That is WRONG and I caught it before it went anywhere. normalizeProps applies ALLOWED_BOOKS BEFORE gradeSlateService.dedupeProps runs, so DFS books never reach the incumbent. The allow-list, for all the coverage it costs, does keep DFS out of the ruler. incumbentFairProb now takes the allow-list (defaulting to the live ALLOWED_BOOKS) and reproduces the real chain. Two tests lock it, including that a prop with no admitted book has NO incumbent -- it is never graded at all, which is the real loss and is already measured as invisible_props. Overstating the incumbent's badness would have been as dishonest as understating it, and more persuasive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
a55dd2a6a0 |
Order Zero Phase 2: three-way book split + challenger consensus ruler
CHALLENGER-FIRST. The live ruler is byte-identical: CURRENT_RULER_VERSION is still v1_first_book, nothing here writes a cache, a grade or a ledger row, and no live code path calls consensusRuler yet. bookRoles.js splits one allow-list into three, because it was answering two different questions -- "can we show this?" and "can we price against this?" -- with the same list, which is what bent the ruler. TAKEABLE the user can actually bet here (drives best price / shopping) REFERENCE may price the fair-prob ruler; never surfaced as a place to bet EXCLUDED DFS pick'em + offshore, permanently barred from all pricing Two deliberate calls, both evidence-based: - The six PropLine-phantom books (caesars/fanatics/bet365/hardrockbet/ pointsbet/thescore) are KEPT despite the order saying remove. They returned zero PropLine quotes, but PropLine is not our only provider and the odds-api backup path may carry them. A book that never appears is never matched, which costs nothing; deleting them risks silently dropping real books on the backup with no upside. Recorded in PHANTOM_ON_PROPLINE rather than enacted as a deletion. - REFERENCE = exchanges + pinnacle + bovada + the four US majors, chosen off the measured coverage curve rather than theory. exchange_only is cleanest (order-book, ~zero vig) but covers 14.3% of MLB and 5.6% of WNBA; adding the US majors gives 28.1% / 46.3%. pinnacle, matchbook and polymarket measured 0% on both sports and add nothing. The honest limitation is recorded in the config: this is a MARKET consensus, not a SHARP one. consensusRuler.js: median de-vigged fair_prob across >=2 reference books posting BOTH sides at the SAME line. Median so one stale exchange cannot drag it. Different lines are never averaged, one-sided quotes never rule, and n<2 falls back to single-book LABELLED as such with the v1 stamp -- never silently mixed, because a column holding both is two rulers wearing one name. The challenger delta runs over the live feed and reports incumbent_book_ roles, which is the real headline: the incumbent is literally first-row- wins, so it reports what KIND of book has been acting as "the market". DFS pick'em has the highest coverage in the feed, so a DFS book can be it. 18 ruler tests + 37 total in the two new suites. Full suite 4021 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
c3bcfaba94 |
Order Zero Phase 1c: return the aggregate-only bodies in full
/sports and /markets/resolution-summary carry no per-prop data and no credentials, and the shape summary alone cannot answer the question they exist to answer -- whether PropLine actually GRADES the sports we cannot settle. A shape is not a number. Both bodies are scrubbed on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
3c466d79cb |
Order Zero Phase 1b: redaction detection + reference-policy curve
Two corrections to the first pass, both of which would have produced a false positive. 1) A non-empty body is NOT proof of access. PropLine's free tier returns the full STRUCTURE of tier-gated endpoints with values stripped plus an upgrade_url -- and the first pass classified /odds/closing and /movement as "works" on structure alone. detectRedaction() now counts actual prices and downgrades works -> partial when a body advertises an upgrade or carries outcomes with zero prices. Same class as the harness that returned a silent false, inverted. 2) One hard-coded reference set forces a yes/no on a question that is really a curve. reference_policy_curve reports strict eligibility (>=2 books, both sides, same line) under exchange_only / exchange_plus_sharp / exchange_plus_us / takeable_only, so the ruler decision is made on coverage-vs-quality rather than on a guess. DFS is absent from every policy by construction and a test asserts it. Also probes /markets/resolution-summary: /exports/resolved-props being 403 tells us we cannot PULL settlements; resolution-summary tells us whether they EXIST to be bought. Different questions. 19 unit tests, still hermetic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
2071b79456 |
Order Zero Phase 1: keyed read-only PropLine verification endpoint
Adds GET /api/internal/propline-verify (internal-key gated, read-only) so Phase 1 can run WHERE THE KEY LIVES. Touches no cache, no ledger, no grade; the live adapter and the live ruler are untouched. Breadth reuses proplineAdapter.fetchRaw -- the exact live request -- so what it measures is what the pipeline actually receives. Reports per sport (never pooled): books/prop from the feed vs after our own ALLOWED_BOOKS, props made INVISIBLE by that filter, reference-book presence, DFS presence reported separately, and consensus eligibility. Consensus eligibility is deliberately strict: >=2 REFERENCE books posting BOTH sides at the SAME line. A one-sided quote cannot be de-vigged, and two books at different lines are not the same market -- counting either would overstate how much of the slate can carry a real ruler. Probes the documented-but-unverified endpoints (/sports, /context, /odds/closing, /movement, /results, /exports/resolved-props for four sport keys) and classifies works/partial/no, with 403 = tier-gated and 200-but- empty = partial rather than works. Key safety is the other locked property: the key goes via axios params, never string-interpolated, and every emitted string passes scrubKeys() which removes the literal key AND any surviving apiKey= query value. A test asserts a thrown transport error carrying the key cannot escape. 13 unit tests, hermetic (no network, no key). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
293367917c |
Order Zero: book-breadth test + accrual clock correction (measure-only)
STEP 0 disproved the premise before any request was fired. PropLine's OpenAPI contract states verbatim that `bookmakers` omitted = ALL books, so proplineAdapter omitting it is correct and always was. Firing a guessed param would have RESTRICTED the response and produced exactly the false negative the order warned about. The real cause is ours: PropLine sends 18 books; oddsNormalizer ALLOWED_BOOKS intersects them at exactly 5 -- which is precisely the "5 MLB books" the 2.18 audit measured. Measured on real public data (no key, no quota): 4.41 books/prop from the feed, 1.50 after our filter, and 12 of 34 props go invisible entirely. Also corrected: "73% single-book" is the long tail of deep props sole-posted by DraftKings or Bovada. On the core props we grade, the market is 10-12 books wide. pinnacle appears on 0 of 40 MLB props -- the independent low-vig references present on 100% of core props are exchanges (novig/smarkets/kalshi). DFS pick'em also covers 100% but is not a market price and must never enter a consensus. Verdict is outcome (d) ALREADY OPEN, not (a)/(b)/(c) -- all three assumed the feed was the constraint. Ruler change scoped (not built): split one allow-list into takeable/reference/excluded, fair_prob_lock becomes a median consensus with n>=2 or a labelled fallback. Gated on exchange price validation + the WNBA measurement, which needs the PropLine key (prod-only, absent locally). MLB isotonic p_win declared PROVISIONAL until re-run on the real ruler. Side finding: we use 1 of 29 endpoints. /odds/closing, /movement, /odds/history, /best-line, /ev, /results, /exports/resolved-props, /context (free) map directly onto documented gaps -- and resolution across 33 sports suggests "no free settled feed for NBA/soccer" may be a $19/mo problem, not a data problem. Documented, not verified. Plan edits: §10.1 rewritten, §10.2/§10.5 corrected, and §11 adds the sequential post-completion accrual clock -- pre-completion data does not count, no pooling across the completion boundary, two clocks stated separately, per-sport clocks, verification gate before any accrual, users onboarded to a complete product only. §9.1's "6-10 weeks out" corrected: that is accrual duration, not distance to the answer. The ruler change independently forces the same no-pooling boundary by arithmetic. No API key was used, printed, or committed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
c98338ef23 |
plan: add §10 — aggregator + paid-model gaps, and the one root cause behind both
Answers "what makes this the top product, not just a finished one."
THE REFRAME: the aggregator gap and the model gap are the SAME gap in two places.
Our "market" is often ONE book — MLB props are 73% single-book, and
proplineAdapter sends only {apiKey, markets} with NO regions/bookmakers param
(:152), so we take PropLine's default response. That single fact causes four
problems we had been treating as unrelated: no line shopping (the category's #1
free hook), a fair_prob_lock that is a de-vigged single soft book rather than a
consensus (the bent ruler the model is judged against), weak CLV (cannot measure
beat-the-close against one book), and no steam/disagreement detection (needs >=2
books to exist).
So the highest-leverage unblocked action in the whole plan is a cheap API test:
does PropLine return more books with a regions/bookmakers param on our tier? One
request, and if it works it upgrades the free product, the model's denominator and
the CLV instrument simultaneously.
Aggregator gaps catalogued: book breadth, true consensus, historical odds archive
(started — closing_captures 844k rows, lock_lines new, but in-grade history capped
at 24 points, so no full open->close series), market breadth (11 live vs the
category's 50+), ingested alt-line ladders, injury/lineup wire, player news.
Paid-model gaps catalogued: distribution instead of a point (distribution.js
already computes survival probabilities and rungs but is proj-v1.1, ledger-only
and lost to the champion); opportunity/playing-time projected FIRST with its own
uncertainty (the single biggest available modelling gain); per-stat models instead
of one additive index; matchup granularity that actually reaches the grade;
applied calibration; a backtest harness (blocked by the archive gap — you cannot
backtest a price you never stored); CLV as north star.
THE PATTERN: almost every model capability is ALREADY BUILT AND DISCONNECTED.
VYNDR does not have a building problem, it has a connection-and-proof problem plus
one genuine ingestion gap that starves both halves. The expensive part is largely
done, but no new feature fixes it.
Ordering principle recorded: get MLB genuinely good BEFORE replicating across six
sports — a copied-six-times thin model is six times the maintenance for the same
absent edge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
37ee952e26 |
plan: add §9 — what is actually missing for the product to work, not just be built
The phases counted unbuilt code. This section names what is missing for VYNDR to do what it claims, including the parts that are not builds. THE CENTRAL GAP: there is no demonstrated edge yet. Every measurement this session returned null, negative or unproven — served grade r~0.005 and inverted; all three p_win-vs-fair_prob formulations negative on both sports and both splits; p_win alone on MLB holdout p~0.07; WNBA negative; CLV null by guard; ROI-by-grade likely an artifact. The product's core claim is not currently supported by our own data, and building all 23 orders without closing this leaves a well-built product that does not do the thing it sells. What closes it is sample and honest iteration, not code — roughly 6-10 weeks at the current accrual, a clock engineering cannot shorten and that must not be faked. Also named: the projection is thin (l5/l20 + opponent rank + rest + usage, with similarity/archetypes/conditions/Bayesian all built and disconnected, so connecting them is a hypothesis not a guarantee); it is a one-sport product today (NBA and soccer do not even settle); there are 3 users and 0 paid so nothing is validated by usage; there is NO distribution path at all, which appears in no phase and belongs on the board as its own track; the last mile is unclosed (push-to-book is a teaser, no affiliate live); and operational fragility remains (single box, two-sport settlement, three credentials flagged including a Stripe live key that transited a transcript, no staging). The honest summary: the truth infrastructure is genuinely well built and this codebase does not lie about what it knows. What is not yet true is that the model beats the market — not disproven, unmeasured at adequate n. The finish line is 23 orders PLUS a verdict from accrued data we cannot rush, and the discipline to report that verdict honestly if it says the edge is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
e3ca1650d9 |
plan: specs/MASTER-PLAN.md — single source of truth, 7 phases, ~23 orders, defined END
Consolidation only. Nothing built, wired or promoted. NOTHING WAS RE-VERIFIED and no query was run — all 22 artifacts produced this session plus the completion matrix were taken as KNOWN, per the order's own clause. The verification ledger at the top of the plan lists exactly what was taken as known and which four items remain genuinely open (sport order, board-reasoning gating, the CLV flag, team colours) — each open because it needs a decision or a build, not a query. The plan captures all six tracks in one document: per-sport models (MLB's 8-layer stack with each layer marked BUILT/PARTIAL/NOT-WIRED, plus the sport order), design implementation (61 catalogued items), surfaces, the resolution tail, the sport boundary, and the Chrome audit. The through-line it makes visible: MLB's layers 2, 3, 5 and 6 are BUILT AND NOT CONNECTED, while layer 8 (the grade ladder) is connected and meaningless (r~0.005, inverted). MLB's fix is connection, not construction. Phasing is by dependency: MLB model truth -> resolution tail -> surfaces/design (parallel lane) -> sport boundary -> sport rollout (one order per sport) -> monetization finish -> Chrome audit and hardening. ~23 orders total, ~11 unblocked today, so "how many sessions left" now has a real answer. DEFINITION OF DONE is explicit and countable: MLB layers 1-8 connected with a monotone held-out-proven ladder; every listed sport finished on the same template or explicitly abstaining with its reason recorded; all 61 design items built; every surface reachable and honest; the resolution pipeline firing end-to-end; the sport boundary a registry; the Chrome audit passed; and the record publishable on its own terms with no claim outrunning its evidence. STATE.md now points at the plan and is demoted to history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
6d87d7a33c |
report: p_win recalibration holdout — MLB qualifies on isotonic, WNBA abstains
Measure-only. p_win not flipped live, no grade rebuilt, no calibrator deployed. Per the doctrine, MLB and WNBA were fitted, selected and judged as SEPARATE models — and they reach opposite verdicts. No global instrument was fitted. METHOD: time-forward split per sport (earlier fits, later proves). Both instruments fitted on TRAIN only — single-parameter Platt and isotonic-with-pooling. Inputs p_win + outcome only; no market field, no closing value, no lookahead. Nothing about edge/CLV/beat-the-close enters any pass/fail line. MEASUREMENT CORRECTION made mid-run: the first pass reported mean|p - outcome| (~0.46-0.51), which is NOT calibration — it is noise-dominated individual error on 0/1 rows and would have made every instrument look identical. Reliability is only meaningful on BUCKETS (bucket mean predicted vs bucket actual rate, n-weighted), the metric T0 used. All reported numbers use the corrected metric. HOLDOUT RELIABILITY (lower better): MLB n=119/4 buckets — raw 0.1038, Platt 0.1120, ISOTONIC 0.0939. WNBA n=93/3 buckets — raw 0.1322, Platt 0.0491, isotonic 0.0667. HOLDOUT RESOLUTION: MLB raw 0.1388 -> Platt 0.1284 -> isotonic 0.1225. WNBA raw -0.1201 -> Platt +0.1269 -> isotonic +0.0322. Fitted Platt: MLB a=-0.381 b=+0.705; WNBA a=+0.040 b=-0.081. MLB QUALIFIES, MODESTLY — instrument selected BY HOLDOUT, not assumed: isotonic beats both raw and Platt, and Platt actually made MLB worse. Reliability improves 0.1038 -> 0.0939 (~10% relative, real but modest) and resolution SURVIVES (0.1388 -> 0.1225, not crushed). Both Mandate-3 conditions hold. WNBA ABSTAINS — its Platt result is the best number in the report and is REJECTED as a fake win. The fitted slope is b = -0.081, negative and near zero, so sigmoid(0.040 - 0.081*logit p) is nearly constant at ~0.51 for every input: it "calibrates" by discarding the prediction and emitting the base rate, which is exactly the failure Mandate 3 pre-registered. Its apparent resolution gain (-0.120 -> +0.127) is the sign flip, not skill — it would serve the opposite of its own forecast, fitted on n~96 of anti-signal. Isotonic says the same quietly (resolution collapses to +0.032). HONEST CEILING: MLB is a usable-but-unimpressive forecaster (holdout resolution ~0.12, reliability ~0.094, n=119); WNBA has no honest forecast today. Holdout n and bucket counts (4 and 3) suffice to reject WNBA and prefer isotonic for MLB, NOT to certify a letter ladder, and the T0 pathology is reduced rather than cured. CANNOT DETERMINE: per-archetype calibration (Mandate 3d) — bucket n falls below the reporting floor once split by sport AND archetype on 442 rows. Queries committed at scripts/pwin-calibration-holdout.sql. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
249b3e8235 |
report: grade diagnostic T0 — p_win is MISCALIBRATED, and it explains the inversion
STOPPED at the T0 gate as instructed. Nothing fixed, no recalibration applied, no grade touched. T1-T4 deliberately not run. T0 FIRES ON BOTH PRE-REGISTERED CONDITIONS. Condition 1 (mean |predicted-actual| > 0.05): MLB ~0.094, WNBA ~0.139. Condition 2 (monotonic slope): over-confidence GROWS with the prediction — MLB +0.034 -> +0.043 -> +0.084 -> +0.190 -> +0.189; WNBA +0.044 -> +0.109 -> +0.349. Worst cases: MLB predicted 0.842 actual 0.652 (n=23), predicted 0.917 actual 0.727 (n=11); WNBA predicted 0.730 actual 0.381 (n=21). WHY THIS EXPLAINS THE INVERSION, mechanically: p_win is over-stated and the overstatement SCALES with p_win, so p_win - fair_prob_lock is largest exactly where p_win is most inflated. Those props hit less than claimed, so the edge measure correlates negatively. The market was never the problem — fair_prob_lock is not a bent ruler, the thing subtracted from it is. It also explains why p_win ALONE still carries signal (+0.23 MLB): rank survives miscalibration, differences do not. This independently reconfirms the 2026-07-26 calibration finding (+0.02 at p<.5 -> +0.19 at p>=.8) on a newer, larger population, so it is structural rather than sampling noise. PART 0: P0a — only the GRADED side's fair prob is stored (fair_prob_lock; no opposite-side field), so T1's two-side-sum check cannot run and must use the stated no-vig recompute fallback. P0b — projection_locked_at exists as a timestamptz so T2 is potentially runnable, but distinctness from lock time was NOT verified because T0 gated it. Two cautions recorded before Part 2 runs: the top MLB buckets where the error is worst hold n=23 and n=11, so a flexible per-bucket correction would fit noise — isotonic with pooling or single-parameter Platt is safer; and calibration fixes magnitudes, so if the market is genuinely better the repaired edge may still land at ~0, which would be the honest ceiling and gets reported rather than graded around. Query committed at scripts/grade-calibration-t0.sql. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
ea1157d709 |
report: grade fix Part 1 — the p_win-vs-fair_prob rebuild is REFUTED by the data
STOPPED at the Part 1 gate. Nothing rebuilt, no grade changed, no cutover. THE FINDING: grading on p_win vs fair_prob does not work. All three candidate edge formulations correlate NEGATIVELY with outcomes, on both sports, overall, and in both time splits (n=432 decided rows carrying p_win AND fair_prob_lock): ALL n=432 champ -0.0016 p_win ALONE +0.1221 additive -0.0615 ratio -0.1161 logodds -0.0438 MLB n=240 champ +0.0984 p_win ALONE +0.2278 additive -0.0336 ratio -0.1350 logodds -0.0124 WNBA n=192 champ -0.1143 p_win ALONE -0.0842 additive -0.1326 ratio -0.1281 logodds -0.1243 Subtracting the market's lock-time fair probability destroys and inverts the signal. The plain reading: props where the model most disagrees with the market are LESS likely to hit — the market is better than the model, so "edge vs market" is anti-predictive here, while the raw probability retains some skill alone. WHAT DOES CARRY SIGNAL: p_win alone, MLB only, and it is modest. Time-forward split — TRAIN (07-21..07-26, n=120) r=0.2770; HOLDOUT (07-26..07-30, n=120) r=0.1647, with the additive edge negative in BOTH halves. So p_win survives forward validation directionally but the holdout is NOT significant (t~1.81, p~0.07). Suggestive, not proven. WNBA MUST ABSTAIN: every measure negative including p_win itself (-0.084). Forcing one threshold across both sports would make a coin-flip sport look sharp, which the order forbids. LOOKAHEAD GUARD SATISFIED: fair_prob_lock is the lock-time field, populated on 432 decided rows, range 0.145-0.713. closing_prob (415 rows) is the CLOSE and was NOT used in any correlation — using it would have manufactured a correlation. SAMPLE REALITY: 1103 decided rows but only 432 carry both instrument fields, so a per-sport train/holdout split leaves ~120 per half — enough to show direction, not to certify a letter ladder. I did not tune toward a win: three pre-registered candidates were tested and all three failed; picking a fourth because the first three lost is the overfitting the order guards against. Recommended instead: grade MLB on p_win alone with WNBA abstaining and label it modest/accruing (A-RATED hold stays); or wait ~6 weeks for n~500 MLB; or investigate WHY the market-relative edge inverts, which is the more valuable question. Both queries committed at scripts/grade-correlation-proof.sql so no number here has to be taken on trust. Working settlement untouched; dead resolve endpoint not wired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
40c61fbb0b |
report: resolution + CLV investigation — Part 1 premise false, Part 2 is an env flag
Nothing built. No poller wired, no capture change, no env flipped.
PART 1 — GRADES ALREADY AUTO-SETTLE. snapshotScheduler resolves settleAllOutcomes
(:64) and settleAllLedgers (:67) and runs them FIRST at every snapshot slot before
grading (its own comment at :393, Session 61). The record is self-populating: 937
settled rows, growing daily (07-24 through 07-30: 20, 25, 44, 26, 98, 62, 91), and
/api/accuracy reads it live at 937 @ 58% (MLB 526 @62%, WNBA 411 @54%).
/api/grading/resolve is a separate unreferenced legacy path, not the settlement
path. Wiring an ESPN poller to it would create a SECOND settlement path racing the
working one and double-count an append-only ledger — so nothing was built.
The DNP/VOID requirement is already satisfied: outcome carries void and
unrecoverable as terminal states, and getModelAggregate excludes both from the
record denominator, so a DNP is never counted as a loss (105 void rows exist).
Idempotency is enforced too — settleLedger guards on .is('outcome', null) and
outcomeService dedupes on nameKey|stat|line|side|date.
THE REAL GAP is smaller and different: settlement covers MLB + WNBA only. NBA and
soccer grade but never settle because no free settled-result feed is wired. That
is a per-sport feed problem, not a missing poller.
PART 2 — clvCaptureReliable() is ONE LINE:
return process.env.CLV_CAPTURE_RELIABLE === '1';
It measures nothing. It fails because the operator has not set the flag, not
because the capture is unreliable. So there is no capture code to repair for the
guard to pass — flipping one env var publishes beat_close_pct immediately, which
makes this a judgement call and precisely the "make a number appear" move the
honesty guard forbids.
The guard itself works: beat_close_pct and clv_distribution publish only when the
flag AND settled>=20 AND clv_sample>0; with it off /record shows NOT PUBLISHED YET
and the computable 34/937 = 3.6% is never the publishing path (clvPanel returns
null and a test forbids the fallback).
CANNOT DETERMINE (Supabase MCP upstream-auth outage): the close-vs-locked
distribution, which is the direct test for the old silent-overwrite bug. The exact
query is in the report. A decision rule is stated BEFORE seeing the number so it
cannot be fitted to it: set the flag only if close_moved is a clear majority of
rows carrying a close AND coverage of settled rows is high enough that the
percentage describes the record rather than the captured subset. If either fails,
leave it off — a CLV near zero because close==locked is the fabrication to avoid
and it would look like success.
PART 3 — full outstanding board included in the report, covering model work
(A-flood grade fix on p_win vs fair_prob, the collapsed-output re-adjudication
list, calibration/time-series with no honest source, price-triplet MODEL leg),
surfaces (D1 mount, share cards, notifications, Offseason, /system, S3 media,
45 unwired glyphs, /record has no nav link) and infra (NBA/soccer never settle,
three credentials still flagged for rotation, migration drift 023-029).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
bedbb8c008 |
Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no Stripe object was created and no payment was run. B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot returns a price KEY; this module is the only place a key becomes a Stripe id, and it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS BOOT when any of the four is unset — verified by deleting one: it throws "BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no longer sell at the wrong rate or 503 a customer at checkout. B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts at seat 99 both read 99, both got founder). It now calls claim_founder_slot and uses the returned key. The promo-code bypass is retired: founderCode no longer influences price or metadata, and getPriceId THROWS if handed a code rather than silently granting a founder rate. metadata.is_founder is renamed is_founder_audit and the webhook no longer reads it — caller-supplied metadata must never decide who pays the lifetime founder price. TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at standing. Both silent options are irreversible — standing permanently overcharges someone who was entitled to founder, and granting founder without a slot pushes past the 100 cap at permanent prices. A full cap is NOT an error and still returns standing normally, per "never error to the customer": a full cap is a real answer, a DB blip is not. B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and mirrors users.founder_status in the SAME txn, so they cannot drift again (they already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end state. If finalize errors, the tier is still set so a PAID customer is never left unentitled, but no founder flag is guessed. B4 SIGNATURE VERIFICATION was already present (constructEvent with STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled: https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed, customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed. VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved live and confirmed active with correct amounts and monthly recurrence (14.99 / 24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder except the claim (greps clean; the legacy helper now throws). V4 the handler reads customer/subscription/metadata.user_id and calls finalize with signature verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with both founder flags at 0. Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan confirmed NO tracked file contains the key material. Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
7c6fd95e68 |
Build 2 Phase A: real founder cap — atomic claim, race PROVEN, flags collapsed
DB only. No Stripe call, no checkout/webhook rewire (Phase B). Migrations 035,
036, 037 applied to prod and tracked; repo files added.
035 SCHEMA TRUTH — user_profiles gains stripe_customer_id and
stripe_subscription_id (G3 proved the webhook stores neither today, yet
finalize and grandfather reconciliation both key off the subscription id), plus
a partial unique index so a subscription id resolves to exactly one profile.
036 THE MECHANISM — founder_slots is a real TABLE replacing the decorative view.
The claim is a single UPDATE whose target row is chosen FOR UPDATE SKIP LOCKED;
no count is read in the decision path. UNIQUE(slot_number) plus a PARTIAL
UNIQUE(user_id) WHERE status <> 'free' (one live slot per user). Seeded 100 free.
Q1 global pool: the slot travels with the user, so analyst->desk keeps founder
with no second claim. Q2: release_expired_slots handles TTL abandonment ONLY —
cancelled slots retire, so the counter only rises. A6 redirects
founder_pricing_seats to count claimed slots, capped 100.
PRICE IDS ARE NOT IN SQL. claim_founder_slot returns a price KEY
(analyst_founder / analyst_standing / desk_founder / desk_standing) and the Node
layer maps it to STRIPE_PRICE_* env with a boot assertion — adopted over
hardcoding so a typo fails at boot instead of becoming a permanent mis-charge.
A7 FLAG COLLAPSE — finalize_founder_slot is now the SINGLE writer of both
founder flags in ONE transaction: user_profiles.founder_pricing is canonical and
users.founder_status mirrors it. founder_status is NOT dropped (G5 proved it
live: written at stripeService:163, served at routes/stripe:95, loaded in
middleware/auth:24 PROFILE_COLUMNS). Only the independent write is retired —
the two flags had already drifted in prod (1 vs 0).
A9 RACE TEST, run in Supabase before any Stripe:
- pool squeezed to ONE free slot; three distinct users claimed concurrently
-> EXACTLY ONE is_founder=true on slot 100, two returned analyst_standing,
zero double-allocation.
- idempotency: the winner claiming again returned the SAME slot 100 and still
held exactly 1 live slot (two tabs cannot take two seats).
- constraint layer proven directly: a raw UPDATE granting that user a SECOND
live slot was REJECTED by the partial unique index, and verify-after-write
confirmed state unchanged (1 live slot, target row untouched).
HONEST LIMIT: the three claims contend within one transaction via LATERAL, so
this proves the claim logic, the SKIP LOCKED path and the constraint that makes
parallel safe — but it is not N genuinely parallel backend sessions. True
multi-session concurrency is not drivable through this SQL interface and should
be exercised once in Phase B against the test key.
037 NEXAPAY DROP — own migration, evidence-led (G4: zero code refs, column
empty). VYNDR is Stripe-only.
CLEAN BASELINE (Q3) verified after the test: 100 free slots, 0 non-free, counter
0/100, and BOTH founder flags cleared to 0 across user_profiles and users — the
inconsistent test record is no longer enshrined as a founder.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
e970ab1ef3 |
report: Build 2 Review Zero — G1-G6 + DB verified; two order expectations wrong
No build, no migration, no Stripe object touched. Awaiting Kev on Q1-Q3. TWO EXPECTATIONS IN THE ORDER ARE WRONG: 1. G5 — users.founder_status is LIVE, not dead. Written by the webhook (stripeService.js:163), read and served by routes/stripe.js:95 as is_founder, and present in middleware/auth.js:24 PROFILE_COLUMNS so it loads on EVERY authenticated request. The guardrail says don't write it unless G5 proves it live — G5 proves it live, so A5 must NOT drop it. 2. THE TWO FOUNDER FLAGS ALREADY DISAGREE IN PROD: user_profiles.founder_pricing is true on 1 of 3 profiles while users.founder_status is true on 0 of 3. The webhook writes both from the same isFounder, so this is a dual-write that has already drifted. The build must pick one canonical flag and derive or retire the other; two independently-writable founder flags is how a founder loses their rate on one code path. GREPS: G1 founder_pricing has exactly one writer (the webhook mirror) and four readers (partners MRR attribution, the profile API, the profile badge). G2 the promo-code bypass is the ONLY founder gate today — getPriceId(tier, founderCode) against VALID_FOUNDER_CODES, stamped into metadata.is_founder, which the webhook then trusts, so a code alone mints a founder at any seat number. G3 the webhook DOES set tier + subscription_status=active + founder_pricing (closing an earlier CANNOT DETERMINE: a paid sub does flip the Build-1 gate) but stores NO stripe_subscription_id, confirming A1. G4 nexapay has ZERO code references and the column is empty, so A5's drop is evidence-supported as its own migration. G6 price selection is getPriceId -> line_items. DB VERIFIED: user_profiles has nexapay_customer_id and NO stripe_customer_id / stripe_subscription_id (A1 needed); users already carries stripe_customer_id; founder_pricing_seats is a VIEW; 3 profiles, 1 flagged founder. CANNOT DETERMINE: the four Stripe price IDs — no STRIPE_SECRET_KEY or STRIPE_PRICE_* in this environment, so I could not independently re-verify that the IDs in the order are what prod will charge. Since A3 would hardcode them, a typo becomes a permanent mis-charge; recommend reading them from env (already the pattern) with a boot assertion that all four resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
14f3ce95b9 |
report: Build 2 Review Zero — payment mechanism BLOCKED, no Stripe credentials
Nothing built. No Stripe object created or changed, no price logic touched.
STOPPED because there is no STRIPE_SECRET_KEY in this environment (.env holds
only ODDS/SUPABASE/INTERNAL keys). The order's standing floor requires
founder/standing/grandfather/race all verified server-side; none of that is
verifiable here, the standing price objects cannot be created, and the
concurrent-checkout race cannot be exercised. On a payment path the failure modes
are permanent and customer-facing — a race bug mis-prices a subscriber forever,
a grandfather bug overcharges one every month — so it must not ship unverified.
VERIFIED ANYWAY:
- Stripe IS live and FOUNDER price objects DO exist. /api/founders/count returns
{available:true, claimed:0, total:100}, and routes/founders.js returns
{available:false} whenever countFounderSeats() is null, which it is when
!STRIPE_SECRET_KEY || founderPrices.length === 0. So available:true proves the
secret key and at least one founder price ID are configured in prod, and
claimed:0 is a real count rather than a fallback.
- THE COUNTER IS NOT A GATE. It is a cached (300s) READ, not a claim; founder
pricing is gated by CODE + EXPIRY, not by the count, so anyone holding
FOUNDER2026 gets the founder rate at any seat number and the cap is decorative.
Two simultaneous checkouts at slot 99 would both read 99 and both get founder —
there is no lock or unique constraint anywhere in the path.
- The gate reads users.tier via config/tiers.js reasoning_visible, so a
successful subscription must set users.tier for Build 1's gate to open.
CANNOT DETERMINE: whether the STANDING price objects exist (env unreadable, and
getPriceId falls back SILENTLY to a PRICE_UNCONFIGURED sentinel, so a missing
standing object would not surface until the first post-cap checkout 400s in front
of a paying customer); whether the webhook writes users.tier on
checkout.session.completed.
DESIGN IS SETTLED for when it unblocks: a founder_slots table with a unique
constraint on (tier, slot_number) claimed before the Stripe call — the unique
index, not a count read, is what makes the race impossible; price selection from
the claim rather than a code, with the code+expiry bypass retired; grandfathering
by simply never calling Stripe price-migration on a founder sub;
founder-follows-upgrade by claiming on the target tier and releasing the slot on
cancellation; honest display that shows no number when the count is unavailable
(the existing route already sets that precedent).
PREREQUISITES, all needing Kev and none of them code: confirm/create the two
standing price objects and set STRIPE_PRICE_ANALYST / STRIPE_PRICE_DESK; confirm
the webhook sets users.tier; provide a Stripe test-mode key so the race,
grandfather and end-to-end unlock can be exercised rather than asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
e8b15c705a |
docs: free proof surface recorded (/record, hollow-preserving, CLV honest-absent)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
5930f18d81 |
Free proof surface: /record — tier-record-forward, honest CLV building panel
Presentation over existing endpoints. src/ untouched (git diff empty): no grade, model or ledger change. Pricing/migration are Builds 2/3. TIER-RECORD-FORWARD. /record reads the canonical public aggregates (/api/accuracy + /api/ledger/model) and prints them as-is. B 60% n512 and C 57% n413 ship with C honestly BELOW B; A (n2), D (n5) and F (n5) render HOLLOW with their real sample instead of a rate. Sport slicing (all/mlb/wnba) is client-side because the endpoints ignore ?sport= — mlb 526 @62%, wnba 411 @54% come from the sports map. THE LOAD-BEARING RULE, enforced in lib/proofRecord.js and locked by tests: where the source withholds a percentage it stays null. A is 1/2 and therefore 50% is derivable — a test asserts we do NOT derive it, because the API withheld it on purpose (n < 20). CLV IS AN HONEST ABSENCE, NOT A NUMBER. beat_close_pct is null because clvCaptureReliable() has not passed. The panel says "NOT PUBLISHED YET" and explains that any percentage printed today would be measuring our collection gaps as much as our edge; it surfaces the accruing sample (937) but no rate. Tests assert the panel never falls back to clv_beat/clv_sample (34/937 = 3.6%) and that the serialized panel contains no "3.6" — that number is computable and would be wrong, which is the exact fabrication this surface exists to refuse. The panel is built to receive a real number later without a redesign. HELD, and named on the page rather than faked: calibration and accuracy-over-time are absent because there is no honest source (no claimed-vs-actual endpoint; window_days fixed at 30 with no series). The page says so, and says it is not because they are unflattering. A page-level test asserts no hard-coded percentage exists in the markup, so no figure can drift from the aggregate, and that the page never touches /api/snapshot or itemized rows — the Build-1 gate holds and the exploit stays dead. Floor: 320 suites / 3986 tests green (16 new), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
4c302b5722 |
report: free proof surface Review Zero — three of four leads have no data source
Nothing built. Docs only. The premise was that this is cheap assembly over
existing aggregates; verified, it is not.
0.1 FILTERABILITY — the endpoints are NOT filterable. Probed live:
/api/ledger/accuracy?sport=mlb -> total 937
?sport=wnba -> total 937
?window=7 -> total 937
identical payloads; the params are ignored (the req.query reads at
routes/ledger.js:61-63 belong to a different route than /accuracy at :68).
Sport and tier CAN be sliced client-side from /api/accuracy's sports map and
/api/ledger/model's by_tier. TIME WINDOW CANNOT — window_days is fixed at 30
inside getModelAggregate with no param and no stored series, so
"accuracy over time" has no data source.
0.3 CLV CANNOT LEAD WITH A NUMBER. /api/ledger/model exposes the aggregate, and
live it returns beat_close_pct = null and clv_distribution = null despite
clv_sample 937. They are null BY DESIGN: ledgerService publishes them only
when clvCaptureReliable() passes, and it does not — the capture is still the
starved instrument the 07-28 repair improved but did not finish. The trap to
avoid is exact: clv_beat/clv_sample = 34/937 = 3.6% is computable and would
be WRONG, because the value is null due to instrument distrust, not a missing
division. Publishing it would be the marketing fabrication this order most
forbids. CLV can only lead with an honest absence.
0.2 The honest-record laws are ALREADY enforced at source: buckets return
A pct:null (n=2), B 60% (512), C 57% (413), D pct:null, F pct:null — thin
tiers already refuse to round. C genuinely sits below B, which is the
unflattering truth and must be shown as-is.
0.4 CALIBRATION CURVE has no data source — clv_distribution is null and there is
no claimed-vs-actual endpoint; the 07-26 calibration work was a one-off
read-only measurement, never wired to a served surface.
BUILDABLE NOW: tier hit-rates by sport with existing hollows preserved,
client-side sport/tier filtering, the capped 3-call sample, and honest state copy
including a CLV not-yet-publishable panel that names the reliability guard.
NEEDS ITS OWN ORDER FIRST: CLV as a leading number (blocked on capture
reliability, not presentation), accuracy over time (needs a param or daily
series), the calibration curve (needs a claimed-vs-actual endpoint).
RECOMMENDS shipping tier-record-forward with an honest CLV building panel rather
than CLV-forward — CLV-forward with a null cannot lead, and with 3.6% would be a
lie. That preserves the premise's strongest claim (a real thin honest record
out-credibilizes a fake fat one) without inventing a number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
7cf3892e76 |
docs: corrected gate recorded + cache-busting verification lesson
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
713f90183f |
Build 1 CORRECTED: itemized grades are PAID (live AND settled) — exploit killed
Serving/gating change only. src/services/ untouched: no grade, model or
settlement-logic change. Pricing = Build 2, migration = Build 3.
WHY THE PRIOR GATE WAS WRONG: freeing grades at resolution made the free tier a
ONE-DAY-DELAYED FEED OF THE WHOLE PRODUCT — settlement is nightly, so a bettor
watching one cycle behind got the entire method free. There is now NO
per-grade resolution flip: an itemized grade, tonight's or last week's, is
Analyst+.
FREE now gets, none of it itemizing the nightly slate:
1. the full data aggregator (unchanged — schedule, per-book lines, stats,
streaks, hubs)
2. the AGGREGATE track record, which ALREADY EXISTS and is public:
/api/accuracy (sample 937, byGrade tiers, per-sport mlb+wnba, min_sample 20)
and /api/ledger/accuracy (per-grade buckets). The honest-record laws are
already honored there — A/D/F return pct:null under the n>=20 threshold
rather than a fake percentage.
3. a CAPPED, day-rotated sample of resolved calls for texture: cap 3, stable
within a day, rotates across days, and only RESOLVED rows are eligible so a
live read can never be sampled. The cap is what kills the exploit — three
rotating past calls cannot reconstruct a nightly slate, whereas the full
settled list is the feed one cycle late.
4. the locked shell of tonight's reads: they exist, and their shape.
EVERY itemized grade for an unentitled tier now loses grade, confidence,
confidence_basis, reasoning, kill_conditions_triggered, projection, edge_pct,
matchup_grade, form, alt_lines and kelly, and is stamped locked. Free-side DATA
survives so the board still reads as real: player, market, line, book_odds,
fair_odds (the de-vigged fair number is the free hook and is never the paywall),
season/last10 stats, archetype — and `outcome`, because a RESULT is a fact
rather than a judgment.
The tease stays aggregate-only (live_locked {count, tiers}) computed from the
ungated rows and never joined back to one, and no gated row carries a grade, so
nobody can work out which prop is the A.
Floor: 319 suites / 3970 tests green, web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
7ddf159e4a |
docs: Build 1 settled/live gate recorded + live anonymous fingerprint
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
6d36e05bfe |
Build 1: the settled/live gate — unresolved is paid, resolved is free
Serving/gating change only. src/services/ untouched (git diff empty): no grade,
model or settlement-logic change. Pricing and migration are Builds 2 and 3.
Push scoring untouched.
THE RULE: a grade is PAID while its outcome is unknown and becomes FREE the
moment it resolves.
Resolution is read ONLY from a written outcome — never from time, game status or
gradedAt. A game can be final long before the settle pass runs, so treating
"probably over" as settled is exactly how a live edge would leak; a test asserts
an hours-old gradedAt with no outcome is still LIVE. void and unrecoverable ARE
resolutions (terminal results, no live edge left). isResolved FAILS CLOSED:
null outcome, {} with no result, and empty-string result all read as LIVE, so a
settlement failure withholds content rather than exposing it — the same
direction resolveTierFromRequest fails.
FREE/ANON: settled grades pass through IN FULL, reasoning and kill conditions
included — settled reads are the proof product and cost nothing once the outcome
is known. That also converts the previously-unenforced board reasoning leak into
a deliberate rule rather than an oversight.
LIVE grades for unentitled tiers are reduced to a shell: every piece of model
JUDGMENT is dropped (grade, confidence, confidence_basis, reasoning,
kill_conditions_triggered, projection, edge_pct, matchup_grade, form, alt_lines,
kelly) and `locked: true` is stamped so the card renders the unlock prompt. The
free-side DATA stays so the tease is real rather than empty: player, market,
line, book_odds, fair_odds, season/last10 stats, archetype, gradedAt, history.
fair_odds deliberately survives — the de-vigged fair number is the free hook and
is never the paywall. A test asserts the serialized free row carries no trace of
the withheld judgment.
THE TEASE IS AGGREGATE ONLY: live_locked = {count, tiers} computed from the
ungated rows and never joined back to one, and no gated row carries a grade — so
a free viewer learns that N reads exist and their tier shape without being able
to work out WHICH prop is the A.
Gate order in the route: stripModelPrice (S67) first, then gateLiveGrades.
Entitled tiers get the array back by reference — zero cost, zero change.
Floor: 319 suites / 3971 tests green (10 new), web build exit 0.
One test note: the route-level supertest case was removed deliberately — it
needs a live Redis and hangs on ioredis' reconnect timer in a single-suite local
run (known behaviour, CLAUDE.md). The gate contract is fully covered by pure
tests; the wire is verified against prod anonymously in the fingerprint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
dbc1416485 |
docs: tier redesign spec recorded (gate discriminator exists; counter is display-only; base is 3 users)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
4a4a3428d8 |
spec: tier redesign (Option 2, settled-free / live-paid) — design + build order
Report-first. Nothing built; no tier, price, gate or Stripe object changed.
REVIEW ZERO findings that shape the design:
0.2 The ladder is HALF-EXPRESSIBLE already — PRICE_MAP separates founder from
standing objects, so lifetime grandfathering is native (a sub created against
a founder price stays on it). BUT founder access is gated by CODE + EXPIRY
(FOUNDER2026/VYNDR/BETONBLK/EARLYBIRD, expiry 2026-12-31), NOT by seat count:
anyone with a code gets founder pricing at any seat number. A real
Stripe-derived counter exists (/api/founders/count, live 0 of 100) but only
DISPLAYS — and it is cached 300s, so it cannot enforce "slot 100 and 101
differ permanently". Making the counter the gate, transactionally and
uncached at checkout-session creation, is a real build.
0.3 The paid->free flip point already exists ON THE SERVED PAYLOAD: settlement
writes ledger_entries.outcome + settled_at, and /api/snapshot already merges
per-grade results — live WNBA returns 25 grades, 5 carrying
outcome {result:'hit', actual:1}. So the gate discriminator (outcome != null)
is present on the exact object to be gated; no new pipeline needed.
0.4 THE MIGRATION IS NOT WHAT THE ORDER ASSUMES: the users table holds 3 users,
all free, created Jun 12-19, and ZERO paid. There is no warm mass base — the
"founder launch to existing users" is a courtesy note to 3 people, and the
launch's real audience is people who have not signed up yet.
DESIGN: free = full data aggregator + the COMPLETE settled record (letter,
reasoning, edge, outcome — browsable and filterable), which is the proof hook.
Analyst = tonight's live grades + reasoning + edge, unlimited. Desk = + alt
ladder, Kelly, portfolio, engine2. Reasoning/grade/edge are ONE paid unit while
live and become free together at resolution — which also converts today's
unenforced board-reasoning leak into a deliberate rule.
GATE: outcome == null => live => Analyst+; outcome != null => settled => free.
Filter whole grades server-side (not field-strips) so a live grade cannot leak
partially; never infer resolution from time or game status, only from a written
outcome; fail closed to LIVE so a settle failure withholds rather than exposes;
void/unrecoverable are terminal and therefore free.
BUILD ORDER: (1) the settled/live gate, (2) the free settled-record surface —
noted as arguably shipping WITH (1), since gating live grades without it leaves
free users no graded content at all, (3) Stripe ladder + transactional counter +
grandfather rule + retire the code gate, (4) the founder note to the 3,
(5) pricing visuals (already designed in the package).
CANNOT DETERMINE: whether the four Stripe price objects exist in the dashboard
(env not readable here) — flagged as a prerequisite for build 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
c6ef4cfb2b |
docs: tier structure recorded — free board uncapped, board reasoning ungated vs config intent
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
844ab96f21 |
report: tier structure pull — the declared free-tier gate is unenforced on the board
Read-only. Nothing changed.
FREE TIER, EXACTLY:
- Board /api/snapshot: NO count limit. The only gate is
stripModelPrice(grades, tier) at routes/snapshot.js:106-107 — no slice, no
volume branch. Live anonymous right now: MLB 5, WNBA 25 = the full board.
The "3 scans/day" cap rations the SCAN path only.
- Grade letter: fully visible on every tier (grade_visible: true). Anon also
receives confidence, edge_pct and VYNDR's own projection.
- Edge fields: correctly stripped. p_win/ev_pct/model_odds/value/takeable are
ALL absent from the anonymous payload, with model_price_locked stamped so the
card shows a lock teaser rather than an absent leg. This half works as designed.
THE HEADLINE — the two paths disagree on reasoning:
- Scan REDACTS it: tierGating.js lockReasoning + lockKillConditions +
tier_gated + upgrade hint, driven by free.reasoning_visible = false.
- Board SERVES IT IN FULL: snapshotGating MODEL_FIELDS is
[model_odds, p_win, ev_pct, value, takeable] — reasoning is not in the list.
Verified live anonymously: full reasoning.summary plus a kill condition WITH
its reason.
Intent: config/tiers.js declares free: { reasoning_visible: false } with the
comment "blurred — frontend renders tier-locked". One of the two paths does not
enforce the product's own declared line, so the evidence reads as oversight
rather than funnel — a funnel would be declared in config, not contradicted by
it. Flagged with the counterweight: board reasoning is good marketing and the
data layer is already free, so closing it is a monetization tightening (Kev's
call), not a fabrication fix.
FREE DATA IS A REAL AGGREGATOR, not just a limited graded view: schedule,
per-book lines, player stats, streaks, hot lists, team hubs, public record — all
public and uncapped (probed live).
PAID (config/tiers.js, checkout.js:4): analyst $14.99 / desk $44.99. Analyst is
unlimited reads; Desk differentiates on capability (alt ladder, Kelly, portfolio,
engine2). africa tier is defined but activation is blocked on a DB CHECK
constraint. api_access is false on every tier. book_odds/fair_odds deliberately
pass through on all tiers — the de-vigged fair number is the hook and is never
the paywall; only model_odds gates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
43281bb885 |
report: D1-close Review Zero — mount not performed, three findings
Nothing changed: no mount, no row edit, no data threading. Docs only. 1. THE RATIONALE DOES NOT REACH THE ROW. StripProp carries stat/line/side/grade/ gradedAt/delta/awaiting/outcome/movement/revisedFrom/book/bestBook/dead/ history — no reasoning, no kill_conditions_triggered — and buildPlayerStripsFromProps never threads them. Mounting the hover needs a new field on the strip contract threaded through the slate adapter: additive, but a data-path change rather than a mount. 2. THE 0.3 PREMISE INVERTS — THE RATIONALE IS ALREADY PUBLIC. Verified live and anonymously against prod: /api/snapshot/wnba returns reasoning.summary with no locked flag plus kill_conditions_triggered. stripModelPrice removes model_odds/p_win/ev_pct/value/takeable but NOT reasoning. So the full model rationale already ships to every anonymous browser on the main board, while the same content IS tier-gated on the scan path (tierGating.js). Mounting the hover would leak nothing new, but would surface content that is currently shipped-but-unrendered, and the product gates it in one place while serving it openly in another. That is a monetization/consistency decision, so it is reported with three options rather than resolved unilaterally. 3. ROW-GRAMMAR IS LAW AND LOCKS StatStrip's SOURCE ORDER. rowGrammar.test.js asserts element order via src.indexOf on the component source; adding a rationale affordance or a team chip moves those offsets, so specs/ROW-GRAMMAR.md and the test must be amended in the same commit. That makes this spec-amending work needing its own slot decisions, not an additive mount. Safely mountable with no blockers: reveal.js (wraps the row list, no StatStrip internals, no new data, no grammar slot). teamChips needs a grammar slot; rowRationale needs the data threading AND the gating decision AND a slot. Recommends splitting D1-close into: mount reveal now; a ROW-GRAMMAR amendment order for the chip + rationale slots; then the rationale mount once the gating decision is made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
a0501f99c0 |
docs: D1 finish recorded (rationale real-or-absent, reveal once-on-view, 10/80 chip coverage)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
085e8a3a63 |
D1 finish: row-hover rationale, IntersectionObserver reveal, team-gradient chips
Additive frontend. Backend untouched (git diff src/ = empty): no grade, model,
classifier or ledger change. Scope held to the row anatomy these three items
need — no System-artboard-wide rebuild. Push scoring untouched.
REVIEW ZERO — the two checks that decided whether these could be honest:
0.2 RATIONALE SOURCE — VERIFIED REAL. Live snapshot grades carry `reasoning`
and `kill_conditions_triggered`. The summary is built by analyzeViaEngine1
from the actual feature vector (l5/l20 averages, gap to the line, home/away,
opponent defensive rank, rest days) and kills carry real codes + reasons.
So the hover shows genuine grade truth, not a placeholder.
0.3 TEAM COLOURS — PARTIAL, and deliberately left partial. The System artboard
defines a colour pair for only 10 teams (BOS CHC CHI DEN LAD MIL MIN NYY PIT
SD), lifted verbatim; lib/teams.js holds ~80. The other ~70 are NOT invented
— a wrong team colour is a recognition error the user reads as fact. Unknown
teams get the honest-neutral chip (muted border, no colour claim), never a
guess and never a blank gap. Coverage is reported by coverage(), not hidden.
SHIPPED:
- web/src/lib/rowRationale.js — rationaleFor() returns real summary + kills, or
NULL. No generic fallback: an empty hover is honest, a manufactured "why" is a
fabricated model explanation. A locked/tier-gated reasoning is treated as
ABSENT rather than paraphrased or leaked, and a kill condition with no reason
explains nothing so it is dropped.
- web/src/lib/reveal.js — IntersectionObserver reveal that fires ONCE then
unobserves ("react to truth, then rest"), reuses D1-A's bootDelayMs for the
60ms stagger so there is ONE source of truth for the timing, and reveals
IMMEDIATELY when IntersectionObserver is absent (SSR/test) so a missing API can
never hide real content. Reduced motion is handled by the existing CSS, so the
row is visible either way.
- web/src/lib/teamChips.js — Rev-3 geometry (10px, 135deg, before the abbr,
inside the row) plus the ranked opacity ramp 1/.86/.64/.48 so chips dim with
their row. Swap-ready for licensed logos at the same size.
Floor: 318 suites / 3961 tests green (15 new), web build exit 0.
The three modules are pure and unit-locked; mounting them into the live row
components is a follow-up, and the visual result belongs in the Chrome audit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
e34e99c426 |
docs: D1-A recorded (glyph buckets, boundary channel completed, primitives)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
3d1a3c7794 |
D1-A: combat glyphs, boundary-channel blue, reaction primitives, READ-FAB
Additive frontend/visual. Backend untouched (git diff src/ = empty): no grade,
model, classifier or ledger change. The 41->74 registry expansion is HELD for
D1-B. Push scoring untouched.
REVIEW ZERO — classifier coverage bounded the glyph wiring. Three buckets, and
the computation was redone three times before it was right (the frontend keys
GLYPHS by ARCHETYPE NAME while the backend keys `glyph:` by SHAPE NAME, and most
registry keys are unquoted identifiers — the first two passes mis-parsed both):
(a) classifier-backed, already wired: 38
(b) classifier-backed, package SVG exists, NOT wired -> WIRED HERE: 6
striker, grappler, pressure, counter, grinder, finisher — all MMA/combat
archetypes in archetypeService.js that were rendering EMOJI fallbacks
('*', 'x', '>', '<>') where the package ships real 24-grid duotone marks.
(c) package SVG with no classifier -> HELD for D1-B: 39 (wiring them would
render nothing)
(d) classifier-backed but NO package SVG: 2 ('dual threat', 'paint boss') —
a DESIGN gap, not a build gap; flagged for D1-B.
GLYPHS map 38 -> 44 keys, deliberately far short of the package's 83.
BOUNDARY CHANNEL — the blue tokens already existed (--priced-out set) and were
applied on NoMarketState and the scan void box, but PriceTriplet's NO_MODEL
("line not priced") still rendered in neutral text, so the channel was applied
inconsistently. NO_MODEL now renders in the channel, completing "every boundary
state or none". Token-only (no hex fallback and no hex in prose — PriceTriplet's
own test forbids literal hex, and it caught both).
REACTION PRIMITIVES — new web/src/lib/reactions.js + globals.css keyframes at the
exact HANDOFF timings: flash .75s ease-out, boot stagger 60ms steps, reactions
gated 1.5s, WIRE hold 6s. nudge() REFUSES a no-op (null/absent direction -> no
flash) so the primitive cannot be attached to an idle loop — a flash without a
new datum is the UI lying about the feed. Reduced-motion honoured.
READ-FAB — aligned to the exact package geometry: 50px circle, translateY(-14px),
6px void ring (was 46px, marginTop -16, 3px ring).
CARD TOKEN — audit correction: #0E0E14 was already tokenised as --bg-1/--card;
the audit's "1 file" was counting the raw hex, not the token. No change needed.
Floor: 317 suites / 3946 tests green (16 new), web build exit 0.
NOT DONE THIS ORDER (reported, not silently dropped): row-hover rationale and
IntersectionObserver reveal (Phase 3 item 8) and team-gradient chips (Phase 4
item 10) are not implemented — they need the System artboard's row anatomy,
which is a larger port than the rest of D1-A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
49565b5f02 |
docs: design-vs-build gap audit recorded (61 items, 6 build waves)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
e257474cc8 |
report: design-vs-build gap audit — 61 items enumerated, 18 absent
Package specs/design-reference (Jul 22) audited against the CURRENT repo
(
|
||
|
|
91911cfb1c |
docs: Wave 3 recorded — /compare live-verified; resolution tail scoped
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
bf7c0a3c08 |
Wave 3: /compare built (real head-to-head); resolution tail scoped, not shipped
No grade, ledger or scoring change. Push scoring untouched.
REVIEW ZERO 0.3/0.4 — THE RESOLUTION TAIL DOES NOT FIRE. The resolver is
POST /api/grading/resolve (routes/grading.js:208), and its fanout at :356-371
covers webPush, telegram and discord — but:
- share-card generation: SPEC'D-NOT-BUILT. Not in the fanout at all (grep
shareCard in grading.js = 0). shareCards/renderer.js exists with ZERO
callers, so the component is built but no step would ever invoke it.
- push notifications: BUILT-NOT-FIRING. In the fanout but gated on
webPush.configured() (VAPID). push_subscriptions = 0 rows and
user_notifications = 0 rows — nothing ever subscribed or delivered.
- Telegram result posts: BUILT-NOT-FIRING (gated on BOT_TOKEN + CHANNEL_ID).
- Discord result posts: BUILT-NOT-FIRING (gated on webhookFor('results')).
- recap (all-Final trigger): SPEC'D-NOT-BUILT. No recap file exists in src/.
AND THE WHOLE TAIL IS UNREACHABLE: nothing calls /api/grading/resolve — there is
no ESPN poller in the repo. The live settlement path is the scheduler's
settleAllOutcomes + settleAllLedgers, which fans out to opsNotify only (ops
alerts), with no user-facing output. So even the built channels have no trigger.
Per the order's own rule, ShareCard, /notifications, result posts and recap are
therefore ALL SCOPED, none shipped — no dead shells over a silent pipeline.
BUILT — /compare. Semantics (0.2): a same-market head-to-head, two players with
every row a measure BOTH sides are scored on, aligned via alignRows so the
numbers are comparable — deliberately not two disconnected graded props. Reads
the live /api/stats/player/:name?sport= aggregate. Honest-absent three ways: an
unresolved side reads NO DATA while the other still renders; a measure only one
side has renders a dash, never 0; if neither resolves the page refuses to
compare. NO VERDICT — it shows measures and says the reader draws the call.
Two pre-existing tests (vyndrPhaseE, vyndrParityQA) asserted the in-development
placeholder; both superseded rather than deleted — they now assert the stronger
properties against the real page (live fetch, no sample players, NO VERDICT,
NO DATA, "not a zero").
Floor: 316 suites / 3930 tests green (10 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
831d09bdde |
docs: Wave 1 wiring recorded + honest fingerprint limitation (nav entries -> Chrome audit)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
ff7f5d8d2d |
Wave 1: wire /intelligence, /slip, /parlay + /marketplace honesty pass
Wiring + one copy pass. No grade, ledger, model or scoring change (diff empty
across intelligence/, ledgerService, outcomeService, gradeSlateService).
REVIEW ZERO — each surface proven with real data BEFORE wiring:
0.1 /intelligence vs /system are NOT duplicates. System.dc.html is a
multi-surface artboard (TERMINAL + INTELLIGENCE + WIRE sections), not the
design for a distinct /system route; its INTELLIGENCE section is already
realised as the live app/intelligence/page.tsx. No /system page exists and
none should be built as a second copy — the prod 404 is correct.
0.2 /intelligence renders live and gates SERVER-side, not by blur: the proxy
requires auth and limits by tier (desk 50 signals / non-desk 8), and
returns 401 to an anonymous caller (verified live). No leak.
0.3 /slip parses a real DraftKings slip end to end: 3/3 legs,
needs_review false, Aaron Judge total_bases over 1.5 @ -115. Honest limit
recorded: parsers are layout-rigid, an unsupported layout yields ZERO legs
rather than wrong ones (never-guess), so real-world OCR hit-rate across
layouts is CANNOT DETERMINE until user slips arrive.
0.4 /parlay direct route hits the real correlation builder on the same
ParlayContext the drawer uses.
0.5 /marketplace advertised four unbuilt things but made NO performance or
profit claim, and its capture was already real (/api/waitlist upserts to a
waitlist table). The gap was tense, not fabrication.
WIRED: Nav MORE gains Intelligence, Slip Reader and Marketplace; Parlay Lab
re-pointed from the drawer hash to /parlay (the drawer is unaffected —
ParlayPanel stays mounted with its floating badge).
GATING: /intelligence added to GATED_ROUTES because its feed 401s signed-out, so
an ungated link would land visitors on a permanently empty page. /parlay stays
OPEN deliberately — it is the free parlay funnel and gating it would be a
monetization regression.
/marketplace honesty pass: every item body now opens "Not built yet." /
"Not written yet." / "Not produced yet." with what is planned; the subhead states
it is not a purchase, not a pre-order and not a promise of a ship date; the
playbook item carries "No profit claim, no promised return". The capture stays
real — no fake button. Unit-locked.
Floor: 315 suites / 3920 tests green (12 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
6328a5ce92 |
docs: boundary not written (no promotion); build triage waves recorded
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |