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.

Severity-tier routing of settlement exceptions A flagged exception is scored against an escalation config that reads its financial exposure and breach magnitude. The score assigns one of three tiers. Informational exceptions are logged to an audit sink. Review-required exceptions are posted to an analyst queue. Block-invoice exceptions page on-call and halt the invoice. All three tiers emit a structured JSON alert to their configured channel. Flaggedexception rec Score vs configexposure, band× Assign tier3-way Informationalaudit-log sink Review-requiredanalyst queue Block-invoicepage + halt run JSON alertto channel

Prerequisites

  • Python packages: the standard library covers the core — decimal for money, dataclasses and enum for the tier model, json for the alert payload, and hashlib for the audit digest. pandas is 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, a settlement_interval, a Decimal exposure_usd (the financial weight of the break), a band_multiple (how many band-widths past tolerance the deviation sits), and a config_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:

  1. 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.
  2. Monotonic escalation. For a fixed band-multiple, raising exposure_usd across 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.
  3. Block correctness. block_invoice is True if and only if at least one alert carries blocks_invoice. Assert both directions so a misconfigured policy cannot silently drop the halt.
  4. Replay hash. Re-routing the same frame under the same config_version must reproduce the same audit_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.