From da8bfdf1db920cfb3b2037e44582b37c714fe729 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 20 Jul 2026 13:37:52 -0400 Subject: [PATCH] =?UTF-8?q?Render=20directional-CLV=20badge=20=E2=80=94=20?= =?UTF-8?q?Analyst+Desk,=20server-gated,=20receipt-bearing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA --- src/config/tiers.js | 10 +++- src/routes/ledger.js | 29 +++++++++- src/services/ledgerService.js | 14 ++++- tests/unit/clvBadge.test.js | 76 ++++++++++++++++++++++++ tests/unit/clvServerGate.test.js | 83 +++++++++++++++++++++++++++ web/src/components/vyndr/ClvBadge.tsx | 52 +++++++++++++++++ web/src/components/vyndr/index.ts | 1 + web/src/lib/clvBadge.js | 50 ++++++++++++++++ 8 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 tests/unit/clvBadge.test.js create mode 100644 tests/unit/clvServerGate.test.js create mode 100644 web/src/components/vyndr/ClvBadge.tsx create mode 100644 web/src/lib/clvBadge.js diff --git a/src/config/tiers.js b/src/config/tiers.js index eb8d2fb..b24e310 100644 --- a/src/config/tiers.js +++ b/src/config/tiers.js @@ -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)); diff --git a/src/routes/ledger.js b/src/routes/ledger.js index b78a579..7f3a659 100644 --- a/src/routes/ledger.js +++ b/src/routes/ledger.js @@ -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: [] }); diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 8035c2a..fb4d8a1 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -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; } diff --git a/tests/unit/clvBadge.test.js b/tests/unit/clvBadge.test.js new file mode 100644 index 0000000..8350b88 --- /dev/null +++ b/tests/unit/clvBadge.test.js @@ -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']); + }); +}); diff --git a/tests/unit/clvServerGate.test.js b/tests/unit/clvServerGate.test.js new file mode 100644 index 0000000..d7ffd46 --- /dev/null +++ b/tests/unit/clvServerGate.test.js @@ -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/); + }); +}); diff --git a/web/src/components/vyndr/ClvBadge.tsx b/web/src/components/vyndr/ClvBadge.tsx new file mode 100644 index 0000000..f8520ad --- /dev/null +++ b/web/src/components/vyndr/ClvBadge.tsx @@ -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 ( + + {badge.label} + {/* The RECEIPT is the persuasive part — the label alone is just a claim. */} + + {badge.receipt} + + + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index eff54e7..f45cd69 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -61,3 +61,4 @@ export { gradeHex, gradeBadgeSize, } from '@/lib/vyndrTokens'; +export { default as ClvBadge } from './ClvBadge'; diff --git a/web/src/lib/clvBadge.js b/web/src/lib/clvBadge.js new file mode 100644 index 0000000..703f455 --- /dev/null +++ b/web/src/lib/clvBadge.js @@ -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 };