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:
Kev
2026-07-31 07:29:24 -04:00
parent 7ddf159e4a
commit 713f90183f
4 changed files with 130 additions and 121 deletions
+16 -12
View File
@@ -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: [] });
+57 -38
View File
@@ -72,27 +72,27 @@ module.exports = {
};
/* ===========================================================================
* BUILD 1 — THE SETTLED/LIVE GATE (2026-07-31, specs/tier-redesign-spec.md)
* BUILD 1 (CORRECTED) — ITEMIZED GRADES ARE PAID, LIVE **AND** SETTLED.
*
* THE RULE: a grade is PAID while its outcome is unknown, and becomes FREE the
* moment it resolves. Resolution is the flip point, and it 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 "probably over" is exactly how a
* live edge would leak.
* WHY THE RESOLUTION-FLIP WAS WRONG: settlement is nightly, so freeing a grade
* at resolution turns the free tier into a ONE-DAY-DELAYED FEED OF THE WHOLE
* PRODUCT. A bettor watching one cycle behind gets the entire method for free.
* That exploit is why there is no per-grade flip here: an itemized grade —
* tonight's or last week's — is Analyst+.
*
* FAIL CLOSED: anything we cannot prove is resolved is treated as LIVE (paid).
* A settlement failure therefore WITHHOLDS content rather than exposing it —
* the same direction `resolveTierFromRequest` fails.
* WHAT FREE GETS INSTEAD, and it is not a crippled demo:
* 1. the whole data aggregator (schedule, per-book lines, stats, streaks, hubs)
* 2. the AGGREGATE track record — tier hit-rates, CLV, calibration, accuracy
* over time — served by /api/accuracy + /api/ledger/accuracy, computed FROM
* settled data but never itemizing the nightly slate
* 3. a CAPPED, DAY-ROTATED sample of resolved calls for texture
* 4. the locked shell of tonight's reads: they exist, and their shape
*
* THE LOCKED SHELL: an unentitled viewer still sees that tonight's reads EXIST
* and their shape — player, market, line, the real book/fair prices, the stats
* anyone can already get free — plus an AGGREGATE count and tier distribution.
* What is withheld is the model's JUDGMENT. And the distribution is aggregate
* ONLY: per-row tier is never emitted, so no one can work out WHICH prop is the A.
* THE LINE: aggregate proof is free; the itemized judgment is the product.
* ========================================================================= */
/** Model JUDGMENT on a live read. Data stays; the verdict and its argument go. */
const LIVE_JUDGMENT_FIELDS = Object.freeze([
/** Model JUDGMENT on ANY itemized grade. Data stays; the verdict and its argument go. */
const ITEMIZED_JUDGMENT_FIELDS = Object.freeze([
'grade', 'confidence', 'confidence_basis', // the verdict
'reasoning', 'kill_conditions_triggered', // its argument
'projection', 'edge_pct', // our number vs the line
@@ -100,10 +100,7 @@ const LIVE_JUDGMENT_FIELDS = Object.freeze([
'alt_lines', 'kelly', // Desk tools
]);
/**
* A grade is RESOLVED only when a real outcome is written on it. `void` and
* `unrecoverable` ARE resolutions (terminal results, no live edge left).
*/
/** Kept for the free SAMPLE + the aggregate: is a real outcome written on this row? */
function isResolved(g) {
if (!g || typeof g !== 'object') return false;
const o = g.outcome;
@@ -112,15 +109,12 @@ function isResolved(g) {
return typeof o === 'object' && typeof o.result === 'string' && o.result.trim() !== '';
}
/** Does this tier get tonight's live reads? (free/anon: no) */
function entitledToLiveGrades(tierName) {
/** Does this tier get itemized grades at all? (free/anon: no) */
function entitledToItemizedGrades(tierName) {
return canAccess(tierName, 'reasoning_visible');
}
/**
* The aggregate tease: how many live reads exist and their tier shape.
* Aggregate ONLY — never joined back to a row.
*/
/** Tonight's tease — AGGREGATE ONLY, never joined back to a row. */
function liveLockedSummary(grades) {
const live = (Array.isArray(grades) ? grades : []).filter((g) => g && !isResolved(g));
const tiers = {};
@@ -132,27 +126,52 @@ function liveLockedSummary(grades) {
return { count: live.length, tiers };
}
const FREE_SAMPLE_CAP = 3;
/**
* gateLiveGrades(grades, tierName) — entitled tiers pass through untouched.
* Unentitled: resolved grades pass FULL (including reasoning — settled is the
* proof product and costs nothing post-resolution); unresolved grades are
* reduced to the shell.
* freeSample(grades, dayKey) — a TASTE, not the archive.
*
* Up to FREE_SAMPLE_CAP RESOLVED calls, in FULL (reasoning + outcome), chosen by
* a day-derived offset so the set rotates daily and is stable within a day. The
* cap is what kills the exploit: 3 rotating past calls cannot reconstruct a
* nightly slate, whereas the full settled list is the feed one cycle late.
*/
function gateLiveGrades(grades, tierName) {
function freeSample(grades, dayKey) {
const resolved = (Array.isArray(grades) ? grades : []).filter(isResolved);
if (resolved.length === 0) return [];
const key = String(dayKey || '');
let h = 0;
for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
const start = resolved.length ? h % resolved.length : 0;
const out = [];
for (let i = 0; i < Math.min(FREE_SAMPLE_CAP, resolved.length); i += 1) {
out.push(resolved[(start + i) % resolved.length]);
}
return out;
}
/**
* gateItemizedGrades(grades, tierName) — entitled tiers pass through untouched.
* Unentitled: EVERY grade (live or settled) loses its judgment and is stamped
* `locked`, keeping only the free-side data so the board still reads as real.
*/
function gateItemizedGrades(grades, tierName) {
if (!Array.isArray(grades)) return grades;
if (entitledToLiveGrades(tierName)) return grades;
if (entitledToItemizedGrades(tierName)) return grades;
return grades.map((g) => {
if (!g || typeof g !== 'object') return g;
if (isResolved(g)) return g; // settled ⇒ free, in full
const shell = { ...g };
for (const f of LIVE_JUDGMENT_FIELDS) delete shell[f];
shell.locked = true; // the card renders the unlock prompt
for (const f of ITEMIZED_JUDGMENT_FIELDS) delete shell[f];
shell.locked = true;
return shell;
});
}
module.exports.LIVE_JUDGMENT_FIELDS = LIVE_JUDGMENT_FIELDS;
module.exports.ITEMIZED_JUDGMENT_FIELDS = ITEMIZED_JUDGMENT_FIELDS;
module.exports.isResolved = isResolved;
module.exports.entitledToLiveGrades = entitledToLiveGrades;
module.exports.entitledToItemizedGrades = entitledToItemizedGrades;
module.exports.entitledToLiveGrades = entitledToItemizedGrades; // back-compat alias
module.exports.liveLockedSummary = liveLockedSummary;
module.exports.gateLiveGrades = gateLiveGrades;
module.exports.freeSample = freeSample;
module.exports.FREE_SAMPLE_CAP = FREE_SAMPLE_CAP;
module.exports.gateItemizedGrades = gateItemizedGrades;
+56 -70
View File
@@ -1,8 +1,7 @@
/**
* BUILD 1 — the settled/live gate (specs/tier-redesign-spec.md).
* The load-bearing monetization boundary: unresolved = paid, resolved = free.
* BUILD 1 CORRECTED — itemized grades are PAID (live AND settled).
* The property that matters: the one-day-behind exploit must be DEAD.
*/
const request = require('supertest');
const g = require('../../src/utils/snapshotGating');
const settled = { player: 'Settled Sam', stat_type: 'points', line: 12.5, book_odds: -110, fair_odds: -104,
@@ -10,92 +9,79 @@ const settled = { player: 'Settled Sam', stat_type: 'points', line: 12.5, book_o
kill_conditions_triggered: [{ code: 'X', reason: 'r' }], projection: 15, edge_pct: 20,
outcome: { result: 'hit', actual: 14 } };
const live = { player: 'Live Larry', stat_type: 'assists', line: 5.5, book_odds: -120, fair_odds: -112,
season_avg: 6, archetype: { primary: { name: 'CONDUCTOR' } }, gradedAt: { line: 5.5, odds: -120 },
grade: 'A', confidence: 75, confidence_basis: 'grade_band', reasoning: { summary: 'secret why' },
season_avg: 6, archetype: { primary: { name: 'CONDUCTOR' } }, grade: 'A', confidence: 75,
confidence_basis: 'grade_band', reasoning: { summary: 'secret why' },
kill_conditions_triggered: [{ code: 'Y', reason: 'secret' }], projection: 7.1, edge_pct: 29,
matchup_grade: 'B', form: 'hot', alt_lines: [{}], kelly: { units: 1 } };
const J = ['grade', 'confidence', 'confidence_basis', 'reasoning', 'kill_conditions_triggered',
'projection', 'edge_pct', 'matchup_grade', 'form', 'alt_lines', 'kelly'];
describe('resolution is the flip point — and only a WRITTEN outcome counts', () => {
test('a real outcome resolves; void/unrecoverable are terminal ⇒ resolved', () => {
expect(g.isResolved(settled)).toBe(true);
expect(g.isResolved({ outcome: { result: 'void' } })).toBe(true);
expect(g.isResolved({ outcome: 'unrecoverable' })).toBe(true);
describe('THE EXPLOIT IS DEAD — settled itemized grades are PAID too', () => {
const out = g.gateItemizedGrades([settled, live], 'free');
test('a SETTLED grade is locked for free — no one-day-delayed feed', () => {
for (const f of J) expect(out[0][f]).toBeUndefined();
expect(out[0].locked).toBe(true);
});
test('FAILS CLOSED — anything unproven is LIVE (paid), never assumed settled', () => {
expect(g.isResolved(live)).toBe(false);
expect(g.isResolved({ outcome: null })).toBe(false);
expect(g.isResolved({ outcome: {} })).toBe(false); // no result field
expect(g.isResolved({ outcome: { result: '' } })).toBe(false);
expect(g.isResolved({})).toBe(false);
expect(g.isResolved(null)).toBe(false);
});
test('resolution is NEVER inferred from time or game status', () => {
// a grade locked hours ago with no outcome is still LIVE
expect(g.isResolved({ gradedAt: { timestamp: '2020-01-01T00:00:00Z' } })).toBe(false);
});
});
describe('free tier — settled is FULL, live is a shell', () => {
const out = g.gateLiveGrades([settled, live], 'free');
test('a SETTLED grade passes through in full, reasoning included (the proof product)', () => {
expect(out[0].grade).toBe('A');
expect(out[0].reasoning.summary).toBe('real why');
expect(out[0].kill_conditions_triggered).toHaveLength(1);
expect(out[0].edge_pct).toBe(20);
expect(out[0].projection).toBe(15);
});
test('a LIVE grade loses every piece of model JUDGMENT', () => {
for (const f of ['grade', 'confidence', 'confidence_basis', 'reasoning', 'kill_conditions_triggered',
'projection', 'edge_pct', 'matchup_grade', 'form', 'alt_lines', 'kelly']) {
expect(out[1][f]).toBeUndefined();
}
test('a LIVE grade is locked for free', () => {
for (const f of J) expect(out[1][f]).toBeUndefined();
expect(out[1].locked).toBe(true);
});
test('a LIVE grade KEEPS the free-side data (the tease is real, not empty)', () => {
expect(out[1].player).toBe('Live Larry');
expect(out[1].stat_type).toBe('assists');
expect(out[1].line).toBe(5.5);
expect(out[1].book_odds).toBe(-120);
expect(out[1].fair_odds).toBe(-112); // the fair leg is NEVER the paywall
test('NEITHER serialized row carries a trace of judgment', () => {
const json = JSON.stringify(out);
expect(json).not.toMatch(/real why|secret why|grade_band|hot/);
});
test('free-side DATA survives on both, so the board still reads as real', () => {
expect(out[0].player).toBe('Settled Sam');
expect(out[1].fair_odds).toBe(-112); // the fair leg is never the paywall
expect(out[1].season_avg).toBe(6);
expect(out[1].archetype).toBeTruthy();
});
test('the serialized free payload contains no trace of live judgment', () => {
const json = JSON.stringify(out[1]);
expect(json).not.toMatch(/secret why|secret|grade_band|hot/);
expect(out[0].outcome).toEqual({ result: 'hit', actual: 14 }); // the RESULT is a fact, not judgment
});
});
describe('the tease is AGGREGATE ONLY — never which prop is which tier', () => {
test('reports count + tier distribution', () => {
const s = g.liveLockedSummary([settled, live, { grade: 'B' }, { grade: 'C' }]);
expect(s.count).toBe(3); // settled excluded from the live count
expect(s.tiers).toEqual({ A: 1, B: 1, C: 1 });
describe('the free SAMPLE is a taste, not the archive', () => {
const pool = Array.from({ length: 40 }, (_, i) => ({ player: `P${i}`, grade: 'B', outcome: { result: 'hit' } }));
test('capped at 3 no matter how large the settled pool', () => {
expect(g.FREE_SAMPLE_CAP).toBe(3);
expect(g.freeSample(pool, '2026-07-31')).toHaveLength(3);
expect(g.freeSample(pool, '2026-07-31').length).toBeLessThan(pool.length);
});
test('no gated ROW carries a tier, so the aggregate cannot be joined back', () => {
const out = g.gateLiveGrades([live, { player: 'P2', grade: 'B' }], 'free');
for (const row of out) expect(row.grade).toBeUndefined();
test('rotates by day but is STABLE within a day', () => {
const a = g.freeSample(pool, '2026-07-30').map((x) => x.player);
const b = g.freeSample(pool, '2026-07-31').map((x) => x.player);
expect(a).toEqual(g.freeSample(pool, '2026-07-30').map((x) => x.player)); // stable
expect(a).not.toEqual(b); // rotates
});
test('only RESOLVED calls can be sampled — never a live read', () => {
expect(g.freeSample([live, settled], 'd')).toEqual([settled]);
expect(g.freeSample([live], 'd')).toEqual([]);
});
});
describe('entitled tiers are untouched', () => {
test('analyst and desk get the array back by reference — zero cost, zero change', () => {
describe('entitled tiers get everything, untouched', () => {
test('analyst and desk get the array by reference — live AND archive', () => {
const rows = [settled, live];
expect(g.gateLiveGrades(rows, 'analyst')).toBe(rows);
expect(g.gateLiveGrades(rows, 'desk')).toBe(rows);
expect(g.entitledToLiveGrades('free')).toBe(false);
expect(g.entitledToLiveGrades(undefined)).toBe(false); // anonymous
expect(g.gateItemizedGrades(rows, 'analyst')).toBe(rows);
expect(g.gateItemizedGrades(rows, 'desk')).toBe(rows);
expect(g.entitledToItemizedGrades('free')).toBe(false);
expect(g.entitledToItemizedGrades(undefined)).toBe(false);
});
});
// NOTE: the route-level assertion is deliberately NOT here. It needs a live Redis,
// and a single-suite run with redis down hangs on ioredis' reconnect timer (a known
// local behaviour, see CLAUDE.md). The gate's CONTRACT is fully covered by the pure
// tests above; the wire is verified against PROD anonymously in the fingerprint.
describe('the tease shows shape, never which prop', () => {
test('aggregate count + tiers, and no gated row carries a grade', () => {
const s = g.liveLockedSummary([settled, live, { grade: 'B' }]);
expect(s.count).toBe(2); // settled excluded from LIVE count
expect(s.tiers).toEqual({ A: 1, B: 1 });
for (const row of g.gateItemizedGrades([live, { grade: 'B' }], 'free')) {
expect(row.grade).toBeUndefined();
}
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long