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
This commit is contained in:
+14
-4
@@ -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 } = require('../utils/snapshotGating');
|
||||
const { stripModelPrice, gateLiveGrades, liveLockedSummary, entitledToLiveGrades } = require('../utils/snapshotGating');
|
||||
const { resolveTierFromRequest } = require('../utils/requestTier');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -104,7 +104,15 @@ 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);
|
||||
const gate = (grades) => stripModelPrice(grades, tier);
|
||||
// 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));
|
||||
const cacheHeader = req.headers.authorization ? 'private, max-age=30' : 'public, max-age=30';
|
||||
const [snap, outcomeLog, rosterBlob] = await Promise.all([
|
||||
cacheGet(`snapshot:${sport}:latest`),
|
||||
@@ -116,13 +124,15 @@ router.get('/:sport', async (req, res) => {
|
||||
const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport);
|
||||
if (snap && Array.isArray(snap.grades)) {
|
||||
res.set('Cache-Control', cacheHeader);
|
||||
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: gate(enrich(snap.grades)), deltas: snap.deltas || [] });
|
||||
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) });
|
||||
}
|
||||
// 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);
|
||||
return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrich(grades)), deltas: [] });
|
||||
const enrichedEnv = enrich(grades);
|
||||
return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrichedEnv), deltas: [], live_locked: teaseFor(enrichedEnv) });
|
||||
} catch (err) {
|
||||
console.error('[snapshot]', err.message);
|
||||
return res.status(200).json({ sport, grades: [], deltas: [] });
|
||||
|
||||
Reference in New Issue
Block a user