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:
+57
-38
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user