Tracking State Primacy Notification Deadlines
The clock that governs a public notice does not start when an operator notices a problem; it starts the moment a violation becomes known, and it runs against a deadline that depends on both the tier of the violation and the primacy agency that oversees your system. This page, part of the Public Notification Workflows for SDWA Violations section, is about the bookkeeping layer that keeps those clocks honest: how to encode each agency’s deadline rules as versioned configuration, compute the exact instant a notice is due, measure the time still remaining, and escalate the moment a deadline slips past. It deliberately leaves the question of what fires a notice to automating Tier 1 public notification triggers and the question of how a message is delivered to dispatching multi-channel notifications. Here the single concern is the deadline itself, and getting it wrong is its own reportable violation.
Federal public-notification rules under 40 CFR Part 141 Subpart Q set the floor: Tier 1 acute-risk notices within 24 hours, Tier 2 notices within 30 days, and Tier 3 notices bundled into the annual report. But states with primacy routinely adopt tighter windows, shorter repeat-notice intervals, and their own definitions of when the clock begins. A deadline engine that hard-codes the federal numbers will quietly miss the very state rules that primacy agencies enforce most aggressively.
Prerequisites & Environment Setup
The implementation targets Python 3.11+ so that the standard-library zoneinfo module is available for timezone-aware arithmetic; every deadline is computed against a real IANA zone, never a naive datetime. Deadline definitions are validated with pydantic so that a malformed configuration fails loudly at load time rather than silently producing a wrong due date. The only third-party dependency is pydantic; zoneinfo and datetime ship with the interpreter.
Before writing any code you need one non-package input: the current public-notification rule set for each primacy agency your utility reports to. Most systems answer to a single state, but wholesalers, multi-state authorities, and tribal systems under direct EPA primacy may track several at once. Treat that rule set the way the Monitoring Frequency Scheduling module treats sampling calendars: as reviewed, version-controlled configuration with an effective date, not as constants buried in code. When a primacy agency amends a window, the change should arrive as a new configuration revision with its own effective date, so that a deadline computed last quarter is still reproducible against the rule that was in force at the time. That reproducibility is what an auditor asks for when a notice is disputed, and it is far easier to guarantee from a version history than to reconstruct from memory.
python3 -m venv .venv && source .venv/bin/activate
pip install "pydantic==2.7.*"
# tzdata is bundled on most platforms; install it explicitly where the OS omits it
pip install "tzdata==2024.1"
Step-by-Step Implementation
Step 1 — Model the per-agency deadline configuration
Each rule is a small record: which tier it applies to, the maximum interval to first notice, the interval at which the notice must be repeated until the violation is resolved, and the timezone whose local midnight or business hours the agency measures against. Encoding this as a pydantic model turns a spreadsheet of state rules into a validated table. The tighter_of semantics matter: when a state window is shorter than the federal floor, the state value governs; the loader below stores whatever the agency published and never assumes the federal number.
from __future__ import annotations
from datetime import timedelta
from enum import IntEnum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class Tier(IntEnum):
TIER_1 = 1 # acute risk to health
TIER_2 = 2 # non-acute risk
TIER_3 = 3 # informational / annual
class DeadlineRule(BaseModel):
"""One primacy agency's public-notification deadline for one tier."""
agency: str = Field(..., description="Primacy agency code, e.g. 'US-EPA', 'CA-SWRCB'")
tier: Tier
first_notice: timedelta = Field(..., description="Max interval from known-at to first notice")
repeat_interval: Optional[timedelta] = Field(
None, description="Cadence of repeat notices until resolved; None = no repeat"
)
timezone: str = Field("UTC", description="IANA zone the agency measures deadlines in")
citation: str = Field("", description="Regulatory citation for audit provenance")
@field_validator("first_notice", "repeat_interval")
@classmethod
def _must_be_positive(cls, v: Optional[timedelta]) -> Optional[timedelta]:
if v is not None and v <= timedelta(0):
raise ValueError("deadline intervals must be positive")
return v
# Version-controlled table. Federal floor (Subpart Q) plus two state overrides
# that tighten the Tier 2 window. Load this from YAML/DB in production.
DEADLINE_RULES: list[DeadlineRule] = [
DeadlineRule(agency="US-EPA", tier=Tier.TIER_1, first_notice=timedelta(hours=24),
repeat_interval=None, timezone="UTC",
citation="40 CFR 141.202(b)"),
DeadlineRule(agency="US-EPA", tier=Tier.TIER_2, first_notice=timedelta(days=30),
repeat_interval=timedelta(days=90), timezone="UTC",
citation="40 CFR 141.203(b)"),
DeadlineRule(agency="US-EPA", tier=Tier.TIER_3, first_notice=timedelta(days=365),
repeat_interval=None, timezone="UTC",
citation="40 CFR 141.204(b)"),
# State override: 10-day Tier 2 window, tighter than the 30-day floor.
DeadlineRule(agency="CA-SWRCB", tier=Tier.TIER_2, first_notice=timedelta(days=10),
repeat_interval=timedelta(days=90), timezone="America/Los_Angeles",
citation="22 CCR 64463.4"),
DeadlineRule(agency="TX-TCEQ", tier=Tier.TIER_1, first_notice=timedelta(hours=24),
repeat_interval=timedelta(days=1), timezone="America/Chicago",
citation="30 TAC 290.122"),
]
Step 2 — Resolve the governing rule and compute the due instant
Given a violation’s tier and the agency that oversees it, the tracker selects the matching rule and adds the interval to the moment the violation became known — not the moment it occurred. Subpart Q keys the clock to the point of knowledge, so the tracker takes an explicit known_at timestamp that upstream detection supplies. All arithmetic runs in the agency’s declared zone and the result is normalized back to UTC for storage, which keeps the due instant stable across daylight-saving transitions.
from dataclasses import dataclass
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
class NoRuleError(LookupError):
"""No configured deadline rule matched the agency/tier pair."""
def resolve_rule(agency: str, tier: Tier,
rules: list[DeadlineRule] = DEADLINE_RULES) -> DeadlineRule:
matches = [r for r in rules if r.agency == agency and r.tier == tier]
if not matches:
raise NoRuleError(f"no deadline rule for agency={agency} tier={tier}")
if len(matches) > 1:
raise ValueError(f"ambiguous rules for agency={agency} tier={tier}")
return matches[0]
@dataclass(frozen=True)
class NoticeDeadline:
agency: str
tier: Tier
known_at: datetime # UTC, when the violation became known
due_at: datetime # UTC, first-notice deadline
citation: str
def compute_deadline(agency: str, tier: Tier, known_at: datetime,
rules: list[DeadlineRule] = DEADLINE_RULES) -> NoticeDeadline:
if known_at.tzinfo is None:
raise ValueError("known_at must be timezone-aware")
rule = resolve_rule(agency, tier, rules)
# Add the interval in the agency's local wall-clock frame, then store UTC.
local = known_at.astimezone(ZoneInfo(rule.timezone))
due_local = local + rule.first_notice
due_at = due_local.astimezone(timezone.utc)
return NoticeDeadline(
agency=agency, tier=tier,
known_at=known_at.astimezone(timezone.utc),
due_at=due_at, citation=rule.citation,
)
Step 3 — Measure remaining time and classify status
The time still available on a deadline is simply the difference between the due instant and now:
When is positive the notice is still pending; when it crosses zero the deadline is breached. A small band before zero — a configurable warning threshold — lets the engine surface a notice that is at risk while there is still time to act. The classifier below returns a status so that dashboards and the escalation layer share one definition of “overdue.”
from enum import Enum
class DeadlineStatus(str, Enum):
PENDING = "PENDING" # comfortably ahead of the deadline
AT_RISK = "AT_RISK" # inside the warning band, not yet overdue
OVERDUE = "OVERDUE" # deadline passed, notice not confirmed sent
def time_remaining(deadline: NoticeDeadline,
now: Optional[datetime] = None) -> timedelta:
now = now or datetime.now(timezone.utc)
return deadline.due_at - now # Δt = t_deadline − t_now
def classify(deadline: NoticeDeadline,
notice_sent_at: Optional[datetime] = None,
warning_band: timedelta = timedelta(hours=4),
now: Optional[datetime] = None) -> DeadlineStatus:
now = now or datetime.now(timezone.utc)
# A confirmed send before the due instant closes the obligation.
if notice_sent_at is not None and notice_sent_at <= deadline.due_at:
return DeadlineStatus.PENDING
remaining = deadline.due_at - now
if remaining <= timedelta(0):
return DeadlineStatus.OVERDUE
if remaining <= warning_band:
return DeadlineStatus.AT_RISK
return DeadlineStatus.PENDING
The warning band should scale with the tier: a four-hour band is meaningful against a 24-hour Tier 1 clock but pointless against an annual Tier 3 deadline, where a band of several weeks gives compliance staff room to assemble the notice. The status vocabulary here maps cleanly onto the alert codes produced by Violation Code Classification, so an overdue deadline can be raised as its own distinct alert type rather than being folded into the original water-quality violation.
Step 4 — Track many deadlines and escalate the overdue
A production system carries dozens of open deadlines at once, each on its own clock and often against a different agency. The tracker below holds the live set, re-classifies them on every poll, and yields two actionable lists: those that have entered the warning band and those already overdue. For overdue Tier 1 and repeating Tier 2 obligations it also computes the next repeat-notice instant from the rule’s repeat_interval, so the escalation layer knows not just that a notice is late but when the next one is owed. The register and resolve methods bound the set: a deadline enters when detection reports a known violation and leaves only when the obligation is closed, either by a confirmed send or by the violation being cleared. Keeping that set small and explicit is what lets a single scan call drive both an operator dashboard and an automated escalation without either one re-deriving the deadline math.
class DeadlineTracker:
def __init__(self, rules: list[DeadlineRule] = DEADLINE_RULES):
self._rules = rules
self._open: dict[str, NoticeDeadline] = {}
def register(self, violation_id: str, agency: str, tier: Tier,
known_at: datetime) -> NoticeDeadline:
dl = compute_deadline(agency, tier, known_at, self._rules)
self._open[violation_id] = dl
return dl
def resolve(self, violation_id: str) -> None:
self._open.pop(violation_id, None)
def next_repeat_at(self, deadline: NoticeDeadline) -> Optional[datetime]:
rule = resolve_rule(deadline.agency, deadline.tier, self._rules)
if rule.repeat_interval is None:
return None
return deadline.due_at + rule.repeat_interval
def scan(self, now: Optional[datetime] = None,
warning_band: timedelta = timedelta(hours=4)):
now = now or datetime.now(timezone.utc)
at_risk, overdue = [], []
for vid, dl in self._open.items():
status = classify(dl, warning_band=warning_band, now=now)
if status is DeadlineStatus.OVERDUE:
overdue.append((vid, dl, self.next_repeat_at(dl)))
elif status is DeadlineStatus.AT_RISK:
at_risk.append((vid, dl))
return at_risk, overdue
Configuration Reference
The federal floor and two representative state overrides are shown below; every primacy agency you report to needs its own reviewed row with an effective date and citation.
| Agency | Tier | First notice | Repeat interval | Timezone | Citation |
|---|---|---|---|---|---|
| US-EPA | 1 | 24 hours | none | UTC | 40 CFR 141.202(b) |
| US-EPA | 2 | 30 days | 90 days | UTC | 40 CFR 141.203(b) |
| US-EPA | 3 | 365 days | none | UTC | 40 CFR 141.204(b) |
| CA-SWRCB | 2 | 10 days | 90 days | America/Los_Angeles | 22 CCR 64463.4 |
| TX-TCEQ | 1 | 24 hours | 1 day | America/Chicago | 30 TAC 290.122 |
| Field / status | Type | Default | Meaning |
|---|---|---|---|
first_notice |
timedelta | — | Maximum interval from known_at to first notice |
repeat_interval |
timedelta | None |
Cadence of repeat notices until the violation is resolved |
timezone |
str | UTC |
IANA zone the agency measures the deadline in |
warning_band |
timedelta | 4 hours | Lead time before due that flips status to AT_RISK |
PENDING |
status | — | Ahead of the deadline, or a confirmed send closed the obligation |
AT_RISK |
status | — | Inside the warning band, not yet overdue |
OVERDUE |
status | — | Due instant passed with no confirmed send; escalate |
Verification & Testing
Deadline math must be deterministic under a fixed now, and the daylight-saving behavior must be pinned by test, because a one-hour drift on a 24-hour clock is the difference between compliant and late.
from datetime import datetime, timezone
def _rule_ca_tier2():
return [r for r in DEADLINE_RULES
if r.agency == "CA-SWRCB" and r.tier == Tier.TIER_2]
def test_state_override_beats_federal_floor():
known = datetime(2026, 3, 1, 12, 0, tzinfo=timezone.utc)
dl = compute_deadline("CA-SWRCB", Tier.TIER_2, known)
assert dl.due_at - known == timedelta(days=10) # not the 30-day floor
def test_remaining_and_overdue_classification():
known = datetime(2026, 7, 1, 0, 0, tzinfo=timezone.utc)
dl = compute_deadline("US-EPA", Tier.TIER_1, known) # 24h window
before = datetime(2026, 7, 1, 20, 0, tzinfo=timezone.utc)
after = datetime(2026, 7, 2, 1, 0, tzinfo=timezone.utc)
assert time_remaining(dl, now=before) == timedelta(hours=4)
assert classify(dl, now=before) is DeadlineStatus.AT_RISK
assert classify(dl, now=after) is DeadlineStatus.OVERDUE
def test_confirmed_send_closes_obligation():
known = datetime(2026, 7, 1, 0, 0, tzinfo=timezone.utc)
dl = compute_deadline("US-EPA", Tier.TIER_1, known)
sent = datetime(2026, 7, 1, 10, 0, tzinfo=timezone.utc)
late = datetime(2026, 7, 3, 0, 0, tzinfo=timezone.utc)
assert classify(dl, notice_sent_at=sent, now=late) is DeadlineStatus.PENDING
Acceptance criteria before promoting the tracker to production:
Troubleshooting & Gotchas
- The clock starts at the wrong moment. Adding the interval to when the violation occurred rather than when it became known silently shortens or lengthens every deadline. Feed
compute_deadlinethe point-of-knowledge timestamp that detection records, and store both timestamps for audit. - Federal numbers quietly override a state rule. If the configuration only carries the Subpart Q floor, a 10-day state Tier 2 window is missed by 20 days. Load every agency’s published rule and let
resolve_ruleraiseNoRuleErrorwhen a pair is unconfigured, rather than defaulting to federal values. - Naive datetimes drift by an hour twice a year. Computing a 24-hour deadline on a naive local timestamp across a daylight-saving boundary lands the due instant 60 minutes off. Require
tzinfoon every input and do the arithmetic in the agency’s IANA zone before normalizing to UTC. - Escalation storms on a repeating deadline. A Tier 1 rule with a one-day repeat interval will re-fire every scan once overdue. Debounce by recording the last escalation instant and only re-alerting once
next_repeat_athas passed. - A sent notice never clears the deadline. If the dispatch layer does not report a confirmed-send timestamp back to the tracker,
classifykeeps returningOVERDUE. Close the loop from the delivery path described in the multi-channel dispatch section soresolveornotice_sent_atis set on success.
Related
- Public Notification Workflows for SDWA Violations — parent section and the end-to-end notification contract
- Automating Tier 1 Public Notification Triggers — sibling workflow that starts the clock this tracker runs
- Dispatching Multi-Channel Notifications (SMS, Email, and Web) — sibling delivery path that reports the confirmed-send timestamp
- Monitoring Frequency Scheduling — the versioned-calendar discipline this configuration mirrors
- Violation Code Classification — maps overdue deadlines to distinct internal alert codes