Resolving out-of-office approver fallbacks without stalling reports

A reimbursement report freezes the instant it lands in the queue of an approver who is on leave, because most systems have no deterministic answer to “who signs when the assigned approver is away.” This guide detects an unavailable approver from an out-of-office calendar and a delegation table, then re-routes to a fallback approver by a fixed, auditable ladder — named delegate, then skip-level manager, then a role pool — so the report keeps moving without a human chasing it. It is the availability layer for the deterministic graph defined by the parent Approval Chain Routing guide, within the broader Reimbursement Routing & Approval Sync framework, and it is the check that the sibling building delegation and escalation chains guide calls before it ever follows a delegation edge.

Deterministic out-of-office fallback ladder An assigned approver enters an availability probe that reads an out-of-office calendar and a delegation table. If the approver is available, a dashed shortcut keeps the primary approver and routes straight to the acting-approver stage. If not, the report enters a fallback ladder that is tried in fixed order: a named delegate first, then the skip-level manager, then a role pool selected round-robin. The first available candidate that would not revisit the chain becomes the acting approver, the report is routed and posted, and the substitution is written to an append-only, SHA-256 chained audit ledger recording the approver, the availability source, and the fallback reason. Availability probe, then a fixed fallback ladder that keeps the report moving available → keep primary approver Assigned approver from the chain Availability probe OOO calendar · delegation table Fallback ladder first available wins Acting approver not already in chain Route & post to ERP export 1 · named delegate 2 · skip-level manager 3 · role pool (round-robin) Append-only fallback audit ledger SHA-256 chained · approver · availability_source · fallback_reason

Why Standard Approaches Fail

Handling an out-of-office approver “when it comes up” fails in three specific ways that each strand real money in a queue.

  • Availability is never actually checked. Most routers assume the assigned approver is present and only discover otherwise when the report ages past its deadline. By then the employee has emailed AP, a manager has been pulled in by hand, and the routing decision that finally moves the report exists nowhere in the audit trail. Availability must be an explicit probe against an out-of-office calendar and a delegation table before the report is ever assigned.
  • Non-deterministic fallback. When a fallback does happen, it is often “whoever the coordinator picks,” so the same out-of-office situation resolves to a different substitute each time and cannot be reproduced for audit. A fallback has to be an ordered ladder evaluated the same way every run.
  • Fallback that re-approves or loops. A careless substitute picker can route the report to someone already in its chain — the skip-level manager who happens to be the department head two hops up — which either double-counts an approval or, worse, creates a cycle. The mistyped-delegation loop this prevents is the same failure the building delegation and escalation chains guide guards against; here we add the constraint that a fallback candidate already in the chain is skipped outright.

The remedy is a deterministic availability probe followed by a fixed fallback ladder whose candidates are filtered against the existing chain, with every substitution written to an audit ledger that names the availability source and the reason.

Architecture & Algorithm

Availability is a function of time: an approver is unavailable when the routing moment falls inside one of their out-of-office windows. Modeling windows as explicit UTC intervals keeps the check reproducible regardless of the caller’s timezone.

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import datetime, timezone

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


@dataclass(frozen=True)
class OutOfOffice:
    start: datetime
    end: datetime


@dataclass
class ApproverRecord:
    approver_id: str
    role: str
    manager_id: str | None            # skip-level fallback target
    delegate_id: str | None           # explicit delegation-table entry
    ooo: tuple[OutOfOffice, ...] = ()


def is_available(rec: ApproverRecord, at: datetime) -> bool:
    """True when the approver has no active out-of-office window at `at` (UTC)."""
    moment = at.astimezone(timezone.utc)
    for window in rec.ooo:
        if window.start <= moment <= window.end:
            logger.info("approver_ooo", extra={"approver": rec.approver_id})
            return False
    return True

The fallback ladder is the deterministic core. fallback_candidates builds the ordered list — delegate, then skip-level, then a sorted role pool — and select_acting_approver returns the first candidate that is both available and not already in the chain, raising NoFallbackError when the ladder is exhausted rather than silently returning an unavailable approver.

from __future__ import annotations

import logging
from datetime import datetime

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


class NoFallbackError(RuntimeError):
    """Every candidate in the ladder is unavailable or would revisit the chain."""


def fallback_candidates(rec: ApproverRecord, directory: dict[str, ApproverRecord],
                        role_pool: dict[str, list[str]]) -> list[str]:
    """Deterministic ordered ladder: delegate, then skip-level, then role pool."""
    ladder: list[str] = []
    if rec.delegate_id:
        ladder.append(rec.delegate_id)
    if rec.manager_id:
        ladder.append(rec.manager_id)
    for candidate in sorted(role_pool.get(rec.role, [])):   # sorted => deterministic
        if candidate != rec.approver_id:
            ladder.append(candidate)
    seen: set[str] = set()
    ordered = [c for c in ladder if not (c in seen or seen.add(c))]
    logger.debug("fallback_ladder", extra={"primary": rec.approver_id, "ladder": ordered})
    return ordered


def select_acting_approver(rec: ApproverRecord, directory: dict[str, ApproverRecord],
                           role_pool: dict[str, list[str]], at: datetime,
                           already_in_chain: frozenset[str]) -> str:
    """Pick the first available fallback that does not revisit the chain."""
    if is_available(rec, at) and rec.approver_id not in already_in_chain:
        return rec.approver_id
    for candidate in fallback_candidates(rec, directory, role_pool):
        if candidate in already_in_chain:
            continue
        if candidate in directory and is_available(directory[candidate], at):
            logger.info("fallback_selected",
                        extra={"primary": rec.approver_id, "acting": candidate})
            return candidate
    raise NoFallbackError(f"no available fallback for {rec.approver_id}")

Every substitution must leave a record that explains itself. record_fallback folds the decision into a SHA-256 chained audit row, capturing whether the substitution was driven by the out-of-office calendar or the delegation table so an auditor can trace exactly why a different name signed.

from __future__ import annotations

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

logger = logging.getLogger("expense.routing.fallback.audit")


def record_fallback(report_id: str, primary: str, acting: str,
                    source: str, prev_hash: str) -> dict:
    """Emit a SHA-256 chained audit row explaining the substitution."""
    entry: dict = {
        "report_id": report_id,
        "primary_approver": primary,
        "acting_approver": acting,
        "substituted": primary != acting,
        "availability_source": source,     # "OOO_CALENDAR" or "DELEGATION_TABLE"
        "decided_at": datetime.now(timezone.utc).isoformat(),
        "prev_hash": prev_hash,
    }
    body = json.dumps(entry, sort_keys=True, separators=(",", ":"))
    entry["entry_hash"] = hashlib.sha256((prev_hash + body).encode("utf-8")).hexdigest()
    logger.info("fallback_recorded", extra={"report_id": report_id, "acting": acting})
    return entry

Because the probe reads explicit UTC windows and the ladder is evaluated in a fixed order with a stable tie-break, the same directory state always yields the same acting approver — the reproducibility that turns an out-of-office substitution into defensible evidence instead of a coordinator’s judgment call.

Step-by-Step Integration

  1. Probe availability before assignment, not after timeout. Run is_available against the out-of-office calendar the moment a hop becomes active, so an absent approver is substituted immediately rather than discovered when the report is already late.

  2. Keep the fallback order in config. Store the ladder order — delegate, skip-level, role pool — as configuration so finance can reorder it without a redeploy, and pin it so an audit reconstruction resolves to one ladder.

  3. Filter candidates against the existing chain. Pass the set of approvers already in the chain into select_acting_approver so a fallback can never re-approve or create a loop; this is the availability-side complement to the cycle guard in the parent Approval Chain Routing graph.

  4. Verify the substitution end to end before going live:

    from datetime import datetime, timezone
    
    now = datetime(2026, 7, 16, 15, 0, tzinfo=timezone.utc)
    directory = {
        "u_mgr": ApproverRecord("u_mgr", "MANAGER", manager_id="u_dir", delegate_id="u_peer",
                                ooo=(OutOfOffice(datetime(2026, 7, 14, tzinfo=timezone.utc),
                                                 datetime(2026, 7, 20, tzinfo=timezone.utc)),)),
        "u_peer": ApproverRecord("u_peer", "MANAGER", manager_id="u_dir", delegate_id=None),
        "u_dir": ApproverRecord("u_dir", "DIRECTOR", manager_id=None, delegate_id=None),
    }
    pool = {"MANAGER": ["u_peer", "u_mgr"]}
    
    acting = select_acting_approver(directory["u_mgr"], directory, pool, now, frozenset())
    assert acting == "u_peer"          # primary is out of office -> delegate stands in
    
    entry = record_fallback("R-501", "u_mgr", acting, "OOO_CALENDAR", prev_hash="genesis")
    assert entry["substituted"] is True
    assert entry["entry_hash"]         # every substitution is fingerprinted for audit
  5. Escalate an exhausted ladder, do not silently hold. When select_acting_approver raises NoFallbackError, route the report to the exception queue and let Approval SLA Monitoring track its deadline; a report with no available approver is a staffing gap that a human must resolve.

  6. Write the audit row on every decision. Append a record_fallback entry for both the kept-primary and the substituted case, so the ledger proves the approver was checked even when no substitution occurred.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
Delegate is also out of office Single substitution strands the report Ladder continues to skip-level then role pool; NoFallbackError only when all are out
Fallback already in the chain Double approval or a routing cycle already_in_chain filter skips any candidate already placed
Out-of-office window in local time Boundary check off by hours Store windows as explicit UTC intervals; convert the routing moment with astimezone
Empty role pool Ladder ends early and over-raises Seed each role pool with at least two holders; alert when a pool has one
Stale calendar feed Absent approver reads as available Treat a stale feed as unavailable (fail safe) and flag the feed for repair
Non-deterministic pool order Same case picks different substitutes Sort the role pool so the tie-break is stable and reproducible
Overlapping out-of-office windows Redundant checks, same result Any matching window returns unavailable; overlaps are harmless, not an error

FAQ

How do I detect that an approver is actually unavailable?

Read two sources at route time: an out-of-office calendar modeled as explicit UTC intervals, and the delegation table that records a standing hand-off. The is_available check returns false when the routing moment falls inside any out-of-office window, and a delegation-table entry is treated as an unconditional substitution. Checking both at assignment, rather than waiting for a timeout, is what stops the report from stalling in the first place.

What order should the fallback ladder use?

Try the named delegate first, then the skip-level manager, then a role pool selected with a stable tie-break, because that order moves from the most specific, intended stand-in to the most general safety net. Keep the order in configuration so finance can adjust it, and pin it so every audit reconstruction resolves to the same ladder. The first candidate that is available and not already in the chain wins.

How do I keep a fallback from re-approving or looping?

Pass the set of approvers already in the report’s chain into the selector and skip any candidate that appears in it. This is the availability-side complement to the cycle guard in the routing graph: the graph prevents a delegation edge from revisiting a node, and the fallback selector prevents a substitute from being someone who already signed, so no approver is ever asked to approve the same report twice.

What happens when every fallback is also unavailable?

The selector raises a no-fallback error rather than returning an unavailable approver, and the report is routed to an exception queue where a human resolves the staffing gap. The SLA monitor keeps tracking the deadline so the report is still visible and time-bounded. Silently holding the report would recreate exactly the stall this design exists to prevent.

Is the substitution recorded for audit?

Yes. Every decision, including the case where the primary approver was available and kept, writes a SHA-256 chained audit row naming the primary, the acting approver, whether a substitution occurred, and the availability source that drove it. Folding the previous entry’s hash into each row makes the trail tamper-evident, so an auditor can prove both who approved and why a different name signed.