Routing Excursion Alerts by Severity in Python
Not every excursion deserves a 3 a.m. phone call, and not every excursion can wait for the morning shift. Severity routing is the deterministic mapping from a scored event to a concrete recipient, channel, and acknowledgement deadline — the layer that decides an INFO-grade drift lands in a monitored chat room while a CRITICAL breach of a narrow-therapeutic-index biologic rings the quality-on-call phone until someone answers. This guide builds that mapping in Python with acknowledgement tracking, as the routing core of the escalation and alert routing for excursion events pipeline. The routing table is not a convenience; under ICH Q10 it is a change-controlled quality-system artifact, and every dispatch and acknowledgement it produces is an audit record.
Regulatory hook
The controlling clause is 21 CFR Part 11 §11.10(e): the audit trail must record operator entries and actions with secure, computer-generated time stamps. Severity routing generates two kinds of such records — the dispatch (the system notified a named recipient at a stamped time) and the acknowledgement (an authenticated operator accepted ownership). §11.10(g) adds the authority dimension: an acknowledgement is only meaningful if it carries a verified operator identity, so an anonymous “ack” button is non-compliant. Layered on top, ICH Q9 quality risk management requires the response to scale with the quantified risk, which is precisely what a deterministic severity-to-tier map encodes: proportionality made mechanical and reproducible rather than left to on-shift judgement.
Prerequisites
- Python 3.11 or newer (the example uses
enum.Enum,Protocol, and timezone-awaredatetime). - Standard library for the routing core:
hashlib,hmac,datetime,enum, anddataclassesare all stdlib. - Transport clients as needed:
pip install "slack-sdk>=3.27"for chat, or an SMS/voice provider SDK; the routing logic stays independent of any one vendor behind a channel protocol. - An authenticated identity provider for acknowledgements — SSO, LDAP, or a signed callback token — so
acknowledged_byis a verified identity, not free text (§11.10(g)). - Secret custody: channel credentials and the acknowledgement-token key must come from a KMS, HSM, or secrets manager, never inline in source.
- Upstream event: each alert carries a
score, anexcursion_id, and a product-criticality flag, emitted by duration-based scoring for temperature excursions, and has already passed deduplicating excursion alerts with fingerprinting.
Step-by-Step Implementation
Step 1 — Make the severity mapping deterministic
The first rule of a defensible routing table is that the same inputs always yield the same severity. Encode the boundaries as data, elevate criticality-flagged products by one band, and keep the function pure so a replay during Operational Qualification reproduces the routing decision exactly.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
class Severity(str, Enum):
INFO = "INFO"
WARNING = "WARNING"
CRITICAL = "CRITICAL"
@dataclass(frozen=True)
class SeverityPolicy:
warning_at: float = 26.0
critical_at: float = 76.0
def classify(self, score: float, critical_product: bool) -> Severity:
# ICH Q9 proportionality: response scales with quantified risk. A product
# with a narrow therapeutic index is escalated one band harder so a
# moderate score on a fragile biologic still rings the quality tier.
if score >= self.critical_at or (critical_product and score >= self.warning_at):
return Severity.CRITICAL
if score >= self.warning_at:
return Severity.WARNING
return Severity.INFO
Assert the boundaries, including the criticality elevation, so routing is reproducible:
p = SeverityPolicy()
assert p.classify(25.9, False) is Severity.INFO
assert p.classify(26.0, False) is Severity.WARNING # exact boundary
assert p.classify(40.0, True) is Severity.CRITICAL # elevated by product flag
Step 2 — Bind each severity to a channel and an acknowledgement SLA
Routing binds a severity to a recipient tier, a transport channel, and a bounded acknowledgement window. Keep the binding as data so it can be version-controlled and fingerprinted; the routing logic must never hard-code a phone number.
@dataclass(frozen=True)
class Route:
recipient: str # on-call role, resolved to a person by the rota
channel: str # abstract channel key: "chat", "sms", "voice"
ack_window: timedelta # §11.10(e) time-boxed accountability
SEVERITY_ROUTES: dict[Severity, Route] = {
Severity.INFO: Route("shift_technician", "chat", timedelta(minutes=30)),
Severity.WARNING: Route("shift_supervisor", "sms", timedelta(minutes=10)),
Severity.CRITICAL: Route("quality_on_call", "voice", timedelta(minutes=5)),
}
Step 3 — Dispatch through a vendor-agnostic channel
Abstract transport behind a Protocol so the routing engine — the part validation scrutinises — never imports a vendor SDK. The dispatcher records the send as an audit entry with a transport receipt.
from typing import Protocol
class NotificationChannel(Protocol):
def send(self, channel: str, recipient: str, subject: str, body: str) -> str:
"""Deliver a notification, returning a transport receipt id."""
class SeverityRouter:
def __init__(self, policy: SeverityPolicy, channel: NotificationChannel,
routes: dict[Severity, Route]):
self.policy = policy
self.channel = channel
self.routes = routes
def dispatch(self, excursion_id: str, score: float, critical_product: bool,
now: datetime) -> dict:
severity = self.policy.classify(score, critical_product)
route = self.routes[severity]
deadline = now + route.ack_window
receipt = self.channel.send(
route.channel, route.recipient,
subject=f"[{severity.value}] excursion {excursion_id}",
body=f"Acknowledge by {deadline.isoformat()}.",
)
# §11.10(e): the dispatch is a time-stamped record of a system action.
return {
"excursion_id": excursion_id,
"severity": severity.value,
"recipient": route.recipient,
"channel": route.channel,
"ack_deadline_utc": deadline.isoformat(),
"dispatched_at": now.isoformat(),
"transport_receipt": receipt,
"acknowledged_by": None,
}
Step 4 — Track acknowledgements with a verified identity
An acknowledgement closes the accountability loop, so it must carry an authenticated operator id and a stamped time, and it must reject a stale or forged callback. Bind the acknowledgement token to the excursion with an HMAC so a replayed token from another event cannot acknowledge this one.
import hashlib
import hmac
class AckTracker:
def __init__(self, signing_key: bytes):
if len(signing_key) < 32:
raise ValueError("signing_key must be at least 32 bytes")
self.signing_key = signing_key
def issue_token(self, excursion_id: str) -> str:
# Bind the ack token to the specific event so it cannot be replayed
# against a different excursion (§11.10(g) authority / non-repudiation).
return hmac.new(self.signing_key, excursion_id.encode("utf-8"),
hashlib.sha256).hexdigest()
def acknowledge(self, record: dict, operator_id: str, token: str,
now: datetime) -> dict:
if not operator_id:
# §11.10(g): an acknowledgement without a verified identity is not an
# accountable action and must be rejected.
raise ValueError("acknowledgement requires an authenticated operator id")
expected = self.issue_token(record["excursion_id"])
if not hmac.compare_digest(expected, token): # constant-time compare
raise ValueError("acknowledgement token does not match this excursion")
record["acknowledged_by"] = operator_id
record["acknowledged_at"] = now.isoformat() # contemporaneous §11.10(e)
record["overdue"] = now.isoformat() > record["ack_deadline_utc"]
return record
Prove that only the correct token from a named operator acknowledges the event:
tracker = AckTracker(b"k" * 32)
now = datetime(2026, 7, 14, 2, 0, tzinfo=timezone.utc)
rec = {"excursion_id": "EXC-7", "ack_deadline_utc": "2026-07-14T02:05:00+00:00",
"acknowledged_by": None}
good = tracker.issue_token("EXC-7")
acked = tracker.acknowledge(rec, "qa.jdoe", good, now)
assert acked["acknowledged_by"] == "qa.jdoe"
# A token minted for a different event must be rejected.
try:
tracker.acknowledge(rec, "qa.jdoe", tracker.issue_token("EXC-OTHER"), now)
assert False, "expected rejection of a mismatched token"
except ValueError:
pass
Step 5 — Wire routing to the escalation ladder
Severity routing dispatches the first notification; the acknowledgement deadline it sets is what the escalation sweep watches. When a deadline lapses unacknowledged, the escalation ladder climbs to the next tier and re-dispatches at the higher severity binding. Keep the two concerns separate: this router owns the severity-to-recipient decision, and the ladder owns the timeout-to-climb decision, so each is independently testable and independently validated.
def route_and_open(router: SeverityRouter, tracker: AckTracker,
excursion_id: str, score: float, critical_product: bool,
now: datetime) -> tuple[dict, str]:
record = router.dispatch(excursion_id, score, critical_product, now)
token = tracker.issue_token(excursion_id) # handed to the recipient's ack link
return record, token
Confirm the end-to-end path produces a routed, acknowledgeable record:
class _Recorder:
def __init__(self): self.sent = []
def send(self, channel, recipient, subject, body):
self.sent.append((channel, recipient)); return "rcpt-1"
router = SeverityRouter(SeverityPolicy(), _Recorder(), SEVERITY_ROUTES)
now = datetime(2026, 7, 14, 2, 0, tzinfo=timezone.utc)
rec, tok = route_and_open(router, AckTracker(b"k" * 32), "EXC-3", 88.0, True, now)
assert rec["severity"] == "CRITICAL" and rec["channel"] == "voice"
Compliance Validation Checklist
Run this as part of computerized-system validation; every item is something an auditor can independently confirm for this control.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
A fragile biologic’s excursion routes as WARNING |
Product-criticality flag not passed to classify |
Thread critical_product from master data into every dispatch so ICH Q9 elevation applies |
| Acknowledgements accepted from anyone | acknowledged_by populated from free text |
Require an authenticated identity from SSO/LDAP; reject empty operator ids (§11.10(g)) |
| An old ack link closes a new event | Token not bound to the excursion id | Mint the token with an HMAC over excursion_id and compare with compare_digest |
| Late acknowledgement shows as on-time | Deadline not compared at ack time | Compute and store overdue when acknowledging, comparing against ack_deadline_utc |
| Changing paging vendor forces re-validation of routing | Vendor SDK imported into routing logic | Keep transport behind the NotificationChannel protocol so routing stays vendor-agnostic |
Related
- Escalation & Alert Routing for Excursion Events — the pipeline this router drives.
- Deduplicating Excursion Alerts with Fingerprinting — suppresses storms before routing.
- Duration-Based Scoring for Temperature Excursions — supplies the score that severity maps from.
- Mapping Excursion Severity to CAPA Priority in Python — carries the routed severity into the corrective-action layer.
For architectural context, this guide supports Escalation & Alert Routing for Excursion Events, part of the broader Temperature Excursion Detection & Automated Rule Engines section.