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.

Deterministic approval chain routing graph A validated expense report enters a tier resolver that selects an ordered approver sequence from the report amount. The report then walks a deterministic approver graph, manager to department head to finance, one node at a time. At every node a delegation and escalation resolver substitutes an out-of-office approver's delegate and escalates any node that breaches its SLA timer. A cycle guard tracks the visited set and rejects any edge that would revisit an approver, so a delegation loop such as A to B to A can never stall the report. When the final node approves, the report is posted and every hop is written to an append-only, SHA-256 chained approval audit ledger. Deterministic approver graph with delegation, escalation and a cycle guard Validated report from policy checks Tier resolver amount → sequence threshold tiers Manager tier 1 Dept head tier 2 Finance tier 3 Approved & posted to ERP Delegation & escalation resolver per node: substitute OOO delegate · escalate on SLA timeout deterministic fallback order Cycle guard visited set rejects A→B→A Append-only approval audit ledger SHA-256 chained · one row per hop · approver · decision · timestamp

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

  1. Load tiers and directory as versioned config. Publish ROUTE_TIER_TABLE and the approver directory from source control so every route resolves against a snapshot you can reproduce during an audit.
  2. Deploy behind an exception queue. Wire ROUTE_ON_MISSING_HOLDER=EXCEPTION_QUEUE and ROUTE_ON_CYCLE=EXCEPTION_QUEUE; a report that cannot be routed deterministically must surface to a human, never auto-approve or disappear.
  3. Cap chain depth. Set ROUTE_MAX_CHAIN_DEPTH and alert when a resolution approaches it — a deep chain almost always means a delegation edge is pointing somewhere it should not.
  4. 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.
  5. Watch substitution and cycle rates. Page when the delegate-substitution rate spikes (a sign of an availability-feed outage) or when any CycleError reaches the exception queue, because a cycle is a directory defect, not a transient error.
  6. 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.