Alerting on Good-Standing Status Changes Across a Portfolio
This guide is part of the Good-Standing Certificate Automation area within the Secretary of State Portal & API Ingestion framework. It takes the typed StatusChange events produced by the portfolio diff and turns them into routed alerts: a regression to not-in-good-standing pages counsel within minutes, a lapse into delinquency opens a ticket, and a benign reinstatement lands quietly in a weekly digest — with flapping suppressed and a defined escalation ladder so the right person is woken at the right severity and no one else is.
Scope of This Page
This page covers the alert router: severity classification (mapping a change’s direction and destination status to a rank), deduplication and flap suppression (collapsing repeat and oscillating events so a single lapse is not paged five times), and channel routing with an escalation ladder (page, ticket, or digest, with unacknowledged criticals climbing the ladder). It deliberately excludes the upstream detection — computing the change set is owned by Bulk Good-Standing Certificate Polling and Status Diffing — and the downstream transport of a message once a channel is chosen, which is owned by Calendar Sync & Notification Pipelines. Here we assume a stream of typed StatusChange events and decide what each one is worth and where it goes.
The Constraint That Forces Severity Ranking
Not every status change carries the same legal weight, and treating them uniformly is its own failure: page counsel for every event and the pages become noise that gets muted; digest every event and a genuine emergency waits a week. The severity of a good-standing change is a function of the destination status and its statutory consequence. A California entity moving to suspended under Cal. Corp. Code § 2205 has lost the capacity to prosecute or defend a lawsuit — that is an operational emergency measured in hours, not days. A Delaware corporation declared void under 8 Del. C. § 510 cannot close a financing. By contrast, a move from good standing to delinquent under a curable grace window — a late New York biennial statement under N.Y. BCL § 408, or a Texas franchise-tax delinquency still inside its cure period under Tex. Tax Code § 171 — is urgent but recoverable, and a recovery back to good standing after a reinstatement is informational. The router must therefore encode the same consequence hierarchy the compliance organisation already uses for deadlines, which is why its severity mapping is aligned with the Priority Scoring Algorithms that rank filing obligations: the two systems agree that statutory consequence, not event frequency, sets the rank.
Prerequisites
- Python 3.10+ — for
matchon the change kind and destination status,X | Yunions, andenumseverities. - A stream of typed
StatusChangeevents — emitted per run by the portfolio differ, each carrying the previous and current canonical status. - A monotonic clock and short-lived state — an in-process cache (or Redis for a multi-worker fleet) keyed by a change fingerprint, holding a last-emitted timestamp and a flap window per entity.
- A channel abstraction — a thin sink that accepts
(channel, alert)and hands off to the actual page/ticket/digest transport; this module chooses the channel, it does not send the message. - Standard library —
collections.deque,dataclasses,enum,json,logging,timefor the dedupe cache, flap tracker, and structured logging.
Implementation: A Severity-Ranked Alert Router with Suppression
The router classifies each StatusChange into a severity, fingerprints it to drop exact duplicates inside a dedupe window, checks a per-entity flap tracker to suppress an oscillating entity, and routes the survivor to a channel — paging for critical, a ticket for high, a digest for informational — with an escalation note attached to unacknowledged criticals. Comments mark the compliance-critical lines.
from __future__ import annotations
import enum
import json
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("good_standing.alerting")
class GoodStandingStatus(enum.IntEnum):
NOT_IN_GOOD_STANDING = 0
DELINQUENT = 1
GOOD_STANDING = 2
class Severity(enum.IntEnum):
INFO = 0
HIGH = 1
CRITICAL = 2
class Channel(str, enum.Enum):
PAGE = "page" # wakes counsel — reserved for CRITICAL regressions
TICKET = "ticket" # same-business-day queue for HIGH, curable lapses
DIGEST = "digest" # periodic roll-up for INFO recoveries and renewals
@dataclass(frozen=True)
class StatusChange:
entity_id: str
jurisdiction: str
previous: GoodStandingStatus
current: GoodStandingStatus
observed_at: datetime
@dataclass(frozen=True)
class Alert:
entity_id: str
jurisdiction: str
severity: Severity
channel: Channel
headline: str
escalates: bool # a CRITICAL that climbs the ladder if unacknowledged
def classify(change: StatusChange) -> Severity:
"""Severity is a function of the destination status and direction, not frequency."""
if change.current > change.previous:
return Severity.INFO # a recovery / reinstatement is never an emergency
match change.current:
case GoodStandingStatus.NOT_IN_GOOD_STANDING:
return Severity.CRITICAL # suspended/void — capacity to sue or close is lost
case GoodStandingStatus.DELINQUENT:
return Severity.HIGH # past due but inside a curable grace window
case _:
return Severity.INFO
_CHANNEL_FOR: dict[Severity, Channel] = {
Severity.CRITICAL: Channel.PAGE,
Severity.HIGH: Channel.TICKET,
Severity.INFO: Channel.DIGEST,
}
class AlertRouter:
"""Classify, deduplicate, flap-suppress, and route good-standing status changes."""
def __init__(self, dedupe_ttl_s: float = 3600.0,
flap_threshold: int = 3, flap_window_s: float = 86_400.0) -> None:
self._dedupe_ttl = dedupe_ttl_s # exact-duplicate window
self._flap_threshold = flap_threshold # changes within window that mark flapping
self._flap_window = flap_window_s
self._last_emitted: dict[str, float] = {} # fingerprint -> monotonic ts
self._flaps: dict[str, deque[float]] = {} # entity -> recent change times
@staticmethod
def _fingerprint(change: StatusChange, severity: Severity) -> str:
# Same entity + same destination severity within TTL is the "same" alert.
return f"{change.entity_id}|{change.jurisdiction}|{severity.name}"
def _is_duplicate(self, fp: str, now: float) -> bool:
last = self._last_emitted.get(fp)
return last is not None and (now - last) < self._dedupe_ttl
def _is_flapping(self, entity_id: str, now: float) -> bool:
window = self._flaps.setdefault(entity_id, deque())
window.append(now)
while window and (now - window[0]) > self._flap_window:
window.popleft() # evict observations older than the flap window
return len(window) >= self._flap_threshold
def route(self, change: StatusChange) -> Alert | None:
now = time.monotonic()
severity = classify(change)
fp = self._fingerprint(change, severity)
# Suppression 1: an exact repeat inside the dedupe window is counted, not re-sent.
if self._is_duplicate(fp, now):
logger.info(json.dumps({"event": "alert_deduped", "fingerprint": fp}))
return None
# Suppression 2: an oscillating entity is collapsed — but never a CRITICAL, because
# a real loss of good standing must page even if the entity has been noisy.
if severity is not Severity.CRITICAL and self._is_flapping(change.entity_id, now):
logger.warning(json.dumps({
"event": "alert_suppressed_flapping",
"entity_id": change.entity_id, "severity": severity.name,
}))
return None
self._last_emitted[fp] = now
channel = _CHANNEL_FOR[severity]
alert = Alert(
entity_id=change.entity_id, jurisdiction=change.jurisdiction,
severity=severity, channel=channel,
headline=f"{change.entity_id} ({change.jurisdiction}): "
f"{change.previous.name} -> {change.current.name}",
escalates=severity is Severity.CRITICAL, # criticals climb the ladder on no-ack
)
logger.info(json.dumps({
"event": "alert_routed", "entity_id": alert.entity_id,
"severity": alert.severity.name, "channel": alert.channel.value,
"escalates": alert.escalates,
}))
return alert
def route_batch(router: AlertRouter, changes: list[StatusChange]) -> list[Alert]:
"""Route a run's worth of status changes; suppressed events return no alert."""
alerts = [a for c in changes if (a := router.route(c)) is not None]
logger.info(json.dumps({
"event": "batch_routed", "changes": len(changes), "alerts": len(alerts),
"paged": sum(a.channel is Channel.PAGE for a in alerts),
}))
return alerts
The router is intentionally stateful but bounded: its only memory is the dedupe cache and the per-entity flap window, both self-evicting, so it does not accumulate unbounded state across runs. Crucially, flap suppression never applies to a CRITICAL — an entity that has been noisy can still genuinely lose good standing, and swallowing that page to reduce noise would be exactly the wrong trade. Suppression buys quiet on the recoverable events and spends none of that budget on the emergency.
Configuration Reference
These parameters govern severity and suppression behaviour and are configuration data owned by compliance, not branches in the routing loop.
| Parameter | Suggested value | Justification |
|---|---|---|
dedupe_ttl_s |
3600 s | Collapses the same entity+severity re-firing across back-to-back sweeps into one alert; too long masks a real re-lapse after a recovery. |
flap_threshold |
3 changes | Marks an entity that has changed status three times in the window as flapping — usually an unstable read, not three real transitions. |
flap_window_s |
86 400 s | The observation window for flap counting; scoped to a day so a legitimately eventful entity over weeks is not misread as flapping. |
| CRITICAL suppression | never | A loss of good standing pages even for a noisy entity; suppressing a CRITICAL to cut noise inverts the risk trade-off. |
| Severity source | destination status | Rank follows statutory consequence, aligned with Priority Scoring Algorithms — frequency governs suppression, not severity. |
| Channel map | page / ticket / digest | One severity maps to exactly one channel so routing is legible and the escalation boundary is immutable. |
Failure Modes and Fallback Routing
Each fault maps onto the four-tier scheme in the parent framework’s error categorization & retry logic — transient, statutory, data-validation, and system — and the router responds to each differently.
- An unstable read oscillates an entity between good and delinquent (transient). The flap tracker counts the changes in the window and, once past
flap_threshold, suppresses the HIGH/INFO churn while still counting it, so a genuinely flaky portal produces one suppression record instead of a dozen tickets. If any oscillation lands on not-in-good-standing, the CRITICAL bypasses suppression and pages regardless. - A real regression re-fires every sweep because the entity stays delinquent (statutory). The dedupe window collapses the repeats into a single alert per TTL, so counsel is not re-paged hourly for a known-open lapse; the ticket remains open and the escalation ladder, not a new alert, drives urgency. Tune
dedupe_ttl_sshorter than the statutory cure window so a lapse that persists past cure re-alerts. - The channel sink is unreachable when a CRITICAL is routed (system). The router’s job ends at choosing the channel; delivery is owned by Calendar Sync & Notification Pipelines, which must treat a failed page delivery as its own retryable event. A routed-but-undelivered CRITICAL should never be silently dropped — the
escalatesflag exists so the delivery layer climbs the ladder on no-acknowledgement. - A malformed change with an impossible transition arrives (data-validation). A change whose
currentequalspreviousshould never reach the router — the differ drops unchanged reads — but if one does,classifytreats a same-rank move as INFO rather than crashing, and the anomaly is logged for the upstream differ to investigate instead of paging counsel over a non-event.
Frequently Asked Questions
Why rank severity by destination status instead of by how far the entity moved?
Because the legal consequence lives in where the entity landed, not the size of the jump. A one-step move from delinquent to not-in-good-standing is a full emergency — the entity has lost the capacity to sue or close — while a two-step recovery from suspended back to good standing is merely good news. Ranking by destination status keeps the router aligned with the statutory consequence hierarchy the compliance team already uses in Priority Scoring Algorithms, so the same lapse is treated as equally serious whether it is scored for routing or alerted on here.
Won't suppressing flapping hide a real loss of good standing?
No — flap suppression never applies to a CRITICAL. The router suppresses the HIGH and INFO churn of an oscillating entity because that noise is almost always an unstable read rather than a dozen real transitions, but a move to not-in-good-standing bypasses the flap check entirely and pages regardless of how noisy the entity has been. Suppression spends its noise budget only on recoverable events; it never buys quiet at the cost of missing an emergency.
How does dedupe avoid re-paging counsel every sweep for a lapse that stays open?
Each alert is fingerprinted by entity, jurisdiction, and severity, and a repeat inside the dedupe_ttl_s window is counted but not re-sent. So a delinquency that persists across hourly sweeps produces one alert per TTL rather than one per sweep; the open ticket and the escalation ladder carry the urgency after that. Set the TTL shorter than the statutory cure window so a lapse that survives past its cure period re-alerts rather than staying silent.
Does this module actually send the page, ticket, or digest?
No. The router decides severity and channel and stops there; the actual delivery — paging, opening the ticket, appending to the digest, and retrying a failed send — is owned by Calendar Sync & Notification Pipelines. Keeping routing and delivery separate means the routing logic stays a pure, testable classification while transport failures are handled where retry and acknowledgement tracking already live.