Commit Graph

469 Commits

Author SHA1 Message Date
builtbykev 03efdda33c Arm the S59 invariant by fixing its input; matchup sourcing = BUILDABLE
PART 1 -- PREMISE CORRECTION, then the real fix.

The order said the invariant's blocker was removed because "team is now
populated 416/416". It is not: what became 416/416 is home_team/away_team.
`team` (the PLAYER'S roster team) is still 0/416. Arming the guard off
home_team would compare the prop's game to itself -- always a match, a
permanent no-op that LOOKS armed. That would be worse than leaving it
disarmed, because it would read as a working guard.

The guard is also ALREADY fail-safe by construction (`if (knownTeam &&
gameTeams && ...)`), so Part 1's requirement was met in code all along.
What was missing was the data.

ROOT CAUSE: /sports/1/players returns currentTeam as { id, link } with NO
name, so searchPlayer's `hit.currentTeam?.name` was ALWAYS undefined and
every resolve returned team: null. The id is present on 1342/1342 and the
/teams list (already cached 24h) maps id -> name, so resolving it costs no
new request. Verified: Schwarber -> Philadelphia Phillies, Ohtani -> Los
Angeles Dodgers, Judge -> New York Yankees.

Five tests lock the fail-safe: drops only on a positive not-in-game;
abstains on unknown player team; abstains on unknown game participants;
and a row carrying only home_team/away_team does NOT satisfy the guard --
so the tautology can never be reintroduced.

PART 2 -- MATCHUP SOURCING: BUILDABLE. Measured on tonight's real board
against the free feeds, by VALUE not endpoint presence (the environment
trap: wired and null 634/634):

  opposing starter   29/30 team-sides (home 14/15, away 15/15)
  pitcher hand       1342/1342 (pitchHand.code)
  batter hand        1342/1342 (batSide.code; L 416 / R 848 / S 78)

SHARED DEPENDENCY, and it is the finding: /sports/1/players -- a list we
ALREADY fetch and cache -- carries currentTeam.id, batSide AND pitchHand.
One join unlocks the invariant's input and two of the three matchup inputs
at once. The third (probable starter) comes from the schedule hydrate that
already exists.

So matchup is BUILDABLE and is the next order; SOURCE-LINEUPS-first is NOT
needed. Archetype-level reach on the opposing starter is available too
(the SP resolves to a player id, so the existing classifier applies) --
noted, not built.

Champion p_win, ranking, calibration and both accruing verdicts untouched.

Gates: 4,082 tests / 327 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
2026-08-01 23:29:00 -04:00
builtbykev 0d43fb7db8 arch-v1 axis audit: env/matchup were dead; environment fixed
Report for the audit + fix already committed. Records the two things worth
carrying forward:

1. The environment axis has NOT yet been observed writing to the ledger,
   and I am not claiming it has. recordPipelineGrades upserts with
   ignoreDuplicates and dedupes on (user_id, player_key, stat, line, side,
   game_id) -- correctly, so a re-run never overwrites the original lock.
   Today's 429 rows predate the fix, so the axis cannot backfill onto them;
   first ledger observation is tomorrow's slate. What IS directly verified
   is the resolver (105/120) and the join key (416/416) -- the two things
   that were actually broken.

2. Matchup is not fixed and is not claimed as fixed. It needs the opposing
   starter and BOTH hands, and the audit shows three separate absences:
   oppPitcherByTeam 0, handById 0, bats 0/120. Fixing the pitcher feed
   without the hands, or the hands without the feed, still produces an axis
   that fires on zero rows.

Also noted: the S59 slate JOIN INVARIANT keys off the same null `team`
field, so it is currently inert too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 04:12:28 -04:00
builtbykev 4435856f46 Audit finds env/matchup axes DEAD in prod; fix the environment join
STEP 0 AUDIT -- the "already partly live" premise was half true: the CODE
is wired, the axes are NOT firing. Across 634 graded prod rows the
environment and matchup axes fired on ZERO rows, while 13 archetype axes
fired normally (power 80, swing_miss 69, contact 56, launch 51,
line_drive 43, ...) plus opportunity 142. Ledger confirms it from the
other side: env_multiplier, env_park_base, env_weather_mod, wx_forecast
and env_weather_state are ALL null on 634/634.

ROOT CAUSE, located rather than inferred. A drop-off audit against the
live snapshot: with_team_field 0/120, with_bats 0/120, with_playerId
120/120, oppPitcherByTeam 0, handById 0. `team` is a KEY on every stored
grade and NULL on 416/416 -- so an environment resolver keyed off the
player's roster team could never find a venue, while buildContext sat
there with all 30 teams mapped and 14 weather forecasts resolved and
unused. Coors composes to 1.241 the moment it gets a key.

FIX -- and it is the more correct join, not just a workaround. The park
and the weather belong to the GAME, not to the player's roster team, and
the game rides on the prop from the odds feed. gradeBestSide now carries
home_team/away_team onto the graded row (the legacy grade shape dropped
them), and contextFor joins on the game first, keeping the roster team as
a fallback. This no longer depends on a stats-resolve that can
legitimately fail.

MATCHUP/PLATOON IS NOT FIXED HERE and is not claimed as fixed: it needs
the opposing starter and both hands, and the audit shows
oppPitcherByTeam=0, handById=0 and bats=0 on the slate -- three separate
absences. Per "one axis at a time" that is its own order with its own
diagnosis, not a second fix smuggled into this one.

Champion p_win, ranking, calibration and opportunity_drift's accruing
verdict are all untouched.

Gates: 4,077 tests / 326 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
2026-08-01 04:05:43 -04:00
builtbykev c9d56d5668 Audit endpoint: where the env/matchup context chain drops off
The arch-v1 environment and matchup axes fired on ZERO prod rows across
634 graded props while archetype axes fired normally, and buildContext
works locally (15 games, 14 with weather, Coors composing to 1.241). So
the failure is downstream of buildContext and has to be located, not
inferred from an absence.

Replays buildContext + contextFor against the CURRENT cached snapshot
grades and counts the drop-off at each hop: team field present -> resolves
to an abbr -> abbr matches a game -> environment produced; and bats /
playerId / opposing-pitcher known -> matchup produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 04:01:57 -04:00
builtbykev 48a2f764ac opportunity_drift: coverage 94%, collinearity PASSES, holdout n-blocked
STEP 1 -- input mapped and measured. opportunity_drift 94% coverage on 100
real props: 100% for batters (total_bases, hits, home_runs), 40-67% for
pitchers, which is correct -- pitchers accumulate few at-bats so the ratio
is genuinely undefined and ABSTAINS rather than being invented.

STEP 2 -- THE COLLINEARITY GUARD PASSES DECISIVELY. Pearson r on n=94:
drift vs l20_avg -0.020, vs l5_avg +0.027, vs ab_per_game -0.029. All
essentially zero, so the axis is orthogonal to every existing projection
input and carries information the projection does not already contain.

That also validates the ratio-over-level decision EMPIRICALLY: ab_per_game
is the same quantity over the same denominator as l20_avg, so the level
would have been redundant. Dividing by the player's own baseline removed
the collinearity -- r = -0.029 against the very quantity it is built from.

STEP 3 -- live as a challenger, verified on prod over an induced 416-grade
snapshot: 142 of 276 rows (51.4%) carry the opportunity axis, the
challenger moved on 190 rows, mean |delta| 0.034, range -0.089..+0.108.
Champion p_win and the live grade path are unchanged.

STEP 4 -- HOLDOUT IS n-BLOCKED BY CONSTRUCTION and I am not manufacturing
one. Settled rows carrying the axis: 0. Its first rows carry game_date
2026-08-01 -- games that have not been played. Running the test on rows the
axis never touched would dilute the comparison with rows where challenger
=== champion by construction, making a null result look like a small
positive one. Query committed for when n arrives; it filters to
axis-carrying rows for exactly that reason, buckets before measuring
reliability, and splits time-forward. BOTH metrics must improve or the axis
is shelved.

A MEASUREMENT TRAP RECORDED: the first prod run showed drift at 0% while
ab_per_game read 94% -- indistinguishable from "the feature does not
compute". It was the 120-second feature-vector cache serving payloads
written by the previous image. A new feature field is invisible for one
cache generation after deploy. I nearly reported it absent, having already
confirmed atBats is present in the live statsapi payload and that the code
produced drift = 1.05 locally on that exact data; the contradiction
between those two facts is what saved it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 03:23:24 -04:00
builtbykev 092f8f09cd Build opportunity_drift axis on challengerProjection (arch-v1)
Champion p_win and the live grade path are BYTE-IDENTICAL: the axis writes
only to p_win_challenger / challenger_adjustments in the ledger.

STEP 1 -- MAP THE INPUT. MLB_LOG_FIELD now maps at_bats -> 'atBats'.
Deliberately NOT added to outcomeService's map or liveTracking's
LIVE_BOX_FIELD: those exist to SETTLE and TRACK graded props, and nothing
grades at-bats, so adding it there would imply a settlement path for a
market we do not carry. A test asserts the settle map still lacks it.

STEP 2 -- DRIFT, NOT LEVEL. opportunity_drift = mean(last-5 atBats) /
(season atBats / games). The LEVEL is collinear with l20_avg (same
games denominator; hits/game ~= (hits/AB) x (AB/game)), so the projection
already embeds it multiplicatively and adding it would double-count. A
deviation from the player's own baseline is the part the projection does
not contain.

HONEST ABSENCE throughout: fewer than 3 at-bat rows, no at-bats in the
logs, or no season baseline all leave drift UNDEFINED -- never 1.0 by
default and never 0. Number(null) === 0 here would read as "zero at-bats",
the strongest possible fade, invented from missing data. Four tests cover
the absent paths.

STEP 3 -- THE AXIS. opportunityNudge composes in the same log-odds space
as park and platoon (log of a ratio), with two guards the measured axes do
not need: a +/-10% DEADBAND (a rest day or a blowout can move a 5-game
window without any role change) and a tighter cap (0.15 vs the
environment's 0.30) so a noisy PROXY cannot outvote measured signals.
Every adjustment carries is_proxy: true and
proxy_for: 'confirmed_batting_order' so nothing downstream can mistake it
for a lineup feed.

The axis can stand ALONE -- without it the early return would gate
opportunity off on exactly the thin-classification rows it is most likely
to help.

Zero extra I/O: analyzeViaEngine1 attaches drift from the feature vector
it has already built, and attachChallenger reads it off the grade. Nothing
re-fetches in a loop that runs over hundreds of props.

COLLINEARITY GUARD added to the coverage probe: Pearson r of drift against
l20_avg / l5_avg / ab_per_game, returning null under n=8 rather than
reporting a correlation on a handful of rows. If drift just re-encodes the
projection, the axis is dead signal and gets shelved.

Gates: 4,073 tests / 326 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
2026-08-01 03:15:47 -04:00
builtbykev 8a02c75aec Step 0 input check: stop before wiring opportunity, and why
READ-ONLY. Live grade path byte-identical -- no layer wired, no threshold
moved, no challenger added, no holdout run.

INPUTS ARE 100% POPULATED (n=80 real MLB props, through the grader's own
path): ab_per_game, rest_days, l5_avg, l20_avg, l10_stddev and
game_count_in_7d all 100%; opp_rank_stat 65% overall and 0% on
stolen_bases. So there is no honest-degradation problem to solve.

FOUR FINDINGS THAT STOP THE WIRING, three of which would have made the
work unmeasurable or wrong:

1. THE PREMISE IS WRONG. There is no built opportunity layer to connect.
   ab_per_game is consumed in exactly one place -- analyzeViaEngine1:379,
   which renders "4.3 AB/G" on the grade card. engine1 has NO opportunity
   or usage factor at all. A projected opportunity was never built;
   building one is construction, not connection.

2. THE INPUT IS THE WRONG SHAPE. ab_per_game = season atBats/games. It is
   a per-player CONSTANT (measured: varies for 3 of 20 players, and those
   cannot be legitimate since the value can't depend on stat_type), so it
   can only move all of a player's props together, never separate them.
   And it is collinear with the projection: l20_avg = seasonTotal/games,
   the SAME denominator, so l20_avg already embeds opportunity
   multiplicatively. Adding it additively double-counts.

3. THE REAL INPUT DOES NOT EXIST. depthChartService returns battingOrder:
   null for MLB ("the one lineup slot the free schedule feed exposes") and
   PropLine /context carries lineup_confirmed as a BOOLEAN, not the order.

4. ARCHITECTURE: wiring it into engine1 would be unmeasurable BY THIS
   ORDER'S OWN TEST. Step 2 proves reliability and resolution, both
   measured on p_win. engine1 factors move the grade LETTER and never
   touch p_win. The layer belongs in probabilityEstimator, which already
   adjusts on opp_rank_stat, home_away and a consistency pull.

SEQUENCING IS ALSO STALE: challengerProjection (arch-v1) is already live
with archetype, matchup (platoon) and environment (park) axes, writing
p_win_challenger to the ledger. Step 2 of the order's sequence is partly
done -- and the harness this order needed already exists.

RECOMMENDED INSTEAD, as its own order: an `opportunity` axis on that
harness driven by DRIFT, not level -- recent AB/G (last 5) over season
AB/G. A deviation is not collinear the way the level is. Per-game atBats
is present in the statsapi log rows but MLB_LOG_FIELD never maps it, so it
is a small contained BUILD, which is why it gets its own order. Honest
caveat carried forward: it is still a proxy, not tonight's opportunity.

PROBE BUG RECORDED: the first run reported 0% for every feature including
l5_avg, on a pipeline that had just graded 365 props -- impossible, so the
probe was wrong. getFeatures takes camelCase and returns { features: {} };
I passed snake_case and read the top level. Fixed to call
computeFeaturesForProp. Same class as the earlier silent-false harness: a
measurement that makes working code look broken invites you to "fix"
something that was never broken.

Gates: 4,059 tests / 325 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
2026-08-01 02:40:46 -04:00
builtbykev 212c08b11f Fix the Step 0 probe: it was measuring itself, not the pipeline
The first run reported 0% coverage for EVERY feature including l5_avg --
which projectionFor requires, on a pipeline that had just graded 365
props. That is impossible, so the probe was wrong, not the pipeline.

Two bugs, both in my probe: featureCache.getFeatures takes camelCase
(playerName/statType) and I passed the prop's snake_case shape, and it
returns { features: {...} } while I read the top level. Either alone
yields all-zeros.

Now calls computeFeaturesForProp -- the grader's own entry point -- so it
measures what the grade path actually sees. Same class as the earlier
harness that returned a silent false: a measurement that makes working
code look broken is more dangerous than no measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:36:00 -04:00
builtbykev 3e78217678 Step 0 input check: read-only feature-coverage probe
Before wiring any layer into the grade, measure whether its inputs are
actually populated on real props. A layer wired onto sparse inputs does not
degrade gracefully by default -- Number(null) === 0 turns a missing
opportunity into 'zero opportunity', a fabricated input rather than an
absent one.

Reports population per feature, SPLIT BY stat_type, because a feature can
be 100% present for batters and 0% for pitchers and a pooled number would
hide exactly that. Also reports whether ab_per_game varies across a
player's own props -- a per-player constant can only move all of a
player's props together, which is a very different thing from a per-prop
opportunity signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:34:33 -04:00
builtbykev ecdc644621 Fix superseded assertion after the ?limit= bisect hook
runSnapshot now takes an opts object, so the route call is ('mlb', {}).
Asserted as EMPTY rather than loosened to any-object: a stray limit
reaching production would silently cap every run, which is the exact bug
the hook exists to diagnose.

I pushed the previous commit without reading the suite result -- the
failure was already on screen. Caught and fixed immediately after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:23:24 -04:00
builtbykev 11b0139481 Verify the cap raise on prod: 7 -> 365 graded props
Induced, not projected. DEFAULT_LIMIT=500 produced 365 graded props in
114s (was 7 in 16s) -- 52x the board. All 365 carry a unique forecast_rank
and ZERO leak p_win to anonymous callers, so the tier gating holds at 50x
the volume. Anon payload 220KB in 0.44s. Stat mix went from three stats to
ten. Health green.

Measured cost curve via the ?limit= bisect hook: 1->42s, 25->58s, 60->42s,
120->66s, 500->114s. About 42s of that is FIXED overhead (odds fetch,
roster logs, archetype classify, retention), paid whether we grade 1 prop
or 500 -- grading is the cheap part.

MY PRE-FLIGHT ESTIMATE WAS WRONG. I predicted ~72s from per-prop latency
measured in isolation, which ignored the fixed cost. Real figure 114s.

A FALSE ALARM RECORDED because acting on it would have meant reverting a
fix that works: the first induced run 502'd at 13.4s and I hypothesised
load -- memory or a proxy timeout under 20x the work. Wrong. A limit=25 run
then 502'd in 2 seconds, which no amount of load explains, and both
recovered on retry. The 502s were the deploy rolling, not the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:22:30 -04:00
builtbykev 8d052131c5 Add a bisect hook (?limit=) to the internal snapshot trigger
The cap raise 25 -> 500 made an induced snapshot 502 at 13.4s and the run
did not complete in background either, while a 25-prop run had completed
in 16.3s. That rules out a simple duration timeout and means the cause has
to be measured, not guessed. ?limit= bounds one run so the regression can
be bisected without a prod env change; omitted, the real DEFAULT_LIMIT
applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:12:07 -04:00
builtbykev 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 (f0c8b4f), commented "bound the herd" -- a guard
written before anyone measured what a grade costs. So I measured it:
721ms mean / 666ms median / 1024ms p90 per grade => ~72s for 500 props at
concurrency 5. Both callers tolerate that: the cron runs 5x/day and
recordDownstream is fire-and-forget.

PART 2 -- item 3 ONLY, because that is what the diagnosis supports.
DEFAULT_LIMIT 25 -> 500, env-tunable via GRADE_SLATE_LIMIT. Concurrency
stays 5 deliberately: the cap raise already multiplies load ~20x, and
concurrency decides how hard we hit statsapi at once. One variable at a
time.

Items 4/5/6 have nothing to act on and I am not manufacturing work for
them: 0 false thresholds to loosen (loosening would be manufacturing
grades); /context wiring is worth doing for grade QUALITY but would not
have graded one extra prop here, so it is not claimed as a coverage win;
archetypes are display-side and do not gate grading at all.

THE REFUSAL RATE DOES NOT DROP, AND THAT IS CORRECT. No threshold lowered,
no grade forced. The board grows because the cap stops discarding 95.7% of
the slate.

Flagged in advance rather than discovered later: snapshot payload and
ledger volume both scale with the same multiple. If the response gets
unwieldy the fix is a response-side cap on what the BOARD returns, never a
re-cap on what gets graded -- grading everything and serving a slice is
honest; grading a slice and calling it the slate is what this fixes.

Gates: 4,052 tests / 324 suites green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 02:06:25 -04:00
builtbykev 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
2026-08-01 02:02:53 -04:00
builtbykev 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
2026-08-01 01:55:43 -04:00
builtbykev 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
2026-08-01 01:29:20 -04:00
builtbykev 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
2026-08-01 01:24:55 -04:00
builtbykev 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
2026-08-01 01:12:47 -04:00
builtbykev 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
2026-08-01 00:57:12 -04:00
builtbykev 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
2026-08-01 00:55:04 -04:00
builtbykev 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
2026-08-01 00:50:54 -04:00
builtbykev 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
2026-07-31 23:52:28 -04:00
builtbykev 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
2026-07-31 23:47:13 -04:00
builtbykev 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
2026-07-31 23:44:37 -04:00
builtbykev 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
2026-07-31 23:39:38 -04:00
builtbykev 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
2026-07-31 23:37:23 -04:00
builtbykev 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
2026-07-31 23:34:03 -04:00
builtbykev 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
2026-07-31 23:19:23 -04:00
builtbykev 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
2026-07-31 22:35:16 -04:00
builtbykev 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
2026-07-31 22:16:11 -04:00
builtbykev 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
2026-07-31 22:15:10 -04:00
builtbykev 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
2026-07-31 21:26:21 -04:00
builtbykev 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
2026-07-31 20:12:20 -04:00
builtbykev 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
2026-07-31 19:57:21 -04:00
builtbykev 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
2026-07-31 19:38:19 -04:00
builtbykev 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
2026-07-31 18:42:44 -04:00
builtbykev 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
2026-07-31 17:19:39 -04:00
builtbykev 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
2026-07-31 17:07:54 -04:00
builtbykev 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
2026-07-31 08:30:32 -04:00
builtbykev 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
2026-07-31 07:56:08 -04:00
builtbykev 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
2026-07-31 07:53:44 -04:00
builtbykev 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
2026-07-31 07:43:12 -04:00
builtbykev 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
2026-07-31 07:31:45 -04:00
builtbykev 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
2026-07-31 07:29:24 -04:00
builtbykev 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
2026-07-31 07:21:36 -04:00
builtbykev 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
2026-07-31 07:19:51 -04:00
builtbykev 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
2026-07-31 07:07:13 -04:00
builtbykev 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
2026-07-31 07:06:33 -04:00
builtbykev 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
2026-07-31 06:41:15 -04:00
builtbykev 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
2026-07-31 06:40:43 -04:00