Compliance

alerting

A five-stage alert-delivery pipeline (dedup, rate-cap-to-digest, IANA-timezone quiet hours, multi-channel send, one audit row) the SOC 2 CC7.2 control.

@caisson/alerting is Caisson's SOC 2 CC7.2 alert-delivery control: a deterministic five-stage pipeline between an event and a delivered alert. Every dependency, including the clock, is injected, dedup, rate-cap, quiet hours, delivery, and audit are pure functions of their inputs.

What it does

  • Dedup on an open incident's key: dedup() suppresses a repeat event while an incident sharing its dedupeKey is still open, so a flapping check doesn't re-fire an alert that already has a live incident.
  • Rate-cap to a digest, never a drop: rateCap() checks the recipient's recent send count against a per-event-type RateCapPolicy; once maxPerWindow is reached the outcome flips to "digest" instead of "deliver": noisy alert types back off, they don't vanish.
  • IANA-timezone quiet hours: quietHours() resolves the recipient's local hour via the stdlib Intl.DateTimeFormat (no timezone-database dependency) and holds delivery inside the configured window, except a "critical" severity event always delivers.
  • Four delivery channels behind one port: email, webhook, Slack, and Telegram all implement the same AlertChannel port; deliverAll() runs them via Promise.all and catches every throw into a failed DeliveryResult, so one channel being down never blocks the others.
  • A structured audit row per outcome: processAlert() always calls auditSink.record() exactly once: delivered, suppressed, held, or digested.

Install

bun add @caisson/alerting

Quickstart

import {
  processAlert,
  createEmailChannel,
  createWebhookChannel,
  createInMemoryAuditSink,
} from "@caisson/alerting";

const result = await processAlert(event, {
  openIncidents, // { dedupeKey }[] — from your incident store
  recentCount, // this recipient's sends in the current rate-cap window
  ratePolicy: { maxPerWindow: 5 },
  recipientTz: "America/New_York",
  quietPolicy: { startHour: 22, endHour: 7 },
  now: new Date(),
  channels: [createEmailChannel(emailer), createWebhookChannel({ url })],
  auditSink: createInMemoryAuditSink(), // or a Postgres-backed AlertAuditSink
});

// result.outcome: "delivered" | "suppressed" | "held" | "digested"

The pipeline, in order

export async function processAlert(
  event: AlertEvent,
  deps: ProcessAlertDeps,
): Promise<ProcessAlertResult> {
  if (dedup(event, deps.openIncidents)) {
    return finish(event, deps, "suppressed", []);
  }
  if (rateCap(event, deps.recentCount, deps.ratePolicy) === "digest") {
    return finish(event, deps, "digested", []);
  }
  if (quietHours(event, deps.recipientTz, deps.quietPolicy, deps.now) === "hold") {
    return finish(event, deps, "held", []);
  }
  const deliveries = await deliverAll(event, deps.channels);
  return finish(event, deps, "delivered", deliveries);
}

Each stage can short-circuit to its own outcome before a channel is ever touched, and finish() fires on every path, the audit row is written whether or not anything actually delivered.

Drivers

ChannelFactoryTransport
CapturecreateCaptureChannel()In-memory; test-only.
EmailcreateEmailChannel(emailer)Delegates to an injected @caisson/email Emailer.
WebhookcreateWebhookChannel(config)fetchWithTimeout POST; optional HMAC signature.
SlackcreateSlackChannel(config)fetchWithTimeout POST to an incoming-webhook URL.
TelegramcreateTelegramChannel(config)fetchWithTimeout POST to a bot-API sendMessage.

Webhook, Slack, and Telegram config URLs pass @caisson/kernel's assertSafePublicUrl at the Zod schema boundary and assertSafePublicUrlResolved again at the fetch call, a DNS-rebinding recheck, and every outbound POST sets redirect: "error" so a 3xx can't hop the request to a private host after the check.

Configuration surface

DEFAULT_EVENT_TYPE_REGISTRY maps eventType -> { defaultSeverity, channels, ratePolicy }, a small seed (four event types), not an exhaustive catalog. Extend it with your own event types per real usage; AlertEventSchema is the one Zod .strict() boundary every stage consumes.

Composing with the base

Alerting is a plain package on top of the base substrate: it reuses @caisson/kernel's fetchWithTimeout and SSRF guards, and delegates its email channel to @caisson/email rather than opening a second email path. Its audit sink writes to a plain, RLS-forced Postgres table (alert_audit_log), explicitly not the hash-chained WORM chain that @caisson/audit-worm owns; the two products stay deliberately distinct.

A real dependency, not a manifest listing

Alerting is a workspace:* dependency the Compliance bundle re-exports at runtime (export * from '@caisson/alerting'), not a promise on a manifest. Buy it standalone, or get it composed into Compliance.