ADR-041: Bounded Human-Approval Gate Timeout (Fail-Closed)¶
Status: Accepted
Date: 2026-06-29
Related: agentic_v2/governance/approval.py (evaluate_tool_approval),
agentic_v2/settings.py (agentic_approval_timeout_seconds),
[[ADR — governance/HITL approval gates]]
Context¶
evaluate_tool_approval consults the registered ApprovalProvider before a
gated tool runs:
That await was unbounded. An ApprovalProvider is an arbitrary integration
point — a human in a UI, a queue consumer, a remote webhook. If it hangs (a
crashed approver, a dropped WebSocket, a wedged queue), the gated tool call
blocks indefinitely. In practice it does not block forever — the step's own
timeout_seconds eventually cancels the step — but the approval wait silently
consumes the entire step timeout budget before the tool even runs, so the
step times out with a generic step-timeout error rather than a clear "approval
never arrived" signal, and a short-timeout step can never both wait for approval
and do useful work.
The gate already fails closed in one direction: a missing provider DENIES. A non-responsive provider had no equivalent bound.
Decision¶
Bound the provider wait with a configurable timeout and fail closed on timeout — the same posture as a missing provider.
- New setting
agentic_approval_timeout_seconds: float, envAGENTIC_APPROVAL_TIMEOUT_SECONDS. - Default
1800.0(30 minutes). Generous for a human approver, but bounds a truly hung provider. A non-positive value (<= 0) disables the bound and restores the prior wait-forever behavior for callers who deliberately want it. - On
TimeoutError, returnApprovalOutcome(allowed=False, decision=DENIED, error_message="… timed out after Ns waiting for <provider> (fail-closed)")and log at WARNING. The tool is never executed.
Why a bounded default rather than opt-in (0): the whole point of the change is to remove an indefinite block, so an opt-in default would leave the hazard in place for everyone who does not explicitly configure it. The gate's security posture is fail-closed; a timeout is just another reason the gate could not obtain an explicit approval, so denying is consistent. 30 minutes is long enough that a legitimate human-approval flow is unaffected in the common case; flows that genuinely need longer (or unbounded) set the value explicitly.
Consequences¶
- A hung/unreachable approver now denies a gated tool within a bounded time instead of consuming the step budget; the error message names the timeout and the provider, so the failure is diagnosable.
- Behavior change: a provider that legitimately takes longer than 30 minutes
to respond now DENIES by default. Such flows must raise
AGENTIC_APPROVAL_TIMEOUT_SECONDS(or set0to disable). This is called out here so the change is not silent. - Fail-closed is preserved end to end: missing provider → DENIED; timeout → DENIED; explicit deny → DENIED. Only an explicit, in-time APPROVED allows the tool.
Alternatives considered¶
- Opt-in (default
0/disabled) — rejected: leaves the indefinite-block hazard in place by default, so the fix would do nothing for the common case. - Fail open on timeout (treat as approved) — rejected outright: a non-responsive approver auto-approving a gated (high-impact) tool is a privilege-escalation vector, the exact opposite of the gate's purpose.
- Rely on the step-level
timeout_seconds— rejected: it cancels the whole step with a generic error and still lets the approval wait burn the entire budget; it does not distinguish "tool slow" from "approver never answered."