A canary is temporary: bound activation with a fail-closed lease
`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
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user