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
This commit is contained in:
+16
-12
@@ -19,7 +19,7 @@ const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots');
|
||||
// Session 67 — the model price never leaves the server for an unentitled
|
||||
// viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was
|
||||
// being bypassed here on every graded row. Same layer as the CLV gate.
|
||||
const { stripModelPrice, gateLiveGrades, liveLockedSummary, entitledToLiveGrades } = require('../utils/snapshotGating');
|
||||
const { stripModelPrice, gateItemizedGrades, liveLockedSummary, freeSample, entitledToItemizedGrades } = require('../utils/snapshotGating');
|
||||
const { resolveTierFromRequest } = require('../utils/requestTier');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -104,15 +104,19 @@ router.get('/:sport', async (req, res) => {
|
||||
// downgraded to `private` for authenticated callers — a CDN must never
|
||||
// hand a paid payload to an anonymous viewer.
|
||||
const tier = await resolveTierFromRequest(req);
|
||||
// BUILD 1 (2026-07-31) — THE SETTLED/LIVE GATE. Order matters: strip the model
|
||||
// PRICE first (S67), then withhold tonight's live JUDGMENT for unentitled tiers.
|
||||
// A RESOLVED grade passes through in full — reasoning included — because settled
|
||||
// reads are the free proof product and cost nothing once the outcome is known.
|
||||
const gate = (grades) => gateLiveGrades(stripModelPrice(grades, tier), tier);
|
||||
// The tease is an AGGREGATE ONLY (count + tier shape). It is computed from the
|
||||
// ungated rows and never joined back to one, so a free viewer can see that N
|
||||
// reads exist and their distribution without learning WHICH prop is the A.
|
||||
const teaseFor = (grades) => (entitledToLiveGrades(tier) ? null : liveLockedSummary(grades));
|
||||
// BUILD 1 CORRECTED (2026-07-31) — ITEMIZED GRADES ARE PAID, LIVE AND SETTLED.
|
||||
// The earlier resolution-flip freed settled grades, which made the free tier a
|
||||
// ONE-DAY-DELAYED FEED of the whole product. Order still matters: strip the model
|
||||
// PRICE first (S67), then withhold judgment on EVERY itemized grade.
|
||||
const gate = (grades) => gateItemizedGrades(stripModelPrice(grades, tier), tier);
|
||||
// Free proof, none of it itemizing the nightly slate:
|
||||
// - the tease: AGGREGATE count + tier shape, computed from the ungated rows and
|
||||
// never joined back to one, so nobody can tell WHICH prop is the A
|
||||
// - the sample: a CAPPED, day-rotated handful of resolved calls for texture
|
||||
const unentitled = !entitledToItemizedGrades(tier);
|
||||
const dayKey = new Date().toISOString().slice(0, 10);
|
||||
const teaseFor = (grades) => (unentitled ? liveLockedSummary(grades) : null);
|
||||
const sampleFor = (grades) => (unentitled ? freeSample(grades, dayKey) : null);
|
||||
const cacheHeader = req.headers.authorization ? 'private, max-age=30' : 'public, max-age=30';
|
||||
const [snap, outcomeLog, rosterBlob] = await Promise.all([
|
||||
cacheGet(`snapshot:${sport}:latest`),
|
||||
@@ -125,14 +129,14 @@ router.get('/:sport', async (req, res) => {
|
||||
if (snap && Array.isArray(snap.grades)) {
|
||||
res.set('Cache-Control', cacheHeader);
|
||||
const enrichedSnap = enrich(snap.grades);
|
||||
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: gate(enrichedSnap), deltas: snap.deltas || [], live_locked: teaseFor(enrichedSnap) });
|
||||
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: gate(enrichedSnap), deltas: snap.deltas || [], live_locked: teaseFor(enrichedSnap), free_sample: sampleFor(enrichedSnap) });
|
||||
}
|
||||
// Fallback: the grades envelope (no deltas yet).
|
||||
const env = await cacheGet(`grades:${sport}`);
|
||||
const grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||||
res.set('Cache-Control', cacheHeader);
|
||||
const enrichedEnv = enrich(grades);
|
||||
return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrichedEnv), deltas: [], live_locked: teaseFor(enrichedEnv) });
|
||||
return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrichedEnv), deltas: [], live_locked: teaseFor(enrichedEnv), free_sample: sampleFor(enrichedEnv) });
|
||||
} catch (err) {
|
||||
console.error('[snapshot]', err.message);
|
||||
return res.status(200).json({ sport, grades: [], deltas: [] });
|
||||
|
||||
Reference in New Issue
Block a user