diff --git a/src/services/lineageCanaryConfig.js b/src/services/lineageCanaryConfig.js index 7c8e164..df7000c 100644 --- a/src/services/lineageCanaryConfig.js +++ b/src/services/lineageCanaryConfig.js @@ -1,79 +1,220 @@ -'use strict'; +"use strict"; /** - * LINEAGE CANARY CONFIG — the ONE resolver. + * LINEAGE CANARY LEASE — the ONE resolver, now BOUNDED IN TIME. * - * -- WHY THIS MODULE EXISTS ---------------------------------------------- - * The rollout stalled at RUNTIME_UNVERIFIED because nothing could answer - * "is MLB lineage effectively enabled?" without waiting for a scheduled - * snapshot to write a row. Exposing the answer requires a status route to read - * the config — and a status route that parses the environment ITSELF would be a - * second version of the truth, free to drift from the gate it claims to report. + * -- WHY THIS CHANGED ----------------------------------------------------- + * The previous contract parsed `LINEAGE_CANARY_SPORTS=mlb` once at module load + * and froze the answer. `isEnabled()` then returned the same boolean for the + * lifetime of the process. On 2026-08-29 the flag was left set; the 22:00Z, + * 01:00Z and 03:00Z scheduled snapshots each wrote lineage overnight with no + * operator present. The history they wrote happened to be correct. That is luck, + * not a safety property: lineage is append-only evidence, so a defective canary + * would have written irreversible wrong evidence just as silently. * - * So the parse lives here, once. `snapshotService` consumes it for the actual - * lineage write gate, and the internal status probe consumes the SAME frozen - * state. Observability reports the reality the system acts on, or it is not - * observability. + * A canary is temporary BY DEFINITION. This encodes that into the activation + * contract instead of relying on somebody remembering. * - * -- EFFECTIVE-TIME SEMANTICS -------------------------------------------- - * The value is resolved ONCE, at module load, exactly as the previous inline - * constant was. It is therefore FIXED FOR THE LIFETIME OF THE PROCESS: + * -- THE SYNTAX ----------------------------------------------------------- + * LINEAGE_CANARY_SPORTS=mlb@2026-08-30T18:00:00Z + * ^^^ ^^^^^^^^^^^^^^^^^^^^ + * sport absolute UTC expiry * - * read from process.env yes - * parsed once at module startup yes - * re-read per lineage decision no - * can change without a restart no + * Absolute instant only. No duration ("4h"), no local timezone, no implicit + * offset -- a relative duration would silently restart on every redeploy, which + * is the exact defect being removed. * - * That is what makes the process start time a defensible lower bound for - * "this runtime has had this effective lineage state since at least then". - * Changing the variable in Coolify restarts the container, which yields a new - * process and therefore a new boundary. + * -- LEGACY `mlb` NO LONGER ACTIVATES ANYTHING --------------------------- + * The bare sport form is now INVALID_MISSING_EXPIRY, not a valid indefinite + * activation. This is load-bearing: leaving it working would leave the defect + * in place behind a nicer-looking alternative. + * + * -- EXPIRY IS EVALUATED AT THE WRITE GATE, NOT AT STARTUP --------------- + * Parsing the raw string once is fine; it cannot change without a restart. + * EFFECTIVE ENABLEMENT is not frozen. `isEnabled(sport, now)` re-reads the clock + * on every call, so expiry takes effect with no operator, no restart, no Redis + * and no network. A design where an expired canary keeps writing until someone + * restarts it is the same failure wearing a different hat. + * + * -- NO EXTERNAL DEPENDENCY ---------------------------------------------- + * Lease truth is local parsed configuration plus the wall clock. An unreachable + * Redis or Supabase must never be what decides whether lineage may write. */ /** Where the effective value came from. Never the value itself. */ const CONFIGURATION_SOURCE = Object.freeze({ - ENVIRONMENT: 'ENVIRONMENT', - DEFAULT: 'DEFAULT', + ENVIRONMENT: "ENVIRONMENT", + DEFAULT: "DEFAULT", }); +/** Lease states, in the language the status probe reports. */ +const LEASE_STATE = Object.freeze({ + OFF: "OFF", // nothing configured + ACTIVE: "ACTIVE", // configured, valid, and now < expires_at + EXPIRED: "EXPIRED", // configured and valid, but the clock passed expiry + INVALID: "INVALID", // configured and unusable — always fails closed +}); + +/** + * THE BOUND, and why it is four hours. + * + * MLB scheduled snapshot ticks are [14,19,22,1,3] UTC. Proven 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. A lease of four + * hours therefore encloses at most TWO scheduled ticks, with an hour of margin. + * + * (An earlier design note claimed six hours admitted two ticks. That was wrong: + * six hours spans 22->01->03 and admits three.) + * + * THIS BOUND IS A FUNCTION OF THE SCHEDULER. Changing SNAPSHOT_HOURS_UTC + * invalidates the proof and requires revalidating MAX_LEASE_MS. A test pins + * both the hours and the bound together so the two cannot drift apart. + */ +const MAX_LEASE_MS = 4 * 60 * 60 * 1000; + +/** The sports this rollout may lease. Widening is a deliberate, separate act. */ +const LEASABLE_SPORTS = Object.freeze(["mlb"]); + const RAW = process.env.LINEAGE_CANARY_SPORTS; - -/** - * Normalised, deterministic, frozen. Sorted so two processes with the same - * effective configuration report byte-identical state regardless of the order - * it was written in. - */ -const SPORTS = Object.freeze( - String(RAW || '') - .split(',') - .map((x) => x.trim().toLowerCase()) - .filter(Boolean) - .filter((x, i, a) => a.indexOf(x) === i) - .sort(), -); - -/** - * DEFAULT means the variable was never set — the dark default. ENVIRONMENT - * means something set it, INCLUDING setting it to empty, because an explicit - * empty is an operator decision and reads differently from an absent one. - */ const SOURCE = RAW === undefined ? CONFIGURATION_SOURCE.DEFAULT : CONFIGURATION_SOURCE.ENVIRONMENT; -/** THE gate. Lineage writes for a sport only if this says so. */ -function isEnabled(sport) { - return SPORTS.includes(String(sport || '').toLowerCase()); +/** + * A strict absolute-UTC ISO instant. Deliberately narrow: `Date.parse` accepts + * plenty of shapes whose meaning depends on the reader's timezone, and a lease + * that means different things on different containers is not a lease. + */ +const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; + +/** + * Parse the raw value ONCE. Returns a frozen description — never a decision. + * The decision needs a clock and is made in `evaluate`. + */ +function parseLease(raw) { + const bad = (reason) => Object.freeze({ + configured: true, valid: false, invalid_reason: reason, sport: null, expires_at: null, + }); + if (raw === undefined || raw === null) { + return Object.freeze({ configured: false, valid: false, invalid_reason: null, sport: null, expires_at: null }); + } + const s = String(raw).trim(); + if (s === "") { + // An explicit empty is an operator decision to be off, not a malformed one. + return Object.freeze({ configured: false, valid: false, invalid_reason: null, sport: null, expires_at: null }); + } + // ONE token only. Two sports leased at once is how a canary quietly widens. + if (s.includes(",")) return bad("INVALID_MULTIPLE_TOKENS"); + + const at = s.indexOf("@"); + if (at === -1) return bad("INVALID_MISSING_EXPIRY"); // the legacy bare `mlb` + if (s.indexOf("@", at + 1) !== -1) return bad("INVALID_SYNTAX"); + + const sport = s.slice(0, at).trim().toLowerCase(); + const stamp = s.slice(at + 1).trim(); + if (!sport) return bad("INVALID_MISSING_SPORT"); + if (!LEASABLE_SPORTS.includes(sport)) return bad("INVALID_SPORT_NOT_LEASABLE"); + if (!ISO_UTC.test(stamp)) return bad("INVALID_EXPIRY_FORMAT"); + + const ms = Date.parse(stamp); + if (!Number.isFinite(ms)) return bad("INVALID_EXPIRY_VALUE"); + + return Object.freeze({ + configured: true, valid: true, invalid_reason: null, + sport, expires_at: new Date(ms).toISOString(), expires_ms: ms, + }); +} + +const LEASE = parseLease(RAW); + +/** The clock, as a finite epoch-ms, or null. A clock we cannot read is not a + * reason to allow writing. */ +function nowMs(now) { + const d = now === undefined ? new Date() : now; + const ms = d instanceof Date ? d.getTime() : Date.parse(d); + return Number.isFinite(ms) ? ms : null; } /** - * The reportable state. Contains no raw environment value by construction — - * `RAW` is never returned, only the normalised set derived from it. + * THE ONE EVALUATOR. The write gate, the status probe and the tests all come + * here, so a status that says EXPIRED can never sit beside a writer that still + * writes. */ -function state() { - return { - enabled: SPORTS.length > 0, - sports: [...SPORTS], +function evaluate(now) { + const t = nowMs(now); + const base = { + configured: LEASE.configured, configuration_source: SOURCE, + config_valid: LEASE.valid, + invalid_reason: LEASE.invalid_reason, + configured_sport: LEASE.sport, + configured_expires_at: LEASE.expires_at, + lease_state: LEASE_STATE.OFF, + effective_enabled: false, + effective_active_sports: [], + remaining_ms: null, + }; + if (!LEASE.configured) return Object.freeze(base); + if (!LEASE.valid) return Object.freeze({ ...base, lease_state: LEASE_STATE.INVALID }); + if (t === null) { + // Fail closed, and say WHY rather than reporting a bare OFF. + return Object.freeze({ ...base, lease_state: LEASE_STATE.INVALID, invalid_reason: "INVALID_CLOCK" }); + } + const remaining = LEASE.expires_ms - t; + // A lease longer than the bound is refused outright — a week-long "canary" is + // the defect this module exists to remove. + if (remaining > MAX_LEASE_MS) { + return Object.freeze({ ...base, lease_state: LEASE_STATE.INVALID, invalid_reason: "INVALID_LEASE_TOO_LONG" }); + } + // `<= 0` covers both expiry and an expiry set at or before now. EXPIRED is + // kept distinct from OFF so automatic containment stays auditable. + if (remaining <= 0) { + return Object.freeze({ ...base, lease_state: LEASE_STATE.EXPIRED, remaining_ms: 0 }); + } + return Object.freeze({ + ...base, + lease_state: LEASE_STATE.ACTIVE, + effective_enabled: true, + effective_active_sports: [LEASE.sport], + remaining_ms: remaining, + }); +} + +/** THE gate. Lineage writes for a sport only if this says so, RIGHT NOW. */ +function isEnabled(sport, now) { + const e = evaluate(now); + if (!e.effective_enabled) return false; + return e.effective_active_sports.includes(String(sport || "").toLowerCase()); +} + +/** The reportable state. Contains no raw environment value by construction. */ +function state(now) { + const e = evaluate(now); + return { + // Back-compatible keys the probe and its tests already consume. + enabled: e.effective_enabled, + sports: [...e.effective_active_sports], + configuration_source: e.configuration_source, + // The lease surface: configured vs EFFECTIVE, so an expired lease is + // visibly different from one that was never set. + configured: e.configured, + config_valid: e.config_valid, + invalid_reason: e.invalid_reason, + configured_sport: e.configured_sport, + configured_expires_at: e.configured_expires_at, + lease_state: e.lease_state, + effective_enabled: e.effective_enabled, + effective_active_sports: [...e.effective_active_sports], + remaining_ms: e.remaining_ms, + max_lease_ms: MAX_LEASE_MS, }; } -module.exports = { SPORTS, isEnabled, state, CONFIGURATION_SOURCE }; +/** + * The CONFIGURED sport list. Deliberately NOT the gate: it says what was asked + * for, never what is permitted now. + */ +const SPORTS = Object.freeze(LEASE.valid && LEASE.sport ? [LEASE.sport] : []); + +module.exports = { + SPORTS, isEnabled, state, evaluate, parseLease, + CONFIGURATION_SOURCE, LEASE_STATE, MAX_LEASE_MS, LEASABLE_SPORTS, +}; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 1d4d2a5..94417d3 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -319,8 +319,11 @@ const lineageCanaryConfig = require('./lineageCanaryConfig'); const LINEAGE_CANARY_SPORTS = lineageCanaryConfig.SPORTS; -function lineageCanaryEnabled(sport) { - return lineageCanaryConfig.isEnabled(sport); +// `now` is threaded so the LEASE is evaluated at WRITE TIME, not at startup. +// A cached boolean here would reintroduce the exact defect the lease removes: +// an expired canary that keeps writing until somebody restarts the process. +function lineageCanaryEnabled(sport, now) { + return lineageCanaryConfig.isEnabled(sport, now); } /** diff --git a/tests/unit/eventPublication.test.js b/tests/unit/eventPublication.test.js index 85a3e31..e3162d3 100644 --- a/tests/unit/eventPublication.test.js +++ b/tests/unit/eventPublication.test.js @@ -443,25 +443,36 @@ describe('CANARY SCOPE — bounded and reversible', () => { } }); - test('MLB is enabled only by explicit configuration', () => { - const m = load('mlb'); - expect(m.lineageCanaryEnabled('mlb')).toBe(true); + test('MLB is enabled only by an explicit BOUNDED LEASE', () => { + // Plain `mlb` is no longer an activation path — it means "forever", which + // is precisely the containment defect. Only sport@absolute-UTC-expiry works. + const now = new Date('2026-08-30T12:00:00Z'); + const legacy = load('mlb'); + expect(legacy.lineageCanaryEnabled('mlb', now)).toBe(false); + + const m = load('mlb@2026-08-30T13:00:00Z'); + expect(m.lineageCanaryEnabled('mlb', now)).toBe(true); for (const sp of ['wnba', 'nba', 'soccer']) { - expect(m.lineageCanaryEnabled(sp)).toBe(false); + expect(m.lineageCanaryEnabled(sp, now)).toBe(false); } + // And it turns itself off when the clock crosses, with no restart. + expect(m.lineageCanaryEnabled('mlb', new Date('2026-08-30T13:00:01Z'))).toBe(false); }); test('an empty value is a kill switch needing no migration revert', () => { const m = load(''); expect(m.LINEAGE_CANARY_SPORTS).toEqual([]); - expect(m.lineageCanaryEnabled('mlb')).toBe(false); + expect(m.lineageCanaryEnabled('mlb', new Date('2026-08-30T12:00:00Z'))).toBe(false); }); test('a sport without a verified event resolver cannot be canaried into safety', () => { - // Widening the flag does NOT create identity: eventIdentity still refuses, - // so a widened sport records UNSUPPORTED_SPORT and its lineage stays honest. - const m = load('mlb,wnba'); - expect(m.lineageCanaryEnabled('wnba')).toBe(true); + // Two defences now. The lease refuses to widen at all (one sport, and only + // a leasable one), AND eventIdentity still refuses to invent identity, so a + // widened sport would record UNSUPPORTED_SPORT and stay honest regardless. + const now = new Date('2026-08-30T12:00:00Z'); + const m = load('mlb@2026-08-30T13:00:00Z,wnba@2026-08-30T13:00:00Z'); + expect(m.lineageCanaryEnabled('wnba', now)).toBe(false); + expect(m.lineageCanaryEnabled('mlb', now)).toBe(false); // the WHOLE config fails closed const r = E.resolveEvent('wnba', dhProp('2026-08-17T17:40:00Z'), DH_GAMES); expect(r.canonical_event_id).toBeNull(); expect(r.event_identity_method).toBe(E.IDENTITY_METHOD.UNSUPPORTED_SPORT); diff --git a/tests/unit/lineageCanaryLease.test.js b/tests/unit/lineageCanaryLease.test.js new file mode 100644 index 0000000..7d822e9 --- /dev/null +++ b/tests/unit/lineageCanaryLease.test.js @@ -0,0 +1,244 @@ +// 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'); + }); +}); diff --git a/tests/unit/runtimeObservability.test.js b/tests/unit/runtimeObservability.test.js index 6ae1e93..ad6a454 100644 --- a/tests/unit/runtimeObservability.test.js +++ b/tests/unit/runtimeObservability.test.js @@ -113,54 +113,86 @@ describe('LINEAGE CONFIG — one parser', () => { const snap = require('../../src/services/snapshotService'); // eslint-disable-next-line global-require const cfg = require('../../src/services/lineageCanaryConfig'); + const now = new Date('2026-08-30T12:00:00Z'); for (const sp of ['mlb', 'wnba', 'nba', 'soccer']) { - expect(snap.lineageCanaryEnabled(sp)).toBe(cfg.isEnabled(sp)); + expect(snap.lineageCanaryEnabled(sp, now)).toBe(cfg.isEnabled(sp, now)); } expect(snap.LINEAGE_CANARY_SPORTS).toBe(cfg.SPORTS); }); - test('unset reports disabled + [] + DEFAULT', () => { + test('unset reports OFF + [] + DEFAULT', () => { const c = loadConfig(undefined); - expect(c.state()).toEqual({ enabled: false, sports: [], configuration_source: 'DEFAULT' }); + const st = c.state(new Date('2026-08-30T12:00:00Z')); + expect(st.enabled).toBe(false); + expect(st.sports).toEqual([]); + expect(st.configuration_source).toBe('DEFAULT'); + expect(st.lease_state).toBe('OFF'); + expect(st.configured).toBe(false); }); - test('mlb reports enabled + ["mlb"] + ENVIRONMENT', () => { + test('LEGACY plain `mlb` no longer activates anything', () => { + // This is the safety repair, expressed as a test. The bare sport form used + // to mean "write forever until somebody remembers"; it is now refused. const c = loadConfig('mlb'); - expect(c.state()).toEqual({ enabled: true, sports: ['mlb'], configuration_source: 'ENVIRONMENT' }); + const st = c.state(new Date('2026-08-30T12:00:00Z')); + expect(st.enabled).toBe(false); + expect(st.effective_enabled).toBe(false); + expect(st.sports).toEqual([]); + expect(st.lease_state).toBe('INVALID'); + expect(st.invalid_reason).toBe('INVALID_MISSING_EXPIRY'); + expect(c.isEnabled('mlb', new Date('2026-08-30T12:00:00Z'))).toBe(false); + }); + + test('a bounded lease reports ACTIVE + ["mlb"] + ENVIRONMENT', () => { + const c = loadConfig('mlb@2026-08-30T13:00:00Z'); + const st = c.state(new Date('2026-08-30T12:00:00Z')); + expect(st.enabled).toBe(true); + expect(st.sports).toEqual(['mlb']); + expect(st.configuration_source).toBe('ENVIRONMENT'); + expect(st.lease_state).toBe('ACTIVE'); + expect(st.configured_expires_at).toBe('2026-08-30T13:00:00.000Z'); }); test('an EXPLICIT empty is ENVIRONMENT, not DEFAULT', () => { // An operator deliberately blanking the value reads differently from never // having set it, even though both are dark. const c = loadConfig(''); - expect(c.state()).toEqual({ enabled: false, sports: [], configuration_source: 'ENVIRONMENT' }); + const st = c.state(new Date('2026-08-30T12:00:00Z')); + expect(st.enabled).toBe(false); + expect(st.sports).toEqual([]); + expect(st.configuration_source).toBe('ENVIRONMENT'); + expect(st.lease_state).toBe('OFF'); }); - test('multiple sports normalise deterministically — sorted, deduped, trimmed, lowercased', () => { - expect(loadConfig('MLB, mlb ,wnba').state().sports).toEqual(['mlb', 'wnba']); - expect(loadConfig('wnba,mlb').state().sports).toEqual(['mlb', 'wnba']); - expect(loadConfig(' MLB ').state().sports).toEqual(['mlb']); - expect(loadConfig(',,mlb,,').state().sports).toEqual(['mlb']); + test('a comma list can no longer widen the canary', () => { + for (const v of ['MLB, mlb ,wnba', 'wnba,mlb', 'mlb@2026-08-30T13:00:00Z,nba@2026-08-30T13:00:00Z']) { + const st = loadConfig(v).state(new Date('2026-08-30T12:00:00Z')); + expect(st.effective_enabled).toBe(false); + expect(st.sports).toEqual([]); + expect(st.lease_state).toBe('INVALID'); + } }); - test('an unsupported value behaves exactly as the gate behaves', () => { - const c = loadConfig('cricket'); - expect(c.state().sports).toEqual(['cricket']); - expect(c.isEnabled('cricket')).toBe(true); // the flag is scope, not validation - expect(c.isEnabled('mlb')).toBe(false); + test('a sport outside the leasable set cannot be leased', () => { + const c = loadConfig('cricket@2026-08-30T13:00:00Z'); + const st = c.state(new Date('2026-08-30T12:00:00Z')); + expect(st.lease_state).toBe('INVALID'); + expect(st.invalid_reason).toBe('INVALID_SPORT_NOT_LEASABLE'); + expect(c.isEnabled('cricket', new Date('2026-08-30T12:00:00Z'))).toBe(false); + expect(c.isEnabled('mlb', new Date('2026-08-30T12:00:00Z'))).toBe(false); }); test('the source-code default cannot hide an environment override', () => { - // The whole point: if Coolify sets mlb, the probe must say mlb. - expect(loadConfig('mlb').state().enabled).toBe(true); - expect(loadConfig(undefined).state().enabled).toBe(false); + const now = new Date('2026-08-30T12:00:00Z'); + expect(loadConfig('mlb@2026-08-30T13:00:00Z').state(now).enabled).toBe(true); + expect(loadConfig(undefined).state(now).enabled).toBe(false); }); }); describe('SECURITY — no raw config, no weakened access', () => { test('the raw environment value is never returned', () => { const c = loadConfig('MLB, wnba '); - const payload = JSON.stringify(c.state()); + const payload = JSON.stringify(c.state(new Date('2026-08-30T12:00:00Z'))); expect(payload).not.toContain('MLB, wnba'); expect(payload).not.toContain(' '); }); @@ -195,18 +227,23 @@ describe('READ ONLY — the probe observes and nothing else', () => { test('it cannot enable lineage — it only reads the frozen state', () => { const c = loadConfig(undefined); - const before = JSON.stringify(c.state()); - c.state(); c.state(); - expect(JSON.stringify(c.state())).toBe(before); + const fixed = new Date('2026-08-30T12:00:00Z'); + const before = JSON.stringify(c.state(fixed)); + c.state(fixed); c.state(fixed); + expect(JSON.stringify(c.state(fixed))).toBe(before); // No setter exists. expect(Object.keys(c).filter((k) => /^(set|enable|disable|update)/i.test(k))).toHaveLength(0); }); test('the reported state is a copy — a caller cannot mutate the gate', () => { - const c = loadConfig('mlb'); - const s1 = c.state(); + const c = loadConfig('mlb@2026-08-30T13:00:00Z'); + const now = new Date('2026-08-30T12:00:00Z'); + const s1 = c.state(now); s1.sports.push('wnba'); - expect(c.state().sports).toEqual(['mlb']); - expect(c.isEnabled('wnba')).toBe(false); + s1.effective_active_sports.push('nba'); + expect(c.state(now).sports).toEqual(['mlb']); + expect(c.state(now).effective_active_sports).toEqual(['mlb']); + expect(c.isEnabled('wnba', now)).toBe(false); + expect(c.isEnabled('nba', now)).toBe(false); }); });