cc5bdf5797
`LINEAGE_CANARY_SPORTS=mlb` was parsed once at module load and frozen, so an enabled canary stayed writable for the whole process lifetime. On 2026-08-29 it was left set and the 22:00Z, 01:00Z and 03:00Z scheduled snapshots each wrote lineage overnight with nobody watching. That history happened to be correct. Lineage is append-only evidence, so a defective canary would have written irreversible WRONG evidence exactly as quietly. Correct history was luck, not a safety property. NEW CONTRACT: LINEAGE_CANARY_SPORTS=mlb@2026-08-30T18:00:00Z Absolute UTC instant only -- no duration, no local timezone. A relative "4h" would silently restart on every redeploy, which is the defect being removed. LEGACY `mlb` NO LONGER ACTIVATES ANYTHING. It is INVALID_MISSING_EXPIRY. Leaving it working would have left the defect in place behind a nicer-looking alternative, so this is the load-bearing half of the repair. EXPIRY IS EVALUATED AT THE WRITE GATE, not at startup. `isEnabled(sport, now)` re-reads the clock on every call and `lineageCanaryEnabled` threads it through, so a lease turns itself off with no operator, no restart, no Redis and no network. A design where an expired canary keeps writing until someone restarts is the same failure in a different hat. MAX_LEASE is FOUR HOURS, and the bound is proven rather than chosen. MLB ticks are [14,19,22,1,3] UTC; exhaustively over a minute grid across UTC date boundaries, the shortest span enclosing THREE consecutive ticks is 22:00 -> 01:00 -> 03:00 = five hours. Four hours encloses at most two, with an hour of margin. (An earlier note claimed six hours admitted two. It admits three; that claim was false and the test now pins the arithmetic.) The bound is a function of the scheduler, so a test reads the hours from sportCadence and fails if a cadence change invalidates the proof. Everything fails closed: absent, empty, bare sport, comma list, two leases, duplicate @, non-leasable sport, missing timezone, numeric offset, malformed, over-long, already-expired, expiry-equals-now, and an unreadable clock. Configured-but-EXPIRED stays distinguishable from never-configured so automatic containment is auditable. No Redis, no Supabase, no counter, no network in the lease path -- an unreachable dependency must never decide whether lineage may write. Lineage algorithms, cache-date, participant and retention identity untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
245 lines
12 KiB
JavaScript
245 lines
12 KiB
JavaScript
// LINEAGE CANARY LEASE — fail-closed, bounded, evaluated at the write gate.
|
|
//
|
|
// The defect this replaces: `LINEAGE_CANARY_SPORTS=mlb` was parsed once at
|
|
// module load and frozen, so an enabled canary stayed writable for the entire
|
|
// process lifetime. On 2026-08-29 it was left set and three scheduled snapshots
|
|
// wrote lineage overnight unattended. The history was correct by luck; lineage
|
|
// is append-only, so a defective canary would have written irreversible wrong
|
|
// evidence just as quietly.
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const MOD = '../../src/services/lineageCanaryConfig';
|
|
const ROOT = path.join(__dirname, '../..');
|
|
|
|
/** Load the module under a given env value. The parse is at module load, so the
|
|
* reload is how a "restart" is simulated. */
|
|
function load(val) {
|
|
const prev = process.env.LINEAGE_CANARY_SPORTS;
|
|
if (val === undefined) delete process.env.LINEAGE_CANARY_SPORTS;
|
|
else process.env.LINEAGE_CANARY_SPORTS = val;
|
|
jest.resetModules();
|
|
// eslint-disable-next-line global-require
|
|
const m = require(MOD);
|
|
if (prev === undefined) delete process.env.LINEAGE_CANARY_SPORTS;
|
|
else process.env.LINEAGE_CANARY_SPORTS = prev;
|
|
return m;
|
|
}
|
|
const at = (iso) => new Date(iso);
|
|
const T0 = '2026-08-30T12:00:00Z';
|
|
const lease = (iso) => `mlb@${iso}`;
|
|
|
|
describe('the bound is a function of the scheduler, and is proven not asserted', () => {
|
|
test('MAX_LEASE cannot enclose three scheduled MLB ticks', () => {
|
|
const cfg = load(undefined);
|
|
// The scheduler's hours, read from the source of truth rather than retyped.
|
|
const sched = fs.readFileSync(path.join(ROOT, 'src/config/sportCadence.js'), 'utf8');
|
|
const m = sched.match(/mlb:\s*\{\s*hours:\s*\[([0-9,\s]+)\]/);
|
|
expect(m).toBeTruthy();
|
|
const HOURS = m[1].split(',').map((n) => parseInt(n.trim(), 10));
|
|
expect(HOURS.sort((a, b) => a - b)).toEqual([1, 3, 14, 19, 22]);
|
|
|
|
// EXHAUSTIVE over a minute grid spanning UTC date boundaries.
|
|
const ticks = [];
|
|
for (let d = 0; d < 4; d += 1) for (const h of HOURS) ticks.push(d * 1440 + h * 60);
|
|
ticks.sort((a, b) => a - b);
|
|
const maxTicks = (durMin) => {
|
|
let worst = 0;
|
|
for (let start = 0; start <= 3 * 1440; start += 1) {
|
|
const n = ticks.filter((t) => t >= start && t <= start + durMin).length;
|
|
if (n > worst) worst = n;
|
|
}
|
|
return worst;
|
|
};
|
|
const leaseMin = cfg.MAX_LEASE_MS / 60000;
|
|
expect(leaseMin).toBe(240);
|
|
expect(maxTicks(leaseMin)).toBeLessThanOrEqual(2);
|
|
|
|
// The minimum three-tick span, stated so a scheduler change breaks this test
|
|
// rather than silently invalidating the bound.
|
|
let minSpan = Infinity;
|
|
for (let i = 0; i + 2 < ticks.length; i += 1) minSpan = Math.min(minSpan, ticks[i + 2] - ticks[i]);
|
|
expect(minSpan).toBe(300); // 22:00 -> 01:00 -> 03:00
|
|
expect(leaseMin).toBeLessThan(minSpan);
|
|
// And the falsified claim: six hours DOES enclose three.
|
|
expect(maxTicks(6 * 60)).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('parse matrix — every invalid shape fails closed', () => {
|
|
const cases = [
|
|
['variable missing', undefined, 'OFF', false],
|
|
['empty string', '', 'OFF', false],
|
|
['whitespace only', ' ', 'OFF', false],
|
|
['LEGACY plain mlb', 'mlb', 'INVALID', false],
|
|
['legacy comma list', 'mlb,wnba', 'INVALID', false],
|
|
['two leases', 'mlb@2026-08-30T13:00:00Z,nba@2026-08-30T13:00:00Z', 'INVALID', false],
|
|
['duplicate @', 'mlb@2026-08-30T13:00:00Z@x', 'INVALID', false],
|
|
['missing sport', '@2026-08-30T13:00:00Z', 'INVALID', false],
|
|
['sport not leasable', 'nba@2026-08-30T13:00:00Z', 'INVALID', false],
|
|
['expiry without timezone', 'mlb@2026-08-30T13:00:00', 'INVALID', false],
|
|
['expiry with an offset', 'mlb@2026-08-30T13:00:00+00:00', 'INVALID', false],
|
|
['malformed expiry', 'mlb@not-a-date', 'INVALID', false],
|
|
['expiry is a duration', 'mlb@4h', 'INVALID', false],
|
|
['trailing junk', 'mlb@2026-08-30T13:00:00Z junk', 'INVALID', false],
|
|
['beyond MAX_LEASE', 'mlb@2026-08-30T17:00:00.001Z', 'INVALID', false],
|
|
['already expired', 'mlb@2026-08-30T11:59:59Z', 'EXPIRED', false],
|
|
['expiry exactly now', 'mlb@2026-08-30T12:00:00Z', 'EXPIRED', false],
|
|
['valid future lease', 'mlb@2026-08-30T13:00:00Z', 'ACTIVE', true],
|
|
['valid at the exact bound', 'mlb@2026-08-30T16:00:00Z', 'ACTIVE', true],
|
|
];
|
|
for (const [name, val, expectState, expectEnabled] of cases) {
|
|
test(`${name} -> ${expectState}`, () => {
|
|
const cfg = load(val);
|
|
const s = cfg.state(at(T0));
|
|
expect(s.lease_state).toBe(expectState);
|
|
expect(s.effective_enabled).toBe(expectEnabled);
|
|
expect(cfg.isEnabled('mlb', at(T0))).toBe(expectEnabled);
|
|
if (!expectEnabled) expect(s.effective_active_sports).toEqual([]);
|
|
});
|
|
}
|
|
|
|
test('an unreadable clock fails closed rather than defaulting to active', () => {
|
|
const cfg = load(lease('2026-08-30T13:00:00Z'));
|
|
expect(cfg.isEnabled('mlb', new Date('nonsense'))).toBe(false);
|
|
expect(cfg.state(new Date('nonsense')).lease_state).toBe('INVALID');
|
|
expect(cfg.state(new Date('nonsense')).invalid_reason).toBe('INVALID_CLOCK');
|
|
});
|
|
|
|
test('a valid lease enables ONLY its own sport', () => {
|
|
const cfg = load(lease('2026-08-30T13:00:00Z'));
|
|
expect(cfg.isEnabled('mlb', at(T0))).toBe(true);
|
|
for (const sp of ['wnba', 'nba', 'soccer', 'nfl']) {
|
|
expect(cfg.isEnabled(sp, at(T0))).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('expiry is dynamic — no restart required', () => {
|
|
test('one process, one parse: ACTIVE then EXPIRED as the clock crosses', () => {
|
|
const cfg = load(lease('2026-08-30T13:00:00Z'));
|
|
// Same module instance throughout — this is the whole point.
|
|
expect(cfg.isEnabled('mlb', at('2026-08-30T12:59:59Z'))).toBe(true);
|
|
expect(cfg.state(at('2026-08-30T12:59:59Z')).lease_state).toBe('ACTIVE');
|
|
expect(cfg.isEnabled('mlb', at('2026-08-30T13:00:00Z'))).toBe(false);
|
|
expect(cfg.state(at('2026-08-30T13:00:00Z')).lease_state).toBe('EXPIRED');
|
|
expect(cfg.isEnabled('mlb', at('2026-08-30T14:00:00Z'))).toBe(false);
|
|
});
|
|
|
|
test('remaining_ms counts down and floors at expiry', () => {
|
|
const cfg = load(lease('2026-08-30T13:00:00Z'));
|
|
expect(cfg.state(at('2026-08-30T12:00:00Z')).remaining_ms).toBe(3600000);
|
|
expect(cfg.state(at('2026-08-30T12:30:00Z')).remaining_ms).toBe(1800000);
|
|
expect(cfg.state(at('2026-08-30T13:30:00Z')).remaining_ms).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('restart semantics — absolute expiry controls', () => {
|
|
test('a restart BEFORE expiry does not reset the lease lifetime', () => {
|
|
const val = lease('2026-08-30T13:00:00Z');
|
|
const first = load(val);
|
|
expect(first.state(at('2026-08-30T12:10:00Z')).remaining_ms).toBe(3000000);
|
|
// "restart" — reload the module, same env value.
|
|
const second = load(val);
|
|
expect(second.state(at('2026-08-30T12:50:00Z')).remaining_ms).toBe(600000);
|
|
expect(second.state(at('2026-08-30T12:50:00Z')).configured_expires_at)
|
|
.toBe(first.state(at('2026-08-30T12:10:00Z')).configured_expires_at);
|
|
});
|
|
|
|
test('a restart AFTER expiry does NOT reactivate it', () => {
|
|
const cfg = load(lease('2026-08-30T11:00:00Z'));
|
|
expect(cfg.isEnabled('mlb', at(T0))).toBe(false);
|
|
expect(cfg.state(at(T0)).lease_state).toBe('EXPIRED');
|
|
});
|
|
});
|
|
|
|
describe('configured-but-expired stays auditable', () => {
|
|
test('EXPIRED is distinguishable from never-configured', () => {
|
|
const off = load(undefined).state(at(T0));
|
|
const exp = load(lease('2026-08-30T11:00:00Z')).state(at(T0));
|
|
expect(off.configured).toBe(false);
|
|
expect(off.lease_state).toBe('OFF');
|
|
expect(exp.configured).toBe(true);
|
|
expect(exp.config_valid).toBe(true);
|
|
expect(exp.lease_state).toBe('EXPIRED');
|
|
expect(exp.configured_sport).toBe('mlb');
|
|
expect(exp.configured_expires_at).toBe('2026-08-30T11:00:00.000Z');
|
|
expect(exp.effective_enabled).toBe(false);
|
|
expect(exp.effective_active_sports).toEqual([]);
|
|
});
|
|
|
|
test('the status never returns the raw environment string', () => {
|
|
const raw = lease('2026-08-30T13:00:00Z');
|
|
const s = JSON.stringify(load(raw).state(at(T0)));
|
|
expect(s).not.toContain('LINEAGE_CANARY_SPORTS');
|
|
});
|
|
});
|
|
|
|
describe('ONE evaluator — status and writer cannot disagree', () => {
|
|
test('the snapshot write gate delegates and threads the clock', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
|
expect(src).toMatch(/function lineageCanaryEnabled\(sport, now\)/);
|
|
expect(src).toMatch(/lineageCanaryConfig\.isEnabled\(sport, now\)/);
|
|
// The gate must not cache the ANSWER at module scope. A frozen boolean is
|
|
// the original defect, so this pins the shape rather than one spelling of
|
|
// it: `isEnabled` may only be called from inside the function body.
|
|
const decl = src.slice(src.indexOf('function lineageCanaryEnabled'));
|
|
const body = decl.slice(0, decl.indexOf('\n}') + 2);
|
|
expect(body).toMatch(/lineageCanaryConfig\.isEnabled\(sport, now\)/);
|
|
// No module-scope evaluation of the gate anywhere outside that function.
|
|
const outside = src.replace(body, '');
|
|
expect(outside).not.toMatch(/lineageCanaryConfig\.isEnabled\(/);
|
|
expect(outside).not.toMatch(/=\s*lineageCanaryEnabled\(/);
|
|
expect(src).toMatch(/commitPublication && persistedRows && lineageCanaryEnabled\(sp\)/);
|
|
});
|
|
|
|
test('for the same config and instant, writer and status agree', () => {
|
|
for (const [val, when] of [
|
|
[lease('2026-08-30T13:00:00Z'), '2026-08-30T12:30:00Z'],
|
|
[lease('2026-08-30T13:00:00Z'), '2026-08-30T13:30:00Z'],
|
|
['mlb', '2026-08-30T12:00:00Z'],
|
|
[undefined, '2026-08-30T12:00:00Z'],
|
|
]) {
|
|
const cfg = load(val);
|
|
jest.resetModules();
|
|
if (val === undefined) delete process.env.LINEAGE_CANARY_SPORTS;
|
|
else process.env.LINEAGE_CANARY_SPORTS = val;
|
|
// eslint-disable-next-line global-require
|
|
const snap = require('../../src/services/snapshotService');
|
|
// eslint-disable-next-line global-require
|
|
const live = require(MOD);
|
|
delete process.env.LINEAGE_CANARY_SPORTS;
|
|
const s = live.state(at(when));
|
|
expect(snap.lineageCanaryEnabled('mlb', at(when))).toBe(s.effective_enabled);
|
|
expect(live.isEnabled('mlb', at(when))).toBe(s.effective_enabled);
|
|
expect(cfg.isEnabled('mlb', at(when))).toBe(s.effective_enabled);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('preservation — only the activation gate changed', () => {
|
|
const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
test('lineage algorithms are untouched', () => {
|
|
const rl = src('src/services/read/readLineage.js');
|
|
expect(rl).toContain("const LINEAGE_VERSION = 'lin@1'");
|
|
expect(rl).toContain("const CLAIM_SCHEMA_VERSION = 'claim@1'");
|
|
expect(rl).toContain("const DIGEST_ALGORITHM_VERSION = 'sha256-json-sorted@1'");
|
|
const ret = src('src/services/retentionService.js');
|
|
expect(ret).toContain('familyScopesFrom');
|
|
expect(ret).toContain('isValidLineageAction');
|
|
// Pin the IDENTITY ITSELF, not merely the constant's name — a changed tuple
|
|
// with an unchanged name is exactly the drift a name check cannot see.
|
|
expect(ret).toContain(
|
|
"const RETENTION_CONFLICT = 'snapshot_id,game_id,canonical_event_id,player_key,stat,line,side';");
|
|
expect(ret).toContain(
|
|
"const VALID_LINEAGE_ACTION_FIELDS = Object.freeze([");
|
|
});
|
|
test('cache-date, participant and intraday repairs are untouched', () => {
|
|
expect(src('src/services/oddsService.js')).toContain("ET_DATE_SPORTS = Object.freeze(['mlb'])");
|
|
expect(src('src/services/event/eventIdentity.js')).toMatch(/ids\.size !== 1/);
|
|
expect(src('src/services/gameBinder.js')).toContain('if (!p.game_date) p.game_date = etFast;');
|
|
expect(src('src/services/intradayRefreshService.js')).toContain('BELIEF_FIELDS');
|
|
});
|
|
});
|