Commit Graph

91 Commits

Author SHA1 Message Date
builtbykev 7f69fef14c Add an on-demand endpoint for the lineup-context ingest
The first prod run wrote zero rows while the parser demonstrably works locally
(144 rows, 10 games with lineups posted), so the zero was wiring rather than
absence -- but diagnosing that required a full snapshot, which now takes about
three minutes and 524s at the edge.

Same reasoning as the statcast refresh endpoint: a job is proven by running it
and reading the result, never by waiting for the slot it rides in. This makes
the ingest verifiable in seconds, so 'zero rows' can be told apart from 'no
lineups posted yet' immediately -- which is the exact confusion the defence
ingest hit when a doubled path 404'd and read as 'Statcast has no fielding
data'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 16:13:53 -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 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 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 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 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 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 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 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 72a14dc4cd Build /api/props/top-graded server selector: rank with p_win, serve without it
New READ endpoint. No grade, ledger row, lock_line, or scoring write. Push
scoring untouched.

REVIEW ZERO CORRECTED THE PREMISE: the handler NEVER EXISTED in any commit
(searched git rev-list --all for a /top-graded definition in src/ — zero hits).
Not "removed" — the three axios callers (cheatsheetGenerator, gradeOfTheDay,
widget) and the Next proxy were written against a phantom endpoint, so those
three content generators have silently received [] for their entire life.
Contract recovered from the four consumers, not guessed: {props:[...]},
?sport=UPPERCASE (absent = all sports, which gradeOfTheDay relies on) + ?limit,
rows carrying player/stat/line/direction/sport/grade/confidence? plus the
player_name/stat_type aliases and game_id.

POPULATED-PATH RISK FOUND: the board's populated branch had never run in prod,
and dashboard/page.tsx:463 calls g.stat.replace(/_/g,' ') UNGUARDED (g.player
also feeds the row key, /scan URL and heading; sport must be UPPERCASE for
SportPill). toRow requires non-empty string player+stat and a finite line,
uppercases sport, and DROPS unrenderable rows — a shorter board beats a broken
one.

THE LEAK BOUNDARY (why this is server-side): the browser cannot rank on p_win
for all tiers because stripModelPrice deliberately withholds it from unentitled
tiers. Order of operations is
  read cache -> RANK with p_win (every tier) -> map rows incl. model fields
    -> stripModelPrice(rows, tier) -> serialize
so a free caller receives the paid RANKING without the paid VALUES. Tier comes
from resolveTierFromRequest, which FAILS CLOSED to 'free'. Cache-Control is
private under a bearer token, public otherwise (the /api/snapshot precedent).

ONE SHARED DEFINITION, no drift: new src/utils/gradeRanking.js
(takeablePWin/descNullsLast/rankGrades). heroPropService now imports
takeablePWin instead of its inline copy (behaviour unchanged — it was that
logic verbatim); the selector imports rankGrades; web/src/lib/slateAdapter
keeps its mirror (the browser cannot import src/, S25) and a test cross-checks
the two on identical fixtures (playerName.js precedent). Board is grade-first
("top GRADES"), hero is p_win-first ("top read") — they differ BY DESIGN and
agree within the leading tier.

HONEST LIMIT: the Next proxy (cachedBackendJson) sends no Authorization header
and caches under a shared key, so via the dashboard every viewer gets the
free-tier payload — correct order, no paid values. That is the SAFE behaviour;
forwarding auth into a shared cache is exactly how a paid payload leaks to
anonymous viewers. Per-tier delivery through the proxy needs a tier-keyed cache
and is not done here.

Verified on real prod snapshot data (anonymous path): MLB 8 props, WNBA 10,
0 paid-field leaks, render-contract safe on every row, sport uppercase.

Floor: 311 suites / 3882 tests green (18 new — leak test uses POPULATED p_win,
not today's nulls: entitled gets p_win and it drove the order, unentitled gets
a byte-identical order with all five MODEL_FIELDS absent and no trace in
JSON.stringify, while book/fair market facts survive). Web build exit 0.
Dashboard visual is auth-gated -> tagged for the Chrome audit, not faked.

Held: edge_pct rescale/retirement (Order B); board columns/contract unchanged;
tier-keyed proxy caching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-29 22:05:30 -04:00
builtbykev 6552281661 CLV instrument repair: fix attachClosingProb read + recoverable market_unavailable
The closing_prob funnel collapsed 100k priced captures -> 59 usable. Root cause
(VERIFIED against prod, join key is PERFECT with 0 mismatches):
- attachClosingProb read closing_captures with .limit(50000) and NO ORDER BY on
  a 730k-row table that is 86% refusal rows -> saw ~7% for MLB, missed most
  priced closes and declared 200+ rows closeless that HAD a capture.
- market_unavailable_reason was write-once/terminal, so a row wrongly declared
  (truncated read / premature declaration before the capture was visible) could
  never recover even once its genuine capture existed. 298 rows (204 MLB + 94
  WNBA) were stuck this way.

Fix (CLV computation only — no grade/locked_odds/outcome touched):
- Read ONLY priced captures (missed_reason IS NULL, both odds NOT NULL), scoped
  to the candidate rows' game_dates -> small AND complete, no arbitrary truncation.
- Drop the market_unavailable exclusion from candidates; make it a re-checkable
  absence: a genuine close now UPGRADES the row (writes closing_prob, clears the
  verdict). closing_prob stays write-once (first true close wins). No capture +
  past game -> still declared absent (honest). No churn on already-absent rows.
- New internal trigger POST /api/internal/ledger/attach-closing[/:sport] for
  backfill + verification (scheduler already runs attach per tick).

Recovers ~312 usable closes (59 -> ~371), MLB included. Capture itself was
healthy all along (94.9% MLB / 95.8% WNBA per-prop coverage). Full suite 3835
green (17/17 instrument tests incl. 2 new recovery cases), web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-28 18:59:12 -04:00
builtbykev e81c9b8c51 Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).

Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).

Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.

Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.

HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-27 00:45:01 -04:00
builtbykev f33091ddb8 Layer 3 Step 4b: public park base, source-pluggable, plus game-level capture
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows
date and teams, never venue or final totals. But the grain is far cheaper than
per-prop or even per-game: ONE statsapi schedule call per game DATE returns
every game that day with venue, linescore and scoring plays. Fifteen games, one
call, verified live.

PUBLIC BASE — the ingestion was already done. The static FanGraphs table from
Session 15 is the public base; this converts its 100-indexed values into the
multipliers the composable architecture wants (Coors 128 becomes 1.28) rather
than ingesting a second copy of a number we already hold.

It is labelled COMMODITY in the code, not just in a comment. Every resolution
carries a provenance record, and the public one reads proprietary: false with
the note "Commodity: a public number. Not a VYNDR derivation." The proprietary
label exists but belongs only to the self-derived version, and only once it
beats this base on the instrument. A surface rendering a park effect can state
which it is rather than implying the flattering one.

Honest-absent where even the PUBLIC number is thin: a relocated club in a
temporary venue gets no factor, because a public number for a park with one
season behind it is no more trustworthy than ours would be.

SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only
accessor, public and derived return identical shapes, and callers never branch
on source — so when self-derived factors clear their floor they swap into the
same slot with nothing downstream to rewrite. A derived source with no factor
available returns absent rather than silently falling back to public, because a
silent fallback would make a proprietary claim out of a commodity number.

GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain,
deduped on game_id, never copied onto prop rows — a game's totals belong to the
game, and duplicating them per prop is how one fact starts disagreeing with
itself. Every field is tied to a named future derivation: venue for park
factors, runs for the run environment, HR totals for HR factors. Nothing else is
stored. Only Final games are captured, since an in-progress total is not a
result, and a game with no scoring plays reports HR as absent rather than zero.
HR totals come from scoring plays, which is complete because every home run
scores at least the batter.

The accrual target is stated rather than promised: 150 home games per venue at
roughly 81 per season means about two seasons before a self-derived factor can
be nominated, and accrualStatus() reports live progress per venue so the wait is
measurable.

Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the
pitcher's home-runs-allowed at the same park, mirrored on the under; San
Francisco negative; Tampa flagged weather-N/A with its factor still applying;
the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19
capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate
game ids.

Migration 035. Induce with POST /api/internal/gamectx/:date, progress at
/gamectx/accrual. Tests 3688 passed / 298 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 01:20:29 -04:00
builtbykev 528cb1a6d0 Layer 1: Statcast mechanism-data ingestion (backfill + nightly refresh)
The data foundation for the archetype and projection layers, built as the
pattern every sport inherits. Layers 2 and 3 are not touched.

PHASE 0 GATE — both match rates measured live, both 100%. Batters 40/40;
PITCHERS 66/66 across five real rosters (CLE, DET, MIN, NYY, LAD) joined by
MLBAM id against the 713-pitcher Savant feed. Zero honest-absent on identity,
because the join is an integer both systems use natively — and the snapshot
pipeline already stores it per graded row.

SOURCE — five Baseball Savant CSV leaderboards, free and public, pulled with
axios and the CSV parser savantAdapter already runs in prod. pybaseball is
deliberately NOT used: it is an MIT wrapper over these same URLs, and adding it
would reintroduce a Python runtime in a stack where the existing Python service
is already offline. min=1 on every feed, not Savant's default min=q, so the
long tail arrives and OUR minimum-sample gate decides what is thin — explicit
and testable rather than silently dropped upstream.

Measured: 1,354 rows per season (604 batters, 750 pitchers), all five feeds in
about five seconds. Pitcher mechanism includes arm angle, GB/FB/LD, chase and
whiff; batters get exit velo, launch angle, barrel and hard-hit, chase and
z-swing. Handedness rides in free on the movement feed (677 pitchers); batter
handedness stays absent pending a roster join rather than being guessed.

BACKFILL AND REFRESH ARE THE SAME CALL — a full re-pull upserted on
(sport, season, source_id). Idempotent and self-healing: a missed night
self-corrects on the next run, with no incremental who-played bookkeeping to
drift out of sync. At 1,354 rows the simple thing is also the robust one.

HONESTY RULES, each with a test: a metric the feed did not carry is null and
never 0; a thin sample is STORED and flagged rather than dropped or inflated,
because thin and missing are different claims; an unjoined player is stored
with a null player_key and joins later; and if every feed comes back empty the
job REFUSES to write, so a bad night can never blank a good table.

Freshness is treated as a truth property. updated_at on every row, and the
scheduler pages on a failed run AND on silent staleness — a job that stops
being scheduled never produces a failure, so staleness has to alarm on its own.
Never-built is deliberately not stale: different condition, different fix, and
paging on a fresh install teaches the operator to ignore the alarm.

Nightly at STATCAST_HOUR_UTC (default 11 UTC, after every game is final), kill
switch STATCAST=0, and induce-able at POST /api/internal/statcast/refresh with
a freshness probe at /statcast/status — we verify a refresh by running it, not
by waiting for the slot.

Migration 030 applied. Promoted columns for the classification-critical metrics
plus a metrics JSONB carrying every raw field, so Layer 2 can reach something we
did not promote without a re-ingest. Raw per-pitch stays out of Postgres on
purpose: one season is ~0.85 GB against a 500 MB plan ceiling, and it is
re-pullable from the free source if Layer 3 ever needs it.

Pattern documented in docs/MECHANISM-DATA.md for NBA tracking and NFL Next Gen.

Tests 3581 passed / 292 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 21:37:30 -04:00
builtbykev aaa41134d4 Close the second leak path: /api/hero-prop bypassed the snapshot gate
The landing hero reads the snapshot from Redis DIRECTLY via heroPropService,
so it never passed through routes/snapshot.js and was still serving model_odds,
ev_pct, value and takeable to anonymous visitors after the first fix. Same
strip, same tier resolution, same private-cache rule for authenticated callers;
the Next proxy now forwards the bearer token.

PRODUCT CONSEQUENCE, FLAGGED RATHER THAN BURIED: the landing hero is served to
anonymous visitors, so it now renders BOOK and FAIR with the model leg LOCKED
instead of the full triplet it showed this morning. That follows the stated
free-tier rule exactly, but it does trade a strong marketing moment (VALUE
+21.1% VS FAIR on the shop window) for consistency of the gate. Reversing is
one line — add 'model_price' to the free tier in src/config/tiers.js, or
special-case the hero route — and is a product call, not a correctness one.

Tests 3557 passed / 291 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 19:52:00 -04:00
builtbykev fbcb00b7b1 Close the public model-price leak; wire the read card's price layer
PHASE 0.5 GATE — the three checks, and one correction.

`fairLine` does not exist. Zero hits across src/ and web/src. Option A as
written had no referent, but it resolves better than feared: `fair_odds` is
already a real de-vigged American price on every graded snapshot row, so there
is nothing to derive.

  Gate 1 (is it a price): PASS. fair_odds is American odds from
  impliedProbToAmerican inside devigTwoWay; fair_prob is the probability. Both
  distinct from `line`, the stat threshold.

  Gate 2 (numeric match): PASS, 8/8 exact. Recomputed fair_odds and fair_prob
  independently from the stored raw over/under prices; every value matched the
  stored one to the integer and to 3dp. Same de-vig, same numbers the component
  was proven against.

  Gate 3 (poison independence): PASS, and proven on the quarantined cohort
  itself. devigTwoWay's inputs are (over_odds, under_odds) — market prices
  only, no model term is reachable. The 8 rows recomputed above are all
  wrong_opponent_grade rows, and their fair prices reproduce exactly from the
  market. The poison is in the grade, not the price. Quarantine therefore
  suppresses the MODEL leg only; the fair leg stands, as designed.

THE LEAK WAS REAL AND ALREADY LIVE. GET /api/snapshot/:sport is public and
unauthenticated, and it was serving model_odds, p_win, ev_pct, value and
takeable to anonymous callers on every graded row — 25 of 25 on the live wnba
board. The Session-66 gate on /api/analyze was bypassed entirely by this
endpoint.

The strip covers more than model_odds, because model_odds is not the only way
to read the model price: p_win IS the price in another base, and ev_pct is
INVERTIBLE — ev is a function of p_win and book_odds, and book_odds is public,
so leaving ev behind hands the price over. All five model-derived fields go.
book_odds, fair_odds, fair_prob, overround and devig_method stay on every tier:
the fair leg is never the paywall. Rows that keep a book+fair pair are stamped
model_price_locked so a gated price is never mistaken for a missing one.

Tier comes from resolveTierFromRequest, which reads a bearer token when one is
present and otherwise returns 'free'. It FAILS CLOSED on every error path, so a
resolution failure can only ever withhold the price. The response now varies by
entitlement, so the /:sport handler downgrades Cache-Control to private for
authenticated callers and the browser proxy forwards the bearer token —
otherwise a CDN could hand a paid payload to an anonymous viewer, or every
request would look anonymous and paid users would lose the leg.

READ CARD — a manual scan carries no market. The request is {player, stat,
line, direction}, so the engine has no over/under prices to de-vig and
book_odds/fair_odds are legitimately absent from its response; that is why the
triplet was hidden there. lookupSnapshotPrices recovers them from the
pre-graded snapshot via the same cache-only read this route already performs
for locked odds and team. The join is exact on player + stat + line + side
(fair_odds is side-specific), and returns nothing unless book and fair are BOTH
present — a user-chosen line the board never graded has no market attached, so
the triplet stays hidden rather than borrowing another line's price.

FAIR-LEG ABSENCE, measured before shipping: 636 graded rows, 636 with book,
636 with fair, 0 one-sided. Absence rate 0.0%. The hero number is not a
sometimes-number on current data.

Tests 3556 passed / 291 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 19:47:42 -04:00
builtbykev da8bfdf1db Render directional-CLV badge — Analyst+Desk, server-gated, receipt-bearing
CARD BADGE ONLY. Ticker-CLV explicitly DEFERRED (named, not lost).

PHASE 1 — SERVER-SIDE GATE AT THE DATA LAYER. A Free request never
RECEIVES dclv data: the CLV columns are appended to the SELECT only behind
canAccess(tier,'clv_badge') (new capability, analyst+desk), and responses
are ALSO stripped as defence in depth so a future SELECT change cannot
quietly leak. No CSS/client gate — data that reaches the browser has left
the building. dclv_fair_lock/fair_close are de-vig internals and are never
sent at all.

SURFACE AUDIT, all six channels, each test-locked to contain no CLV:
public profile (share link), snapshot/card feed, ticker feed, share
card/OG, embeddable widget, newsletter. A test also asserts no
ledger_entries read uses select('*') — a star would auto-leak every new
column, which is exactly how a gate becomes theatre.

PHASE 2 — IMMUTABLE ONCE COMPUTED. A settle can re-run (stat correction,
protested game) and a badge that flips positive->negative AFTER a user saw
or screenshotted it is a credibility failure. First computation wins: dclv
is only computed when dclv_computed_at is null, so a re-settle can never
rewrite a shown badge. Same discipline as the locked grade.

PHASE 3 — RENDER, test-first, ABSENCE IS HONEST. unknown / flat / null /
missing-receipt all render NOTHING — no element, no placeholder, no
"pending". Proven on an ALL-NULL board (today: 0 badges) and a MIXED board
(tomorrow: 1 of 4 badged, badge-less cards clean). Binary states only:
  positive -> MOVED TOWARD US  "graded -110 · closed -145"
  negative -> MOVED AWAY       "graded -110 · closed +120"
The RECEIPT is the persuasive part, so a badge with no numbers is
suppressed rather than shown as a bare claim. Negative is neutral context
and NEVER touches the locked grade — no back-door re-grading.

NO aggregate, count or rollup exists by construction: the module exports
exactly {clvBadge, fmtPrice} and a badge payload carries exactly
{tone,label,receipt} — asserted by test, because an on-screen tally would
be the held aggregate claim through the side door.

Build gotcha hit and fixed: clvBadge is CommonJS (allowJs) with no TS
types, so the .tsx needed an explicit cast at the call site — the build
worker exits 1 on type errors even though compilation "succeeds".

Suite 288/3473 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 13:37:52 -04:00
builtbykev 77a58e4113 Arm harness on our scheduler + start closing-line capture (capture only)
PART A — HARNESS ARMED ON OUR OWN INFRA. snapshotScheduler now runs the
nightly backtest at HARNESS_HOUR_UTC (default 14), appends to
harness_results, and pages via opsWatch.harnessStaleAlarm — a validator
that stops running looks exactly like one that keeps passing. No external
dependency: the join is plain SQL through the service client and the
harness is a pure function. POST /api/internal/harness/run induces the
same code path on demand, because a scheduled mechanism is verified by
inducing it, never by waiting for a slot.

PART B PHASE 0 — GATE PASSED for what is capturable:
- C4 diagnosed: closing_line is ONE overwritable field with no timestamp
  and no provenance. captureClosing writes the current line and, when a
  prop fails to match, silently leaves the earlier value (= the lock) in
  place — so "captured a real close" is indistinguishable from "never
  updated". It is 92% equal, not 100%: 56 rows DID record movement, so
  the defect is provenance, not the value.
- Feeds: normalized props already carry BOTH raw side prices per book,
  with game_time, and the intraday refresh polls every ~20 min during
  slate hours — so the last observable pre-lock line is available.
- SHARP close: pinnacle is in ALLOWED_BOOKS -> a no-vig reference is
  capturable ("beat the market").
- ODAWA: NOT capturable. 'odawa' exists only as a UI preference option in
  onboarding/settings; it is in no adapter, no ALLOWED_BOOKS, no feed. An
  un-capturable source is a finding, not a gap to paper over.
- JOIN: must drop `line` from the natural key, because a close that MOVED
  off the graded line is the entire point of CLV. Verified safe — all 164
  current identity groups have exactly ONE line per
  (sport, player_key, stat, side, game_date). Zero ambiguity.

PART B PHASE 1 — CAPTURE ONLY, built test-first. The refusal was proven
before the capture logic existed: unbound game_time, doubleheader
ambiguity, a missed pre-lock window, or a one-sided price all record
missed_reason with NO price. A stale or mid-day line substituted for a
close would manufacture a CLV proof from a number that was never the
close.

migration 029 closing_captures: append-only, never overwritten (that is
the provenance C4 lacked), BOTH raw side prices so the existing de-vig
engine can compute a fair closing probability later, sharp vs book line
types kept distinct. Wired into the intraday refresh with a capture-rate
alarm — a missed close is unrecoverable.

NO CLV metric built, as ordered. This starts the clock.

Suite 284/3417 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 12:05:16 -04:00
builtbykev 2bfae804da Verify off-box presence by reading the remote dir back
Exit 0 from the backup script is deliberately tied to ON-BOX durability,
so it is not proof the off-box copy landed. GET /api/internal/backup/offbox
runs rsync --list-only against BACKUP_REMOTE using the SAME pinned
known_hosts as the push (checking never disabled) and returns the dumps
actually present, with size and timestamp — so off-box presence is a
verified fact rather than an inference from an exit code.

Needed because the dev box cannot authenticate to the Storage Box: the
authorized key installed there is Kev's ~/vyndr-backup-key, not the
keypair generated in-session, so independent verification has to run from
the container that does hold working credentials.

Suite 280/3338 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 01:14:38 -04:00
builtbykev c4c9b97604 Off-box backup: pin the host key, guarantee the remote dir, page on failure
PHASE 1 — HOST KEY STATICALLY PINNED. ssh-keyscan -p 23 returned an
ED25519 key whose fingerprint EQUALS the out-of-band value
SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM, so it is safe to pin.
scripts/storagebox_known_hosts now carries that verified line and ships to
the container (Dockerfile already COPYs scripts/). backup-db.sh uses
StrictHostKeyChecking=yes + UserKnownHostsFile=<pin> instead of
accept-new, which was trust-on-first-use and would have accepted an
impostor on the very first run. A missing pin file REFUSES the push rather
than silently falling back. Never weakened to accept-new/=no//dev/null —
a test asserts that on executable lines.

PHASE 1b — REMOTE DIR GUARANTEED. The box has only .ssh/, and rsyncing a
file into a missing parent either fails or silently writes the dump AS the
directory name — one file, overwritten nightly, reading as "backups exist"
while retaining exactly one. Uses rsync --mkpath when available, else an
explicit remote mkdir -p ahead of the push.

PHASE 2b — FAILED OFF-BOX PUSH IS NOW LOUD. Off-box is required, so the
failed-push path pages at "urgent" (was "low"/deferred) and the script
emits a machine-readable OFFBOX_OK=1/0/deferred that
POST /api/internal/backup/run surfaces as a distinct offbox_ok field.
Exit code deliberately still reflects ON-BOX durability — a good on-box
dump must not raise a false total-failure alarm. Surfacing the truth, not
manufacturing a failure.

No key material is echoed anywhere; only the PUBLIC host key is committed.

Suite 280/3338 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 01:10:23 -04:00
builtbykev 97e4dc72d5 Backup: chown /app/backups in image + report uid/writability
The real backup run failed: pg_dump could not write to /app/backups —
'Permission denied'. Cause: the container runs as the non-root 'vyndr'
user (Dockerfile USER vyndr) and the Coolify-mounted volume is
root-owned, so the mount is present but unwritable.

- Dockerfile now creates AND chowns /app/backups to vyndr alongside the
  existing /app/data + /app/.pm2 line. Docker seeds ownership into a
  NAMED volume on first creation, so this fixes it for a fresh volume;
  a host bind-mount still needs a host-side chown, which is why the
  next change exists.
- GET /api/internal/backup/verify now reports process uid/gid,
  backup_dir_writable and the access errno, so the exact chown target is
  observable instead of guessed. A mounted-but-unwritable volume reads as
  'configured' everywhere else — this makes it loud.

Suite 278/3310 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 22:30:37 -04:00
builtbykev ef7f17610f Backup: durable on-box volume, off-box DEFERRED, and a real read-back check
BACKUP_DIR is now a persistent volume (/app/backups), so the dump already
survives redeploys — the container-ephemeral risk that made this urgent is
closed. Storage Box SSH auth is not sorted yet, so the off-box push is
explicitly DEFERRED rather than failing:

- gated on BACKUP_OFFBOX=1 (plus BACKUP_REMOTE and BACKUP_SSH_KEY); until
  then the script logs "off-box push DEFERRED" and exits clean.
- if an enabled push DOES fail, it is a LOW-priority "deferred" notice, not
  a failure — the durable on-box dump succeeded, and calling that an
  incident would train us to ignore backup alerts.

Adds the read-back check, because a backup nobody has read is a hope:
countRowsInDump() runs `pg_restore --data-only --table=X -f -` and counts
the rows between `FROM stdin;` and the terminating `\.`, proving the
archive CONTAINS the data rather than merely parsing. Needs no Postgres
server, so it runs inside the API container. Validated against a real
pg_dump from a scratch Postgres: counted exactly 604 rows.

GET /api/internal/backup/verify exposes it (newest dump in BACKUP_DIR,
size, table, rows_in_dump). Unit tests inject spawn/fs so CI needs neither
docker nor pg_restore.

Suite 278/3310 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 22:27:58 -04:00
builtbykev 40aba37f83 Backup: env-injected SSH key, nightly off-box push, triggerable run
Closing the backup for real. Three changes, each fixing something that
would have made the Storage Box target fail or silently rot.

1. SSH KEY COMES FROM ENV, not from the container. Generating a keypair
   inside the API container was the obvious move and it is wrong: the
   container filesystem is ephemeral, so the key dies on the next
   redeploy and the off-box push starts failing silently. backup-db.sh
   now reads BACKUP_SSH_KEY (a Coolify secret), writes it to a 0600 temp
   file per run, and removes it on exit via trap.

2. PORT 23, verified live. Hetzner Storage Box runs full OpenSSH on 23;
   port 22 answers with mod_sftp (SFTP only). Banner-checked both against
   u635423.your-storagebox.de. rsync now uses
   -e "ssh -p ${BACKUP_SSH_PORT:-23} ... -i <key>"; the old invocation had
   no -e at all and would have gone to 22.

3. OFF-BOX PUSH IS NIGHTLY, not Sundays-only. A weekly push meant up to
   six days of dumps existed ONLY inside an ephemeral container, which is
   the same as not existing. Alert copy updated to say exactly that when
   the push fails or is skipped.

Also adds POST /api/internal/backup/run (internal-key gated) so a real
backup can be TRIGGERED and OBSERVED — it returns exit code, duration,
output tail, and whether the remote + ssh key are configured. The backup
can only run where SUPABASE_DB_URL and the Supabase route live (this
container), and there was no way to fire or inspect it without a shell.

Connectivity established this session: Storage Box reachable from the dev
box on 22/23; Supabase :5432 NOT reachable from WSL2 (so the dump must
run in-container, as designed); docker IS available locally, so the
restore-verify can run against a scratch Postgres using the real dump.

Suite 278/3305 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 21:42:27 -04:00
builtbykev 3b12c6ca98 Item 0 — founder count = REAL active Stripe subscriptions (kills the phantom 1)
The counter showed 1/100 from user_profiles (founder_pricing=true AND
subscription_status='active'), but the live Stripe account has ZERO
subscriptions of any status — the "1" is a comped/manually-tiered profile, not a
paying founder. A tier/founder_pricing field on a profile can be set without
ever paying, so it is not proof of a paid seat.

Now the count is Stripe's OWN truth: stripeService.countFounderSeats() counts
ACTIVE subscriptions on a founder price. The route reads that (cached 5 min);
null or any failure → hidden, never a number. A comped profile no longer counts
→ the honest number is 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:22:33 -04:00
builtbykev cb3237cdce Item 6 — Desk showcase renders REAL data (or hides), kills the mocked ladder
The pricing Desk showcase hardcoded an alt-line ladder (1.5 A +7.1% / 2.5 A+
+11.4% / 4.5 C -3.8%), QUARTER-KELLY 2.4%, and PARLAY φ 0.34 — a mocked demo
selling something we weren't proving.

- deskShowcaseService reads the pre-graded snapshot for a real A/B prop's
  alt-line ladder (prefers the one with the most grade variation — the most
  compelling real example). Edge per rung shows only when it's a plausible
  market value; the inflated (model-line)/line artifact on small lines is
  guarded to "—" rather than shown as a fake +91%.
- PARLAY φ is now REAL: the model's same-team correlation (0.34, mirroring the
  frontend parlayMath team constant) computed for TWO REAL same-team legs,
  named. No real same-team pair on the board → the tile hides, never an
  invented number.
- QUARTER-KELLY tile is REMOVED: the snapshot has no odds, so a real
  quarter-Kelly % can't be computed here — a fabricated 2.4% is worse than
  nothing. Kelly stays a real in-app Desk feature; the showcase just doesn't
  fake it.
- DeskShowcase is now a client component fetching /api/desk-showcase; when the
  board has no real ladder the whole visuals column hides (real-or-hidden, same
  law as the hero). The pitch copy is unchanged.

5 service tests. Change-affected suites green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:42:16 -04:00
builtbykev 9b9aab4262 Item 5 — daily hero prop is a live RULE (biggest model-vs-market disagreement)
The landing hero was a static Jokic "Example" card with a name-length pick and a
hardcoded A- 73% +6.2% fallback. Now it's deterministic and live:

- heroPropService.pickHeroProp reads the pre-graded snapshot and selects the
  prop with the LARGEST |projection - line| gap among A/B grades (conviction,
  not noise) — the read where VYNDR disagrees most with the market, the card
  that makes a stranger argue. No curation, no grading (reads cache → no API
  credits). GET /api/hero-prop (backend) + repointed Next proxy.
- The card shows the disagreement EXPLICITLY: the book's line vs VYNDR's model,
  side by side (model in green), with the real grade timestamp ("Graded 2:14
  PM"). The EXAMPLE chip is gone.
- Empty slate → the MOST RECENT real graded read (flagged "LATEST READ", real
  date). Nothing cached → { available:false } and the card HIDES. No
  hand-written fallback — the Jokic card is deleted. Survives a dead night: a
  live rule shows tonight's real MLB read, never a phantom July NBA card.

7 service tests lock the rule (max-gap, A/B gate, projection/line required,
empty→recent, hidden, cross-sport). colorContract updated to the new
disagreement display. Change-affected suites green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:29:55 -04:00
builtbykev 89a2977f57 Item 7 — public accuracy reads the CLEAN ledger; BEAT CLOSE hidden until C4
Kev's call: the 30D accuracy surfaces must read TRUTH, not a cache that can't be
filtered. My earlier degraded-row exclusion only touched getModelAggregate
(Postgres); the public buckets/badge still read outcomeService (Redis outcome
log), which counts degraded projection-0 outcomes and has no field to filter on.

- /api/accuracy (AccuracyBadge) + /api/ledger/accuracy (buckets/ModelRecord)
  now source from the clean Postgres ledger aggregate via new
  ledgerService.getAccuracyView + accuracyBucketsFromAgg (model_value > 0
  excludes degraded rows). Same response shapes → no frontend change. Redis
  outcome log is now read by nothing public; it can age out or be rebuilt.

- BEAT CLOSE is a MEASURED-WRONG ZERO: captureClosing re-records the locked line
  as the "closing" line, so clv is flat on the whole sample and beat_close reads
  0% (comparing a number to itself). Full write-up: specs/audit-data/
  clv-capture-broken.md (the fix belongs to C4). Until then, beat_close_pct +
  clv_distribution are SUPPRESSED at the source (getModelAggregate, gated by
  clvCaptureReliable() / CLV_CAPTURE_RELIABLE=1). Every public surface already
  renders BEAT CLOSE only when non-null, so they all hide it now — no wrong zero
  anywhere. HIT RATE (real) is unaffected.

Suite 271/3261 green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:08:18 -04:00
builtbykev 41fc2b90e2 Item 2 — founder counter is REAL or hidden (kills the hardcoded 47/100)
ClaimMeter rendered a fabricated "47 / 100 CLAIMED" (a hardcoded default; the
comment even said "Cosmetic conversion driver… Static here"). Now:

- GET /api/founders/count counts ONLY real paying founders — user_profiles
  where founder_pricing = true AND subscription_status = 'active' (the
  Stripe-webhook-synced mirror, so we never hammer the Stripe API). Cached 5
  min in Redis on top of that.
- If the source is unavailable (Supabase unconfigured, query error, column not
  migrated, client throws) the endpoint returns { available: false } and the
  ClaimMeter renders NOTHING — counter and progress bar both hidden. We never
  fall back to a number.
- A low real count is shown honestly (0 → "0 / 100"); the truth is the feature.

Next proxy at app/api/founders/count. 6 route tests cover real count, low
count, error/unconfigured/throw → hidden, and cache-hit. Suite 271/3260 green,
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:32:18 -04:00
builtbykev 2d413cfe1e Quota guard: close the silent odds-api drain + reserve floor for MLB
Diagnosis (why 500/500 went unpaged): the only regular odds-api burner was
futuresService, which called axios DIRECTLY — bypassing the gateway, so it
never hit recordCall (the ONE place the WARN/BLOCK pager fires) and never
respected the 95% block. It only syncFromHeaders, which updated the counter's
number SILENTLY. oddsService (which does go through the gateway) only touches
odds-api when PropLine fails, so recordCall for odds-api effectively never ran.
Result: the counter could reach 100% with neither pager firing.

Fixes (a silent drain is now impossible, not just guarded):
- futuresService routes through gateway.fetch('odds-api', …) → counted, blocked
  at 95%, and reserve-gated. Closes the raw-axios bypass.
- Reserve floor in the gateway: a DISCRETIONARY call (futures/soccer) passes
  reserve=ODDS_API_RESERVE (default 50) and is refused while remaining <= reserve.
  The ESSENTIAL MLB prop-backup passes no reserve and may spend to the 95% block.
  → a futures/soccer drain can NEVER starve MLB's backup path.
- quotaTracker.syncFromHeaders (the AUTHORITATIVE number) now fires the same
  once-per-period WARN/BLOCK alert on a crossing — extracted fireThresholdAlert
  shared with recordCall. The header-only drain now pages.
- POST /api/internal/quota/test-alert (internal-key) test-fires the pager
  end-to-end so ntfy delivery is verifiable on demand.

Also (reality-corrected cadence): WNBA restored to the full grid. 2026-07-15
had two AFTERNOON WNBA games finished before the 22 UTC slot — 14 UTC (10am ET)
is the only slot early enough for a 1pm ET game's props, and on PropLine the
extra slots cost a rounding error. Soccer stays the only trimmed sport (the
real odds-api discipline). Assumption corrected by observed data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:07:14 -04:00
builtbykev 7712f0a442 Heartbeat honesty: SYNC badge reads refreshed_at, not grade-lock updated_at
The "SIGNAL LIVE vs STALE 8h" contradiction was a field mismatch, not a dead
pipeline. updated_at is the grade-LOCK time (advances only on a full snapshot,
5×/day — grades never change in-game, so it is intentionally stable). The
SYNC badge measured the 20-min intraday cadence (expected_interval_s=1200)
against that 5×/day field → structurally guaranteed STALE between slots even
when intraday refreshes lines perfectly.

- snapshotService: full snapshot now seeds refreshed_at at lock time
- intradayRefresh already bumps refreshed_at every ~20 min (unchanged)
- /api/snapshot/summary + GET /:sport now expose refreshed_at (was written to
  Redis but never serialized → no public liveness signal existed)
- LiveLayer SYNC badge measures freshness from refreshed_at (fallback updated_at)

Exposing refreshed_at also gives a public heartbeat probe: it advances every
intraday slot, so pipeline liveness is verifiable without container logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:09:18 -04:00
builtbykev f0752b804b Wave 2A: offseason data feeds — news wire + quota-disciplined futures
FREE ESPN news wire + championship-winner futures for the never-dark
offseason hub. Both graceful/empty, never fabricate a market value.

- newsService (mirrors injuryService): per-sport ESPN /news FEEDS, pure
  parseNews → { sport, items:[{id,headline,description,published,type,
  athlete?{name,key},team?,href}] }; athlete/team from categories[] only
  (absent when not present). Cache 15m, injectable, offline-tested.
- oddsNormalizer.normalizeOutrights: NEW branch — outrights outcomes are
  {name,price} with no point, so normalizeProps drops them; keeps them with
  best-price-across-allowed-books per selection. + americanToDecimal.
- oddsService.FUTURES_KEYS: separate map (mlb/nba/wnba championship winner),
  OUT of the daily SPORT_KEYS/snapshot budget.
- futuresService: getFutures(sport,deps) → { sport, updated_at, markets:
  [{key,title,selections:[{name,price,prevPrice?,move?}]}] }. One outrights
  call per 12h TTL (quota-disciplined), FUTURES_ENABLED gate. Price-move
  (shortening/drifting/flat) mirrors computeLineDeltas SHAPE on odds not
  line; prev prices persisted inside the futures:{sport} value (no new key).
  linkNewsToMoves pure causal-tie helper.
- Routes /api/news/:sport + /api/futures/:sport (registered) + Next proxies.
- Tests: newsService, futuresService, oddsNormalizerOutrights (fail→pass,
  no network). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:33:26 -04:00
builtbykev 54fa5853f5 Wave 6: Combat Intelligence Layer (honest free v1)
Net-new MMA/UFC vertical — fight-card discovery, tale-of-the-tape,
style-blend archetypes, ML + round-total odds, and a style-edge VERDICT
(a MODEL read, explicitly NOT a settled grade). Built to
specs/combat-intelligence.md.

Backend:
- combatAdapter: ESPN MMA scoreboard (date-pinned, free JSON) -> fight
  cards + tale-of-tape (record/weight class/rounds/ESPN athlete id);
  defensive parse (null on unknown shape, never throws); injectable
  fetchImpl + cache; pure normalizeCombatOdds (odds-api h2h/totals ->
  ML + round total, allow-listed books, best price). Number(null) guard.
- archetypeService: 6 pinned combat styles in a SEPARATE COMBAT_ARCHETYPES
  registry (FINISHER collides with soccer + its green trips the signal-
  green gate); classify('mma') blends range/tempo/outcome, honest-empty on
  thin data (no forced fallback); styleMatchup() honest verdict.
- oddsService: SPORT_KEYS.mma + MMA_MARKETS=['h2h','totals'] + SPORT_MARKETS
  (no spreads suffix). oddsNormalizer MARKET_MAP h2h/totals.
- config/sports.js + web mirror: mma.active=true (collectData stays false;
  NOT in the graded-props pipeline SPORT_CONFIG or snapshot/settle loop).
- routes/combat.js: GET /api/combat/:date + GET /api/fight/:id (public,
  cached, honest empty off-card) + Next proxies.

Frontend:
- FightCard: two-fighter tale-of-the-tape (initials monogram — no photos),
  GRAPPLER/STRIKER blend bars, discipline pedigree tags, shared
  ArchetypeBadge (sport="mma", unicode glyphs), CENTER VERDICT, ML +
  round-total real; method/round/KO = honest "data-limited", never
  fabricated. Self-hides on a non-two-fighter bout.
- /fight/[id] page (server wrapper + client), EmptyState off-season.
- MMA SportBadge token (#D4AF37); archetypes.js sport-aware resolution.

DEFERRED (per spec, NOT built): matchup-GRADE engine, method/round/props
board, combat settlement, ufcstats scraping.

Tests: +3 suites (31 tests) — combat archetype cross-file color/glyph
match, classify blends, styleMatchup honesty, adapter defensive parse +
odds normalize, FightCard honesty grep; extended oddsNormalizer +
sportMarkets. Full suite 253/253 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:58:22 -04:00
builtbykev 9c85f25aea Merge Wave 5B (wiring/data): pitcher arsenal via Baseball Savant (D4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:04:06 -04:00
builtbykev 11fc5a66d2 Wave 5B: Pitcher Arsenal via Baseball Savant (Statcast)
D4 — build the FREE Baseball Savant adapter for pitch-level identity
(mix / velo / usage% / whiff%), the missing layer statsapi doesn't carry.

- savantAdapter.getPitcherArsenal(id|name) — normalizes two public Savant
  CSV leaderboards (csv=true, NO parsing dependency): pitch-arsenal-stats
  (usage% + whiff% + K%) + pitch-arsenals avg_speed (velo). League-wide,
  cached 24h + in-memory mirror, indexed by MLBAM id. Defensive: null on any
  unrecognized shape; a missing velo/whiff is ABSENT (null), never 0.
  Injectable (fetchImpl/statsCsv/veloCsv/resolveId) → tests hit no network.
  Live endpoints VERIFIED (200, exact columns) from the sandbox.
- GET /api/stats/pitcher/:name/arsenal (stats.js) + Next proxy. MLB-only;
  an error/miss returns { found:false } so the card self-hides honestly.
- PitcherArsenal.tsx (+ barrel) — the mockup's PITCHER IDENTITY strip:
  pitch mix % + velo + whiff%, mono/tabular, ranked by usage, sharpest-whiff
  pitch highlighted green. Self-hides (heading included) when arsenal absent.
  Mounted on the MLB player profile (a pitcher surface). Context, not a
  graded market value.
- Tests: savantAdapter (fake CSV → ranked arsenal; unknown shape/blank cells
  → absent not 0; name→id resolve) + PitcherArsenal source locks (self-hide,
  mono/tabular, em-dash-not-zero). +2 suites / +17 tests (3012 → 3029).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:02:16 -04:00
builtbykev 76c289d4c1 Wave 5A: /u house-mode public profile (D2)
Reserved house handle (default 'vyndr', env HOUSE_HANDLE) resolves to the
PUBLIC model record — getModelAggregate() with no userId (user_id=NULL rows)
— WITHOUT a public_profiles row. It is the ONLY special case; every other
handle keeps the private-by-default, byte-identical-404 no-existence-leak
contract. The house profile is always public and never 404s (a fetch failure
degrades to an honest building state).

- src/routes/profiles.js: house short-circuit + sendHouseProfile (public
  aggregate + by_tier + public settled entries), reserved before the publish
  lookup so a user claim is shadowed.
- PublicProfile.tsx: house label 'VYNDR MODEL · PUBLIC RECORD' + hero/subtitle
  off data.house; keeps the CLV-VERIFIED record hero + TierRecord calibration
  + recent settled reads (misses included).
- opengraph-image.tsx (1200x630): house-branded eyebrow/heading.
- portrait/route.tsx: new 1080x1350 share crop (real aggregate or tagline
  fallback, never a fabricated number).
- Discoverability: 'VIEW AS PUBLIC PAGE ->' on the ledger MODEL header +
  'VIEW PUBLIC RECORD ->' under the landing ModelRecord, both to /u/vyndr.
- tests/unit/houseProfile.test.js: house resolves to user_id=NULL aggregate
  (no public_profiles row) + by_tier; unknown/unpublished user handles stay
  byte-identical 404; page renders house label + TierRecord + portrait crop.

3019 tests green (3012 -> 3019); next build EXIT=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:26:48 -04:00
builtbykev b5d3fd14bb S11 (a1): live tracking — the read locked, the game watched
MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:45:29 -04:00
builtbykev d3637e7abd S6 (a1): display — the full picture under the grammar
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
  law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
  StatStrip violations fixed: MovementChip before the grade (market
  context before model output); ViabilityChips after the archetype
  (identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
  real {t,line} points per grade (seeded with the lock, deduped when
  flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
  renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
  /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
  StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
  buckets, outliers clamped) only past the centralized n>=20 gate;
  ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
  players via /api/players/search per sport + static lib/teams.js
  (soccer deliberately absent); Nav search icon + Search first in the
  mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
  first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
  (4 decorative font files off the slow-4G critical path).

2654 -> 2698 tests (226 suites) green; web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:08:24 -04:00
builtbykev 1d46b446c9 Merge S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	CLAUDE.md
2026-07-11 19:40:43 -04:00
builtbykev caf09840d5 Merge S9 (a1): slip reader — zero-API OCR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:39:56 -04:00
builtbykev 02c17a65c3 S5 (a1): prop viability — lineups, injury wire, date navigation
- lineupService: statsapi hydrate=lineups (live shape verified) →
  CONFIRMED (batting slot) / NOT_IN (team posted without the player) /
  PROJECTED (not posted). 10-min cache, pure parser, injectable.
- NOT_IN visibly KILLS the grade on the slate: struck through + NOT IN
  LINEUP chip, parlay/book actions suppressed. The locked ledger read is
  untouched — honesty is showing the read is dead, not deleting it.
- injuryService: ESPN injuries feed → OUT/GTD/PROB chips (unknown status
  → no chip, never invented). Chips on slate strips via ViabilityChips.
- Date navigation on the Slate: YESTERDAY (results surface — finals +
  THE SETTLE panel of that date's settled reads w/ outcome + CLV chips,
  via new ?date= filter on /api/ledger/model) / TODAY / TOMORROW
  (schedule until lines post). Odds/grades/pitcher layers are TODAY's
  and never fake other dates; 60s poll only refreshes today.
- Routes /api/schedule/:sport/lineups + /injuries + Next proxies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:38:37 -04:00
builtbykev b20145c215 S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:22 -04:00
builtbykev 4e49ee0990 S9 (a1): slip reader — zero-API OCR
tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:17 -04:00
builtbykev fc0c7bfd18 Merge S7 (a1): newsletter — THE VYNDR REPORT
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	CLAUDE.md
2026-07-11 14:32:18 -04:00
builtbykev 48eb420f9e Merge S3 (a1): affiliate + partner plumbing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:31:53 -04:00
builtbykev 0e7871ac0e S4 (a1): the media engine — VOICE templates, /desk, Ghost drafts
4a VOICE v1.1 committed (board start); lint is EXECUTABLE — banned list +
   no-exclamation law enforced in the engine (throws in test, drops in
   prod) and locked by tests. Curly-apostrophe variants covered.
4b mediaEngine: deterministic templates (MORNING WIRE, SIGNAL, STREAK
   WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH) filled
   ONLY from snapshot/ledger/streaks JSON. Record percentages never render
   under n>=20 (counts + 'Record building' below). Stark layer = curated
   committed library (content/stark-lines.json), day-rotated selection —
   selected, never generated.
4c /desk (founder-only: requireAuth + DESK_OWNERS email allowlist,
   deny-by-default): all formats as text + <=280-char pre-segmented tweets
   with per-tweet copy buttons + char counts, wire/numbers-only variants,
   DATA BRIEF block (structured day numbers) with copy-for-claude.ai.
   ntfy ping after the day's first snapshot: 'Desk pack ready'.
4d ghostPublisher: DRAFTS ONLY (status:'draft' test-locked), env-gated
   no-op, HS256 JWT via node crypto (zero new deps). POST
   /api/internal/ghost/drafts saves slate preview + settle drafts.
   Nothing anywhere auto-posts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:31:35 -04:00
builtbykev 32d7200571 S7 (a1): newsletter — THE VYNDR REPORT
Email capture + daily report assembly + operator-triggered Listmonk send.

- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
  (the real signup success surface); double-opt-in note; 'Signups open soon'
  when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
  server-side email validation, forwards to Listmonk subscribers API with
  preconfirm_subscriptions:false (Listmonk sends the confirmation).
  No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
  web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
  STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
  ledgerService.getModelAggregate (percentage only when hit_pct != null —
  n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
  1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
  VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
  only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
  refuses an empty report. Deliberately UNSCHEDULED — only
  POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
  list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.

Tests 2398 -> 2429 (207 suites, all green); next build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:29:22 -04:00
builtbykev 0996320bd1 S3 (a1): affiliate + partner plumbing
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.

- BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js
  (all books enabled:false, Impact/Partnerize param shapes documented,
  empty params skipped). Wired into StatStrip BookItTeaser (real anchor
  now) + scan hand-off links. Every book anchor renders
  rel="sponsored noopener noreferrer" (BOOK_LINK_REL).
- Best-price marker: slateAdapter.detectBestBook (only when >=2 books
  post the SAME line and prices differ — absent beats wrong) + subtle
  signal-green dot in StatStrip. Slate.groupByGame threads the grouped
  per-book rows (books[]) onto PropRowProp instead of discarding them.
- Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch,
  PartnerRefCapture in layout) -> signup metadata partner_ref ->
  internal GET /api/partners/report/:code (requireInternalAuth; honest
  zeros + note until the TODO migration in docs/PARTNERS.md adds
  user_profiles.partner_ref — NOT run). Stripe promo-code convention:
  partner code == promotion code, verbatim.
- Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens
  invariants updated to the new (stronger) rel contract. Web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:28:18 -04:00