6d36e05bfe
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
159 lines
6.7 KiB
JavaScript
159 lines
6.7 KiB
JavaScript
'use strict';
|
|
|
|
const { canAccess } = require('../config/tiers');
|
|
|
|
/**
|
|
* Snapshot tier gating (Session 67) — the model price never leaves the server
|
|
* for an unentitled viewer.
|
|
*
|
|
* WHY THIS EXISTS: `GET /api/snapshot/:sport` is PUBLIC and unauthenticated.
|
|
* Session 66 gated `model_odds` on `/api/analyze`, but the snapshot endpoint
|
|
* bypassed that gate entirely and served the model price to anonymous callers
|
|
* on every graded row. This closes it at the same layer as the CLV gate
|
|
* (`routes/ledger.js` columnsFor/stripClv): strip on the way out, never hide
|
|
* in the client.
|
|
*
|
|
* WHAT MUST GO, AND WHY IT IS MORE THAN `model_odds`:
|
|
* model_odds the gated number itself.
|
|
* p_win model_odds IS `impliedProbToAmerican(p_win)` — shipping p_win
|
|
* is shipping the price in a different base.
|
|
* ev_pct INVERTIBLE. ev is a function of (p_win, book_odds) and
|
|
* book_odds is public, so p_win — and therefore the price — can
|
|
* be recovered exactly from it. A gate that leaves ev behind is
|
|
* not a gate.
|
|
* value a boolean over (ev, takeable); with the band public it leaks a
|
|
* takeable bound on ev. Cheap to drop, so drop them.
|
|
*
|
|
* WHAT STAYS ON EVERY TIER — deliberately:
|
|
* book_odds, fair_odds, fair_prob, overround, devig_method.
|
|
* These are MARKET facts and the de-vig of market facts. The de-vigged fair
|
|
* number is the free-tier hook and the hero of the price layer: THE FAIR LEG
|
|
* IS NEVER THE PAYWALL. Only VYNDR's own price gates.
|
|
*
|
|
* `model_price_locked` is stamped on rows that still carry a book+fair pair so
|
|
* the client renders the lock teaser rather than an absent leg — a gated price
|
|
* must never be mistaken for a missing one.
|
|
*/
|
|
|
|
// Model-derived fields. Every one of these can reconstruct the model price.
|
|
const MODEL_FIELDS = Object.freeze(['model_odds', 'p_win', 'ev_pct', 'value', 'takeable']);
|
|
|
|
// Market facts + their de-vig. Never stripped, on any tier.
|
|
const MARKET_FIELDS = Object.freeze(['book_odds', 'fair_odds', 'fair_prob', 'overround', 'devig_method']);
|
|
|
|
/** Does this tier receive VYNDR's own price? */
|
|
function entitledToModelPrice(tierName) {
|
|
return canAccess(tierName, 'model_price');
|
|
}
|
|
|
|
/**
|
|
* stripModelPrice(grades, tierName) — returns a NEW array with the
|
|
* model-derived fields removed for unentitled tiers. Entitled tiers get the
|
|
* rows back untouched (same reference — no needless copying on the hot path).
|
|
*/
|
|
function stripModelPrice(grades, tierName) {
|
|
if (!Array.isArray(grades)) return grades;
|
|
if (entitledToModelPrice(tierName)) return grades;
|
|
return grades.map((g) => {
|
|
if (!g || typeof g !== 'object') return g;
|
|
const out = { ...g };
|
|
for (const f of MODEL_FIELDS) delete out[f];
|
|
// Only flag a LOCK where there is a price story to lock a third leg onto.
|
|
if (out.book_odds != null && out.fair_odds != null) out.model_price_locked = true;
|
|
return out;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
MODEL_FIELDS,
|
|
MARKET_FIELDS,
|
|
entitledToModelPrice,
|
|
stripModelPrice,
|
|
};
|
|
|
|
/* ===========================================================================
|
|
* BUILD 1 — THE SETTLED/LIVE GATE (2026-07-31, specs/tier-redesign-spec.md)
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
* ========================================================================= */
|
|
|
|
/** Model JUDGMENT on a live read. Data stays; the verdict and its argument go. */
|
|
const LIVE_JUDGMENT_FIELDS = Object.freeze([
|
|
'grade', 'confidence', 'confidence_basis', // the verdict
|
|
'reasoning', 'kill_conditions_triggered', // its argument
|
|
'projection', 'edge_pct', // our number vs the line
|
|
'matchup_grade', 'form', // derived letter/label judgments
|
|
'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).
|
|
*/
|
|
function isResolved(g) {
|
|
if (!g || typeof g !== 'object') return false;
|
|
const o = g.outcome;
|
|
if (!o) return false;
|
|
if (typeof o === 'string') return o.trim() !== '';
|
|
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) {
|
|
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.
|
|
*/
|
|
function liveLockedSummary(grades) {
|
|
const live = (Array.isArray(grades) ? grades : []).filter((g) => g && !isResolved(g));
|
|
const tiers = {};
|
|
for (const g of live) {
|
|
const letter = String((g.grade || '')).trim().toUpperCase().charAt(0);
|
|
if (!letter) continue;
|
|
tiers[letter] = (tiers[letter] || 0) + 1;
|
|
}
|
|
return { count: live.length, tiers };
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function gateLiveGrades(grades, tierName) {
|
|
if (!Array.isArray(grades)) return grades;
|
|
if (entitledToLiveGrades(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
|
|
return shell;
|
|
});
|
|
}
|
|
|
|
module.exports.LIVE_JUDGMENT_FIELDS = LIVE_JUDGMENT_FIELDS;
|
|
module.exports.isResolved = isResolved;
|
|
module.exports.entitledToLiveGrades = entitledToLiveGrades;
|
|
module.exports.liveLockedSummary = liveLockedSummary;
|
|
module.exports.gateLiveGrades = gateLiveGrades;
|