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). // Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false, alt_line_ladder: false,
kelly_sizing: 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({ desk: Object.freeze({
scans_per_day: Infinity, scans_per_day: Infinity,
grade_visible: true, grade_visible: true,
@@ -88,7 +91,10 @@ const TIERS = Object.freeze({
api_access: false, api_access: false,
alt_line_ladder: true, alt_line_ladder: true,
kelly_sizing: 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)); 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 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'; 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() { function sbOrNull() {
try { try {
if (!ledgerService.__internals.isConfigured()) return null; if (!ledgerService.__internals.isConfigured()) return null;
@@ -67,14 +92,14 @@ router.get('/mine', requireAuth, async (req, res) => {
try { try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 100)); const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 100));
let q = sb.from('ledger_entries') let q = sb.from('ledger_entries')
.select(ROW_COLUMNS) .select(columnsFor(req))
.eq('user_id', req.user.id); .eq('user_id', req.user.id);
q = applyFilters(q, req); q = applyFilters(q, req);
const { data, error } = await q const { data, error } = await q
.order('graded_at', { ascending: false }) .order('graded_at', { ascending: false })
.limit(limit); .limit(limit);
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
return res.json({ entries: data || [] }); return res.json({ entries: stripClv(data, req) });
} catch (err) { } catch (err) {
console.error('[ledger/mine]', err.message); console.error('[ledger/mine]', err.message);
return res.status(200).json({ entries: [] }); 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. // We need each row's game_date for the log match — refetch with it included.
const { data: rows } = await sb.from('ledger_entries') 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)); .in('id', open.map((r) => r.id));
// One game-log fetch per unique player. // 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. // 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 // (Grade + locked prices are written hours earlier; the close at lock. A
// CLV function without this trigger would be a correct dead wire.) // 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') const { error: upErr } = await sb.from('ledger_entries')
.update({ .update({
outcome, outcome,
@@ -520,6 +528,8 @@ async function settleLedger(sport, opts = {}) {
}) })
.eq('id', row.id) .eq('id', row.id)
.is('outcome', null); // double-settle guard even across concurrent runs .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; } if (upErr) { pending += 1; continue; }
settled += 1; settled += 1;
} }
+76
View File
@@ -0,0 +1,76 @@
/**
* Session 64 — CLV badge rendering model. Absence must be a NON-ELEMENT.
*/
const { clvBadge } = require('../../web/src/lib/clvBadge');
const read = (o = {}) => ({ dclv_state: 'positive', locked_odds: '-110', closing_odds: '-145', ...o });
describe('ABSENCE IS HONEST — renders NOTHING', () => {
test('unknown → null (no element, not "pending")', () => {
expect(clvBadge(read({ dclv_state: 'unknown' }))).toBeNull();
});
test('flat → null (a held line is not news)', () => {
expect(clvBadge(read({ dclv_state: 'flat' }))).toBeNull();
});
test('missing state → null', () => {
expect(clvBadge(read({ dclv_state: null }))).toBeNull();
expect(clvBadge({})).toBeNull();
expect(clvBadge(null)).toBeNull();
});
test('a Free response (CLV stripped server-side) renders nothing', () => {
const free = { player_name: 'X', grade: 'B', locked_odds: '-110' }; // no dclv_*
expect(clvBadge(free)).toBeNull();
});
test('no receipt numbers → NO badge (a label without evidence is an assertion)', () => {
expect(clvBadge(read({ closing_odds: null }))).toBeNull();
expect(clvBadge(read({ locked_odds: null }))).toBeNull();
});
});
describe('THE RECEIPT — numbers, not just a label', () => {
test('positive carries tone, label and the concrete prices', () => {
const b = clvBadge(read());
expect(b.tone).toBe('confirm');
expect(b.label).toBe('MOVED TOWARD US');
expect(b.receipt).toBe('graded -110 · closed -145');
});
test('negative is CAUTION and also carries the receipt', () => {
const b = clvBadge(read({ dclv_state: 'negative', closing_odds: '+120' }));
expect(b.tone).toBe('caution');
expect(b.receipt).toBe('graded -110 · closed +120');
});
test('positive prices render with an explicit +', () => {
expect(clvBadge(read({ locked_odds: 105, closing_odds: 130 })).receipt)
.toBe('graded +105 · closed +130');
});
});
describe('BOARD SHAPES — all-null and mixed both render clean', () => {
const board = [
read({ dclv_state: 'positive' }),
read({ dclv_state: 'unknown' }),
read({ dclv_state: 'flat' }),
{ player_name: 'free-view', grade: 'B' },
];
test('a MIXED board badges only the real signals', () => {
const badges = board.map(clvBadge);
expect(badges.filter(Boolean)).toHaveLength(1);
expect(badges[1]).toBeNull();
expect(badges[2]).toBeNull();
});
test('an ALL-NULL board (today) produces zero badges and no errors', () => {
const allNull = [read({ dclv_state: 'unknown' }), read({ dclv_state: null }), {}];
expect(allNull.map(clvBadge).filter(Boolean)).toHaveLength(0);
});
});
describe('NO AGGREGATE anywhere', () => {
test('the module exposes no count/rollup helper', () => {
const mod = require('../../web/src/lib/clvBadge');
expect(Object.keys(mod).sort()).toEqual(['clvBadge', 'fmtPrice']);
});
test('a badge payload carries no totals', () => {
const b = clvBadge(read());
expect(Object.keys(b).sort()).toEqual(['label', 'receipt', 'tone']);
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* Session 64 — the CLV gate is SERVER-SIDE, at the data layer.
*
* A Free request must never RECEIVE dclv data. Client/CSS hiding is rejected:
* data that reaches the browser has left the building. These lock the gate and
* the surface audit, so a future column addition can't quietly leak.
*/
const fs = require('fs');
const path = require('path');
const { canAccess } = require('../../src/config/tiers');
const read = (f) => fs.readFileSync(path.join(__dirname, '..', '..', f), 'utf8');
describe('TIER capability', () => {
test('analyst and desk may see the badge; free may not', () => {
expect(canAccess('analyst', 'clv_badge')).toBe(true);
expect(canAccess('desk', 'clv_badge')).toBe(true);
expect(canAccess('free', 'clv_badge')).toBeFalsy();
expect(canAccess(undefined, 'clv_badge')).toBeFalsy();
});
});
describe('SERVER GATE — the data never leaves for an unentitled tier', () => {
const ledger = read('src/routes/ledger.js');
test('CLV columns are appended only via a capability check', () => {
expect(ledger).toMatch(/canAccess\(tier, 'clv_badge'\)/);
expect(ledger).toMatch(/CLV_COLUMNS/);
});
test('the base column list does NOT contain dclv', () => {
// Assert on the literal itself — a nearby comment mentioning dclv is fine.
const m = ledger.match(/const ROW_COLUMNS = '([^']+)'/);
expect(m).toBeTruthy();
expect(m[1]).not.toMatch(/dclv/);
});
test('responses are ALSO stripped — defence in depth, not just the SELECT', () => {
expect(ledger).toMatch(/function stripClv/);
expect(ledger).toMatch(/stripClv\(data, req\)/);
});
test('de-vig internals (fair_lock/fair_close) are never SELECTED for clients', () => {
// They may (and should) appear in the strip list — that is the guard.
const sel = ledger.match(/const CLV_COLUMNS = '([^']+)'/);
expect(sel[1]).not.toMatch(/fair_lock|fair_close/);
expect(ledger).toMatch(/dclv_fair_lock, dclv_fair_close, \.\.\.rest/);
});
});
describe('SURFACE AUDIT — every channel CLV could leak through', () => {
const surfaces = {
'public profile (share link)': 'src/routes/profiles.js',
'snapshot / card feed': 'src/routes/snapshot.js',
'ticker feed': 'src/routes/ticker.js',
'share card / OG': 'src/routes/shareCard.js',
'widget (embeddable)': 'src/routes/widget.js',
'newsletter': 'src/services/newsletterService.js',
};
for (const [name, file] of Object.entries(surfaces)) {
test(`${name} carries NO CLV data`, () => {
expect(read(file)).not.toMatch(/dclv/);
});
}
test('no ledger read uses select("*") — a star would auto-leak new columns', () => {
for (const f of ['src/routes/ledger.js', 'src/routes/profiles.js', 'src/services/ledgerService.js']) {
const src = read(f);
const stars = src.match(/from\('ledger_entries'\)[\s\S]{0,60}?select\('\*'\)/g) || [];
expect(stars).toHaveLength(0);
}
});
});
describe('IMMUTABILITY — a shown badge never silently flips', () => {
const svc = read('src/services/ledgerService.js');
test('dclv is computed only when it has never been computed', () => {
expect(svc).toMatch(/row\.dclv_computed_at\s*\n?\s*\?\s*null/);
});
test('the settle read fetches dclv_computed_at so the guard can see it', () => {
expect(svc).toMatch(/dclv_computed_at, player_key/);
});
});
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { clvBadge } from '@/lib/clvBadge';
/**
* Per-read directional-CLV badge (Session 64). Analyst + Desk only — the data
* itself is withheld server-side for Free, so this component simply never
* receives a state to render.
*
* ABSENCE IS HONEST: unknown / flat / no-receipt return null — no element, no
* placeholder, no "pending". A mixed board (some reads badged, most not) is the
* real state as closes land game-by-game, and a badge-less card must not read
* as worse or broken.
*
* NEGATIVE IS NEUTRAL INFORMATION, NOT A DEMOTION. It never touches the locked
* grade — the grade is locked at first capture and CLV is a separate signal.
*
* No aggregate, count or rollup exists here by construction.
*/
type ClvRead = { dclv_state?: string | null; locked_odds?: string | number | null; closing_odds?: string | number | null } | null | undefined;
type Badge = { tone: string; label: string; receipt: string } | null;
export default function ClvBadge({ read }: { read: ClvRead }) {
// clvBadge is CommonJS (allowJs) so it has no TS types — cast at the call
// site, exactly as gradeAdapter is handled elsewhere.
const badge = clvBadge(read || {}) as Badge;
if (!badge) return null; // the honest non-element
const confirm = badge.tone === 'confirm';
return (
<span
className="mono"
title={confirm
? 'The market moved toward the side we graded, between our lock and the close.'
: 'The market moved away from the side we graded. The grade is unchanged — this is context, not a downgrade.'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 10, letterSpacing: '.06em', textTransform: 'uppercase',
padding: '2px 7px', borderRadius: 3, whiteSpace: 'nowrap',
color: confirm ? 'var(--g-a, #00D4A0)' : 'var(--amber, #FFB347)',
background: confirm ? 'rgba(0,212,160,.10)' : 'rgba(255,179,71,.10)',
border: `1px solid ${confirm ? 'rgba(0,212,160,.30)' : 'rgba(255,179,71,.30)'}`,
}}
>
<span>{badge.label}</span>
{/* The RECEIPT is the persuasive part — the label alone is just a claim. */}
<span style={{ opacity: 0.75, textTransform: 'none', letterSpacing: 0 }}>
{badge.receipt}
</span>
</span>
);
}
+1
View File
@@ -61,3 +61,4 @@ export {
gradeHex, gradeHex,
gradeBadgeSize, gradeBadgeSize,
} from '@/lib/vyndrTokens'; } from '@/lib/vyndrTokens';
export { default as ClvBadge } from './ClvBadge';
+50
View File
@@ -0,0 +1,50 @@
/**
* Directional-CLV badge model (Session 64). CommonJS so it is unit-testable
* and shared by the card without a React import.
*
* ABSENCE IS HONEST. unknown / flat / null / missing render NOTHING — not a
* placeholder, not a zero, not "pending". A board where some reads are badged
* and others are not is the REAL state (closes are captured per game as each
* locks), so a badge-less card must never look broken or worse.
*
* THE RECEIPT IS THE POINT. "MOVED TOWARD US" alone is a claim; "graded 110 ·
* closed 145" is evidence. The numbers are what make it persuasive, so the
* badge always carries them and is suppressed if they are missing.
*
* NO AGGREGATE. There is deliberately no count, rollup, or "N reads moved
* toward us" — an on-screen tally is the held aggregate claim through the side
* door. Per-read only.
*/
/** Format an American price for the receipt. */
function fmtPrice(v) {
if (v == null || v === '') return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
return n > 0 ? `+${Math.round(n)}` : `${Math.round(n)}`;
}
/**
* @param {Object} read { dclv_state, locked_odds, closing_odds }
* @returns {null|{tone,label,receipt}} null = render NOTHING
*/
function clvBadge(read) {
if (!read) return null;
const state = read.dclv_state;
// flat and unknown are both silence — a held line is not news, and a missing
// close is not a zero.
if (state !== 'positive' && state !== 'negative') return null;
const graded = fmtPrice(read.locked_odds);
const closed = fmtPrice(read.closing_odds);
// No receipt → no badge. A bare label is an assertion without evidence.
if (!graded || !closed) return null;
return {
tone: state === 'positive' ? 'confirm' : 'caution',
label: state === 'positive' ? 'MOVED TOWARD US' : 'MOVED AWAY',
receipt: `graded ${graded} · closed ${closed}`,
};
}
module.exports = { clvBadge, fmtPrice };