/** * Escalation — deciding whether an alert notifies anyone right now, and who. * * Driven by a sweep on the event bus's existing `timer.tick.5m`. The sweep * re-evaluates current state before acting rather than trusting what was true * when the step was scheduled: an alert that resolved four minutes ago must not * wake the secondary at minute ten. * * Every reason to stay silent is enumerated as an explicit `skip` reason rather * than an early `return null`, because "why didn't I get paged?" is the * question an alerting system most needs to be able to answer. * * See openspec/changes/add-alerting/design.md D2, S1, S2, S3. */ import type { AlertSeverity } from '../../db/schema'; const MINUTE_MS = 60_000; /** Only `critical` pages; `warning` is recorded and never escalates. */ const SEVERITY_RANK: Record = { warning: 0, critical: 1 }; export function meetsSeverityFloor(severity: AlertSeverity, floor: AlertSeverity): boolean { return SEVERITY_RANK[severity] >= SEVERITY_RANK[floor]; } export interface EscalationStep { stepIndex: number; routeId: string; /** Minutes after escalation begins — not after the previous step. */ delayMinutes: number; } export interface RouteForEscalation { id: string; severityFloor: AlertSeverity; enabled: boolean; } export interface EscalatableAlert { id: string; severity: AlertSeverity; /** Index of the NEXT step to run. */ escalationStep: number; acked: boolean; resolved: boolean; /** Currently explained by an ancestor or a deploy window. */ suppressed: boolean; /** True between un-suppression and the next successful run. */ awaitingConfirmation: boolean; silencedUntil: Date | null; /** Notification is withheld until this instant. */ graceUntil: Date; /** * Escalation clock origin. Set to the un-suppression moment when suppression * lifts, so leaving a maintenance window does not instantly fire every step * whose delay has nominally elapsed since the alert first fired. */ escalationStartedAt: Date; } export type SkipReason = | 'resolved' | 'acked' | 'suppressed' | 'awaiting_confirmation' | 'silenced' | 'within_grace' | 'not_yet_due' | 'policy_exhausted' | 'no_eligible_route'; export type EscalationDecision = | { type: 'skip'; reason: SkipReason } | { type: 'notify'; routeId: string; stepIndex: number; nextStepDueAt: Date | null }; export interface EscalationInput { alert: EscalatableAlert; steps: EscalationStep[]; routes: Map; now: Date; } /** * Decide what escalation should do for one alert at `now`. * * Returns the route to notify and when the following step falls due, or the * reason nothing happens. */ export function decideEscalation(input: EscalationInput): EscalationDecision { const { alert, now } = input; // State re-check (S2). Order is deliberate: the cheapest and most decisive // conditions first, so the reason reported is the most informative one. if (alert.resolved) return { type: 'skip', reason: 'resolved' }; if (alert.acked) return { type: 'skip', reason: 'acked' }; if (alert.suppressed) return { type: 'skip', reason: 'suppressed' }; if (alert.awaitingConfirmation) return { type: 'skip', reason: 'awaiting_confirmation' }; if (alert.silencedUntil && alert.silencedUntil > now) { return { type: 'skip', reason: 'silenced' }; } // The grace window (S1): a failure that clears before it elapses never // notifies at all, which is what stops a flapping check from paging. if (alert.graceUntil > now) return { type: 'skip', reason: 'within_grace' }; const ordered = [...input.steps].sort((a, b) => a.stepIndex - b.stepIndex); // Walk forward from the current step. A route filtered out by its severity // floor is passed over immediately rather than costing its delay in silence — // waiting ten minutes to skip somebody is ten minutes nobody is told. for (let i = alert.escalationStep; i < ordered.length; i++) { const step = ordered[i]; const dueAt = new Date(alert.escalationStartedAt.getTime() + step.delayMinutes * MINUTE_MS); if (dueAt > now) return { type: 'skip', reason: 'not_yet_due' }; const route = input.routes.get(step.routeId); if (!route?.enabled) continue; if (!meetsSeverityFloor(alert.severity, route.severityFloor)) continue; const next = ordered[i + 1]; return { type: 'notify', routeId: route.id, stepIndex: step.stepIndex, nextStepDueAt: next ? new Date(alert.escalationStartedAt.getTime() + next.delayMinutes * MINUTE_MS) : null, }; } // Fell off the end. Distinguish "the policy had no steps left" from "every // remaining step was filtered out" — they look identical to the alert but // mean very different things to whoever configured the policy. const hadRemainingSteps = alert.escalationStep < ordered.length; return { type: 'skip', reason: hadRemainingSteps ? 'no_eligible_route' : 'policy_exhausted', }; } /** * Where the escalation clock restarts when suppression lifts. * * Not `firstFiredAt`: an alert suppressed for forty minutes under a policy with * steps at 0/10/30 would otherwise fire all three at once the instant the * ancestor resolves, which is precisely the storm suppression exists to avoid. */ export function escalationOriginAfterUnsuppression(unsuppressedAt: Date): Date { return unsuppressedAt; }