Public Notification Workflows for SDWA Violations
When a drinking-water violation is confirmed, the regulatory clock does not wait for a human to notice. Public notification is the legal act of telling the people who drink the water that something went wrong, and under the Safe Drinking Water Act it is enforced on deadlines measured in hours, not weeks. This section, part of the Violation Detection & Rule Engine Logic domain, covers how to automate the Public Notification Rule codified at 40 CFR Part 141 Subpart Q: classifying a scored violation into one of three notification tiers, computing the statutory deadline it inherits, rendering the exact content the rule mandates, dispatching that content across every required channel, and closing the loop with a certification of completion the primacy agency can audit. It is written for the compliance officers who sign the certification, the operators who must reach every affected connection, and the Python engineers who build the dispatch service that sits between the rule engine and the public.
Regulatory / Protocol Foundation
Subpart Q divides every SDWA violation and situation into three tiers by the immediacy of the health risk, and the tier — not the contaminant alone — sets every downstream parameter: how fast the notice must go out, which delivery methods are mandatory, and what the notice must say. Getting the tier wrong is itself a violation, so the classification logic is the load-bearing decision of the whole workflow.
Tier 1 covers situations with a significant potential to have serious adverse effects on human health as a result of short-term exposure. The public must be notified as soon as practical but within 24 hours of the utility learning of the violation, and the primacy agency must be consulted within 24 hours as well. The canonical triggers are acute: confirmed E. coli in the distribution system, a nitrate or nitrite exceedance of the maximum contaminant level, a waterborne-disease outbreak, an acute maximum residual disinfectant level breach, or a Groundwater Rule fecal-indicator positive. Nitrate is the archetypal acute chemical contaminant because a single sample above the MCL is an immediate risk to infants:
There is no averaging window to soften a nitrate exceedance — one confirmed sample above 10 mg/L as nitrogen is a Tier 1 event, which is why the source evaluation in MCL Exceedance Logic Implementation flags acute analytes on the instantaneous reading rather than a running average.
Tier 2 covers violations with the potential to have serious adverse effects on human health from longer-term exposure. Notification is required as soon as practical but within 30 days, with a repeat every three months for as long as the violation persists. Most maximum-contaminant-level and treatment-technique violations land here — total trihalomethanes above the running annual average limit, a lead action-level exceedance triggering public education, a filtration or disinfection treatment-technique failure that is not itself acute.
Tier 3 covers monitoring and reporting violations and other situations the agency deems non-urgent. Notice may be delivered with the annual Consumer Confidence Report or within a year of the violation, whichever comes first. A missed sample that the Monitoring Gap Detection Algorithms classify as a reporting lapse is the usual Tier 3 source.
Every tier shares a fixed set of mandatory content elements — omitting any one of them invalidates the notice even if it was delivered on time. The rule enumerates ten required elements:
| # | Mandatory content element | Notes |
|---|---|---|
| 1 | Description of the violation | Contaminant or situation, in plain language |
| 2 | When it occurred | Dates of the violation or event |
| 3 | Potential health effects | Standard health-effects language from Appendix B |
| 4 | Population at risk | Especially sensitive subpopulations (infants, immunocompromised) |
| 5 | Whether alternate water is needed | E.g. “use bottled water for infants under six months” |
| 6 | Actions the consumer should take | Boil-water directive, flushing guidance |
| 7 | What the system is doing | Corrective actions and timeline to return to compliance |
| 8 | System contact | Name and phone number for more information |
| 9 | Standard encouragement language | To distribute the notice to other affected persons |
| 10 | Multilingual requirement | Where a large proportion of consumers are non-English speakers |
Delivery method is also tier-dependent. Tier 1 requires methods designed to reach everyone rapidly — broadcast media, posting, hand or direct delivery, or an equivalent reverse-911 style push. Tier 2 and Tier 3 permit mail or direct delivery and, for non-community systems, posting or hand delivery. Across all tiers the utility must file a certification with the primacy agency within ten days, attesting that the notice was issued and that its content and delivery met the rule. That certification, and the delivery evidence behind it, is the artifact the whole automation exists to produce; it feeds the same recordkeeping duty tracked in the parallel DMR Generation & SDWIS Submission Workflows.
Architecture & Design Decisions
The notification service is a downstream consumer of the rule engine, not a re-implementation of it. It receives a violation that has already been detected, classified, and scored; its job is to translate that regulatory fact into a set of timed, evidenced deliveries. Three design decisions shape everything that follows.
Severity informs priority, but tier is derived from rule text. The Severity Scoring Models produce a numeric score that reflects contaminant health risk, population exposed, and exposure duration — that score drives dispatch priority and escalation aggressiveness, but it must never be what decides the tier. Tier is a deterministic function of the violation category and the acute/non-acute classification defined in Subpart Q. Two utilities with identical severity scores can owe different tiers, and a low-severity but acute event (a small system with a single nitrate exceedance) is still a 24-hour Tier 1. Conflating the two is the most common automation defect in this domain, so the classifier below reads only the regulatory fields and treats the score as metadata.
Content is templated, never hand-assembled. Because the ten mandatory elements must all be present and the health-effects language is prescribed verbatim, the notice body is rendered from a versioned template with a validated context object. A template engine such as Jinja2 gives strict variable resolution and lets the same regulatory text render into an SMS-length summary, a full mailed letter, and an HTML web posting from one source of truth. The alternative — string concatenation in application code — guarantees that some channel eventually ships a notice missing element 3 or element 8.
Channels are adapters behind one dispatch interface. SMS, automated voice, email, web/social posting, broadcast fax to media, and hand-delivery work orders each have different transports, latencies, and evidence of receipt, but the workflow treats them uniformly: every channel implements a common adapter that accepts a rendered notice and returns a delivery receipt. This is what lets the dispatcher retry, escalate, and aggregate receipts without knowing whether it is talking to a telephony API or printing mail labels. The violation category that anchors the whole record is resolved against the shared code set in Violation Code Classification, so the tier decision and the reported code can never disagree.
Phase-by-Phase Implementation
The workflow is built in five phases, each producing an artifact the next depends on: a tier, a deadline, a rendered notice, a set of delivery receipts, and a certification record.
Phase 1 — Classify a scored violation into a notification tier
The classifier consumes the validated violation record emitted by the rule engine and returns the Subpart Q tier. It reads the regulatory category and an is_acute flag; the severity score rides along only to set priority. Encoding the acute contaminant list explicitly — rather than inferring it — keeps the classification auditable against the rule text.
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from enum import IntEnum
from pydantic import BaseModel, Field
class Tier(IntEnum):
TIER_1 = 1 # acute, 24-hour
TIER_2 = 2 # non-acute MCL / treatment technique, 30-day
TIER_3 = 3 # monitoring / reporting, annual
# Contaminants whose single-sample exceedance is an acute (Tier 1) event.
ACUTE_ANALYTES = frozenset({"NITRATE", "NITRITE", "ECOLI", "FECAL_COLIFORM"})
# Violation categories that are inherently Tier 1 regardless of analyte.
ACUTE_SITUATIONS = frozenset({"WATERBORNE_OUTBREAK", "ACUTE_MRDL", "GWR_FECAL_POSITIVE"})
class Violation(BaseModel):
violation_id: str
system_id: str
category: str = Field(..., description="MCL | TT | MONITORING | REPORTING | SITUATION")
analyte: str | None = Field(None, description="EPA contaminant code, if applicable")
is_acute: bool = Field(False, description="Set by the rule engine for acute exceedances")
severity_score: float = Field(0.0, ge=0.0, description="Priority signal, not tier input")
detected_at: datetime = Field(..., description="When the system learned of the violation (UTC)")
def classify_tier(v: Violation) -> Tier:
"""Map a scored violation to its Subpart Q public-notification tier.
Tier is derived only from regulatory fields; ``severity_score`` never
changes the tier, only the dispatch priority downstream.
"""
if v.is_acute or v.category == "SITUATION" and v.analyte in ACUTE_SITUATIONS:
return Tier.TIER_1
if v.analyte in ACUTE_ANALYTES or v.category in ACUTE_SITUATIONS:
return Tier.TIER_1
if v.category in {"MCL", "TT"}:
return Tier.TIER_2
if v.category in {"MONITORING", "REPORTING"}:
return Tier.TIER_3
# Unknown categories fail safe to the most protective tier.
return Tier.TIER_1
Phase 2 — Compute the statutory notification deadline
Each tier carries a fixed offset from the moment the utility learned of the violation. The deadline is simply the trigger time plus that offset, , where is 24 hours for Tier 1, 30 days for Tier 2, and up to one year for Tier 3. Computing it on timezone-aware UTC avoids the daylight-saving ambiguities that would otherwise shift a 24-hour deadline by an hour twice a year. State primacy agencies can shorten these offsets, so the base map is overridable per agency — the mechanism developed in the child section on tracking state-primacy deadlines.
TIER_DELTAS: dict[Tier, timedelta] = {
Tier.TIER_1: timedelta(hours=24),
Tier.TIER_2: timedelta(days=30),
Tier.TIER_3: timedelta(days=365),
}
def notification_deadline(
v: Violation,
tier: Tier,
primacy_override: dict[Tier, timedelta] | None = None,
) -> datetime:
"""Return the UTC instant by which the public notice must be issued.
A primacy agency may prescribe a stricter offset; the most protective
(shortest) applicable window wins.
"""
deltas = dict(TIER_DELTAS)
if primacy_override:
for t, d in primacy_override.items():
deltas[t] = min(deltas[t], d)
trigger = v.detected_at.astimezone(timezone.utc)
return trigger + deltas[tier]
Phase 3 — Render the mandated content template
With the tier and deadline known, the notice body is rendered from a versioned template against a context object that carries all ten required elements. The context is validated first, so a missing health-effects statement or contact number is a rejected render rather than a non-compliant notice on the wire. The same context feeds a short SMS variant and a full letter variant.
from jinja2 import Environment, StrictUndefined, select_autoescape
env = Environment(undefined=StrictUndefined, autoescape=select_autoescape())
LETTER_TEMPLATE = env.from_string(
"""IMPORTANT INFORMATION ABOUT YOUR DRINKING WATER
a drinking water standard.
What happened: When a drinking-water violation is confirmed, the regulatory clock does not wait for a human to notice. Public notification is the legal act of… was detected on .
Health effects:
Who is affected:
What you should do:
What we are doing:
For more information, contact at .
Please share this notice with others who drink this water.
"""
)
class NoticeContext(BaseModel):
system_name: str
violation_verb: str = "violated"
description: str # element 1
occurred_on: str # element 2
health_effects: str # element 3 (verbatim Appendix B language)
population_at_risk: str # element 4
alternate_water: str | None = None # element 5
consumer_actions: str # element 6
corrective_action: str # element 7
contact_name: str # element 8
contact_phone: str
multilingual_note: str = "" # elements 9-10
def render_letter(ctx: NoticeContext) -> str:
"""Render the full mailed/posted notice; StrictUndefined rejects gaps."""
return LETTER_TEMPLATE.render(**ctx.model_dump())
Phase 4 — Dispatch across channels with retry
Each channel is an adapter returning a DeliveryReceipt. The dispatcher walks the tier’s required channels, retries transient failures with capped exponential backoff, and escalates a channel that exhausts its attempts. It never blocks on one slow transport holding up the others, and it records a receipt — success or terminal failure — for every attempt so the certification can prove coverage.
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Protocol
logger = logging.getLogger("public_notification.dispatch")
@dataclass
class DeliveryReceipt:
channel: str
recipient: str
status: str # CONFIRMED | FAILED
attempts: int
at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
detail: str = ""
class ChannelAdapter(Protocol):
name: str
async def send(self, notice: str, recipient: str) -> bool: ...
async def dispatch_channel(
adapter: ChannelAdapter,
notice: str,
recipient: str,
max_attempts: int = 4,
base_delay: float = 1.0,
) -> DeliveryReceipt:
"""Send one notice on one channel with capped exponential-backoff retry."""
for attempt in range(1, max_attempts + 1):
try:
if await adapter.send(notice, recipient):
return DeliveryReceipt(adapter.name, recipient, "CONFIRMED", attempt)
except Exception as exc: # transport error: retry, then escalate
logger.warning("%s attempt %d failed: %s", adapter.name, attempt, exc)
if attempt < max_attempts:
await asyncio.sleep(min(base_delay * 2 ** (attempt - 1), 30.0))
return DeliveryReceipt(
adapter.name, recipient, "FAILED", max_attempts, detail="attempts exhausted"
)
async def dispatch_all(
adapters: list[ChannelAdapter], notice: str, recipient: str
) -> list[DeliveryReceipt]:
"""Fan out to every required channel concurrently; one slow channel
never delays the others. Returns a receipt per channel."""
return await asyncio.gather(
*(dispatch_channel(a, notice, recipient) for a in adapters)
)
Phase 5 — Record the certification of completion
Once dispatch resolves, the workflow assembles the certification: the violation identity, the tier and deadline it was measured against, whether delivery met the deadline, and the full set of receipts. Keying the record on the violation and tier makes re-issuing a repeat notice idempotent rather than duplicative, and the boolean on_time is computed against the deadline from Phase 2, not the wall clock at write time.
class Certification(BaseModel):
violation_id: str
system_id: str
tier: Tier
deadline: datetime
issued_at: datetime
on_time: bool
receipts: list[DeliveryReceipt]
ruleset_version: str
class Config:
arbitrary_types_allowed = True
def certify(
v: Violation,
tier: Tier,
deadline: datetime,
receipts: list[DeliveryReceipt],
ruleset_version: str,
) -> Certification:
"""Build the append-only certification filed with the primacy agency."""
issued = min((r.at for r in receipts), default=datetime.now(timezone.utc))
confirmed = any(r.status == "CONFIRMED" for r in receipts)
return Certification(
violation_id=v.violation_id,
system_id=v.system_id,
tier=tier,
deadline=deadline,
issued_at=issued,
on_time=confirmed and issued <= deadline,
receipts=receipts,
ruleset_version=ruleset_version,
)
Validation, Quality Flags & Edge Cases
A notification moves through a small, explicit state machine, and every transition is written to the audit trail so the certification can be reconstructed. A notice is PENDING when created, DISPATCHED once every required channel has been attempted, CONFIRMED when at least one receipt proves delivery within the deadline, and FAILED when the channels are exhausted with no confirmation before the deadline. Only a CONFIRMED notice advances to CERTIFIED; a FAILED one escalates to an operator and, if the deadline has passed, is itself recorded as a reportable event.
Several edge cases recur and each needs a deterministic guard. Repeat notices are mandatory for a persisting Tier 2 violation every three months until it is resolved; the scheduler must emit a fresh notice keyed on the violation plus the repeat sequence, never silently reusing the first certification. Continuing violations blur the trigger time — if a violation is confirmed, resolved, and recurs, each occurrence is a distinct notification event with its own deadline, so the detected_at field is per-occurrence and never carried forward. Escalation on delivery failure must fail toward more contact, not less: when SMS and voice both exhaust their retries for a Tier 1 event, the workflow escalates to broadcast media and hand-delivery rather than marking the notice complete, because a Tier 1 notice with zero confirmed deliveries before the 24-hour deadline is a reportable failure in its own right. Clock-skew at the boundary matters because the on_time determination is a legal one — every timestamp in the pipeline is timezone-aware UTC so a deadline is never missed or falsely met by an hour.
Deployment & Integration Patterns
The service runs as a stateful worker behind the rule engine rather than inline with detection, so a burst of violations during a plant upset cannot back-pressure the evaluation path. A confirmed, scored violation is enqueued; a pool of workers each own one notification through its full lifecycle — classify, resolve deadline, render, dispatch, certify — and persist every state transition.
Deployment guidance:
- Persist the state machine, not just the outcome. Store each transition (
PENDING→DISPATCHED→ …) with its timestamp in append-only storage, so a crash mid-dispatch resumes without re-sending confirmed channels or losing receipts. - Make dispatch idempotent per channel. Key each send on violation, repeat sequence, and channel so a worker restart cannot double-call a reverse-911 system or mail a duplicate letter.
- Treat the deadline as a hard timer, not a poll. Schedule the deadline as a durable timer the moment the tier is known; a Tier 1 event cannot depend on a cron sweep that runs every fifteen minutes when the whole margin is measured in hours.
- Carry the certification into reporting. The certification and its receipts are the evidence for the ten-day filing, so they flow into the same submission path as the DMR Generation & SDWIS Submission Workflows, and the violation category reconciles against Violation Code Classification.
Production Validation Checklist
Failure Modes & Gotchas
The most dangerous defect in a public-notification workflow is a notice that goes out on time and looks complete but is missing a mandatory content element — because nothing in the dispatch path errors, and the gap surfaces only when the primacy agency reviews the certification months later and rejects it, retroactively converting an on-time notice into a violation. The StrictUndefined render in Phase 3 exists precisely to make that failure loud at render time instead of silent at audit time; a notice must never be assembled by string concatenation that can skip an absent field. The second recurring trap is letting the severity score leak into the tier decision: a high-scoring but non-acute Tier 2 violation does not become Tier 1 because it feels urgent, and a low-scoring nitrate exceedance does not relax to Tier 2 because the affected population is small — tier is rule text, severity is priority, and the two must stay in separate code paths. Finally, delivery confirmation is not delivery: an SMS gateway accepting a message is not proof a consumer received it, so for Tier 1 the workflow must treat unconfirmed pushes as incomplete and keep escalating across independent channels until the deadline, rather than trusting a single transport’s optimistic acknowledgement.
Related
- Violation Detection & Rule Engine Logic — the parent domain that detects, classifies, and scores the violation this workflow notifies on
- Severity Scoring Models — the score that drives dispatch priority without altering the tier
- MCL Exceedance Logic Implementation — the source exceedances that trigger acute and non-acute notices
- Monitoring Gap Detection Algorithms — monitoring and reporting lapses that become Tier 3 notices
- Automating Tier 1 Public Notification Triggers — the 24-hour acute-event path in depth
- Dispatching Multi-Channel Notifications: SMS, Email, and Web — channel adapters, receipts, and escalation
- Tracking State-Primacy Notification Deadlines — per-agency deadline overrides and timers
- DMR Generation & SDWIS Submission Workflows — the parallel reporting duty the certification feeds