Approval Chain Routing: Walking a Validated Report Through a Deterministic Approver Graph
Approval chain routing turns a policy-cleared expense report into a signed, posted liability by walking it through an ordered approver graph — manager, then department head, then finance — where every hop is chosen by rule rather than by whoever happens to be online. Within the broader Reimbursement Routing & Approval Sync framework, this component sits between validation and disbursement: it consumes the clean records emitted by Automated Policy Validation & Anomaly Flagging and produces an approver sequence, a set of routing decisions, and an immutable audit trail that finance can defend line by line. It owns tier selection from amount thresholds, delegation and escalation edges, and cycle detection; it delegates the harder edges of two of those problems to companion guides — the mechanics of building delegation and escalation chains and of resolving out-of-office approver fallbacks. Downstream, the approved report hands off to ERP Export Synchronization for posting and to Payment Batch Reconciliation for settlement; the elapsed-time guarantees that make escalation safe are watched by Approval SLA Monitoring.
Problem Framing & Root Causes
Manual approval routing fails in ways that are boringly predictable and expensive to unwind. The first failure mode is non-determinism: a report routed “to a manager” resolves differently depending on org-chart caching, who is copied on the email, or which mobile app grabbed the notification first, so the same report can take two different paths on two different days and neither is reproducible for audit. The second is threshold drift, where the amount that should have pulled a controller into the chain is compared against a stale limit hard-coded in three places, and a large reimbursement clears on a single manager signature. The third is the stall: an approver goes on leave, the report sits in their queue with no delegate and no timer, and it silently ages past its service-level target until someone escalates by hand — usually the employee waiting to be paid. The fourth, and the most dangerous to automate around, is the routing loop: a delegation table that points Alice’s approvals at Bob while Bob’s delegate is set back to Alice creates an A→B→A cycle that a naive walker will follow forever, pinning a worker and never emitting a decision.
Every one of these is a symptom of treating routing as a workflow of ad-hoc handoffs instead of a traversal over an explicit, versioned graph. The remedy is to model the approver set as data — nodes, tier edges, delegation edges, escalation edges — and to make the walk a pure function of the report amount and the current directory state, with a visited-set guard that converts an infinite loop into a raised error the moment it is detectable.
Design Constraints & Prerequisites
This component is stateless per report: given the same amount, the same tier table, and the same directory snapshot, it must resolve the identical chain, because reproducibility is what makes the audit trail defensible. It reads two pieces of shared state — the amount-threshold tier table and the approver directory (role holders, delegation pointers, escalation edges, and availability) — and writes only to an append-only ledger. Money is compared in integer minor units (cents), never as a float, so that a threshold at exactly the tier ceiling routes the same way on every platform.
| Constraint | Requirement | Rationale |
|---|---|---|
| Upstream contract | report_id, amount_minor, org_unit, submitter_id present and policy-validated |
Routing must never begin on an unvalidated or partially typed record |
| Money representation | Amounts as integer minor units; tier ceilings inclusive | Eliminates float-boundary ambiguity at a tier edge |
| Directory snapshot | Role holders, delegation and escalation edges, and availability resolved at route time | A chain must be reproducible from the snapshot that produced it |
| Determinism | Same amount plus same snapshot yields the same ordered chain | Reproducibility is the core audit guarantee |
| Cycle safety | A visited set guards every delegation and escalation walk | Converts an infinite loop into a raised, logged error |
| Timers | Each pending node carries an SLA deadline handed to the monitor | Escalation must be time-bounded, never open-ended |
| Compliance | Every hop and substitution logged immutably | Satisfies Sarbanes-Oxley segregation-of-duties evidence |
Treat the tier table and directory as configuration-as-code so finance can raise a spend threshold or add a controller tier without a redeploy. Sensitive approver and submitter identifiers referenced by the directory must respect the isolation rules defined in Security & Compliance Boundaries, and the tier ceilings themselves are governed policy values that change under Policy Versioning & Rollout.
Production Python Implementation
The engine is three composable parts: an amount-driven tier resolver that names the ordered roles, a cycle-guarded chain resolver that turns those roles into concrete acting approvers while substituting delegates for anyone unavailable, and an audit committer that chains each resolution into a SHA-256 ledger. Each part is independently testable and emits structured metadata.
Tiers and the approver model
Tier selection is a pure lookup: sort spend bands low to high and take the first ceiling that covers the amount. Roles, not people, live in the tier table, so a reorg never touches the thresholds.
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger("expense.routing.graph")
class Role(str, Enum):
MANAGER = "MANAGER"
DEPARTMENT_HEAD = "DEPARTMENT_HEAD"
FINANCE = "FINANCE"
@dataclass(frozen=True)
class Approver:
approver_id: str
role: Role
delegate_id: str | None = None # who acts when this approver is unavailable
@dataclass(frozen=True)
class Tier:
"""A spend band mapped to the ordered roles that must approve it."""
max_amount_minor: int # inclusive ceiling in minor units (cents)
roles: tuple[Role, ...]
# Ordered low-to-high; the first tier whose ceiling covers the amount wins.
DEFAULT_TIERS: tuple[Tier, ...] = (
Tier(max_amount_minor=50_000, roles=(Role.MANAGER,)),
Tier(max_amount_minor=250_000, roles=(Role.MANAGER, Role.DEPARTMENT_HEAD)),
Tier(max_amount_minor=10 ** 12, roles=(Role.MANAGER, Role.DEPARTMENT_HEAD, Role.FINANCE)),
)
def resolve_tier(amount_minor: int, tiers: tuple[Tier, ...] = DEFAULT_TIERS) -> tuple[Role, ...]:
"""Pick the ordered approver roles for an amount in minor units (e.g. cents)."""
for tier in tiers:
if amount_minor <= tier.max_amount_minor:
logger.debug("tier_resolved", extra={"amount_minor": amount_minor,
"roles": [r.value for r in tier.roles]})
return tier.roles
raise ValueError(f"no tier covers amount_minor={amount_minor}")
Because the ceilings are inclusive and the bands are evaluated in order, a report at exactly 50_000 minor units routes on a single manager while 50_001 pulls in the department head — a boundary that is easy to reason about and trivial to unit-test.
The cycle-guarded chain resolver
The resolver walks the tier’s roles in order. For each role it takes the current holder; if that person is unavailable it follows their delegate pointer, guarding the walk with a per-hop guard set so a delegation loop raises instead of hangs, and guarding the whole chain with a visited set so no one approves the same report twice. The detailed edge cases of this walk — multi-hop delegation, escalation edges, and loop shapes — are the subject of building delegation and escalation chains.
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
logger = logging.getLogger("expense.routing.chain")
@dataclass(frozen=True)
class ApprovalHop:
"""One immutable step in the resolved chain, ready for the audit ledger."""
seq: int
role: str
assigned_approver: str
acting_approver: str # differs from assigned when a delegate stood in
substituted: bool
reason: str
class CycleError(RuntimeError):
"""Raised when routing would send a report to an approver already in its chain."""
def resolve_chain(
amount_minor: int,
role_holders: dict[Role, str],
directory: dict[str, Approver],
unavailable: frozenset[str],
tiers: tuple[Tier, ...] = DEFAULT_TIERS,
) -> list[ApprovalHop]:
"""Resolve a deterministic, cycle-free ordered approval chain.
For each role in the amount's tier, take the role holder; if that approver is
unavailable, follow their delegate pointer until an available approver is
found, guarding against revisiting anyone already placed in the chain.
"""
roles = resolve_tier(amount_minor, tiers)
hops: list[ApprovalHop] = []
visited: set[str] = set()
for seq, role in enumerate(roles):
assigned = role_holders.get(role)
if assigned is None:
raise ValueError(f"no holder for role {role.value}")
acting = assigned
substituted = False
reason = "primary approver available"
guard: set[str] = set()
while acting in unavailable:
substituted = True
if acting in guard:
raise CycleError(f"delegation cycle at approver {acting}")
guard.add(acting)
delegate = directory[acting].delegate_id
if delegate is None:
raise CycleError(f"approver {acting} unavailable with no delegate")
reason = f"delegated from unavailable {acting}"
acting = delegate
if acting in visited:
raise CycleError(f"approver {acting} already earlier in chain")
visited.add(acting)
hop = ApprovalHop(seq=seq, role=role.value, assigned_approver=assigned,
acting_approver=acting, substituted=substituted, reason=reason)
logger.info("chain_hop_resolved", extra={"role": role.value, "acting": acting,
"substituted": substituted})
hops.append(hop)
return hops
def commit_chain_to_ledger(report_id: str, hops: list[ApprovalHop], prev_hash: str) -> dict:
"""Append the resolved chain as a SHA-256 chained, tamper-evident audit record."""
payload: dict = {
"report_id": report_id,
"resolved_at": datetime.now(timezone.utc).isoformat(),
"chain": [asdict(h) for h in hops],
"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("chain_committed", extra={"report_id": report_id,
"entry_hash": payload["entry_hash"]})
return payload
The visited set is what makes the chain a directed acyclic walk rather than an open-ended traversal: the instant a delegate points back into the chain, CycleError fires with the offending approver named, and the report routes to an exception queue instead of vanishing into a loop. Chaining prev_hash into each ledger entry means any retroactive edit to an earlier decision breaks every hash after it, which is exactly the property an auditor tests for.
Configuration Reference
Expose every tunable through environment variables or a central policy store so finance can adjust controls without a code change. Pin the tier table and directory schema versions; a silent change to either is an unlogged change to who can approve spend.
| Key | Type | Default | Rationale |
|---|---|---|---|
ROUTE_TIER_TABLE |
path | /config/tiers.json |
Amount-to-roles bands; edited by finance, versioned in git |
ROUTE_TIER_CEILING_MINOR |
int | 250000 |
Ceiling (cents) above which finance joins the chain |
ROUTE_SLA_HOURS |
int | 24 |
Per-node deadline handed to the SLA monitor before escalation |
ROUTE_MAX_CHAIN_DEPTH |
int | 6 |
Hard cap on resolved hops; a longer chain signals a misconfigured graph |
ROUTE_ON_MISSING_HOLDER |
enum | EXCEPTION_QUEUE |
Behaviour when a role has no holder; never AUTO_APPROVE |
ROUTE_ON_CYCLE |
enum | EXCEPTION_QUEUE |
Terminal action when a CycleError is raised |
ROUTE_LEDGER_PATH |
path | /data/approvals.ndjson |
Append-only audit ledger location; durable storage only |
Validation & Testing
Test the traversal, not the transport: assert that an amount selects the right ordered roles, that an unavailable approver resolves to their delegate, and — most importantly — that a delegation loop raises CycleError rather than hanging. Build fixtures from the real failure modes above.
import pytest
def _directory() -> dict[str, Approver]:
return {
"u_mgr": Approver("u_mgr", Role.MANAGER, delegate_id="u_mgr2"),
"u_mgr2": Approver("u_mgr2", Role.MANAGER, delegate_id=None),
"u_dept": Approver("u_dept", Role.DEPARTMENT_HEAD, delegate_id="u_fin"),
"u_fin": Approver("u_fin", Role.FINANCE, delegate_id=None),
}
def _holders() -> dict[Role, str]:
return {Role.MANAGER: "u_mgr", Role.DEPARTMENT_HEAD: "u_dept", Role.FINANCE: "u_fin"}
def test_amount_selects_two_step_tier():
chain = resolve_chain(120_000, _holders(), _directory(), frozenset())
assert [h.role for h in chain] == ["MANAGER", "DEPARTMENT_HEAD"]
def test_unavailable_manager_falls_back_to_delegate():
chain = resolve_chain(20_000, _holders(), _directory(), frozenset({"u_mgr"}))
assert chain[0].acting_approver == "u_mgr2"
assert chain[0].substituted is True
def test_delegation_cycle_is_rejected():
directory = _directory()
directory["u_mgr2"] = Approver("u_mgr2", Role.MANAGER, delegate_id="u_mgr")
with pytest.raises(CycleError):
resolve_chain(20_000, _holders(), directory, frozenset({"u_mgr", "u_mgr2"}))
Gate deployments on these assertions in CI. Add boundary cases at each tier ceiling (50_000 vs 50_001), confirm a missing role holder raises rather than silently drops a required approver, and verify commit_chain_to_ledger produces a different entry_hash when any hop changes.
Operational Runbook
- Load tiers and directory as versioned config. Publish
ROUTE_TIER_TABLEand the approver directory from source control so every route resolves against a snapshot you can reproduce during an audit. - Deploy behind an exception queue. Wire
ROUTE_ON_MISSING_HOLDER=EXCEPTION_QUEUEandROUTE_ON_CYCLE=EXCEPTION_QUEUE; a report that cannot be routed deterministically must surface to a human, never auto-approve or disappear. - Cap chain depth. Set
ROUTE_MAX_CHAIN_DEPTHand alert when a resolution approaches it — a deep chain almost always means a delegation edge is pointing somewhere it should not. - Hand every pending node a deadline. Emit each hop’s SLA deadline to Approval SLA Monitoring so escalation is time-bounded; a node with no timer is a stall waiting to happen.
- Watch substitution and cycle rates. Page when the delegate-substitution rate spikes (a sign of an availability-feed outage) or when any
CycleErrorreaches the exception queue, because a cycle is a directory defect, not a transient error. - Roll back by config, not code. To widen or tighten who approves a spend band, edit the tier table and redeploy config; because resolution is a pure function of amount plus snapshot, the change is auditable and instantly reversible.
Approval chain routing is a deterministic control, not a workflow of favors. An amount-driven tier table, a cycle-guarded walk over an explicit approver graph, and a hash-chained ledger give finance a routing layer that resolves the same way every time, escalates before it stalls, and can be reconstructed hop by hop for any auditor who asks.
Related
- Reimbursement Routing & Approval Sync — the parent framework this routing layer plugs into
- Building delegation and escalation chains — modeling delegation and time-based escalation edges with cycle guards
- Resolving out-of-office approver fallbacks — detecting an unavailable approver and re-routing deterministically
- Approval SLA Monitoring — the timers that make escalation time-bounded instead of open-ended
- ERP Export Synchronization — where a fully approved report is posted
- Automated Policy Validation & Anomaly Flagging — the upstream stage that produces the validated records routing consumes