Building delegation and escalation chains for expense approvals

An approval stalls when the graph behind it is implicit: a delegate is a name in someone’s calendar, an escalation is an email a coordinator remembers to send, and neither can be replayed or proven. This guide models both as first-class edges — a delegation pointer for who acts when an approver is out, and an escalation pointer plus a timer for who acts when an approver is late — over the deterministic approver graph introduced by the parent Approval Chain Routing guide, itself part of the broader Reimbursement Routing & Approval Sync framework. The hard part is not the happy path; it is guaranteeing that a mistyped delegation table cannot trap a report in an A→B→A loop and that a time-based escalation walks a bounded, cycle-free path to a real decision-maker rather than in circles.

Approver graph with delegation and escalation edges Three approver nodes form the main chain left to right: manager, department head, and finance, joined by solid approve-or-escalate edges that end at an approved terminal. Below each node a dashed delegation edge points to a stand-in used when the node is out of office: a peer manager, a deputy head, and a controller. A cycle guard tracks a visited set and rejects any delegation or escalation edge that revisits a node. Every hop and substitution is written to an append-only, SHA-256 chained audit ledger recording the approver, the reason, and the timestamp. Delegation edges (dashed) and escalation edges (solid) over one graph approve or escalate approve or escalate Manager tier 1 · SLA timer Dept head tier 2 · SLA timer Finance tier 3 · SLA timer Approved & posted delegate if OOO delegate if OOO delegate if OOO Peer manager delegate Deputy head delegate Controller delegate Cycle guard visited set · rejects revisits Append-only audit ledger SHA-256 chained · hop · reason · timestamp

Why Standard Approaches Fail

Bolting delegation and escalation onto a workflow engine as side-effects, rather than modeling them as graph edges, produces three failure modes that show up only under load or on holiday weeks.

  • The delegation loop. Alice delegates to Bob for her vacation; Bob, already covering, delegates back to Alice. A naive resolver that simply follows delegate_id until it finds an available approver will bounce between the two forever, pinning a worker thread and leaving the report in a permanent “routing” state. The loop is invisible in the table because each pointer looks reasonable in isolation.
  • Unbounded escalation. Escalating “up the chain until someone acts” with no cap sends a report climbing an org tree that may itself contain a management cycle (a matrixed report whose two managers escalate to each other), or simply escalates past the finance controller into people with no authority over the spend. Without a length cap and a visited set, escalation is just a slower loop.
  • Time that is not modeled. Treating escalation as “send a reminder after a while” leaves the deadline in a human’s head. Two reports submitted an hour apart escalate on totally different clocks, and no one can prove when the SLA actually elapsed. Escalation has to be a pure function of elapsed time against a fixed policy, watched by Approval SLA Monitoring, or it is not auditable.

The fix is to store delegation and escalation as explicit edges on the same node, resolve availability separately from resolving the next-in-line — the concern owned by resolving out-of-office approver fallbacks — and guard every walk with a visited set and a hard depth cap.

Architecture & Algorithm

Model each approver as a node carrying two outbound edges: a delegate_id for the out-of-office case and an escalates_to for the late case. Keeping them separate matters — the person who covers your inbox while you are away is rarely the person your overdue approvals should climb to. The escalation policy is a small value object of time bands.

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import timedelta

logger = logging.getLogger("expense.routing.delegation")


@dataclass(frozen=True)
class Node:
    approver_id: str
    delegate_id: str | None       # who acts if this node is out of office
    escalates_to: str | None      # who acts if this node is late


@dataclass(frozen=True)
class EscalationPolicy:
    first_reminder: timedelta = timedelta(hours=12)
    escalate_after: timedelta = timedelta(hours=24)
    max_escalations: int = 3

Two pure functions do the work. resolve_active_approver walks delegation edges for an out-of-office start node, and build_escalation_path precomputes the ordered escalation targets. Both carry a visited set, so a cyclic table raises RoutingCycle at the first revisit instead of looping; build_escalation_path also honours the policy’s length cap.

from __future__ import annotations

import logging

logger = logging.getLogger("expense.routing.delegation.walk")


class RoutingCycle(RuntimeError):
    """Delegation or escalation edges form a loop that would trap the report."""


def resolve_active_approver(start: str, graph: dict[str, Node],
                            out_of_office: frozenset[str]) -> str:
    """Follow delegation edges from `start` until an in-office approver is found.

    A visited set turns an infinite A->B->A delegation loop into a raised
    RoutingCycle instead of a hung report.
    """
    current = start
    visited: set[str] = set()
    while current in out_of_office:
        if current in visited:
            raise RoutingCycle(f"delegation cycle through {current}")
        visited.add(current)
        nxt = graph[current].delegate_id
        if nxt is None:
            raise RoutingCycle(f"{current} out of office with no delegate")
        logger.info("delegation_step", extra={"from_id": current, "to_id": nxt})
        current = nxt
    return current


def build_escalation_path(start: str, graph: dict[str, Node],
                          policy: EscalationPolicy) -> list[str]:
    """Precompute the ordered escalation targets, cycle-guarded and length-capped."""
    path: list[str] = [start]
    seen: set[str] = {start}
    node = graph[start].escalates_to
    while node is not None and len(path) <= policy.max_escalations:
        if node in seen:
            raise RoutingCycle(f"escalation cycle at {node}")
        seen.add(node)
        path.append(node)
        node = graph[node].escalates_to
    return path

Finally, time is turned into a decision. escalation_action maps elapsed time to exactly one of WAIT, REMIND, or ESCALATE, and emit_routing_event folds each decision into a SHA-256 chained audit row so the timeline is tamper-evident.

from __future__ import annotations

import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone

logger = logging.getLogger("expense.routing.escalation")


def escalation_action(elapsed: timedelta, policy: EscalationPolicy) -> str:
    """Map elapsed pending time to exactly one action; deterministic bands."""
    if elapsed >= policy.escalate_after:
        return "ESCALATE"
    if elapsed >= policy.first_reminder:
        return "REMIND"
    return "WAIT"


def emit_routing_event(report_id: str, approver: str, action: str, prev_hash: str) -> dict:
    """Append a hash-chained audit row for one routing decision."""
    payload: dict = {
        "report_id": report_id,
        "approver": approver,
        "action": action,
        "at": datetime.now(timezone.utc).isoformat(),
        "prev_hash": prev_hash,
    }
    body = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    payload["entry_hash"] = hashlib.sha256((prev_hash + body).encode("utf-8")).hexdigest()
    logger.info("routing_event", extra={"report_id": report_id, "action": action})
    return payload

Because both walks are pure and guarded, the same graph and the same elapsed clock always yield the same path and the same action — the property that lets an auditor replay a routing decision months later and get the identical answer.

Step-by-Step Integration

  1. Store delegation and escalation as separate columns. Give every approver record a delegate_id and an escalates_to; never overload one field for both, because the covering peer and the escalation authority are different people with different rights.

  2. Resolve availability first, then the next-in-line. Ask resolving out-of-office approver fallbacks whether the primary is available; only walk delegate_id when it is not, so an available approver is never bypassed.

  3. Precompute the escalation path at route time. Call build_escalation_path once when the hop becomes active and store it, so escalation later is an index step, not a fresh graph traversal that could see a changed table mid-flight.

  4. Drive escalation from the SLA monitor’s clock. Feed each pending node’s deadline to Approval SLA Monitoring and let escalation_action decide; do not schedule ad-hoc reminders.

  5. Verify the guards before wiring anything live:

    from datetime import timedelta
    
    graph = {
        "mgr": Node("mgr", delegate_id="mgr_peer", escalates_to="dept"),
        "mgr_peer": Node("mgr_peer", delegate_id=None, escalates_to="dept"),
        "dept": Node("dept", delegate_id="controller", escalates_to="finance"),
        "finance": Node("finance", delegate_id=None, escalates_to=None),
        "controller": Node("controller", delegate_id=None, escalates_to="finance"),
    }
    policy = EscalationPolicy()
    
    # An out-of-office manager resolves to the delegate, deterministically.
    assert resolve_active_approver("mgr", graph, frozenset({"mgr"})) == "mgr_peer"
    # The escalation path is precomputed, cycle-free, and length-capped.
    assert build_escalation_path("mgr", graph, policy) == ["mgr", "dept", "finance"]
    # Timer bands map elapsed time to exactly one action.
    assert escalation_action(timedelta(hours=30), policy) == "ESCALATE"
  6. Log every substitution and escalation. Emit an emit_routing_event row for each REMIND, ESCALATE, and delegation substitution, so the audit ledger carries the full timeline, not just the terminal approval.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
Delegate points back into the chain Infinite A→B→A loop pins the report Visited set in resolve_active_approver raises RoutingCycle at first revisit
Escalation up a management cycle Report climbs forever between two matrixed managers build_escalation_path caps length via max_escalations and guards with seen
Delegate is also out of office Single-hop delegation strands the report Loop continues walking delegate_id until an in-office node or a raised cycle
Clock skew between services Two reports escalate on different timelines Compute elapsed time against one UTC source handed by the SLA monitor
Escalation target lacks spend authority Report escalates past finance into powerless hands Point escalates_to at role-appropriate authority; validate against the tier table
Reminder storms on a slow queue Repeated REMIND actions spam approvers Make escalation_action idempotent per band and record the last action emitted
Table edited mid-flight Precomputed and live paths diverge Snapshot the escalation path at route time; re-resolution requires a new audit entry

FAQ

Should delegation and escalation ever share the same edge?

No. Delegation answers “who covers this inbox while the approver is away,” and escalation answers “who takes over when the approver is late.” Those are usually different people with different authority, so modeling them as one next pointer collapses two distinct policies and makes a late report route to a vacation stand-in who also cannot act. Keep delegate_id and escalates_to as separate edges on the node.

How do I stop a mistyped delegation table from hanging a report?

Guard every delegation walk with a visited set, as resolve_active_approver does: the moment the walk returns to a node it has already seen, raise a cycle error and route the report to an exception queue. Never write a while loop over delegate_id without that guard, because a two-row table can encode an infinite loop, and it will surface at the worst possible time — during a holiday backlog.

What is a sane cap on escalation depth?

Most organizations need at most three escalation hops — approver, their manager, and a finance authority — so a max_escalations of three with a visited set is a safe default. A report that wants to escalate deeper is signalling a misconfigured graph rather than a genuinely long chain of command, and capping the walk turns that misconfiguration into a loud, logged exception instead of a silent climb.

Does escalation change who has already approved?

No. Escalation reassigns the current pending node to the next authority; it never revokes a signature already captured earlier in the chain. The visited set that spans the whole chain ensures an escalation target who happens to have approved an earlier hop is rejected rather than asked to sign twice, preserving segregation of duties.

Where do the escalation timers actually run?

The timers live in Approval SLA Monitoring, which owns the deadlines and the single UTC clock. This guide’s escalation_action is a pure decision function the monitor calls when a deadline elapses, which keeps the escalation logic testable in isolation and the timing authoritative in one place.