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-aware datetime).
  • Standard library for the routing core: hashlib, hmac, datetime, enum, and dataclasses are 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_by is 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, an excursion_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.

python
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:

python
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.

python
@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.

python
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.

python
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:

python
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.

python
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:

python
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

For architectural context, this guide supports Escalation & Alert Routing for Excursion Events, part of the broader Temperature Excursion Detection & Automated Rule Engines section.