Manual-Review Queue Escalation SLAs: Severity Tiers, Aging, and Hold-Pay-by-Default
A record that fails fallback routing does not stop being payroll — it becomes a payroll record with an unresolved question, and if the manual-review queue does not enforce a strict clock against the next pay-cut, that question sits until an auditor asks it first. This page defines the escalation service-level budget for items held by the Fallback Routing Strategies topic within the Core Architecture & Compliance Mapping framework: severity classification, aging-driven escalation tiers, and the hold-pay-by-default rule every decision here must obey — all written to the same immutable-ledger discipline as Compliance Audit Trail Design.
Problem Framing
A quarantine queue without a service-level budget degrades in one of two directions: every item gets the same urgency, so a record that blocks disbursement rots next to a cosmetic general-ledger mismatch, or the team defaults to “hold everything until reviewed,” which turns a five-record exception into a backlog that delays a whole pay run. Neither is fixed by adding reviewers — it is fixed by making severity and time explicit inputs to the routing decision.
Three properties make a naive queue break in production:
- Severity is collapsed into one bucket. A record that will underpay an employee if disbursed incorrectly (a
BLOCKING_DISBURSEMENTitem) and one that only misroutes a general-ledger code (anADVISORYitem) are not the same failure. Treating them identically either holds pay unnecessarily or lets a blocking item wait behind advisory noise. - Escalation ignores the pay-cut deadline. A fixed “review within 24 hours” rule is meaningless if the item was quarantined six hours before the run’s disbursement cutoff. The controlling clock is whichever comes first — the aging-based tier ladder or the run’s actual
pay_cut_utc. - The default on timeout is undefined. Without an explicit default, an engine either pays a guessed rate (a compliance failure) or halts the whole run (an availability failure). The only defensible default is that disbursement was already being withheld — hold-by-default, decided the moment severity is classified, not negotiated at the deadline.
For a BLOCKING_DISBURSEMENT item, the current escalation tier at evaluation time is a function of aging hours and hours remaining to the pay cut:
\text{tier} = \begin{cases} \text{ON\_CALL} & h_{\text{to-cut}} \le 2 \ \text{or} \ h_{\text{aging}} \ge H_{\text{oncall}} \\ \text{TIER\_2} & h_{\text{aging}} \ge H_{\text{tier2}} \\ \text{TIER\_1} & \text{otherwise} \end{cases}
Note that the deadline term can force ON_CALL even when the aging ladder has not yet reached its own threshold — a record quarantined late in the pay cycle escalates immediately, because a slow ladder is not a safe reason to miss a hard cutoff.
Prerequisites & Data Requirements
Every item entering the queue already passed through fallback-routing’s tier hierarchy and carries the fields that hierarchy assigns, plus the fields the escalation evaluator needs. All money and hour arithmetic below uses Decimal; nothing is derived through float.
| Field | Type | Precondition |
|---|---|---|
record_id |
str |
Carried over unchanged from the fallback router’s audit hash; stable across re-injection. |
reason_code |
str (enum) |
One of the router’s quarantine conditions, e.g. UNRESOLVED_JURISDICTION, UNMAPPED_PAY_CODE, AMOUNT_CAP_BREACH. |
severity |
str (enum) |
BLOCKING_DISBURSEMENT or ADVISORY, assigned by the router at quarantine time — never inferred later from aging. |
enqueued_at |
datetime (UTC) |
Timestamp the record entered the queue; immutable, drives aging. |
pay_cut_utc |
datetime (UTC) |
The authoritative disbursement cutoff for the run containing this record, pulled from the run’s own schedule — never derived from wall-clock now(). |
current_tier |
str (enum) |
TIER_1, TIER_2, or ON_CALL; recomputed on every evaluation, never persisted as the source of truth. |
resolved_at |
Optional[datetime] |
Set once a correction is applied; None while the item is open. |
resolution_action |
Optional[str] |
The corrected rule or code applied; required before re-injection. |
held_gross |
Decimal |
The gross amount at risk if disbursement is withheld; rejected at construction if not Decimal. |
Two preconditions sit outside the evaluator’s signature. First, severity is a router-time classification, never re-derived from how long an item has aged, or a stale ADVISORY item that should have been reclassified rides a permissive schedule indefinitely. Second, pay_cut_utc must come from the run’s authoritative schedule — two evaluators disagreeing on “now” because one used a cached clock is exactly the drift that produces a non-reproducible escalation decision.
Step-by-Step Implementation
Step 1 — Model the queue item and severity budgets. Encode BLOCKING_DISBURSEMENT and ADVISORY with distinct hour budgets — blocking items escalate on a tight schedule; advisory items get a longer one since they never withhold pay.
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
import hashlib
import logging
logger = logging.getLogger("payroll.manual_review_queue")
SECONDS_PER_HOUR = Decimal("3600")
class Severity(Enum):
BLOCKING_DISBURSEMENT = "blocking_disbursement"
ADVISORY = "advisory"
class Tier(Enum):
TIER_1 = "tier_1"
TIER_2 = "tier_2"
ON_CALL = "on_call"
# (tier_1_hours, tier_2_hours, on_call_hours) — cumulative hours since enqueue.
TIER_BUDGETS_HOURS: dict[Severity, tuple[Decimal, Decimal, Decimal]] = {
Severity.BLOCKING_DISBURSEMENT: (Decimal("4"), Decimal("8"), Decimal("12")),
Severity.ADVISORY: (Decimal("24"), Decimal("48"), Decimal("72")),
}
@dataclass(frozen=True)
class QueueItem:
record_id: str
reason_code: str
severity: Severity
enqueued_at: datetime
pay_cut_utc: datetime
held_gross: Decimal
resolved_at: Optional[datetime] = None
resolution_action: Optional[str] = None
def __post_init__(self) -> None:
if not isinstance(self.held_gross, Decimal):
raise TypeError(
f"held_gross must be Decimal, got {type(self.held_gross).__name__}"
)
@dataclass(frozen=True)
class EscalationDecision:
record_id: str
tier: Tier
aging_hours: Decimal
hold_disbursement: bool
breached: bool
Step 2 — Compute aging and time-to-pay-cut in Decimal hours. Both quantities divide integer seconds by Decimal("3600") so no float quotient enters the tier decision. Expected output for an item enqueued exactly 5 hours before now: _aging_hours(...) == Decimal("5").
def _aging_hours(item: QueueItem, now: datetime) -> Decimal:
"""Hours elapsed since enqueue, computed on Decimal seconds."""
if now < item.enqueued_at:
raise ValueError("now precedes enqueued_at")
elapsed = Decimal((now - item.enqueued_at).total_seconds())
return elapsed / SECONDS_PER_HOUR
def _hours_to_pay_cut(item: QueueItem, now: datetime) -> Decimal:
"""Hours remaining until the run's disbursement cutoff (negative once past)."""
remaining = Decimal((item.pay_cut_utc - now).total_seconds())
return remaining / SECONDS_PER_HOUR
Step 3 — Determine the escalation tier. Apply the severity-specific budget, and let proximity to the pay-cut deadline force ON_CALL even if the aging ladder has not reached its own threshold. Expected output: a BLOCKING_DISBURSEMENT item aged 1 hour but only 1.5 hours from pay_cut_utc returns Tier.ON_CALL, not Tier.TIER_1.
def _determine_tier(
item: QueueItem, aging_hours: Decimal, hours_to_cut: Decimal,
) -> Tier:
"""Resolve the active tier from aging and deadline proximity."""
tier1_h, tier2_h, oncall_h = TIER_BUDGETS_HOURS[item.severity]
if hours_to_cut <= Decimal("2") or aging_hours >= oncall_h:
return Tier.ON_CALL
if aging_hours >= tier2_h:
return Tier.TIER_2
return Tier.TIER_1
Step 4 — Evaluate the item and apply hold-pay-by-default. A BLOCKING_DISBURSEMENT item holds disbursement from the moment it is quarantined, independent of tier — the ladder only escalates who is paged, never whether pay is withheld. ADVISORY items never hold pay. Expected output: hold_disbursement=True from intake, breached=True once hours_to_cut <= 0.
def evaluate_sla(item: QueueItem, now: datetime) -> EscalationDecision:
"""Evaluate one open queue item's tier, hold status, and breach state."""
if item.resolved_at is not None:
raise ValueError(
"evaluate_sla is for open items only; resolved items "
"re-inject through Step 5"
)
aging_hours = _aging_hours(item, now)
hours_to_cut = _hours_to_pay_cut(item, now)
tier = _determine_tier(item, aging_hours, hours_to_cut)
# Safe default: BLOCKING_DISBURSEMENT holds pay unconditionally while
# open. ADVISORY never blocks disbursement, only the tier escalates.
hold_disbursement = item.severity is Severity.BLOCKING_DISBURSEMENT
breached = hold_disbursement and hours_to_cut <= Decimal("0")
logger.info(
"event=mrq_evaluate record=%s severity=%s tier=%s aging_h=%s "
"hours_to_cut=%s hold=%s breach=%s",
item.record_id, item.severity.value, tier.value, aging_hours,
hours_to_cut, hold_disbursement, breached,
)
return EscalationDecision(
record_id=item.record_id,
tier=tier,
aging_hours=aging_hours,
hold_disbursement=hold_disbursement,
breached=breached,
)
Step 5 — Re-inject corrected items idempotently. Once a reviewer applies a resolution_action, the record must re-enter the run exactly once, even if the correction workflow retries. The key mirrors the router’s own audit-hash pattern from Compliance Audit Trail Design: a fixed tuple, nothing time-varying beyond the resolution timestamp itself. Expected output: the second call for the same resolution returns False and emits no duplicate disbursement log.
_REINJECTED: set[str] = set() # replace with a durable, shared store in production
def _reinjection_key(item: QueueItem) -> str:
"""Stable key so replaying a correction never double-injects a payment."""
payload = (
f"{item.record_id}:{item.resolution_action}:"
f"{item.resolved_at.isoformat()}:{item.held_gross}"
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def reinject_after_correction(item: QueueItem) -> bool:
"""Re-inject a corrected record. Returns True only if newly injected."""
if item.resolved_at is None or item.resolution_action is None:
raise ValueError("cannot re-inject an item without a resolution")
key = _reinjection_key(item)
if key in _REINJECTED:
logger.info(
"event=mrq_reinject_skipped record=%s key=%s reason=already_processed",
item.record_id, key,
)
return False
_REINJECTED.add(key)
logger.info(
"event=mrq_reinject record=%s action=%s hash=%s",
item.record_id, item.resolution_action, key,
)
return True
Verification
Confirm the evaluator against boundary cases specific to escalation, not just tier arithmetic in isolation:
- SLA-breach triggers escalation. Feed a
BLOCKING_DISBURSEMENTitem aged exactly4.0hours and asserttier == Tier.TIER_2; feed3.99hours and asserttier == Tier.TIER_1. The boundary is strictly at or beyond the budget. - Hold-by-default from intake. Feed a
BLOCKING_DISBURSEMENTitem ataging_hours == Decimal("0")and asserthold_disbursement is Trueimmediately — the hold is not earned over time. - Deadline-driven acceleration. Feed an item aged only
1hour withhours_to_cut == Decimal("1.5")and asserttier == Tier.ON_CALL, confirming deadline proximity overrides a slower aging ladder. - Advisory never holds pay. Feed an
ADVISORYitem aged past its ownon_call_hbudget and asserttier == Tier.ON_CALLbuthold_disbursement is False— escalation and hold status are independent axes. - Re-injection idempotence. Call
reinject_after_correctiontwice with the same resolved item and assert the first returnsTrue, the second returnsFalse, with exactly oneevent=mrq_reinjectlog line. - Decimal precision at the boundary. Assert constructing a
QueueItemwith afloatheld_grossraisesTypeError.
Failure Modes
- Escalation computed against a local clock instead of the run’s schedule. Root cause:
pay_cut_utcwas derived from wall-clocknow()at enqueue time rather than pulled from the run’s authoritative schedule, so a delayed batch silently shifts every item’s deadline. Remediation: sourcepay_cut_utcexclusively from the run configuration, and validate it at load time alongside the effective-dating checks in Fallback Routing Strategies. - Re-injection key omits the resolution timestamp or amount. Root cause: the idempotency key was derived only from
record_id, so a second, different correction on the same record collided with the first and was silently skipped, or a retried correction with a staleheld_grossoverwrote a more recent one. Remediation: the key must includeresolution_action,resolved_at, andheld_grossas a fixed tuple, exactly as the fallback router’s own audit hash does. - On-call paging fails silently. Root cause: the paging integration returned a non-2xx response that the evaluator logged but did not treat as a failure, so an item reached
Tier.ON_CALLwith nobody notified and aged straight to the deadline. Remediation: require an explicit acknowledgment event from the paging system before treating an item as escalated, and alert separately whenever an item sits inON_CALLpast a short grace window with no ack on record.
Frequently Asked Questions
What is the difference between BLOCKING_DISBURSEMENT and ADVISORY severity?
BLOCKING_DISBURSEMENT means paying the record incorrectly is worse than paying it late, so disbursement is held from the moment the item is quarantined — an unresolved jurisdiction or an amount breaching a statutory cap are typical examples. ADVISORY means the issue does not put the paycheck at risk, such as a misrouted general-ledger code, so the run proceeds while the item ages through a longer review schedule. Severity is set once by the router at quarantine time and never re-derived from aging.
Why hold pay immediately instead of waiting for the on-call tier to fire?
The tier ladder governs escalation of human attention — who gets paged and how urgently — not whether pay is withheld. Waiting until Tier.ON_CALL to decide the hold would let a BLOCKING_DISBURSEMENT item be disbursed incorrectly for up to eight hours before anyone attempted resolution. Holding pay unconditionally the moment severity is classified removes that window, at the cost of a delayed payment that can be corrected and re-injected once resolved.
What happens if a correction arrives after the run already closed on hold?
A record held at pay-cut is excluded from that run’s disbursement, and the correction re-injects it through the idempotent path in Step 5 for the next eligible run rather than reopening the closed one. Because the reinjection key includes the resolution timestamp and amount, replaying the same correction after a retry never produces a second payment for the same resolved state.
How does the pay-cut deadline interact with the hour-based tier ladder?
Whichever condition is met first controls: the aging-based ladder — four, eight, and twelve hours for blocking items — or proximity to pay_cut_utc. A record quarantined early typically escalates on the aging schedule, while one quarantined close to the cutoff escalates to Tier.ON_CALL immediately regardless of aging, since a slow ladder is not an acceptable reason to miss a hard disbursement deadline.
Related
- Fallback Routing Strategies — the parent topic whose quarantine tier feeds every item this escalation SLA governs.
- Fallback Routing for Unclassified Deductions — the deduction-specific routing logic that produces many of the
BLOCKING_DISBURSEMENTitems reaching this queue. - Compliance Audit Trail Design — the immutable-ledger discipline this queue’s evaluation and re-injection logs must satisfy.
- Retry Semantics, Dead-Letter Queues & Idempotency Keys — the broader idempotency pattern this queue’s re-injection key specializes.