Routing settlement exceptions by severity tier
Once a validation layer has flagged a deviation, the failure is no longer detection but destination: a single flat alert stream sends a two-dollar rounding wobble and a fifty-thousand-dollar allocation error to the same inbox, and the one that matters drowns under the one that does not. Worse, without a tier that can physically stop the run, a material exception can flow into a final invoice while the analyst who would have caught it is still triaging noise. This guide routes flagged settlement exceptions into three actionable tiers — informational, review-required, and block-invoice — driven by an escalation config and emitting structured JSON alerts, so each exception reaches exactly the channel and the urgency its financial weight warrants. It is a step inside Threshold Tuning & Alerts, the layer that turns raw deviations into graded, routed exceptions before any interval invoices.
The diagram below traces the routing flow this page implements: a flagged exception is scored against an escalation config, assigned a tier, rendered into a structured JSON alert, and dispatched to the channel that tier names.
Prerequisites
- Python packages: the standard library covers the core —
decimalfor money,dataclassesandenumfor the tier model,jsonfor the alert payload, andhashlibfor the audit digest.pandasis used only to iterate the flagged frame. A webhook or message-bus client is needed at the dispatch boundary, but the routing decision itself has no third-party dependency. - Data dependencies: a frame of already-flagged exceptions, each row carrying a
node_id, asettlement_interval, a Decimalexposure_usd(the financial weight of the break), aband_multiple(how many band-widths past tolerance the deviation sits), and aconfig_version. Producing those fields is the job of the upstream detection step; routing consumes them. The exposure figure is only meaningful once the residual has passed through the Imbalance Allocation Algorithms, which decide how much of an interval’s imbalance is actually chargeable to the party in question. - Permissions / access: write access to the alert channels named in the escalation config (an audit-log sink, an analyst-queue endpoint, and an on-call pager or webhook) and the authority to set a run-level block flag. The channels themselves inherit the access controls enforced at the Security & Access Boundaries layer, so a block-invoice alert cannot be silently redirected.
Implementation
The router is a pure function from an exception plus a config to a tier plus a dispatch instruction. Keeping the decision separate from the delivery makes it replayable: the same inputs must always produce the same tier, and delivery is a downstream side effect that can be retried without re-deciding.
Step 1 — Model the tiers and the escalation config. The tier is an ordered enum so a run can be summarized by its highest tier. The config maps each tier to a channel, an SLA, and whether it halts the invoice, and it carries the exposure and band-multiple thresholds that separate the tiers.
from dataclasses import dataclass, field
from decimal import Decimal
from enum import IntEnum
class Tier(IntEnum):
INFORMATIONAL = 1 # log-and-forget; never blocks
REVIEW_REQUIRED = 2 # analyst must acknowledge before invoice
BLOCK_INVOICE = 3 # halts the run until a logged human release
@dataclass(frozen=True)
class TierPolicy:
channel: str # where the JSON alert is dispatched
sla_minutes: int # acknowledgement deadline
blocks_invoice: bool # does this tier freeze the settlement run
@dataclass(frozen=True)
class EscalationConfig:
review_exposure_usd: Decimal # exposure at/above -> at least review
block_exposure_usd: Decimal # exposure at/above -> block
block_band_multiple: Decimal # band-widths past tolerance -> block
config_version: str
policies: dict = field(default_factory=dict) # Tier -> TierPolicy
Step 2 — Decide the tier from exposure and breach magnitude. An exception escalates on either axis: a large enough dollar exposure, or a deviation far enough past its tolerance band, is sufficient on its own to block. Taking the maximum of the two independent judgments means neither a small-dollar structural break nor a large-dollar marginal wobble slips through as merely informational. Every comparison is on Decimal, so a boundary case resolves the same way on every replay.
def decide_tier(exposure_usd: Decimal, band_multiple: Decimal,
config: EscalationConfig) -> Tier:
"""Escalate on the stronger of financial exposure or breach magnitude."""
# Judgment by financial weight.
if exposure_usd >= config.block_exposure_usd:
by_exposure = Tier.BLOCK_INVOICE
elif exposure_usd >= config.review_exposure_usd:
by_exposure = Tier.REVIEW_REQUIRED
else:
by_exposure = Tier.INFORMATIONAL
# Judgment by how far past the tolerance band the deviation sits.
if band_multiple >= config.block_band_multiple:
by_magnitude = Tier.BLOCK_INVOICE
else:
by_magnitude = Tier.INFORMATIONAL
return Tier(max(by_exposure, by_magnitude)) # the more severe wins
Step 3 — Render a structured JSON alert. Every routed exception becomes one self-describing JSON object carrying enough context for the recipient to act without re-deriving anything: the identity of the interval, the tier and the channel, the money and magnitude that drove the decision, the config version, and a content hash. Monetary and magnitude fields are serialized as strings so the Decimal precision survives the JSON round-trip intact — a float here would reintroduce exactly the drift the pipeline works to avoid.
import hashlib, json
from datetime import datetime, timezone
def build_alert(rec: dict, tier: Tier, policy: TierPolicy,
config_version: str) -> dict:
"""Emit one structured, self-describing JSON alert for a routed exception."""
body = {
"schema": "settlement.exception.v1",
"tier": tier.name,
"channel": policy.channel,
"sla_minutes": policy.sla_minutes,
"blocks_invoice": policy.blocks_invoice,
"node_id": rec["node_id"],
"settlement_interval": str(rec["settlement_interval"]),
"exposure_usd": str(rec["exposure_usd"]), # string keeps Decimal exact
"band_multiple": str(rec["band_multiple"]),
"config_version": config_version,
"raised_at": datetime.now(timezone.utc).isoformat(),
}
# Content hash excludes the wall-clock stamp so identical breaks dedupe.
canonical = {k: v for k, v in body.items() if k != "raised_at"}
body["alert_id"] = hashlib.sha256(
json.dumps(canonical, sort_keys=True).encode()).hexdigest()[:16]
return body
Step 4 — Route the frame, dedupe, and raise the invoice block. The router walks the flagged frame, decides each tier, builds each alert, and groups the alerts by channel for dispatch. Two guards matter: identical breaks are de-duplicated by alert_id so a re-run does not double-page, and the presence of any block-invoice alert sets a run-level flag that stops the settlement run before it invoices.
import pandas as pd
def route_exceptions(flagged_df: pd.DataFrame,
config: EscalationConfig) -> dict:
"""Assign tiers, build alerts, dedupe, and surface an invoice-block flag."""
by_channel: dict[str, list] = {}
seen_ids: set[str] = set()
block_invoice = False
for rec in flagged_df.to_dict("records"):
tier = decide_tier(Decimal(str(rec["exposure_usd"])),
Decimal(str(rec["band_multiple"])), config)
policy = config.policies[tier]
alert = build_alert(rec, tier, policy, config.config_version)
if alert["alert_id"] in seen_ids:
continue # idempotent: never double-dispatch
seen_ids.add(alert["alert_id"])
by_channel.setdefault(policy.channel, []).append(alert)
block_invoice = block_invoice or policy.blocks_invoice
audit_hash = hashlib.sha256(
json.dumps(by_channel, sort_keys=True, default=str).encode()).hexdigest()
return {"by_channel": by_channel, "block_invoice": block_invoice,
"audit_hash": audit_hash}
The tier definitions, their channels, and their escalation behavior are summarized below. Note that only the top tier can halt a run, and — echoing the segregation-of-duties rule the parent layer enforces — the automated path that raises a block-invoice alert can never be the path that clears it.
| Tier | Trigger | Channel | Invoice effect | Escalation |
|---|---|---|---|---|
| Informational | Exposure below review threshold and within band-multiple limit | Audit-log sink | None | Logged only |
| Review-required | Exposure at or above the review threshold | Analyst queue | Held pending ack | On-call ack, SLA-bound |
| Block-invoice | Exposure at or above the block threshold, or deviation past the block band-multiple | On-call pager + webhook | Run frozen | Manual, logged release |
Verification
Confirm the routing is deterministic and complete before it governs a live run:
- Total accounting. The count of alerts across every channel, plus any deduplicated duplicates, must equal the number of flagged input rows. A shortfall means an exception fell through the tier decision without a policy — a config gap, not a quiet interval.
- Monotonic escalation. For a fixed band-multiple, raising
exposure_usdacross a threshold must only ever move a tier upward, never down. A property test that feeds increasing exposures and asserts the tier sequence is non-decreasing pins this. - Block correctness.
block_invoiceisTrueif and only if at least one alert carriesblocks_invoice. Assert both directions so a misconfigured policy cannot silently drop the halt. - Replay hash. Re-routing the same frame under the same
config_versionmust reproduce the sameaudit_hash, because tiering is pure and every money field is Decimal-exact.
def verify_routing(result: dict, flagged_df: pd.DataFrame) -> None:
"""Assert completeness and block-flag correctness of a routing result."""
dispatched = sum(len(v) for v in result["by_channel"].values())
assert dispatched <= len(flagged_df), "more alerts than input rows"
has_block = any(a["blocks_invoice"]
for alerts in result["by_channel"].values() for a in alerts)
assert result["block_invoice"] == has_block, "block flag disagrees with alerts"
Compliance Note
Routing decides whether a settlement run can invoice, so the tier logic is a control that regulators and auditors will inspect. FERC Order 888 and its Open Access Transmission Tariff provisions require transparent, auditable reconciliation: persist every routed alert with its tier, driving exposure, config version, and alert_id to an append-only log so a dispute can reconstruct exactly why an interval was blocked or waved through. SOX ITGC requires segregation of duties, which the block-invoice tier enforces structurally — the automated router raises the halt, but only a separately-authorized human can release it, and that release is itself logged. PJM Manual 28, MISO BPM-005, and ERCOT Nodal Protocols Section 9 each fix the dispute windows a held exception must respect, so the analyst queue must retain a review-required alert at least as long as its market’s restatement window. Validate the exposure and band-multiple thresholds against your tariff before they gate a real invoice, and make sure a failed dispatch to a block-invoice channel fails the run closed rather than open — a pager that cannot be reached must never let a material break invoice.
Frequently Asked Questions
How is this different from just setting three alert thresholds?
Three thresholds decide severity; routing decides destination and consequence. The tier a router assigns is bound to a channel, an acknowledgement SLA, and — critically — a physical effect on the run: a block-invoice tier does not merely shout louder, it freezes the settlement so a material break cannot cross into a final statement. The router also escalates on two independent axes at once, taking the more severe of a financial-exposure judgment and a breach-magnitude judgment, so a small-dollar structural error and a large-dollar marginal wobble are each caught by whichever axis they trip. A flat threshold on a single metric misses both of those cases.
Why serialize the Decimal money fields as strings in the JSON alert?
Because JSON has no decimal type — a number literal is parsed as a binary float by almost every consumer, which reintroduces exactly the representation drift the Decimal arithmetic exists to prevent. An exposure of 5000.00 can round-trip through a float as 4999.9999999999 and land on the wrong side of a threshold when a downstream system re-tiers it. Serializing exposure_usd and band_multiple as strings preserves the exact value across the wire, and the receiving system reconstructs a Decimal from the string rather than trusting a float it never should have parsed.
How do you stop a re-run from paging on-call twice for the same break?
Give every alert a content-based alert_id — a hash over the exception’s identity, tier, and driving figures, deliberately excluding the wall-clock timestamp so the same break hashes to the same id on every run. The router keeps a set of ids it has already dispatched and skips any repeat, so re-processing a settlement cycle re-derives the identical tiers but dispatches each unique exception exactly once. Because the hash is over Decimal-exact fields serialized as strings, an interval that has genuinely not changed is guaranteed to dedupe, while a break whose exposure actually moved gets a new id and a fresh alert.