diff --git a/packages/loopover-engine/src/calibration/signal-tracking.ts b/packages/loopover-engine/src/calibration/signal-tracking.ts index a6a8f12858..bcaf69dcc0 100644 --- a/packages/loopover-engine/src/calibration/signal-tracking.ts +++ b/packages/loopover-engine/src/calibration/signal-tracking.ts @@ -102,12 +102,46 @@ export function computeRulePrecision(ruleId: string, fired: readonly RuleFiredEv } /** - * Count how many times `ruleId` fired against the exact same `targetKey` within `fired` -- the #7983 - * "same-rule repeat alarm" primitive (a rule re-firing against a target it already fired against once is a - * stronger signal than a bare one-off fire, independent of whether either fire has been overridden yet). - * Pure counting, no time-windowing here -- a caller windows `fired` itself before calling this (e.g. via - * `queryRuleHistory`'s own `sinceMs`), matching how this whole module leaves all storage/scoping to the host. + * Count how many times `ruleId` fired against the exact same `targetKey` within `fired` (a rule re-firing + * against a target it already fired against once -- e.g. an unresolved contributor PR re-triggering the same + * blocker on every push -- is a different signal than a fresh one-off fire, independent of whether either fire + * has been overridden yet). Pure counting, no time-windowing here -- a caller windows `fired` itself before + * calling this (e.g. via `queryRuleHistory`'s own `sinceMs`), matching how this whole module leaves all + * storage/scoping to the host. See {@link evaluateRuleRepeatAlarm} for the DIFFERENT #7983 signal: the same + * rule firing against several DIFFERENT targets, not the same one repeatedly. */ export function computeRuleRepeatCount(ruleId: string, targetKey: string, fired: readonly RuleFiredEvent[]): number { return fired.reduce((count, event) => (event.ruleId === ruleId && event.targetKey === targetKey ? count + 1 : count), 0); } + +/** A same-rule repeat-alarm verdict (#7983): whether `ruleId` has fired against enough DISTINCT targets within + * the caller's already-windowed `fired` list to be a "something is systematically broken" signal -- + * independent of whether any of those firings has been confirmed or reversed by a human yet (unlike + * {@link computeRulePrecision}, this needs no ground truth at all, which is exactly why it can fire fast: the + * 2026-07-21/22 metagraphed incident mis-closed 4 DISTINCT PRs within ~3 hours on the same rule, far faster + * than a precision-over-time breaker's `AUTOTUNE_MIN_DECIDED` sample could ever accumulate real outcomes). */ +export type RuleRepeatAlarmVerdict = { + ruleId: string; + /** Every distinct targetKey `ruleId` fired against, in first-seen order. */ + affectedTargets: string[]; + threshold: number; + triggered: boolean; +}; + +/** + * Evaluate the #7983 same-rule repeat alarm for `ruleId` over an already-windowed `fired` list: `triggered` is + * true once the rule has fired against at least `threshold` DISTINCT targets. Deliberately returns a + * detection-only verdict -- no action, no severity beyond the boolean -- mirroring `src/orb/analytics.ts`'s + * `gamingPatternFlags` precedent ("Detection only — never an automatic action") and this module's own + * "no autonomous behavior" boundary; the host decides how (or whether) to surface a triggered verdict. + */ +export function evaluateRuleRepeatAlarm(ruleId: string, fired: readonly RuleFiredEvent[], threshold: number): RuleRepeatAlarmVerdict { + const affectedTargets: string[] = []; + const seen = new Set(); + for (const event of fired) { + if (event.ruleId !== ruleId || seen.has(event.targetKey)) continue; + seen.add(event.targetKey); + affectedTargets.push(event.targetKey); + } + return { ruleId, affectedTargets, threshold, triggered: affectedTargets.length >= threshold }; +} diff --git a/packages/loopover-engine/test/signal-tracking.test.ts b/packages/loopover-engine/test/signal-tracking.test.ts index 71e0d24fb6..627d390ae6 100644 --- a/packages/loopover-engine/test/signal-tracking.test.ts +++ b/packages/loopover-engine/test/signal-tracking.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { computeRulePrecision, computeRuleRepeatCount, type HumanOverrideEvent, type RuleFiredEvent } from "../dist/index.js"; +import { + computeRulePrecision, + computeRuleRepeatCount, + evaluateRuleRepeatAlarm, + type HumanOverrideEvent, + type RuleFiredEvent, +} from "../dist/index.js"; function fired(ruleId: string, targetKey: string, overrides: Partial = {}): RuleFiredEvent { return { ruleId, targetKey, outcome: "block", occurredAt: "2026-07-22T00:00:00.000Z", ...overrides }; @@ -90,3 +96,61 @@ test("computeRuleRepeatCount: counts only fires matching BOTH ruleId and targetK test("computeRuleRepeatCount: zero fired events yields 0, not an error", () => { assert.equal(computeRuleRepeatCount("rule_a", "a#1", []), 0); }); + +test("barrel: the public entrypoint re-exports evaluateRuleRepeatAlarm (#7983)", () => { + assert.equal(typeof evaluateRuleRepeatAlarm, "function"); +}); + +test("evaluateRuleRepeatAlarm: not triggered below the threshold", () => { + const verdict = evaluateRuleRepeatAlarm("rule_a", [fired("rule_a", "a#1"), fired("rule_a", "a#2")], 3); + assert.equal(verdict.triggered, false); + assert.deepEqual(verdict.affectedTargets, ["a#1", "a#2"]); + assert.equal(verdict.threshold, 3); +}); + +test("evaluateRuleRepeatAlarm: triggers once distinct targets reach the threshold", () => { + const verdict = evaluateRuleRepeatAlarm("rule_a", [fired("rule_a", "a#1"), fired("rule_a", "a#2"), fired("rule_a", "a#3")], 3); + assert.equal(verdict.triggered, true); + assert.deepEqual(verdict.affectedTargets, ["a#1", "a#2", "a#3"]); +}); + +test("evaluateRuleRepeatAlarm: replays the #7469/#7589/#7591/#7594 incident shape -- triggers on the 3rd distinct PR", () => { + const incidentEvents = [ + fired("rule_a", "metagraphed/metagraphed#7469"), + fired("rule_a", "metagraphed/metagraphed#7589"), + ]; + // Should NOT have triggered yet after only 2 distinct PRs (threshold 3). + assert.equal(evaluateRuleRepeatAlarm("rule_a", incidentEvents, 3).triggered, false); + incidentEvents.push(fired("rule_a", "metagraphed/metagraphed#7591")); + // The 3rd distinct PR crosses the threshold -- exactly the "should have alerted after the 2nd or 3rd + // occurrence" bar #7983 itself sets. + const thirdVerdict = evaluateRuleRepeatAlarm("rule_a", incidentEvents, 3); + assert.equal(thirdVerdict.triggered, true); + assert.deepEqual(thirdVerdict.affectedTargets, [ + "metagraphed/metagraphed#7469", + "metagraphed/metagraphed#7589", + "metagraphed/metagraphed#7591", + ]); +}); + +test("evaluateRuleRepeatAlarm: the SAME target firing repeatedly counts once, not once per fire -- only a DISTINCT target grows the count", () => { + const verdict = evaluateRuleRepeatAlarm( + "rule_a", + [fired("rule_a", "a#1"), fired("rule_a", "a#1"), fired("rule_a", "a#1")], + 2, + ); + assert.equal(verdict.affectedTargets.length, 1); + assert.equal(verdict.triggered, false); +}); + +test("evaluateRuleRepeatAlarm: ignores fired events for a DIFFERENT ruleId entirely", () => { + const verdict = evaluateRuleRepeatAlarm("rule_a", [fired("rule_a", "a#1"), fired("rule_b", "a#2"), fired("rule_b", "a#3")], 2); + assert.equal(verdict.affectedTargets.length, 1); + assert.equal(verdict.triggered, false); +}); + +test("evaluateRuleRepeatAlarm: zero fired events never triggers", () => { + const verdict = evaluateRuleRepeatAlarm("rule_a", [], 1); + assert.equal(verdict.triggered, false); + assert.deepEqual(verdict.affectedTargets, []); +}); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6fc40c67dc..8d0ef51b7a 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -636,6 +636,7 @@ import { } from "../review/outcomes-wire"; import { neutralHoldReasonCode, nativeGateActionFromConclusion, recordNativeGateDecision } from "../review/parity-wire"; import { recordContributorGateDecision } from "../review/contributor-calibration"; +import { recordGateBlockersAndCheckRepeatAlarm } from "../review/rule-repeat-alarm-wire"; import { recordPredictedGateCalibration } from "../review/predicted-gate-calibration-ledger"; import type { SubmissionOutcome } from "../review/submitter-reputation"; import type { @@ -10326,6 +10327,13 @@ async function maybePublishPrPublicSurface( outcome: "completed", metadata: { blockerCodes }, }); + // #7983: same-rule repeat alarm — detection + alert only, never adjusts the gate. See + // rule-repeat-alarm-wire.ts's own header comment. + await recordGateBlockersAndCheckRepeatAlarm(env, { + repoFullName, + pullNumber: pr.number, + blockerCodes, + }).catch(() => undefined); } // #preconv-parity (convergence prep): SHADOW-record the gittensory-native gate decision (source= // 'gittensory-native') into review_audit so the pre-cutover parity harness has data to read. RECORD-ONLY, diff --git a/src/review/rule-repeat-alarm-wire.ts b/src/review/rule-repeat-alarm-wire.ts new file mode 100644 index 0000000000..f8ce0bf353 --- /dev/null +++ b/src/review/rule-repeat-alarm-wire.ts @@ -0,0 +1,115 @@ +// ORB wiring for #7983's same-rule repeat alarm. Records a #7982 rule-fired signal for each gate blocker code +// on every gate block, then checks whether that SAME (repo, blocker code) pair has now fired against enough +// DISTINCT PRs within a short window to be a "something is systematically broken" signal — independent of +// whether any of those blocks has since been confirmed or reversed by a human (unlike the precision-over-time +// circuit breaker in auto-tune.ts, which needs a real, DECIDED sample of >= AUTOTUNE_MIN_DECIDED and however +// long that takes to accumulate). This is exactly the gap the 2026-07-21/22 metagraphed incident exposed: 4 +// distinct PRs mis-closed by the same rule within ~3 hours, far faster than any ground-truth-based breaker +// could ever react. +// +// DETECTION + ALERT ONLY (#7983's own stated boundary, mirroring src/orb/analytics.ts's gamingPatternFlags +// precedent: "Detection only — never an automatic action"). This module never holds, closes, or otherwise +// changes any gate/disposition decision — it only records a signal and, once, surfaces a structured alert. +// +// Alert channel: NOT notify-discord.ts/notify-slack (that's a per-REPO, community-facing channel for PR +// action notifications — the wrong audience for "an ORB rule may be systematically broken," which is an +// OPERATOR concern that can span any repo the instance reviews). Uses the same console.error(JSON.stringify( +// {level:"error",...})) idiom src/review/ops-wire.ts's runOpsAlerts already uses for its own operator-anomaly +// detection — forwarded to Sentry by selfhost/sentry.ts's forwardStructuredLogToSentry, the actually-live +// operator-facing channel for exactly this class of "detected an anomaly, not a caught exception" alert. + +import { evaluateRuleRepeatAlarm, type SignalStore } from "@loopover/engine"; + +import { hasRecentAuditEvent, recordAuditEvent } from "../db/repositories"; +import { nowIso } from "../utils/json"; +import { createSignalStore } from "./signal-tracking-wire"; + +/** How far back to look for repeat fires. Matches #7983's own "e.g. 1-24h, tunable" proposal — chosen at the + * wide end so a slow-burn (not just a fast-burst) repeat still gets caught. */ +export const RULE_REPEAT_ALARM_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Distinct-target count that trips the alarm. #7983's own proposal ("e.g. >= 3 within 24h") and its own + * validation bar ("should have alerted after the 2nd or 3rd occurrence" against the real incident replay). */ +export const RULE_REPEAT_ALARM_THRESHOLD = 3; +/** Once triggered, don't re-alert for the SAME (repo, code) pair more often than this — an already-known, + * ongoing incident re-alerting on every subsequent PR would be noise, not new information. Shorter than + * {@link RULE_REPEAT_ALARM_WINDOW_MS} so a genuinely NEW burst (a different day, a fix that regressed again) + * still re-alerts well before the detection window itself would naturally reset. */ +const RULE_REPEAT_ALARM_ALERT_COOLDOWN_MS = 6 * 60 * 60 * 1000; + +/** Repo-scoped rule id (#7983 wants the alarm keyed by "(deployment/repo-or-cohort, rule code)", not a bare + * code across the whole fleet — a code that's simply common everywhere must not look like one repo's rule + * going haywire). Reuses the same signal-tracking `ruleId` seam #7982 already defined; the repo scope is + * folded directly into the id rather than needing a second dimension on {@link SignalStore}. */ +function repeatAlarmRuleId(repoFullName: string, blockerCode: string): string { + return `${repoFullName}:${blockerCode}`; +} + +function alertAuditEventType(ruleId: string): string { + return `rule_repeat_alarm:${ruleId}`; +} + +async function checkAndAlertRuleRepeat( + env: Env, + store: SignalStore, + ruleId: string, + blockerCode: string, + repoFullName: string, +): Promise { + const history = await store.queryRuleHistory(ruleId, Date.now() - RULE_REPEAT_ALARM_WINDOW_MS); + const verdict = evaluateRuleRepeatAlarm(ruleId, history.fired, RULE_REPEAT_ALARM_THRESHOLD); + if (!verdict.triggered) return; + const alertEventType = alertAuditEventType(ruleId); + const alreadyAlerted = await hasRecentAuditEvent( + env, + "loopover", + alertEventType, + new Date(Date.now() - RULE_REPEAT_ALARM_ALERT_COOLDOWN_MS).toISOString(), + ); + if (alreadyAlerted) return; + console.error( + JSON.stringify({ + level: "error", + event: "same_rule_repeat_alarm", + ev: ruleId, + repo: repoFullName, + blockerCode, + distinctTargetCount: verdict.affectedTargets.length, + threshold: verdict.threshold, + affectedTargets: verdict.affectedTargets, + at: nowIso(), + }), + ); + await recordAuditEvent(env, { + eventType: alertEventType, + actor: "loopover", + targetKey: ruleId, + outcome: "completed", + detail: `same-rule repeat alarm: ${blockerCode} fired against ${verdict.affectedTargets.length} distinct PR(s) in ${repoFullName} within ${RULE_REPEAT_ALARM_WINDOW_MS / (60 * 60 * 1000)}h`, + metadata: { repoFullName, blockerCode, affectedTargets: verdict.affectedTargets }, + }).catch(() => undefined); +} + +/** + * Records a #7982 rule-fired signal for every blocker code on a gate block, then runs the #7983 repeat-alarm + * check for each. Best-effort throughout (a failure anywhere in this path is swallowed) — this is a pure + * measurement/alerting side channel and must never affect, delay, or fail the gate decision that produced the + * blocker codes it's recording. + */ +export async function recordGateBlockersAndCheckRepeatAlarm( + env: Env, + args: { repoFullName: string; pullNumber: number; blockerCodes: readonly string[]; occurredAt?: string }, +): Promise { + if (args.blockerCodes.length === 0) return; + // createSignalStore is pure object construction (no I/O), so it never throws — no try/catch needed here. + // recordRuleFired below already swallows its own write failures internally (signal-tracking-wire.ts), so it + // never rejects either; only queryRuleHistory (inside checkAndAlertRuleRepeat) can genuinely reject, which + // this loop's own .catch below covers. + const store = createSignalStore(env); + const occurredAt = args.occurredAt ?? nowIso(); + const targetKey = `${args.repoFullName}#${args.pullNumber}`; + for (const blockerCode of new Set(args.blockerCodes)) { + const ruleId = repeatAlarmRuleId(args.repoFullName, blockerCode); + await store.recordRuleFired({ ruleId, targetKey, outcome: "block", occurredAt }); + await checkAndAlertRuleRepeat(env, store, ruleId, blockerCode, args.repoFullName).catch(() => undefined); + } +} diff --git a/test/unit/rule-repeat-alarm-wire.test.ts b/test/unit/rule-repeat-alarm-wire.test.ts new file mode 100644 index 0000000000..157ca7d3fc --- /dev/null +++ b/test/unit/rule-repeat-alarm-wire.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + RULE_REPEAT_ALARM_THRESHOLD, + recordGateBlockersAndCheckRepeatAlarm, +} from "../../src/review/rule-repeat-alarm-wire"; +import * as repositories from "../../src/db/repositories"; +import { hasRecentAuditEvent } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "metagraphed/metagraphed"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function fireOnce(env: ReturnType, pullNumber: number, blockerCodes: string[], occurredAt: string) { + await recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: REPO, pullNumber, blockerCodes, occurredAt }); +} + +describe("recordGateBlockersAndCheckRepeatAlarm (#7983)", () => { + it("does nothing at all for an empty blockerCodes list", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + await recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: REPO, pullNumber: 1, blockerCodes: [] }); + expect(error).not.toHaveBeenCalled(); + }); + + it("does not alert below the threshold", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + for (let i = 1; i < RULE_REPEAT_ALARM_THRESHOLD; i++) { + await fireOnce(env, i, ["surface_lane_reject"], new Date(now).toISOString()); + } + expect(error).not.toHaveBeenCalled(); + }); + + it("replays the #7469/#7589/#7591/#7594 incident shape: alerts once the 3rd distinct PR is blocked by the same code", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + await fireOnce(env, 7469, ["surface_lane_reject"], new Date(now).toISOString()); + expect(error).not.toHaveBeenCalled(); + await fireOnce(env, 7589, ["surface_lane_reject"], new Date(now + 1000).toISOString()); + expect(error).not.toHaveBeenCalled(); + await fireOnce(env, 7591, ["surface_lane_reject"], new Date(now + 2000).toISOString()); + expect(error).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(error.mock.calls[0]?.[0])); + expect(payload).toMatchObject({ + level: "error", + event: "same_rule_repeat_alarm", + repo: REPO, + blockerCode: "surface_lane_reject", + distinctTargetCount: 3, + threshold: RULE_REPEAT_ALARM_THRESHOLD, + }); + expect(payload.affectedTargets).toEqual([ + `${REPO}#7469`, + `${REPO}#7589`, + `${REPO}#7591`, + ]); + }); + + it("records the trigger as a queryable audit event", async () => { + const env = createTestEnv(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + for (let i = 1; i <= RULE_REPEAT_ALARM_THRESHOLD; i++) { + await fireOnce(env, i, ["surface_lane_reject"], new Date(now + i).toISOString()); + } + const found = await hasRecentAuditEvent( + env, + "loopover", + `rule_repeat_alarm:${REPO}:surface_lane_reject`, + new Date(now - 60_000).toISOString(), + ); + expect(found).toBe(true); + }); + + it("does not re-alert for a 4th PR once already triggered within the cooldown window", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + for (let i = 1; i <= RULE_REPEAT_ALARM_THRESHOLD; i++) { + await fireOnce(env, i, ["surface_lane_reject"], new Date(now + i).toISOString()); + } + expect(error).toHaveBeenCalledTimes(1); + await fireOnce(env, 999, ["surface_lane_reject"], new Date(now + 5000).toISOString()); + expect(error).toHaveBeenCalledTimes(1); // still 1, not 2 + }); + + it("scopes the alarm PER REPO — a different repo's PRs never contribute to or trigger another repo's count", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + await recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: "acme/widgets", pullNumber: 1, blockerCodes: ["surface_lane_reject"], occurredAt: new Date(now).toISOString() }); + await recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: "acme/gizmos", pullNumber: 1, blockerCodes: ["surface_lane_reject"], occurredAt: new Date(now).toISOString() }); + await recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: "acme/widgets", pullNumber: 2, blockerCodes: ["surface_lane_reject"], occurredAt: new Date(now).toISOString() }); + // Only 2 distinct targets for "acme/widgets", 1 for "acme/gizmos" -- neither crosses 3 alone even though + // 3 total blocks happened across the fleet. + expect(error).not.toHaveBeenCalled(); + }); + + it("scopes the alarm PER BLOCKER CODE — a different code on the same repo never contributes to another code's count", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + await fireOnce(env, 1, ["surface_lane_reject"], new Date(now).toISOString()); + await fireOnce(env, 2, ["missing_linked_issue"], new Date(now).toISOString()); + await fireOnce(env, 3, ["surface_lane_reject"], new Date(now).toISOString()); + // "surface_lane_reject" only has 2 distinct targets (#1, #3); "missing_linked_issue" only has 1 (#2). + expect(error).not.toHaveBeenCalled(); + }); + + it("dedupes multiple identical blocker codes in the SAME call into one fired signal, not one per duplicate", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + // A single PR whose gate blockers list happens to repeat the same code twice must still only count as ONE + // distinct target toward that code's alarm, not two. + await fireOnce(env, 1, ["surface_lane_reject", "surface_lane_reject"], new Date(now).toISOString()); + await fireOnce(env, 2, ["surface_lane_reject"], new Date(now + 1000).toISOString()); + expect(error).not.toHaveBeenCalled(); // only 2 distinct PRs total, below threshold 3 + }); + + it("independently tracks and alerts on multiple DIFFERENT blocker codes in the same call", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const now = Date.now(); + for (let i = 1; i <= RULE_REPEAT_ALARM_THRESHOLD; i++) { + await fireOnce(env, i, ["code_a", "code_b"], new Date(now + i).toISOString()); + } + // Both code_a and code_b independently crossed the threshold across the same 3 PRs. + expect(error).toHaveBeenCalledTimes(2); + const events = error.mock.calls.map((call) => JSON.parse(String(call[0])).blockerCode).sort(); + expect(events).toEqual(["code_a", "code_b"]); + }); + + it("a store failure is swallowed, never thrown into the caller (best-effort, must never affect the gate decision)", async () => { + const env = { ...createTestEnv(), DB: null } as unknown as ReturnType; + await expect( + recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: REPO, pullNumber: 1, blockerCodes: ["surface_lane_reject"] }), + ).resolves.toBeUndefined(); + }); + + it("swallows a failure writing the alert-marker audit event -- the trigger itself must never throw even if only that specific write fails", async () => { + const env = createTestEnv(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const realRecordAuditEvent = repositories.recordAuditEvent; + vi.spyOn(repositories, "recordAuditEvent").mockImplementation(async (e, event) => { + if (event.eventType.startsWith("rule_repeat_alarm:")) throw new Error("write failed"); + return realRecordAuditEvent(e, event); + }); + const now = Date.now(); + for (let i = 1; i <= RULE_REPEAT_ALARM_THRESHOLD; i++) { + await fireOnce(env, i, ["surface_lane_reject"], new Date(now + i).toISOString()); + } + // The alert still logs (console.error happens before the audit-marker write), and the whole call still + // resolves cleanly despite the marker write failing. + expect(error).toHaveBeenCalledTimes(1); + }); + + it("defaults occurredAt to the current time when omitted", async () => { + const env = createTestEnv(); + const before = Date.now(); + await recordGateBlockersAndCheckRepeatAlarm(env, { repoFullName: REPO, pullNumber: 1, blockerCodes: ["surface_lane_reject"] }); + const after = Date.now(); + const { createSignalStore } = await import("../../src/review/signal-tracking-wire"); + const history = await createSignalStore(env).queryRuleHistory(`${REPO}:surface_lane_reject`, before - 1000); + expect(history.fired).toHaveLength(1); + const occurredAtMs = new Date(history.fired[0]?.occurredAt ?? "").getTime(); + expect(occurredAtMs).toBeGreaterThanOrEqual(before); + expect(occurredAtMs).toBeLessThanOrEqual(after); + }); +});