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_DISBURSEMENT item) and one that only misroutes a general-ledger code (an ADVISORY item) 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 tnowt_{\text{now}} is a function of aging hours and hours remaining to the pay cut:

haging=tnowtenqueued3600,hto-cut=tpay-cuttnow3600h_{\text{aging}} = \frac{t_{\text{now}} - t_{\text{enqueued}}}{3600}, \qquad h_{\text{to-cut}} = \frac{t_{\text{pay-cut}} - t_{\text{now}}}{3600}

\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.

Manual-review queue escalation timeline A queue item enters at hour zero and moves through three escalation tiers: Tier 1 analyst review from hour zero to four, Tier 2 senior or compliance review from hour four to eight, and on-call escalation from hour eight to twelve. A dashed amber line marks the run's pay-cut deadline, which can force on-call escalation earlier than the aging ladder if the deadline is close. If the item remains unresolved when the deadline arrives, the flow terminates in a hold-pay box: the run closes and disbursement for that record is withheld rather than paid at a guessed rate. Queued from fallback routing Tier 1 0–4h · analyst queue unresolved @4h Tier 2 4–8h · compliance review unresolved @8h On-call escalation 8–12h · pages on-call pay-cut deadline resolution at any tier exits to idempotent re-injection (Step 5) unresolved at deadline HOLD PAY disbursement withheld — never paid at a guessed rate
An unresolved manual-review item escalates through fixed hourly tiers, but the pay-cut deadline can force on-call escalation early; if the deadline still arrives unresolved, the run closes with that record's disbursement held.

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:

  1. SLA-breach triggers escalation. Feed a BLOCKING_DISBURSEMENT item aged exactly 4.0 hours and assert tier == Tier.TIER_2; feed 3.99 hours and assert tier == Tier.TIER_1. The boundary is strictly at or beyond the budget.
  2. Hold-by-default from intake. Feed a BLOCKING_DISBURSEMENT item at aging_hours == Decimal("0") and assert hold_disbursement is True immediately — the hold is not earned over time.
  3. Deadline-driven acceleration. Feed an item aged only 1 hour with hours_to_cut == Decimal("1.5") and assert tier == Tier.ON_CALL, confirming deadline proximity overrides a slower aging ladder.
  4. Advisory never holds pay. Feed an ADVISORY item aged past its own on_call_h budget and assert tier == Tier.ON_CALL but hold_disbursement is False — escalation and hold status are independent axes.
  5. Re-injection idempotence. Call reinject_after_correction twice with the same resolved item and assert the first returns True, the second returns False, with exactly one event=mrq_reinject log line.
  6. Decimal precision at the boundary. Assert constructing a QueueItem with a float held_gross raises TypeError.

Failure Modes

  • Escalation computed against a local clock instead of the run’s schedule. Root cause: pay_cut_utc was derived from wall-clock now() at enqueue time rather than pulled from the run’s authoritative schedule, so a delayed batch silently shifts every item’s deadline. Remediation: source pay_cut_utc exclusively 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 stale held_gross overwrote a more recent one. Remediation: the key must include resolution_action, resolved_at, and held_gross as 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_CALL with 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 in ON_CALL past 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.