Security

Turning a Risk Tier Into an Approval Timeout

Antonio Evans7 min read

A policy file shown as a table with three risk tiers (high, medium, low) and a clock icon next to each, connected by arrows to three outcome boxes labeled auto-deny, escalate, and park.
Key points

Key points

  1. 1Give every risk tier a maximum wait and a default action in the policy file, and enforce both inside the approval step.
  2. 2When the clock expires, you have three choices: auto-deny, escalate to the next approver, or park the request with a visible expiry time.
  3. 3Test the boundary, not just the happy path: one second over the limit must produce the tier's default and a recorded reason.
  4. 4Log the decision as data (tier, deadline, outcome, elapsed time) so audits prove the agent behaved as the policy says.

Start with the policy file, not the code

Most teams write approval logic in code first and then try to describe it in a document. Do the reverse. A policy-as-code approach treats organizational rules as versioned, testable code that a policy engine evaluates at runtime, which gives you a single, authoritative source of truth instead of prose that drifts from behavior (Wiz). The practical benefit for approvals is that the wait limit and the fallback become data you can review in a pull request, not a constant buried in a service.

The key design decision is that the model never decides whether the action is allowed; it only proposes. A policy decision point decides whether the specific actor, agent, action, resource and approval state allow execution (Thomas Thelliez). Your approval timeout is part of that decision: an unapproved request past its deadline is a policy input, and the default action is the policy output.

A minimal policy file can stay readable. This example gives each tier a maximum wait and a named default:

version: 3
defaults:
  clock: "business_hours"
  on_timeout: "auto_deny"
  notify_before: "50%"      # reminder at half the tier limit
tiers:
  low:
    max_wait: "15m"
    on_timeout: "auto_deny"
    approvers: ["team_lead"]
  medium:
    max_wait: "4h"
    on_timeout: "escalate"
    escalate_to: ["security_reviewer"]
  high:
    max_wait: "1_business_day"
    on_timeout: "park"
    park_until: "manual_review"
    require_reason: true

Three things are worth noticing. First, notify_before is relative to the tier limit, so a 15 minute tier gets a nudge at 7.5 minutes without a per-tier reminder rule. Second, clock distinguishes wall-clock hours from business hours, which matters because a 4 hour wait that starts at 6 PM on Friday becomes four minutes of real human availability. Third, require_reason is set on the tier that parks, because a parked request without a stated reason is just a stalled one with better branding.

Pick the default per tier, not globally

The instinct is to pick one fallback and apply it everywhere. That is where most programs go wrong. A global auto-deny punishes low-risk work with needless friction, while a global escalate turns every unanswered low-risk request into a second person's inbox problem. Escalation frameworks in practice define a distinct time to escalate and routing destination per risk category, for example a 12 hour window for critical items against 48 hours for routine ones (Sirion). Copy the shape of that table, not the numbers.

TierMax waitDefault on timeoutWhy this defaultFailure it prevents
Low15 minutesAuto-denyRe-running the request is cheap and self-serviceA queue full of abandoned requests
Medium4 hoursEscalate to a named reviewerSomeone with context exists and is on callSilent stalling in one person's inbox
High1 business dayPark with an expiry and a reasonDenying is worse than waiting; the action is reversibleAn irreversible action taken by a clock

The decision rule I use: if denying and retrying costs less than an hour of human time, auto-deny. If a second person genuinely has better context, escalate. If the action cannot be undone and nobody will be at their desk, park, and make the park visible in a queue that someone owns. That last point matters: parking without an owning queue is just a slower auto-deny that nobody can audit.

Encode the default so it cannot drift

The enforcement point belongs between the agent's proposal and the tool call. Policy-as-code puts a policy decision point before tool invocation, workflow writes and external communications, so the model's output is a request for authorization rather than authorization itself (Thomas Thelliez). If the timeout logic lives in the agent's prompt, it is a suggestion; if it lives in the gateway, it is a control.

A compact shape for the approval step:

from datetime import datetime, timedelta
import yaml

POLICY = yaml.safe_load(open("approval_policy.yaml"))
BUSINESS_HOURS = range(9, 18)  # local business hours, Mon-Fri

def deadline_for(tier, start):
    limit = parse_duration(POLICY["tiers"][tier]["max_wait"])
    if POLICY["defaults"]["clock"] == "business_hours":
        return add_business_time(start, limit, BUSINESS_HOURS)
    return start + limit

def resolve(request, now):
    tier = request["risk_tier"]
    rule = POLICY["tiers"][tier]
    if request["status"] == "approved":
        return {"outcome": "allow", "reason": "approved_by_human"}
    if now < request["deadline"]:
        return {"outcome": "wait", "reason": "within_window"}
    default = rule["on_timeout"]
    if default == "escalate":
        return {"outcome": "wait", "reason": "escalated",
                "notify": rule["escalate_to", "escalate_to"], "deadline": deadline_for(tier, now)}
    if default == "park":
        return {"outcome": "hold", "reason": "parked_for_manual_review"}
    return {"outcome": "deny", "reason": f"timeout_{tier}"}

Every branch returns a machine-readable outcome and a reason string. That reason is the audit evidence. It goes into the record alongside the tier, the deadline, the elapsed time and the policy version, which is what you will need when someone asks why the agent denied a request at 3 AM.

Fail closed. Policy that is not enforced inline, before execution, is documentation (Backslash Security). If the policy engine is unreachable, if the tier is unknown, or if the deadline cannot be parsed, the answer is deny or hold, never allow. A missing tier is itself a policy violation.

Test the boundary, the fallback and the fail-closed case

Most approval bugs are not in the decision table. They are one second on the wrong side of the boundary, or a wrong fallback that nobody noticed because the happy path passed. Automated security and quality checks belong inside the pipeline so issues are caught before deployment rather than in a late-stage review (Harness). Treat this test suite as a quality gate that blocks the merge.

A table-driven test harness that fails on a boundary breach or a wrong fallback:

import pytest
from datetime import timedelta

CASES = [
    # (tier, seconds_after_deadline, expected_outcome, expected_reason)
    ("low",    -1,  "wait",  "within_window"),
    ("low",     0,  "deny",  "timeout_low"),      # exactly at the limit
    ("low",     1,  "deny",  "timeout_low"),      # one second over
    ("medium",  1,  "wait",  "escalated"),
    ("medium",  0,  "wait",  "escalated"),
    ("high",    1,  "hold",  "parked_for_manual_review"),
    ("high",    0,  "hold",  "parked_for_manual_review"),
]

@pytest.mark.parametrize("tier,offset,outcome,reason", CASES)
def test_default_when_clock_expires(tier, offset, outcome, reason):
    req = make_request(tier=tier, status="pending")
    now = req["deadline"] + timedelta(seconds=offset)
    result = resolve(req, now)
    assert result["outcome"] == outcome
    assert result["reason"] == reason

def test_unknown_tier_fails_closed():
    req = make_request(tier="unclassified", status="pending")
    result = resolve(req, req["deadline"] + timedelta(days=30))
    assert result["outcome"] in {"deny", "hold"}

def test_escalation_resets_deadline():
    req = make_request(tier="medium", status="pending")
    now = req["deadline"] + timedelta(seconds=1)
    result = resolve(req, now)
    assert result["deadline"] > now
    assert result["notify"] == POLICY["tiers"]["medium"]["escalate_to"]

Two conventions keep this suite honest. Decide whether now == deadline counts as expired, and write that decision in the policy file as a comment plus a test; the case above treats equality as expired and covers it explicitly. Second, assert on the reason string, not just the outcome. A test that checks only outcome == "deny" passes even when the deny came from a malformed policy, and that is exactly the bug you are trying to catch.

Add a reminder test too. notify_before: "50%" should fire at 7 minutes 30 seconds for the low tier, not at 8 minutes, and not never. That test catches the most common silent failure: the policy says reminders exist and the code never sends them.

Keep business hours and time zones in the test suite

The 15 minute and 4 hour tiers are easy; the 1 business day tier is where implementations break. If the default clock is business hours, a request created on a Friday afternoon has a deadline on Monday, and elapsed wall-clock time is meaningless for reporting. Auto-escalation frameworks stress configurable thresholds and contextual timeout periods adjusted for business hours and organizational calendars (Shyft). Cover the calendar explicitly.

Add cases for a request started at 16:45 local time on a Friday, a request started during a holiday calendar entry, and a request for a user in a different time zone from the approver. Assert that the deadline lands in the same working window as the approver, and that a weekend does not silently consume the high tier's only day. If your system reports "average time to approval," compute it in working time or the number will flatter you on Fridays.

Log the decision so audit can replay it

An approval timeout is a decision, and decisions need evidence. The evidence schema should carry the policy version, the tier, the actor and agent identity, the deadline, the resolution time, the outcome and the reason string. Policy-as-code earns much of its value from continuous compliance, because every change produces a timestamped, versioned record that demonstrates control adherence during audits (Wiz).

Versioning the policy file is not optional. If you change the medium tier from escalate to auto-deny, every historical record evaluated under the old rule should still explain itself. Store the policy version on each approval record, and diff behavior before and after a change by replaying recorded deadlines against the new file in a test.

Access control for the approval path itself follows the same least-privilege logic as model and tool access, which most production systems implement as a combination of role-based and attribute-based rules (Maxim AI). Concretely: the approver identity in the record should be a specific person, not a shared service account, and the agent should be able to read approval state without being able to write it.

A worked example with numbers

A support agent proposes a refund adjustment. The policy assigns it the medium tier, maximum wait 4 hours, default escalate. The request is created at 10:00, so the deadline is 14:00 and the reminder fires at 12:00.

The first approver does nothing. At 14:00 the policy escalates to the security reviewer and resets the deadline to 18:00, which is past the close of business hours, so with a business-hours clock the new deadline lands at 10:00 the next working day. The record shows two windows: 4 hours on the first approver, then the next window on the second. If the reviewer also misses, the medium tier's default does not loop: it must terminate in a deny or a park after the final escalation, and the policy file should say which.

Run the same numbers with a wall-clock setting and the second deadline is 18:00 that same day, which means the reviewer has effectively zero working time. That is the insight the boundary test does not give you: a 4 hour clock is only meaningful if your policy states which kind of 4 hours it is. Write the clock rule into the tier, test it, and the difference between a working escalation and a decorative one becomes visible in a pull request.

If you are wiring this into a production agent that reaches several model providers, put the policy decision point in front of the tool call and keep one gateway for every model. Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models through one OpenAI-compatible key, which keeps the approval path identical regardless of which model proposed the action. When you want a second opinion on where the enforcement point belongs for your line of business, a free 30-minute discovery call is the shortest route: https://quantaintelligence.ai/calendar.

FAQ

FAQ

Should the timer start when the agent proposes the action or when the request enters the approver's queue?

Start it when the request becomes visible to the named approver, and store that timestamp on the record. Starting at proposal time quietly penalizes slow routing, so a 15 minute tier can expire before a human ever sees the queue. The deadline should be computed from the visible timestamp plus the tier limit, and the test suite should assert that gap explicitly.

What happens if the approver responds one second after the deadline?

The policy must answer that before production does. Set the rule in the policy file (the example treats equality as expired and later as expired), then encode it in a boundary test so the behavior cannot drift. If a late approval should still be honored for a grace window, make the grace a named tier field with its own test, not an undocumented code path.

Is escalating always safer than auto-denying?

No. Escalation adds a second person's latency to a request that may be cheap to redo, and repeated escalation creates the confirmation fatigue that least-privilege research describes when every action needs approval (MiniScope, arXiv). Auto-deny is the safer default for reversible, low-cost actions, and escalation pays off only when the next person has genuinely better context.

How do I prevent the agent from approving its own escalated request?

Bind approval authority to a human principal in the policy, and have the decision point reject any approval where the actor is the agent's own identity. This is the same separation that policy-as-code prescribes when it says the model may propose but a policy decision point decides (Thomas Thelliez). Log the attempted self-approval as an incident, not just a denial.

Should the timeout outcome block the pipeline build?

Yes, if the policy file and the test expectations disagree. Integrate the suite as a gate that fails the build on a boundary breach, a wrong fallback or a missing reason string, which is how automated security testing is meant to work inside continuous delivery (Cycode). Pair it with validation of the policy file itself, so a malformed duration is caught at review rather than by a running agent (OWASP).

Sources

Sources

  1. Policy as Code: Benefits, Examples, and How to Get Started
  2. Policy-as-Code for Enterprise AI Agents - Thomas THELLIEZ
  3. How to Configure Auto-Escalation for Stalled Approvals
  4. How to Build Allowlist and Denylist Policies for AI Agents ...
  5. Integrating Automated Security Testing in CI/CD Pipelines - Harness
  6. Streamlining Approval Timeout Escalation Procedures
  7. Understanding LLM Access Control - Maxim AI
  8. PDF A Least Privilege Framework for Authorizing Tool Calling Agents
  9. How to Integrate Security Testing into CI/CD Pipelines - Cycode
  10. Input Validation - OWASP Cheat Sheet Series
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles