Routing Reconciliation Exceptions into Prioritized Work Queues
When the matcher gives up on a transaction, the break has to go somewhere — and the somewhere is not a single flat inbox. A $9.4M Fedwire that failed sanctions re-screening, a $12.00 ACH credit off by a fee, and a duplicate-debit alert cannot share a queue, a priority, or an owner. Within the broader exception routing and fraud-pattern detection practice, this reference covers the layer that sits between the matching engine and the analyst desk: a declarative routing-rules engine that classifies each break, selects a destination queue, assigns a priority, and enforces the controls that keep a high-value wire away from the person who originated it. It assumes the break already carries the state it was born in — the exception lifecycle state machine stamps every item PENDING_REVIEW before routing ever runs — and that any fraud signals attached to it came from upstream fraud-pattern detection.
Routing is a control surface, not a convenience. Get it wrong and the same break lands in three queues, a low-value rounding item starves a wire dispute of an analyst, or — the failure examiners care about most — the wire an analyst keyed yesterday routes back to that same analyst for approval today. The engineering goal is a routing decision that is deterministic, explainable, and auditable: given a break and a rule set, exactly one queue and one priority come out, and the reason is written down. This page builds that engine predicate by predicate, defines the queue topology it targets, and closes the segregation-of-duties gap that turns a routing bug into a compliance finding.
The Routing Context: What a Break Carries
A routing decision is only as good as the fields it can see. Before any rule fires, the classifier flattens the raw break into a stable RoutingContext: the rail (ACH, FEDWIRE, CHIPS, SWIFT), the exception type the matcher assigned (NO_MATCH, AMOUNT_VARIANCE, DUPLICATE_SUSPECT, SANCTIONS_RESCREEN, RETURN_UNAPPLIED), the transaction amount as decimal.Decimal, any fraud score attached upstream, and the identities of the analysts who touched the underlying payment (originator and approver) so segregation of duties can be enforced later. Normalizing here means the rule predicates stay small and total — every rule sees the same shape, and a missing field is a hard error, not an implicit None that a predicate silently swallows.
Risk tier is derived, not supplied. A break does not arrive labelled "high risk"; the classifier computes the tier from amount thresholds and rail so that a Decimal("1000000.00") Fedwire is HIGH regardless of which matcher tier produced it, while a sub-$50 ACH residue is LOW. Deriving the tier in one place — rather than sprinkling amount comparisons through the rules — is what keeps the threshold auditable: when compliance asks "what makes a wire high-value here", the answer is a single constant, not a scavenger hunt through a rule table.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
class RiskTier(str, Enum):
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
# High-value threshold is one auditable constant, not scattered literals.
HIGH_VALUE_CENTS = Decimal("100000.00") # $100k+ is always HIGH
MEDIUM_VALUE_CENTS = Decimal("2500.00")
@dataclass(frozen=True, slots=True)
class RoutingContext:
"""Normalized break, ready for predicate evaluation."""
txn_id: str
rail: str # "ACH" | "FEDWIRE" | "CHIPS" | "SWIFT"
exception_type: str # "NO_MATCH" | "AMOUNT_VARIANCE" | ...
amount: Decimal
fraud_score: Decimal # 0..1, 0 when no signal fired
originator_id: str | None # analyst who keyed the underlying wire
approver_id: str | None # analyst who released it
tier: RiskTier = RiskTier.LOW
def classify(raw: RoutingContext) -> RoutingContext:
"""Derive the risk tier from amount and rail in exactly one place."""
amt = raw.amount.copy_abs()
if amt >= HIGH_VALUE_CENTS or (raw.rail in ("FEDWIRE", "CHIPS") and amt >= MEDIUM_VALUE_CENTS):
tier = RiskTier.HIGH
elif amt >= MEDIUM_VALUE_CENTS:
tier = RiskTier.MEDIUM
else:
tier = RiskTier.LOW
# dataclass is frozen; return a copy with the derived tier set
return RoutingContext(
txn_id=raw.txn_id, rail=raw.rail, exception_type=raw.exception_type,
amount=raw.amount, fraud_score=raw.fraud_score,
originator_id=raw.originator_id, approver_id=raw.approver_id, tier=tier,
)
The Rule Engine: Predicate to Queue, First Match Wins
A routing rule is a predicate over the RoutingContext paired with a destination queue and a base priority. The engine evaluates rules top-down and stops at the first predicate that returns True — the same first-match discipline that keeps the amount tolerance gate deterministic. Ordering is therefore semantic: the sanctions-rescreen rule and the high-value-wire rule sit at the top precisely because they must win over the generic "any Fedwire" rule beneath them. Because the last rule is a total catch-all, there is no fall-through hole — every break routes somewhere, even if only to a manual triage queue, which is the single most important property for auditability.
Rules are data, not code branches. Encoding each rule as a small dataclass with a named predicate lets the rule set live in version control with an approval workflow, hot-reload without a deploy, and — crucially — carry a stable rule_id into the audit record so a routing decision can be reconstructed months later. A predicate should be pure and cheap: it reads the context, returns a boolean, and touches nothing else. The moment a predicate performs I/O or mutates state, the engine stops being replayable and the audit trail stops being trustworthy.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Callable
@dataclass(frozen=True, slots=True)
class RoutingDecision:
queue: str
priority: int # lower = more urgent (P0 highest)
rule_id: str
reason: str
@dataclass(frozen=True, slots=True)
class Rule:
"""A predicate paired with the queue and priority it selects."""
rule_id: str
predicate: Callable[[RoutingContext], bool]
queue: str
priority: int
reason: str
class Router:
"""First-match router over an ordered, immutable rule set."""
def __init__(self, rules: tuple[Rule, ...]) -> None:
if not rules:
raise ValueError("router requires at least a catch-all rule")
# The final rule MUST be a total catch-all so nothing falls through.
if rules[-1].predicate(_ANY) is not True:
raise ValueError("last rule must be a total catch-all")
self._rules = rules
def route(self, ctx: RoutingContext) -> RoutingDecision:
for rule in self._rules:
if rule.predicate(ctx):
return RoutingDecision(rule.queue, rule.priority, rule.rule_id, rule.reason)
# Unreachable given the catch-all invariant, but fail loud, never silent.
raise RuntimeError(f"no rule matched {ctx.txn_id}; catch-all invariant broken")
# Sentinel used only to prove the catch-all predicate is total at construction.
_ANY = RoutingContext(
txn_id="_probe", rail="ACH", exception_type="NO_MATCH",
amount=Decimal("0.00"), fraud_score=Decimal("0"),
originator_id=None, approver_id=None,
)
ROUTING_RULES: tuple[Rule, ...] = (
Rule("R01-sanctions", lambda c: c.exception_type == "SANCTIONS_RESCREEN",
queue="sanctions-hit", priority=0, reason="ofac_rescreen_priority"),
Rule("R02-highval-wire", lambda c: c.rail in ("FEDWIRE", "CHIPS") and c.tier is RiskTier.HIGH,
queue="wire-highvalue", priority=0, reason="high_value_wire_review"),
Rule("R03-fraud", lambda c: c.fraud_score >= Decimal("0.80"),
queue="fraud-review", priority=1, reason="fraud_score_over_threshold"),
Rule("R04-dup-ach", lambda c: c.rail == "ACH" and c.exception_type == "DUPLICATE_SUSPECT",
queue="ach-duplicate", priority=2, reason="duplicate_debit_suspect"),
Rule("R05-lowval", lambda c: c.tier is RiskTier.LOW,
queue="lowvalue-autoclose", priority=4, reason="low_value_residue"),
Rule("R99-catchall", lambda c: True,
queue="manual-triage", priority=3, reason="unclassified_break"),
)
Queue Topology: Rail, Type, and Risk Tier
Queues are organized along three intersecting axes, and the axis a break routes on is a policy decision, not an accident of code. The rail axis separates ACH from wire because the two desks have different tooling, different regulatory clocks, and different analysts — an ACH return under the NACHA Operating Rules is a different investigation than a Fedwire recall under UCC 4A. The exception-type axis carves out queues that demand a specialist workflow regardless of rail: a sanctions-rescreen hit goes to a dedicated OFAC queue whether it rode ACH or wire, and a duplicate-debit suspect goes to a dedup queue where the analyst can see the sibling entry. The risk-tier axis splits the same exception type by consequence, so a high-value wire variance never shares a queue — or an SLA — with a rounding residue.
The axes are not orthogonal in practice; they compose. A single break is a point in (rail, type, tier) space, and the rule ordering decides which axis dominates when they conflict. That is why R01-sanctions outranks R02-highval-wire: a sanctioned counterparty is a specialist-queue problem before it is a high-value-review problem. Designing the topology is largely the exercise of deciding those precedence conflicts up front and encoding them as rule order, so the engine never has to guess.
Priority Ordering and Backpressure
A queue is not FIFO. Within a queue, items are ordered by a priority score so an aging high-value wire surfaces above a fresh low-value one, and the full weighting of amount, age, risk, and regulatory deadline is developed in priority scoring for exception work queues. The router assigns a base priority band (P0–P4); the scorer resolves order within a band. Keeping those two responsibilities separate matters: the base band is a coarse, auditable policy ("sanctions hits are always P0"), while the score is a continuous re-ranking that responds to age and deadline pressure without ever letting a P2 item leapfrog a P0.
Backpressure is the safety valve. When a queue's depth crosses a ceiling — an intermediary outage dumps 40,000 unmatched entries at once — the engine must signal upstream to throttle intake and page on-call rather than silently accepting an unbounded backlog that no SLA can honor. The complementary risk is the SLA timer itself: every item carries a deadline derived from its exception type and regulatory clock, and an item approaching breach must be re-prioritized upward automatically, which is the mechanism detailed in the SLA timers and exception aging reference. Backpressure protects the system from overload; SLA-driven re-prioritization protects the individual item from starving inside it.
Segregation of Duties: The Non-Negotiable Filter
The one rule that cannot be expressed as a queue selection is a negative constraint: an analyst must never own an exception on a payment they originated or approved. Four-eyes control under NACHA and the FFIEC exists precisely so that the person who released a $9M wire is not the person who later clears the break on it, and routing is where that control is enforced structurally rather than left to a runbook. The segregation-of-duties (SoD) filter sits after queue selection and before assignment: it takes the chosen queue and the candidate owner, and if the owner is the break's originator_id or approver_id, the item spills to a second-line queue where a different team picks it up.
Enforcing SoD at routing time — not at assignment time deep in the UI — is what makes it auditable and unbypassable. The filter is a pure function of the context, so its decision is logged with the same rule_id discipline as everything else, and a penetration test that tries to hand an analyst their own wire is blocked by the same code path that a legitimate assignment flows through. The mechanics of choosing which eligible analyst gets the item, with load balancing and the SoD exclusion applied together, are the subject of auto-assignment rules for exception analysts.
Failure Modes
Routing fails quietly and expensively. The four scenarios below are the ones that surface in post-incident reviews of reconciliation desks, each with the root cause and the guardrail that prevents it.
| Failure scenario | Root cause | Mitigation |
|---|---|---|
| Low-priority starvation | Priority scorer never re-ranks aged P4 items; they sit unworked for weeks behind a steady stream of P0/P1 arrivals | Add an age-driven priority floor so an item's score rises with business-day age; alert when any item exceeds its SLA regardless of tier |
| Misroute on missing field | A predicate reads a field the classifier left None; the boolean coerces to False and the break silently skips its correct rule |
Make the classifier reject incomplete contexts (fail-closed); type predicates so a missing field raises, never defaults; keep the total catch-all as the last line of defense |
| Hot-queue overload | An intermediary outage dumps tens of thousands of unmatched entries into one queue, blowing past any SLA and hiding urgent items | Backpressure ceiling per queue throttles intake and pages on-call; shed low-value auto-close candidates to a batch job so the human queue stays workable |
| Duplicate assignment | The same break is routed twice (retry after a partial failure) and lands with two analysts, wasting effort and risking conflicting dispositions | Idempotent routing keyed on txn_id + exception_type; assignment writes are conditional (compare-and-set) so a second route is a no-op, logged as a dedup |
Compliance & Auditability
Routing decisions are examinable records, not internal plumbing. Every decision the Router returns must be appended to an immutable log carrying the rule_id that fired, the queue chosen, the priority band, the SoD outcome, the actor (here, the system), and a UTC timestamp — the same append-only discipline the immutable audit trails reference specifies for reconciliation events generally. An examiner reconstructing "why did this $9M wire sit in second-line review for six hours" needs the rule id, the SoD spill reason, and the priority score at each moment, and none of that is recoverable if routing logs are mutable or absent.
Three specific obligations shape the design. Segregation of duties is a four-eyes control the FFIEC IT Examination Handbook and NACHA's risk-management rules expect institutions to enforce and evidence; encoding it as a logged filter is how you evidence it. Reg E (12 CFR 1005.11) error-resolution clocks mean a routing delay is not neutral — an item that misroutes and ages toward the 10-business-day provisional-credit deadline is a compliance exposure, so the routing audit must timestamp intake precisely enough to prove the clock, a linkage detailed in the Reg E error resolution reference. Finally, because routing rules are a control surface, changes to ROUTING_RULES belong in version control with an approval workflow and a parallel run against historical breaks before activation — a mis-ordered rule is a control failure, not a bug.
Testing & Verification
Routing is deterministic, which makes it eminently testable: the same context must always yield the same decision, and the invariants (catch-all totality, SoD enforcement, no duplicate assignment) are properties you assert directly. Test the precedence conflicts explicitly — a sanctions hit on a high-value wire must route to sanctions-hit, not wire-highvalue — because those are exactly the cases a careless rule reorder breaks.
import pytest
from decimal import Decimal
def ctx(**kw):
base = dict(
txn_id="T1", rail="ACH", exception_type="NO_MATCH",
amount=Decimal("10.00"), fraud_score=Decimal("0"),
originator_id=None, approver_id=None,
)
base.update(kw)
return classify(RoutingContext(**base))
def test_catchall_is_total():
# Construction fails loudly if the last rule is not a total catch-all.
Router(ROUTING_RULES) # must not raise
def test_sanctions_outranks_high_value_wire():
c = ctx(rail="FEDWIRE", exception_type="SANCTIONS_RESCREEN",
amount=Decimal("9000000.00"))
d = Router(ROUTING_RULES).route(c)
assert d.queue == "sanctions-hit"
assert d.priority == 0
assert d.rule_id == "R01-sanctions"
def test_high_value_wire_beats_low_value_fallback():
c = ctx(rail="FEDWIRE", exception_type="NO_MATCH", amount=Decimal("500000.00"))
d = Router(ROUTING_RULES).route(c)
assert d.queue == "wire-highvalue"
def test_low_value_residue_autocloses():
c = ctx(rail="ACH", exception_type="AMOUNT_VARIANCE", amount=Decimal("3.00"))
d = Router(ROUTING_RULES).route(c)
assert d.queue == "lowvalue-autoclose"
assert d.priority == 4
def test_every_break_routes_somewhere():
# Property: no context escapes without a decision.
for etype in ("NO_MATCH", "AMOUNT_VARIANCE", "DUPLICATE_SUSPECT", "RETURN_UNAPPLIED"):
d = Router(ROUTING_RULES).route(ctx(exception_type=etype))
assert d.queue # always non-empty
Frequently Asked Questions
Why first-match ordering instead of scoring every rule and taking the best?
First-match makes routing deterministic and auditable: the rule_id that fired is a single, explainable cause, and reordering rules is a reviewable change with predictable semantics. A best-score approach forces you to reason about tie-breaks across the whole rule set and makes "why did this route here" a probabilistic answer rather than a citation. Precedence conflicts — a sanctions hit on a high-value wire — are resolved once, in rule order, and stay resolved.
What stops a break from falling through the rule set with no queue?
The Router constructor asserts that the final rule is a total catch-all (its predicate returns True for any context) and refuses to build otherwise. The catch-all routes anything unclassified to a manual triage queue rather than dropping it, so the worst case is a human looking at an unexpected break, never a break that silently vanishes. The test_catchall_is_total and test_every_break_routes_somewhere cases lock that invariant in.
How is segregation of duties different from just assigning to another analyst?
SoD is a negative constraint enforced structurally at routing time: the item is barred from its originator and approver before any assignment logic runs, and the bar is a logged, pure-function decision that no UI path can bypass. Simply "assigning to someone else" leaves the door open for a manual reassignment back to the originator under load. The routing filter spills such items to a second-line queue so four-eyes is preserved by construction, not by discipline.
Where does the priority score fit versus the base priority the router assigns?
The router assigns a coarse base band (P0–P4) that is a policy statement — "sanctions hits are always P0" — and is cheap and auditable. The priority scorer then orders items within a band by amount, business-day age, risk signal, and regulatory-deadline proximity, so an aging high-value wire surfaces first without ever crossing a band boundary. The two are deliberately separate so a continuous re-rank can never override a policy-level urgency.
What happens when a queue overloads during an intermediary outage?
The per-queue backpressure ceiling trips: intake is throttled, on-call is paged, and low-value auto-close candidates are shed to a batch job so the human queue stays workable. The alternative — accepting an unbounded backlog — quietly breaches every SLA at once and buries the urgent items that were already there. Backpressure protects the system; the SLA-driven re-prioritization then protects each individual item from starving inside the throttled queue.
Related guides in this collection
- Priority Scoring for Exception Work Queues — the weighted score that orders items within a queue by amount, age, risk, and deadline.
- Auto-Assignment Rules for Exception Analysts — skills-based assignment with load balancing and the segregation-of-duties exclusion.
- Modeling the Exception Lifecycle as a Finite-State Machine — the state a break carries into routing and the transitions it moves through afterward.
- Fraud-Pattern Detection Signals in Payment Reconciliation — the upstream signals that populate the fraud score routing keys on.
- Exception Routing & Fraud-Pattern Detection — the parent reference for what happens to a break after the matcher gives up.
- Immutable Audit Trails for Reconciliation Decisions — the append-only log every routing decision is written to.