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.
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_iduntil 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
-
Store delegation and escalation as separate columns. Give every approver record a
delegate_idand anescalates_to; never overload one field for both, because the covering peer and the escalation authority are different people with different rights. -
Resolve availability first, then the next-in-line. Ask resolving out-of-office approver fallbacks whether the primary is available; only walk
delegate_idwhen it is not, so an available approver is never bypassed. -
Precompute the escalation path at route time. Call
build_escalation_pathonce 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. -
Drive escalation from the SLA monitor’s clock. Feed each pending node’s deadline to Approval SLA Monitoring and let
escalation_actiondecide; do not schedule ad-hoc reminders. -
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" -
Log every substitution and escalation. Emit an
emit_routing_eventrow for eachREMIND,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.
Related
- Approval Chain Routing — the parent guide that defines the deterministic approver graph these edges extend
- Resolving out-of-office approver fallbacks — the availability check that decides when a delegation edge is followed
- Approval SLA Monitoring — the timers and clock that drive time-based escalation
- Reimbursement Routing & Approval Sync — the framework this routing behaviour belongs to