Auto-Assignment Rules for Exception Analysts
The routing engine has placed a $9M Fedwire recall at the top of the high-value queue. Now a harder question: who gets it? Not the analyst who keyed the wire yesterday — that would collapse the four-eyes control the whole desk exists to preserve. Not an ACH-only analyst with no wire-recall training. And not the one analyst already holding twelve open items while three colleagues sit idle. Within the broader exception routing and fraud-pattern detection practice, and downstream of routing reconciliation exceptions into prioritized work queues, this page builds the assignment function that answers "who": skills-based eligibility, load balancing against per-analyst capacity, and a segregation-of-duties (SoD) exclusion that structurally bars an analyst from owning an item they originated or approved.
Assignment runs after the priority scorer has chosen which item is next; its job is to pick the owner. The order of operations is what makes the control sound: filter to skill-eligible analysts, remove anyone the SoD rule excludes, then pick the least-loaded of who remains. Reverse those steps — balance load first, check SoD later — and you build a system that can hand an analyst their own wire and only notice afterward. What follows is the spec, the copy-paste function, calibration of skill tags and capacity, a before/after walkthrough, and the failure modes that turn auto-assignment into a compliance gap.
Concept Spec: Eligibility, Then Exclusion, Then Balance
Assignment is a three-stage filter over the analyst pool, and the stage order is the design. Let be the set of on-shift analysts. Stage one keeps those whose skill tags cover the item's required skill: . Stage two removes anyone barred by segregation of duties — the item's originator or approver: . Stage three picks the analyst in with the most spare capacity, breaking ties deterministically. Each stage only ever shrinks the candidate set, so SoD can never be "balanced away" — an excluded analyst is gone before load is even considered.
Spare capacity is capacity - open_count, and an analyst at or over capacity is not a valid target no matter how well they match. The tie-break, when two eligible analysts have equal spare capacity, must be stable — lowest analyst id wins — so the same item assigns to the same person on a retry rather than bouncing. Selecting from a pool of analysts is per item (one pass to filter, one to find the max), and assigning a batch of items is with memory for the mutable load counters. Because is a desk of tens, not millions, this is trivially fast; the engineering risk is never performance, it is getting the exclusion order right.
Full Annotated Python Implementation
The function below returns either the chosen analyst id or an explicit AssignmentResult explaining why no assignment was possible — an all-excluded deadlock must never silently drop an item or, worse, fall back to assigning it anyway. Load counters are passed in and updated by the caller so a batch assigns evenly instead of piling onto whoever looked idle at the first item.
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Iterable
@dataclass(frozen=True, slots=True)
class Analyst:
analyst_id: str
skills: frozenset[str] # e.g. {"WIRE_RECALL", "OFAC", "ACH_RETURN"}
capacity: int # max concurrent open items
on_shift: bool = True
@dataclass(frozen=True, slots=True)
class ExceptionItem:
txn_id: str
required_skill: str # skill an owner must hold
amount: Decimal
originator_id: str | None # keyed the underlying payment
approver_id: str | None # released it
@dataclass(frozen=True, slots=True)
class AssignmentResult:
txn_id: str
assigned_to: str | None
reason: str
def assign_item(
item: ExceptionItem,
analysts: Iterable[Analyst],
open_counts: dict[str, int],
) -> AssignmentResult:
"""Skills-based assignment with load balancing and SoD exclusion.
Order is load-bearing: skill filter, THEN SoD exclusion, THEN least-loaded.
SoD can never be traded off against load because excluded analysts are
removed before capacity is compared.
"""
# Stage 1: skill-eligible and on shift.
skilled = [
a for a in analysts
if a.on_shift and item.required_skill in a.skills
]
if not skilled:
return AssignmentResult(item.txn_id, None,
f"no on-shift analyst with skill {item.required_skill!r}")
# Stage 2: segregation of duties — never the originator or approver.
excluded = {item.originator_id, item.approver_id} - {None}
eligible = [a for a in skilled if a.analyst_id not in excluded]
if not eligible:
# Every skilled analyst is SoD-excluded: DO NOT bypass the control.
return AssignmentResult(item.txn_id, None,
"all skilled analysts excluded by SoD; escalate to second line")
# Stage 3: least-loaded, with spare capacity, stable tie-break.
def spare(a: Analyst) -> int:
return a.capacity - open_counts.get(a.analyst_id, 0)
with_room = [a for a in eligible if spare(a) > 0]
if not with_room:
return AssignmentResult(item.txn_id, None,
"all eligible analysts at capacity; queue backpressure")
# Most spare capacity wins; ties break on lowest analyst_id for stability.
chosen = max(with_room, key=lambda a: (spare(a), _neg_id(a.analyst_id)))
open_counts[chosen.analyst_id] = open_counts.get(chosen.analyst_id, 0) + 1
return AssignmentResult(item.txn_id, chosen.analyst_id, "assigned")
def _neg_id(analyst_id: str) -> tuple[int, ...]:
"""Invert id ordering so max() picks the LOWEST id on a capacity tie."""
return tuple(-b for b in analyst_id.encode())
def assign_batch(
items: Iterable[ExceptionItem],
analysts: list[Analyst],
open_counts: dict[str, int],
) -> list[AssignmentResult]:
"""Assign a priority-ordered batch, updating load counters as we go."""
results: list[AssignmentResult] = []
for item in items: # items arrive already priority-ranked
results.append(assign_item(item, analysts, open_counts))
return results
Calibration: Skill Tags, Capacity, and Four-Eyes
The function is only as good as the skill taxonomy and capacity numbers behind it:
- Skill tags should be coarse enough to cover a desk but specific enough to protect a specialized workflow.
WIRE_RECALL,OFAC,ACH_RETURN, andDUPLICATE_DEBITare useful grains; a singleGENERALISTtag defeats the purpose, while a tag per rule id makes every item unassignable. Map each queue'srequired_skilldeliberately — a sanctions-hit item requiresOFAC, not merelyWIRE, so it never lands with an analyst who cannot dispose of an OFAC match. - Capacity is a real number, not infinity. Set it from measured throughput — how many concurrent open items an analyst clears without SLA slippage — and lower it for high-value or complex queues where each item demands more attention. An over-set capacity turns load balancing into "assign everything to whoever has the highest ceiling", which starves nobody but overloads someone.
- Four-eyes coverage must be verified, not assumed. If a desk has only two
WIRE_RECALLanalysts and one of them originated a given wire, the SoD filter leaves exactly one eligible owner — fine until that analyst also approved it, at which point the item must escalate to a second line rather than assign. Calibrate staffing so every skill has at least three cleared analysts, or the SoD exclusion will routinely deadlock on the highest-value items.
Store skills, capacities, and the queue-to-skill map in version-controlled config so a staffing change is a reviewed, auditable event, and parallel-run any capacity change against a week of historical volume before activation.
Before and After: A Wire Recall Assigned
A WIRE_RECALL item, FW-90114 for $9,000,000.00, reaches the front of the high-value queue. It was keyed by analyst a-payne and released by a-cho. Four analysts are on shift:
| analyst | skills | capacity | open now | spare | eligible? |
|---|---|---|---|---|---|
a-payne |
WIRE_RECALL, OFAC | 8 | 3 | 5 | no — originator |
a-cho |
WIRE_RECALL | 8 | 1 | 7 | no — approver |
a-diaz |
WIRE_RECALL, ACH_RETURN | 6 | 5 | 1 | yes |
a-berg |
ACH_RETURN | 6 | 0 | 6 | no — lacks skill |
A load-first system would hand the wire to a-cho (spare 7, the emptiest suitable desk) — and hand the approver their own release to clear, a direct four-eyes breach. A skills-only system might pick a-berg, who is completely idle but cannot process a wire recall at all. The correct pipeline runs skill filter first (a-berg drops — no WIRE_RECALL), then SoD exclusion (a-payne and a-cho drop — originator and approver), leaving exactly one eligible analyst: a-diaz, with a single unit of spare capacity. assign_item returns AssignmentResult("FW-90114", "a-diaz", "assigned") and increments a-diaz's load to 6. The most-loaded remaining analyst wins here only because they are the only one who is both skilled and SoD-clear — which is exactly why staffing every skill with a third cleared analyst matters: had a-diaz also been at capacity, the item would have returned "all eligible analysts at capacity; queue backpressure" and escalated rather than silently overloading them.
Failure Modes & Guardrails
Three failures turn auto-assignment from a control into a liability:
- All-excluded deadlock. When every skilled analyst is the item's originator or approver — common on a thin specialist desk — a naive implementation either loops forever, drops the item, or falls back to assigning it anyway. The function returns an explicit
"all skilled analysts excluded by SoD"result that must escalate to a second-line queue; the guardrail is staffing (three cleared analysts per skill) plus an alert whenever an item cannot be assigned for SoD reasons, because a chronic deadlock signals an understaffed skill, not a code bug. - Capacity overflow. If assignment ignores
open_countor reads a stale counter, one analyst accumulates items past their real throughput while others idle, blowing the very SLAs the priority scorer worked to protect. Pass a live, mutatedopen_countsthrough the batch (asassign_batchdoes), treat an at-capacity analyst as ineligible, and surface a"queue backpressure"result so overflow pages on-call instead of piling silently. - SoD bypass through stage reordering. The single most dangerous change anyone can make to this code is moving the load-balance step before the SoD exclusion, or reading
originator_id/approver_idfrom a mutable source that can be cleared. Enforce the order with a test that asserts an originator is never assigned their own item even when they are the emptiest desk, and derive the SoD identities from the immutable audit record of who keyed and released the payment — the same append-only log the immutable audit trails reference specifies — so the exclusion cannot be defeated by tampering with a mutable field.
Frequently Asked Questions
Why filter for segregation of duties before balancing load, not after?
Because each stage only shrinks the candidate set, so an analyst removed by the SoD filter can never be reintroduced by load balancing. If you balanced first and checked SoD second, you would compute a "best" assignment and then have to reject it — and the tempting shortcut under load is to skip the recheck. Removing the originator and approver before capacity is compared makes the four-eyes control structural: the emptiest desk is simply never a candidate if it belongs to the person who released the wire.
What happens when every skilled analyst is excluded by SoD?
The function returns assigned_to=None with a reason to escalate to a second-line queue, and it never falls back to assigning the item anyway. That outcome is a staffing signal, not an error to swallow: it means the skill is too thin for four-eyes to hold. The guardrail is to staff at least three cleared analysts per skill and alert on any SoD deadlock so operations can rebalance the desk before it recurs on high-value items.
How does load balancing stay fair across a batch rather than one item?
The caller threads a single mutable open_counts dict through assign_batch, incrementing it as each item is assigned. That way the second item sees the load the first one just added, so a priority-ordered batch spreads evenly instead of stacking onto whoever looked idle at the first decision. Without the shared counter, every item in the batch would independently pick the same "emptiest" analyst and overload them.
Why break capacity ties on the lowest analyst id?
For determinism. When two eligible analysts have identical spare capacity, an arbitrary tie-break makes the same item assign to different people across retries, which corrupts the audit trail and confuses the desk. Selecting the lowest id (via the _neg_id inversion inside max) gives a total, reproducible order, so a retried assignment lands on the same analyst and the decision is defensible after the fact.
Related guides in this collection
- Routing Reconciliation Exceptions into Prioritized Work Queues — the parent reference whose segregation-of-duties filter this assignment step completes.
- Priority Scoring for Exception Work Queues — how the item reaching this assignment step was ranked to the front of its queue.
- Modeling the Exception Lifecycle as a Finite-State Machine — the ownership and state transitions an assigned break moves through next.
- Immutable Audit Trails for Reconciliation Decisions — the append-only record of who keyed and released a payment that the SoD exclusion reads from.