Render directional-CLV badge — Analyst+Desk, server-gated, receipt-bearing

CARD BADGE ONLY. Ticker-CLV explicitly DEFERRED (named, not lost).

PHASE 1 — SERVER-SIDE GATE AT THE DATA LAYER. A Free request never
RECEIVES dclv data: the CLV columns are appended to the SELECT only behind
canAccess(tier,'clv_badge') (new capability, analyst+desk), and responses
are ALSO stripped as defence in depth so a future SELECT change cannot
quietly leak. No CSS/client gate — data that reaches the browser has left
the building. dclv_fair_lock/fair_close are de-vig internals and are never
sent at all.

SURFACE AUDIT, all six channels, each test-locked to contain no CLV:
public profile (share link), snapshot/card feed, ticker feed, share
card/OG, embeddable widget, newsletter. A test also asserts no
ledger_entries read uses select('*') — a star would auto-leak every new
column, which is exactly how a gate becomes theatre.

PHASE 2 — IMMUTABLE ONCE COMPUTED. A settle can re-run (stat correction,
protested game) and a badge that flips positive->negative AFTER a user saw
or screenshotted it is a credibility failure. First computation wins: dclv
is only computed when dclv_computed_at is null, so a re-settle can never
rewrite a shown badge. Same discipline as the locked grade.

PHASE 3 — RENDER, test-first, ABSENCE IS HONEST. unknown / flat / null /
missing-receipt all render NOTHING — no element, no placeholder, no
"pending". Proven on an ALL-NULL board (today: 0 badges) and a MIXED board
(tomorrow: 1 of 4 badged, badge-less cards clean). Binary states only:
  positive -> MOVED TOWARD US  "graded -110 · closed -145"
  negative -> MOVED AWAY       "graded -110 · closed +120"
The RECEIPT is the persuasive part, so a badge with no numbers is
suppressed rather than shown as a bare claim. Negative is neutral context
and NEVER touches the locked grade — no back-door re-grading.

NO aggregate, count or rollup exists by construction: the module exports
exactly {clvBadge, fmtPrice} and a badge payload carries exactly
{tone,label,receipt} — asserted by test, because an on-screen tally would
be the held aggregate claim through the side door.

Build gotcha hit and fixed: clvBadge is CommonJS (allowJs) with no TS
types, so the .tsx needed an explicit cast at the call site — the build
worker exits 1 on type errors even though compilation "succeeds".

Suite 288/3473 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-20 13:37:52 -04:00
parent dcdad60896
commit da8bfdf1db
8 changed files with 309 additions and 6 deletions
+8 -2
View File
@@ -74,7 +74,10 @@ const TIERS = Object.freeze({
// Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false,
kelly_sizing: false,
}),
// Session 64 — per-read directional-CLV badge. Analyst+Desk only;
// Free never RECEIVES the data (gated server-side, not hidden client-side).
clv_badge: true,
}),
desk: Object.freeze({
scans_per_day: Infinity,
grade_visible: true,
@@ -88,7 +91,10 @@ const TIERS = Object.freeze({
api_access: false,
alt_line_ladder: true,
kelly_sizing: true,
}),
// Session 64 — per-read directional-CLV badge. Analyst+Desk only;
// Free never RECEIVES the data (gated server-side, not hidden client-side).
clv_badge: true,
}),
});
const VALID_TIERS = Object.freeze(Object.keys(TIERS));
+27 -2
View File
@@ -25,6 +25,31 @@ const VALID_SPORTS = new Set(['nba', 'mlb', 'wnba', 'soccer']);
const VALID_TIERS = new Set(['A', 'B', 'C', 'D', 'F']);
const ROW_COLUMNS = 'id, player_key, player_name, sport, stat, line, side, locked_odds, book, grade, edge, confidence, model_value, graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade';
// Session 64 — DIRECTIONAL-CLV COLUMNS ARE SERVER-GATED.
// They are appended to the SELECT only for a tier entitled to see them, so a
// Free request never RECEIVES the data — it is not sent-and-hidden, and there
// is no client/CSS gate to bypass. `dclv_fair_*` stay server-side entirely:
// they are the de-vig internals, not the receipt.
const CLV_COLUMNS = 'dclv, dclv_state, dclv_computed_at';
const { canAccess } = require('../config/tiers');
/** Columns for this caller. Free gets the base list, unchanged. */
function columnsFor(req) {
const tier = (req && req.user && req.user.tier) || 'free';
return canAccess(tier, 'clv_badge') ? `${ROW_COLUMNS}, ${CLV_COLUMNS}` : ROW_COLUMNS;
}
/** Defence in depth: strip CLV keys from any row on an unentitled response,
* so a future SELECT change cannot quietly start leaking. */
function stripClv(rows, req) {
const tier = (req && req.user && req.user.tier) || 'free';
if (canAccess(tier, 'clv_badge')) return rows || [];
return (rows || []).map((r) => {
const { dclv, dclv_state, dclv_computed_at, dclv_fair_lock, dclv_fair_close, ...rest } = r || {};
return rest;
});
}
function sbOrNull() {
try {
if (!ledgerService.__internals.isConfigured()) return null;
@@ -67,14 +92,14 @@ router.get('/mine', requireAuth, async (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 100));
let q = sb.from('ledger_entries')
.select(ROW_COLUMNS)
.select(columnsFor(req))
.eq('user_id', req.user.id);
q = applyFilters(q, req);
const { data, error } = await q
.order('graded_at', { ascending: false })
.limit(limit);
if (error) throw new Error(error.message);
return res.json({ entries: data || [] });
return res.json({ entries: stripClv(data, req) });
} catch (err) {
console.error('[ledger/mine]', err.message);
return res.status(200).json({ entries: [] });
+12 -2
View File
@@ -403,7 +403,7 @@ async function settleLedger(sport, opts = {}) {
// We need each row's game_date for the log match — refetch with it included.
const { data: rows } = await sb.from('ledger_entries')
.select('id, player_name, stat, line, side, closing_line, game_date, settle_attempts')
.select('id, player_name, stat, line, side, closing_line, game_date, settle_attempts, dclv_computed_at, player_key')
.in('id', open.map((r) => r.id));
// One game-log fetch per unique player.
@@ -499,7 +499,15 @@ async function settleLedger(sport, opts = {}) {
// the read is final — the only moment BOTH ends of the comparison exist.
// (Grade + locked prices are written hours earlier; the close at lock. A
// CLV function without this trigger would be a correct dead wire.)
const dclvRes = await computeDirectionalForRow(sb, sp, row, opts);
// IMMUTABLE ONCE COMPUTED (Session 64). A settle can re-run — stat
// correction, protested/replayed game — and a badge that flips
// positive→negative AFTER a user saw or screenshotted it is a credibility
// failure. So the FIRST computation wins: if dclv_computed_at is already
// set, we do not recompute or overwrite. The lock is the same discipline
// the grade itself uses.
const dclvRes = row.dclv_computed_at
? null
: await computeDirectionalForRow(sb, sp, row, opts);
const { error: upErr } = await sb.from('ledger_entries')
.update({
outcome,
@@ -520,6 +528,8 @@ async function settleLedger(sport, opts = {}) {
})
.eq('id', row.id)
.is('outcome', null); // double-settle guard even across concurrent runs
// NOTE: dclv fields are only present in the update payload when
// dclv_computed_at was null, so a re-settle can never rewrite a shown badge.
if (upErr) { pending += 1; continue; }
settled += 1;
}