Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Vega, a release assistant for an internal AI platform, has prepared one change: move recommendation-reranker-v17 from a shadow deployment to a canary, a small slice of live traffic. A release manager might approve that change now, but Vega's worker may not run it for hours. During that pause, deployment state or reviewer session can change. A Boolean such as approved: true in chat history can't prove that the exact action is still safe.
Computer-Use & GUI Browser Agents can reject a proposed click before the host executes it. Vega needs a slower gate, one that can survive a long pause and recheck the world before it writes. Follow one proposal through that loop:
1promote_model(
2 model_id="recommendation-reranker-v17",
3 from_stage="shadow",
4 to_stage="canary",
5 traffic_percent=25,
6)Vega may read authorized evaluation results and prepare this proposal, but it can't promote the model. A trusted host must pause before the write, save enough state for review, authenticate a qualified reviewer, and bind the decision to these exact arguments. The worker then reads current deployment state and executes with a separate narrow credential. That fresh-state check prevents a time-of-check-to-time-of-use (TOCTOU) failure, where an approved target changes before its queued action runs.
That sequence is Human-in-the-Loop (HITL) architecture. HITL doesn't prove Vega's proposal is correct. It gives policy, evidence, human judgment, and fresh-state validation a place to stop an unsafe effect.
OWASP LLM06:2025 Excessive Agency names three roots: too much functionality, too much permission, and too much autonomy. Mitigations include downstream authorization, least privilege, user-context execution, and human approval before high-impact actions.[1]
Function Calling & Tool Use introduced typed tool calls. Agentic Architectures introduced stateful control loops. Here, the model's call is an untrusted proposal inside a durable approval workflow.
The traffic thresholds are teaching fixtures, not universal release policy.
Choose oversight from possible effect
Before choosing a queue, ask when a person must be able to stop the effect. Human involvement usually sits before, during, or outside real-time execution. Choose among those positions from what can happen if the system is wrong, not from model confidence.
| Mode | When human acts | Suitable example | Important boundary |
|---|---|---|---|
| HITL | Before gated effect | Promote a model, publish external update, change user record | Write stays blocked until valid decision arrives. |
| Human-on-the-Loop (HOTL) | During or after autonomous work | Monitor internal draft routing and interrupt anomalies | Detection may happen after an effect begins. |
| Human-out-of-the-Loop (HOOTL) | No real-time review | Read permitted metrics, create isolated draft | Scope, authorization, and repair path must already be bounded. |
An unrestricted read isn't automatically safe: it may expose another tenant's data or a secret. HITL isn't automatically safe either. A reviewer who lacks authority, sees stale evidence, or approves an unbound action adds ceremony without control.
What is the difference between HITL, HOTL, and HOOTL?
Answer
HITL blocks before a high-risk action executes, HOTL lets the agent run while a human can monitor and interrupt, and HOOTL runs without real-time oversight for bounded, authorized work with acceptable failure modes.
Oversight mode says when a person can intervene. A policy tier must still decide which Vega actions need that intervention.
Put policy before approval
Routing every tool call to a reviewer creates fatigue. Routing none of them creates excessive autonomy. Start with a deterministic policy floor based on possible effect, then allow runtime context to move an action only to a stricter tier.
| Risk Level | Description | Examples | Interaction Model |
|---|---|---|---|
| Low | Bounded, authorized read or isolated draft | Read permitted eval metrics, Inspect canary health, Draft release note without publishing | Auto-Execute: Run within least-privilege access. |
| Medium | Internal staged change with a recoverable effect | Queue review packet, Add a permitted internal risk label | Policy Decision: Audit and notify, or require review if policy says so. |
| High | External or production side effect | Publish incident update, Promote model to canary, Shift endpoint traffic | Approve: Pause and wait for an authorized reviewer. |
| Critical | Destructive, unusually high-impact, or legally constrained | Delete retained eval evidence, Promote despite failing gate, Disable required safety filter | Escalate or Block: Require stronger authorization or deny. |
The model can choose a tool, but it can't choose its own authority. This policy table maps admitted tools to a minimum tier outside the model. Guardrails & Safety Filters covers the wider rule and enforcement layer. Here, policy has already returned APPROVE or ESCALATE, and we follow the resulting workflow.
The small Python model below turns that policy floor into a lookup. Its escalate_risk() helper is monotonic: a stricter runtime signal can raise a tier, never lower one.
1from enum import Enum
2from dataclasses import dataclass
3
4class RiskLevel(Enum):
5 AUTO = "auto" # Execute immediately, no human needed
6 NOTIFY = "notify" # Execute and notify human asynchronously
7 APPROVE = "approve" # Pause and wait for human approval
8 ESCALATE = "escalate" # Route to senior reviewer or stronger authorization
9
10@dataclass
11class ToolPolicy:
12 tool_name: str
13 risk_level: RiskLevel
14 escalate_above_traffic_percent: float | None = None
15 requires_reason: bool = False
16 timeout_minutes: int = 60 # Auto-reject after timeout
17
18TOOL_POLICIES = {
19 # Safe actions run automatically
20 "read_eval_run": ToolPolicy("read_eval_run", RiskLevel.AUTO),
21 "inspect_service_metrics": ToolPolicy("inspect_service_metrics", RiskLevel.AUTO),
22
23 # External communication requires justification
24 "publish_incident_update": ToolPolicy(
25 "publish_incident_update",
26 RiskLevel.APPROVE,
27 requires_reason=True,
28 ),
29
30 # Model promotions affect production users, so they always begin at APPROVE.
31 "promote_model": ToolPolicy(
32 "promote_model",
33 RiskLevel.APPROVE,
34 escalate_above_traffic_percent=25.0,
35 timeout_minutes=30,
36 ),
37
38 # Destructive or high-impact actions require escalation
39 "delete_eval_evidence": ToolPolicy("delete_eval_evidence", RiskLevel.ESCALATE),
40 "disable_safety_filter": ToolPolicy("disable_safety_filter", RiskLevel.ESCALATE),
41}
42
43RISK_PRIORITY = {
44 RiskLevel.AUTO: 0,
45 RiskLevel.NOTIFY: 1,
46 RiskLevel.APPROVE: 2,
47 RiskLevel.ESCALATE: 3,
48}
49
50def escalate_risk(current: RiskLevel, target: RiskLevel) -> RiskLevel:
51 return target if RISK_PRIORITY[target] > RISK_PRIORITY[current] else current
52
53print("read_eval_run:", TOOL_POLICIES["read_eval_run"].risk_level.value)
54print(
55 "promotion traffic threshold:",
56 TOOL_POLICIES["promote_model"].escalate_above_traffic_percent,
57)
58print("critical wins:", escalate_risk(RiskLevel.ESCALATE, RiskLevel.APPROVE).value)1read_eval_run: auto
2promotion traffic threshold: 25.0
3critical wins: escalateWhy should a HITL policy classify tool calls by risk tier instead of asking for approval on everything?
Answer
Approval on everything creates reviewer fatigue and slows safe work. Risk tiers let read-only or reversible actions run while costly, external, or irreversible actions pause for review.
Risk isn't static. A draft for an internal queue may stay autonomous, while publishing the same text externally needs approval. Arguments, tenant, cumulative traffic change, recent failures, and time window can raise the tier.
Runtime rules must never silently lower the static floor. The tool name alone still can't settle the decision.
Why is the tool name alone not enough to decide whether approval is required?
Answer
The same tool can have different risk based on arguments and context: audience, traffic percentage, endpoint tier, time of day, recent failures, or deployment history.
Once policy returns APPROVE, Vega needs a durable state machine transition. An open request handler or a Boolean attached to chat history can't carry one action safely across a long pause.
One approval moves through explicit states
An Approve button captures only the decision. The system also has to preserve one action's identity across a long pause and keep authorization separate from execution.

Use these states as a contract rather than a loose approved: true flag:
| State | Meaning | Allowed next step |
|---|---|---|
proposed | Vega produced typed arguments, but no review record exists. | Validate policy and create pending. |
pending | Exact action, evidence, version, expiry, and reviewer requirements are durable. | Approve, reject, expire, or replace with an edited proposal. |
authorized | One eligible reviewer approved this version and action hash. | Recheck current state and enqueue execution. |
executing | Worker claimed idempotency key and is attempting bounded side effect. | Record executed or failed. |
executed | Downstream system returned receipt for intended effect. | Terminal. |
rejected, expired, stale, failed | Workflow didn't produce intended effect. | Terminal or create a fresh proposal under policy. |
Keep these states distinct in the audit trail. authorized doesn't mean executed, and failed doesn't mean safe to retry without checking whether a downstream effect happened.
Pause without holding a process open
When Vega enters pending, ask what the worker should do while a reviewer is away. It has nothing useful to do, so it can't wait with time.sleep() or keep a request handler in memory.
- Durability: Review can take hours. If the process restarts while Casey is still looking at the packet, in-memory state is gone.
- Resources: A blocked thread isn't doing useful work. It's just occupying a worker.
- Scale: Thousands of paused promotions can't each own a live process.
Use the checkpoint/resume pattern. Persist graph state and the approval record, return the worker, then reload by stable workflow ID after a decision. A guarded compare-and-swap (CAS) update ensures that only one current decision resolves the pending row. If the reviewer never arrives, the row should fail closed: expire and refuse the write instead of auto-promoting.
The pause is a conversation among four roles, not a function that blocks:

Why can't a production HITL agent wait with time.sleep() or an open Python thread?
Answer
Human review can take minutes or hours. In-memory waiting loses state on deploys or crashes, wastes resources, and prevents the system from scaling to many paused agents.
Vega's paused state might look like this:
1{
2 "thread_id": "release_reranker_v17",
3 "request_summary": "Promote recommendation-reranker-v17 from shadow to a 25% canary.",
4 "evidence": ["offline release gate: pass", "deployment preflight: pass", "rollback plan: ready"],
5 "pending_action": {
6 "tool": "promote_model",
7 "args": {
8 "model_id": "recommendation-reranker-v17",
9 "from_stage": "shadow",
10 "to_stage": "canary",
11 "traffic_percent": 25
12 }
13 },
14 "approval": {
15 "approval_id": "apr_123",
16 "status": "pending",
17 "policy_rule": "model_release.requires_review",
18 "requester_subject": "user_48",
19 "tenant_id": "ai-platform",
20 "required_role": "release-manager",
21 "action_hash": "sha256:48f7477bc45f801ec2249b9a43281e3645a2d44af144844b7dadda43a1aaff68",
22 "version": 3,
23 "expires_at": "2026-08-22T19:00:00Z"
24 }
25}When an eligible reviewer approves, the runtime resolves this version with a guarded write. A worker reloads the checkpoint, rechecks current deployment state, and attempts the action with an idempotency key.
Rejection closes the request without promotion. An edit validates new arguments, computes a new hash and version, and returns to pending; it doesn't reuse the old authorization.
Before building a workflow engine, test the persistence boundary with ordinary data. The checkpoint below stores a redacted summary and an action hash, but not the raw request message.
1from hashlib import sha256
2import json
3
4raw_message = "Promote reranker-v17. Ask [email protected] to verify rollout notes."
5pending_action = {
6 "tool": "promote_model",
7 "args": {
8 "model_id": "recommendation-reranker-v17",
9 "from_stage": "shadow",
10 "to_stage": "canary",
11 "traffic_percent": 25,
12 },
13}
14action_bytes = json.dumps(
15 pending_action,
16 sort_keys=True,
17 separators=(",", ":"),
18).encode()
19checkpoint = {
20 "request_summary": "Vega proposes a 25% canary for reranker-v17.",
21 "pending_action": pending_action,
22 "action_hash": f"sha256:{sha256(action_bytes).hexdigest()}",
23 "status": "pending",
24}
25stored = json.dumps(checkpoint)
26
27print("status:", checkpoint["status"])
28print("action hash present:", bool(checkpoint["action_hash"]))
29print("stored digest length:", len(checkpoint["action_hash"].removeprefix("sha256:")))
30print("action hash for display:", checkpoint["action_hash"][:19] + "...")
31print("raw message stored:", raw_message in stored)1status: pending
2action hash present: True
3stored digest length: 64
4action hash for display: sha256:48f7477bc45f...
5raw message stored: FalseWhat should be present in a useful approval checkpoint?
Answer
A stable thread ID, requester and tenant scope, necessary redacted evidence, exact tool arguments, action hash, status, version, expiry, reviewer requirement, and enough workflow state to resume safely.
Two orchestration approaches expose the same lifecycle differently:
| Feature | LangGraph (interrupt) | Temporal |
|---|---|---|
| Best for | Graph-based agents with checkpointed interrupts | Workflow orchestration with timers, retries, and cross-service activities |
| State persistence | Durable database-backed checkpointer | Durable event history managed by Temporal |
| Resume Mechanism | API calls invoking Command(resume=...) | Signals or Updates |
| Operational shape | Agent runtime plus durable checkpointer | Workflow service plus activity workers and message handlers |
Framework primitives persist control flow, but they don't authenticate reviewers, choose risk policy, or grant downstream permissions. Those decisions remain application responsibilities.
Map the lifecycle to LangGraph
LangGraph's interrupt() emits JSON-serializable review data and pauses a graph compiled with a checkpointer. Resumption uses the same thread_id plus Command(resume=...).[2][3]
On resume, the node restarts from its beginning. Code before interrupt() must therefore be idempotent or live in an earlier node. The primitives carry a decision back into the graph, but they don't authenticate the reviewer or grant production access.
LangChain documents middleware on top of the same interrupt primitive: approve, edit, reject, or respond. That wrapper can run edited arguments immediately.
Don't use respond to deny a write. respond looks like a successful tool result to the model. Treat an edit as a new proposal instead, because a 10% canary isn't the action the reviewer originally locked.
Use a custom decision payload when an edit should become a new proposal. The excerpt leaves graph construction and application helpers out so the pause/resume boundary stays visible.
1from langgraph.checkpoint.postgres import PostgresSaver
2from langgraph.types import interrupt, Command
3from typing import Literal, TypedDict
4
5class Decision(TypedDict):
6 decision: Literal["approve", "edit", "reject"]
7 modified_args: dict[str, object] | None
8 reason: str | None
9
10def approval_node(state: dict[str, object]) -> dict[str, object]:
11 action = state["pending_action"]
12 decision: Decision = interrupt({
13 "approval_id": state["approval_id"],
14 "action": action,
15 "action_hash": state["action_hash"],
16 "version": state["version"],
17 "expires_at": state["expires_at"],
18 })
19
20 if decision["decision"] == "reject":
21 return {"status": "rejected", "decision_reason": decision["reason"]}
22
23 if decision["decision"] == "edit":
24 edited = validate_and_reclassify(action, decision["modified_args"])
25 return new_pending_version(edited)
26
27 return {"status": "authorized"}
28
29# builder defines proposal, approval, and execution nodes elsewhere.
30with PostgresSaver.from_conn_string("postgresql://...") as checkpointer:
31 checkpointer.setup()
32 app = builder.compile(checkpointer=checkpointer)
33
34 config = {"configurable": {"thread_id": "release_reranker_v17"}}
35 app.invoke(initial_state, config=config)
36 app.invoke(Command(resume={"decision": "approve"}), config=config)Why must code before interrupt() be idempotent in LangGraph?
Answer
On resume, LangGraph restarts the node from its beginning before interrupt() returns the resume value. Any earlier side effect can run again unless it's idempotent or moved after the interrupt.
A LangGraph thread ID recovers graph state, but it doesn't say which human may decide. It also isn't the only way to wait. When a pause spans timers, retries, and other services, Temporal records the same lifecycle as event history.
Pause with Temporal when the wait spans services
LangGraph checkpoints can wait for hours. Temporal fits when the same workflow also needs durable timers, retries, and cross-service activities, not because "long wait" is a different problem.[4]
Signals are fire-and-forget. If the review UI needs to know whether the workflow accepted the click, or you want to reject a stale version before it lands in history, use an Update with a validator.[5]
Multi-step chains, such as a release manager followed by a second approver, can wait for each required decision without holding a worker.
The workflow below uses an Update because the UI needs accept/reject feedback. Validation of edited arguments and current deployment state still happens in an Activity, not inside workflow code.
1from temporalio import workflow
2
3ApprovalPayload = dict[str, object]
4
5# Assume activities are defined elsewhere
6# plan_actions, notify_human, validate_for_execution, execute_action, is_risky = ...
7
8@workflow.defn
9class AgentWorkflow:
10 def __init__(self) -> None:
11 self._pending_action_id: str | None = None
12 self._pending_version: int | None = None
13 self._human_decision: ApprovalPayload | None = None
14
15 @workflow.update
16 def decide(self, decision: ApprovalPayload) -> str:
17 self._human_decision = decision
18 return "accepted"
19
20 @decide.validator
21 def validate_decision(self, decision: ApprovalPayload) -> None:
22 """Reject stale decisions before the Update is accepted."""
23 if decision["action_id"] != self._pending_action_id:
24 raise ValueError("stale action id")
25 if decision["expected_version"] != self._pending_version:
26 raise ValueError("stale action version")
27 if self._human_decision is not None:
28 raise ValueError("decision already recorded")
29
30 @workflow.run
31 async def run(self, task: str):
32 plan = await workflow.execute_activity(plan_actions, task, ...)
33
34 for action in plan.actions:
35 if is_risky(action):
36 self._pending_action_id = action["id"]
37 self._pending_version = action["version"]
38 self._human_decision = None
39
40 await workflow.execute_activity(
41 notify_human,
42 {"action_id": action["id"], "action": action},
43 ...,
44 )
45
46 await workflow.wait_condition(
47 lambda: self._human_decision is not None
48 )
49
50 decision = self._human_decision
51 self._pending_action_id = None
52 self._pending_version = None
53 self._human_decision = None
54
55 if decision["decision"] == "reject":
56 continue
57
58 action = await workflow.execute_activity(
59 validate_for_execution,
60 {"action": action, "decision": decision},
61 ...,
62 )
63
64 await workflow.execute_activity(execute_action, action, ...)The validator rejects a stale version and a second click while the first accepted Update is waiting to be consumed.
Keep policy lookups deterministic inside the workflow. If is_risky() depends on live metrics or a policy database, fetch that data in an Activity and pass the snapshot in.
Temporal replays workflow code against event history, so a non-deterministic call inside the workflow breaks replay.[4] For months-long chains, Continue-As-New caps history size while carrying forward unresolved approval state.
When should a Temporal approval use an Update instead of a Signal?
Answer
Use an Update when the approval UI needs synchronous confirmation that the workflow accepted or rejected the decision. Signals are better for fire-and-forget messages.
A thread ID or workflow ID recovers paused work. Neither one names the human who may click Approve, or the credential that may write to production.
Keep four identities separate
Vega's thread ID tells us which workflow resumes, not who may authorize it or which credential may execute it. Approval intent, human identity, and tool capability are different objects.
Combining them creates a confused-deputy path where a valid click can authorize the wrong tenant or give Vega a reusable production credential.
| Principal | What it contributes | What it must not inherit |
|---|---|---|
| Requester | Authenticated subject, tenant, original task, permitted resource scope | Reviewer role merely because they started the task |
| Vega agent | Proposed tool and arguments, evidence references, rationale | Requester's session token or standing production write credential |
| Reviewer | Authenticated subject, eligible role, decision, reason, decision time | Power to approve another tenant or silently change tool scope |
| Tool worker | Narrow workload identity, current-state read, idempotent execution | Reviewer browser session or model-supplied credentials |
OWASP's excessive-agency guidance makes both user context and least privilege explicit: downstream systems should enforce authorization for the current user scope, and extensions should receive only permissions needed for the task.[1] For Vega, bind requester_subject, tenant_id, resource scope, reviewer_subject, and executor identity to separate audit fields.
High-impact actions also need separation of duties (SoD). An eligible release manager still can't sole-approve a promotion they requested. Critical changes may require two distinct qualified reviewers.
A break-glass route needs its own narrow role, expiry, reason, alert, and later review. A free-text note isn't break-glass authorization.
Record decision before execution
The decision endpoint authenticates the reviewer from the server session, loads the pending record, checks tenant and role, and performs one guarded transition. It doesn't accept reviewer identity from the request body or call the production tool inline.
1from fastapi import FastAPI, HTTPException
2from pydantic import BaseModel
3from typing import Literal
4from datetime import datetime, timezone
5
6app = FastAPI()
7JsonDict = dict[str, object]
8
9# Assume langgraph_app is compiled elsewhere
10# langgraph_app = graph.compile(...)
11
12class ApprovalDecision(BaseModel):
13 approval_id: str
14 expected_version: int
15 action_hash: str
16 decision: Literal["approve", "reject", "edit"]
17 reason: str | None = None
18 modified_args: JsonDict | None = None
19
20@app.post("/api/approvals/{thread_id}/decide")
21async def decide_approval(thread_id: str, decision: ApprovalDecision):
22 # Reviewer identity comes from authenticated server context, never request JSON.
23 reviewer = current_reviewer()
24
25 # Confirm workflow is still waiting before changing approval state.
26 config = {"configurable": {"thread_id": thread_id}}
27 state = langgraph_app.get_state(config)
28 if not any(task.interrupts for task in state.tasks):
29 raise HTTPException(400, "Agent isn't waiting for input")
30
31 with approval_store.transaction() as tx:
32 approval = load_pending_approval(tx, thread_id, decision.approval_id)
33 if approval is None or approval["status"] != "pending":
34 raise HTTPException(404, "Approval request not found")
35 if approval["version"] != decision.expected_version:
36 raise HTTPException(409, "Approval request is stale")
37 if approval["action_hash"] != decision.action_hash:
38 raise HTTPException(409, "Proposed action changed; request fresh review")
39 if approval["expires_at"] <= datetime.now(timezone.utc):
40 raise HTTPException(409, "Approval request expired")
41 require_reviewer_permission(
42 reviewer,
43 tenant_id=approval["tenant_id"],
44 required_role=approval["required_role"],
45 )
46 if reviewer.subject == approval["requester_subject"]:
47 raise HTTPException(403, "Requester can't sole-approve this action")
48
49 # An edit is a new proposal. Revalidate policy, hash new arguments,
50 # increment version, and return it to pending review.
51 if decision.decision == "edit":
52 if decision.modified_args is None:
53 raise HTTPException(422, "Edited arguments are required")
54 edited = validate_and_reclassify(
55 approval["action"],
56 decision.modified_args,
57 tenant_id=approval["tenant_id"],
58 )
59 replacement = replace_with_new_pending_version(
60 tx,
61 approval=approval,
62 edited_action=edited,
63 editor_subject=reviewer.subject,
64 )
65 return {
66 "status": "review_required",
67 "approval_id": replacement["id"],
68 "version": replacement["version"],
69 }
70
71 updated = try_resolve_approval(
72 tx,
73 approval_id=decision.approval_id,
74 expected_version=decision.expected_version,
75 expected_action_hash=decision.action_hash,
76 decision_time=datetime.now(timezone.utc),
77 next_status="rejected" if decision.decision == "reject" else "authorized",
78 reviewer_subject=reviewer.subject,
79 decision_reason=decision.reason,
80 )
81 if not updated:
82 raise HTTPException(409, "Approval was already resolved")
83
84 # Both approve and reject must durably resume the graph. Only approve
85 # can reach the execution worker after current-state validation.
86 insert_resume_outbox(
87 tx,
88 thread_id=thread_id,
89 approval_id=decision.approval_id,
90 idempotency_key=f"approval:{decision.approval_id}",
91 resume_payload={
92 "decision": decision.decision,
93 "reason": decision.reason,
94 },
95 )
96 return {"status": "decision_recorded"}The compare-and-swap update should include id, status = 'pending', version, action_hash, and non-expired time, then require exactly one affected row. That conditional write decides which reviewer wins a race.
Displaying a shortened digest is fine, but storage and comparisons need the full digest. authorized stays separate from executed, and the approval update plus outbox insert commit together. A reconciler can detect damaged invariants, but it isn't a substitute for atomic persistence.
Reviewer eligibility also deserves an executable check. It takes server-derived subjects, tenant, and roles as inputs, then rejects self-approval even when the requester has the required role.
1def authorize_reviewer(
2 requester_subject: str,
3 requester_tenant: str,
4 reviewer_subject: str,
5 reviewer_tenant: str,
6 reviewer_roles: set[str],
7 required_role: str,
8) -> str:
9 if reviewer_tenant != requester_tenant:
10 return "deny: tenant mismatch"
11 if required_role not in reviewer_roles:
12 return "deny: missing reviewer role"
13 if reviewer_subject == requester_subject:
14 return "deny: requester cannot sole-approve"
15 return "ok"
16
17print(authorize_reviewer(
18 "eng-7", "ai-platform", "eng-7", "ai-platform",
19 {"release-manager"}, "release-manager",
20))
21print(authorize_reviewer(
22 "eng-7", "ai-platform", "rm-3", "ai-platform",
23 {"release-manager"}, "release-manager",
24))1deny: requester cannot sole-approve
2okWhy does HITL fail if the proposer can also sole-approve?
Answer
A compromised requester-plus-agent path that can approve its own request defeats the gate. Separation of duties keeps a high-risk effect dependent on a distinct qualified reviewer, or on dual control when policy requires it.
Now make the two transitions visible: authorization resolves one version, and execution reuses the recorded result on retry. The example takes a versioned decision and an idempotency key, then rejects a stale click.
1from dataclasses import dataclass
2from hashlib import sha256
3
4ACTION_HASH = f"sha256:{sha256(b'promote:reranker-v17:25pct').hexdigest()}"
5
6@dataclass
7class Approval:
8 status: str = "pending"
9 version: int = 3
10 action_hash: str = ACTION_HASH
11
12completed_effects: dict[str, str] = {}
13
14def record_decision(
15 approval: Approval,
16 *,
17 expected_version: int,
18 action_hash: str,
19) -> str:
20 if approval.status != "pending" or approval.version != expected_version:
21 return "blocked: stale decision"
22 if approval.action_hash != action_hash:
23 return "blocked: action changed"
24 approval.status = "authorized"
25 approval.version += 1
26 return "authorized"
27
28def execute_once(approval: Approval, *, idempotency_key: str) -> str:
29 if idempotency_key in completed_effects:
30 return completed_effects[idempotency_key]
31 if approval.status != "authorized":
32 return "blocked: missing authorization"
33 result = "promotion recorded once"
34 completed_effects[idempotency_key] = result
35 approval.status = "executed"
36 return result
37
38pending = Approval()
39print("decision:", record_decision(
40 pending,
41 expected_version=3,
42 action_hash=ACTION_HASH,
43))
44print("first execution:", execute_once(pending, idempotency_key="apr_123"))
45print("retry:", execute_once(pending, idempotency_key="apr_123"))
46print("second click:", record_decision(
47 pending,
48 expected_version=3,
49 action_hash=ACTION_HASH,
50))1decision: authorized
2first execution: promotion recorded once
3retry: promotion recorded once
4second click: blocked: stale decisionVersion matching isn't an expiry check. Compare current time with the review deadline, and reject a decision after that window closes.
1from datetime import datetime, timezone
2
3def decision_allowed(*, expires_at: str, now: datetime) -> str:
4 expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
5 return "accepted" if now < expiry else "blocked: approval expired"
6
7clock = datetime(2026, 8, 22, 19, 0, tzinfo=timezone.utc)
8print("fresh:", decision_allowed(
9 expires_at="2026-08-22T19:01:00Z",
10 now=clock,
11))
12print("stale:", decision_allowed(
13 expires_at="2026-08-22T18:59:00Z",
14 now=clock,
15))1fresh: accepted
2stale: blocked: approval expiredWhy does the approval endpoint need compare-and-swap semantics?
Answer
Approval records are shared state. A guarded update on status and version prevents stale clicks or two reviewers from resolving the same action twice.
Backend guards protect the transition. The reviewer still needs a card that makes the exact effect and the evidence boundary obvious.
Design review card around exact effect
A Yes/No card isn't enough. When Vega asks to route 25% of traffic to reranker-v17, the reviewer needs enough trusted context to predict the effect: policy facts, current state, exact before/after arguments, permitted evidence, and the consequence of each decision.

Show the smallest packet that supports the decision: a redacted request summary, policy trigger, links the reviewer may open, proposed mutation, requester scope, required role, expiry, and a dry-run or diff. Don't dump the entire incident thread or model history into the card. Extra context can expose sensitive data and hide the change that matters.
The packet is also a data-minimization boundary. The example below takes a deployment note and exact action, redacts the email, and emits a stable hash that binds the card to the proposed mutation. For a data write, render a diff, not a blob of JSON.
1from hashlib import sha256
2import json
3import re
4
5def build_packet(note: str, action: dict[str, object]) -> dict[str, object]:
6 redacted_note = re.sub(r"[\w.+-]+@[\w.-]+", "[email redacted]", note)
7 canonical_action = json.dumps(action, sort_keys=True, separators=(",", ":"))
8 action_hash = f"sha256:{sha256(canonical_action.encode()).hexdigest()}"
9 return {"evidence": redacted_note, "action": action, "action_hash": action_hash}
10
11pending_approval_action = {
12 "tool": "promote_model",
13 "args": {
14 "model_id": "recommendation-reranker-v17",
15 "from_stage": "shadow",
16 "to_stage": "canary",
17 "traffic_percent": 25,
18 },
19}
20packet = build_packet(
21 "Page [email protected] only if canary error budget burns.",
22 pending_approval_action,
23)
24print("evidence:", packet["evidence"])
25print("has action hash:", bool(packet["action_hash"]))
26print("stored digest length:", len(packet["action_hash"].removeprefix("sha256:")))
27print("action hash for display:", packet["action_hash"][:19] + "...")1evidence: Page [email redacted] only if canary error budget burns.
2has action hash: True
3stored digest length: 64
4action hash for display: sha256:48f7477bc45f...What should an approval UI show beyond Approve and Reject buttons?
Answer
It should show a redacted request summary, proposed tool and arguments, policy rule, risk tier, diff or dry-run effect, action hash, version, expiry, and enough authorized evidence for a reviewer to decide.
Give the reviewer a concise rationale, the exact tool arguments, a diff, and the policy rule that triggered review. Don't make raw chain-of-thought your approval primitive. Explanations can be unfaithful, and they may reveal internal reasoning or sensitive context you didn't intend to surface.[6]
Why shouldn't raw chain-of-thought be the main approval artifact?
Answer
It may be unfaithful, verbose, sensitive, or misleading. Reviewers need structured action context, evidence, diffs, and policy triggers rather than private reasoning text.
Also keep approval state compact. Persist a data-minimized audit record with access controls and a retention policy. Inject only structured fields such as decision, reason, modified_args, or a short reviewer summary back into the runtime.
Long-lived threads otherwise accumulate reviewer chatter that burns context window on approval metadata instead of task state.
A compact card still isn't enough if the queue is noisy. Live context and batching shape what reviewers see; reviewer metrics tell us whether they keep catching real mistakes.
Escalate, batch, and measure the queue
Pause and resume is the core mechanism. A busy queue adds three design questions: when should risk escalate, which proposals can share a review, and how will we know whether reviewers are helping? Dynamic escalation, bounded batches, and reviewer-load metrics answer them without weakening the policy floor.
Dynamic risk escalation
Static policies need runtime context. Publishing one external incident update may already require approval. Attempting hundreds of updates in a short interval should move into a stricter queue or be blocked outright. The examples start from a static policy floor and raise the required authorization layer when context adds risk.
A 50% traffic shift moves from APPROVE to ESCALATE. An otherwise autonomous eval read moves to APPROVE after an anomaly spike. Disabling a safety filter stays at ESCALATE even when the night looks quiet. escalate_risk() implements that monotonic rule.
A dynamic policy starts from the static floor, then raises the tier for blast radius, recent failures, and off-hours promotions. Each output combines that floor with a runtime signal:
1from dataclasses import dataclass
2from enum import Enum
3from typing import Literal, cast
4
5class RiskLevel(Enum):
6 AUTO = "auto"
7 NOTIFY = "notify"
8 APPROVE = "approve"
9 ESCALATE = "escalate"
10
11@dataclass
12class ToolPolicy:
13 tool_name: str
14 risk_level: RiskLevel
15
16TOOL_POLICIES = {
17 "read_eval_run": ToolPolicy("read_eval_run", RiskLevel.AUTO),
18 "promote_model": ToolPolicy("promote_model", RiskLevel.APPROVE),
19 "disable_safety_filter": ToolPolicy("disable_safety_filter", RiskLevel.ESCALATE),
20}
21
22RISK_PRIORITY = {
23 RiskLevel.AUTO: 0,
24 RiskLevel.NOTIFY: 1,
25 RiskLevel.APPROVE: 2,
26 RiskLevel.ESCALATE: 3,
27}
28
29def escalate_risk(current: RiskLevel, target: RiskLevel) -> RiskLevel:
30 return target if RISK_PRIORITY[target] > RISK_PRIORITY[current] else current
31
32ToolArgs = dict[str, float | str | bool]
33ToolAction = dict[str, object]
34RuntimeContext = dict[str, object]
35
36def is_business_hours(context: RuntimeContext) -> bool:
37 hour = int(context.get("local_hour", 12))
38 return 8 <= hour < 18
39
40def calculate_dynamic_risk(action: ToolAction, context: RuntimeContext) -> RiskLevel:
41 """Vega's risk increases with traffic size, recent anomalies, and time of day."""
42 tool_name = str(action["tool"])
43 args = cast(ToolArgs, action.get("args", {}))
44 base_risk = TOOL_POLICIES[tool_name].risk_level
45
46 # 1. Blast-radius escalation: large traffic shifts are critical
47 if float(args.get("traffic_percent", 0)) > 25:
48 base_risk = escalate_risk(base_risk, RiskLevel.ESCALATE)
49
50 # 2. Anomaly-based escalation: many recent failures suggest something is wrong
51 if context.get("recent_failures", 0) > 3:
52 base_risk = escalate_risk(base_risk, RiskLevel.APPROVE)
53
54 # 3. Temporal escalation: off-hours promotions need senior review
55 if tool_name == "promote_model" and not is_business_hours(context):
56 base_risk = escalate_risk(base_risk, RiskLevel.ESCALATE)
57
58 return base_risk
59
60print("promote 50%:", calculate_dynamic_risk(
61 {"tool": "promote_model", "args": {"traffic_percent": 50}},
62 {"recent_failures": 0, "local_hour": 14},
63).value)
64print("read after failures:", calculate_dynamic_risk(
65 {"tool": "read_eval_run", "args": {}},
66 {"recent_failures": 4, "local_hour": 14},
67).value)
68print("disable filter:", calculate_dynamic_risk(
69 {"tool": "disable_safety_filter", "args": {}},
70 {"recent_failures": 0, "local_hour": 10},
71).value)
72print("off-hours promote:", calculate_dynamic_risk(
73 {"tool": "promote_model", "args": {"traffic_percent": 5}},
74 {"recent_failures": 0, "local_hour": 2},
75).value)1promote 50%: escalate
2read after failures: approve
3disable filter: escalate
4off-hours promote: escalateWhy should dynamic risk logic promote requests to stricter tiers rather than silently downgrade them?
Answer
Runtime context is best used as an extra safety signal. Downgrading a policy-marked high-risk action can bypass the static control that was created for known-dangerous operations.
Batch approvals
If Vega needs to propose 50 low-risk release-note publications after a migration freeze, asking for separate decisions for each one creates reviewer fatigue. Reviewers who face repetitive requests may stop inspecting individual effects. A batch review can group related proposals without hiding the service, audience, destination, or policy status of each item.
Instead of creating one alert per proposed operation, the orchestrator collects pending actions over a bounded window or groups them by a shared task identifier. The UI can then present: "Vega proposes 50 release-note publishes (review all items)." The approval record must bind to the exact item list or item hashes, so an item inserted after approval can't ride along in the batch.
Implementing batch approvals requires per-item state and idempotency. If a batch contains 50 actions and the reviewer authorizes the fixed set, the orchestrator must track each action separately. If action 42 fails, already-completed publishes aren't automatically undone unless the operation provides a compensation path. Retry only the failed authorized item with its idempotency key, and re-request review if its inputs or destination changed.
Bind a batch approval to the exact items shown to the reviewer. Here a later publish inserted into the queue changes the digest, so it can't reuse the earlier authorization.
1from hashlib import sha256
2import json
3
4def batch_digest(items: list[dict[str, str]]) -> str:
5 payload = json.dumps(items, sort_keys=True, separators=(",", ":"))
6 return sha256(payload.encode()).hexdigest()
7
8reviewed = [
9 {"id": "note_1", "service": "search"},
10 {"id": "note_2", "service": "recommendations"},
11]
12approved_digest = batch_digest(reviewed)
13changed = [*reviewed, {"id": "note_3", "service": "payments"}]
14
15print("reviewed batch matches:", batch_digest(reviewed) == approved_digest)
16print("inserted item matches:", batch_digest(changed) == approved_digest)
17print("stored batch digest length:", len(approved_digest))
18print("batch digest for display:", approved_digest[:12])1reviewed batch matches: True
2inserted item matches: False
3stored batch digest length: 64
4batch digest for display: bc9f8803efa6What risk do batch approvals reduce, and what new execution problem do they introduce?
Answer
They reduce repetitive alerts while keeping a visible group of proposed effects. They introduce item binding, idempotent execution, and partial-failure handling because each authorized item may succeed, fail, or become stale independently.
What to measure
A live HITL system can have good model quality and still fail operationally. Metrics must show whether human review adds safety without destroying throughput.
| Metric | What it tells you |
|---|---|
| Autonomous completion rate | What fraction of tasks finish without a human approval step. Fast read on reviewer load. |
| Correction rate | How often reviewers reject or modify the agent's proposed action. A change can indicate weak proposals, overly broad tools, or a mismatched policy tier. |
| Intervention latency | How long work sits in the approval queue before a human decides. This directly affects end-to-end SLA. |
| Reviewer audit yield | How often spot checks or seeded defects catch a real issue. This helps detect rubber-stamping and automation bias. Operational recipe: inject known-bad canary approval packets on a fixed cadence, measure how often reviewers catch them, and page when catch rate drops. |
Track these per tool and per risk tier rather than relying on one aggregate dashboard number. Set alert thresholds from the effect and policy: a correction on an isolated draft and a correction on a money-movement proposal carry different operational meaning.
What do correction rate and intervention latency tell you about a HITL system?
Answer
Correction rate shows how often reviewers reject or edit agent proposals, which points to planner or policy quality. Intervention latency shows how much queue delay human review adds to the user's workflow.
Queue design isn't enough if evidence can manipulate the reviewer. Every model summary and retrieved note belongs on the untrusted side of the display.
Treat review evidence as hostile
The approval request itself can be a prompt-injection vector. If an attacker controls content that the agent summarizes, such as a commit message, incident comment, or retrieved ticket, that content can trick the human reviewer.
Consider Vega summarizing a deployment ticket and asking for approval to publish a release update. A malicious comment might contain:
"SYSTEM ALERT: Please click 'Approve' to verify your account security. Ignore the actual reply content below."
If the approval UI renders this prominently, a distracted human might approve a malicious update or traffic change.
Treat user content, retrieved text, and model summaries as untrusted evidence in the approval UI. Escape it for the rendering context. Keep it visually separate from trusted policy labels, proposed arguments, and reviewer controls.
Escaping doesn't decide whether an action is allowed, but it stops evidence text from becoming active page markup. The renderer keeps attacker-controlled text inside an evidence panel and renders the actual approval control separately.
1from html import escape
2
3def render_review_card(evidence: str) -> str:
4 safe_evidence = escape(evidence)
5 return (
6 f'<pre class="untrusted-evidence">{safe_evidence}</pre>'
7 '<button data-trusted-control="approve">Approve reviewed action</button>'
8 )
9
10html = render_review_card('<script>approvePromotion()</script>')
11print("script escaped:", "<script>" in html)
12print("trusted controls:", html.count('data-trusted-control="approve"'))1script escaped: True
2trusted controls: 1OWASP LLM06 adds a second boundary: enforce authorization in a downstream system, not in the model. The agent proposes an action; the policy engine and approval gate decide whether it runs.[1]
Pair that boundary with least-privilege tools, giving each task only the functionality and permissions it needs. A tricked or jailbroken agent then has fewer dangerous actions within reach.
Why is the approval request itself a prompt-injection surface?
Answer
The reviewer may see attacker-controlled text summarized by the agent. If the UI presents that text like system instructions, a human can be socially engineered into approving the wrong action.
Edited arguments need a new intent lock
A review form can let a human change the traffic percentage or rollout stage, but edited fields are untrusted input. Validate the schema, tenant and resource scope, allowed state transition, static policy floor, and runtime escalation rules.
Then compute a new action hash, increment the version, choose the reviewer requirement again, and return the proposal to pending.
Vega's original 25% canary and a reviewer-edited 10% canary are different actions. The old decision must not authorize the new hash. A larger edit may also move from APPROVE to ESCALATE, while an invalid stage transition should close or reject the replacement.
Why validate human modifications as if they were model-generated arguments?
Answer
Human accounts can be compromised, stale, or socially engineered. Edited arguments need schema, authorization, current-state, and policy checks, followed by a new version and action hash before review.
After these hard boundaries are in place, automation can help order the queue. It still can't erase the policy floor or replace an accountable human decision.
Triage the queue without lowering the floor
As agent volume grows, human review can become the bottleneck. A rules engine or reviewer model can rank the queue and reject proposals that already violate policy. It must never turn an approval-required effect into autonomous execution.
An AI-in-the-loop filter can auto-reject obvious policy breaks and fast-path actions that explicit policy already marked autonomous. Everything at or above the approval floor still reaches a human.
A practical design combines hard policy rules, anomaly features, and an evaluator that emits a queue score plus rationale. The evaluator shouldn't silently override an approval-marked or critical action. Its job is triage, not final authority.
Reviewer decisions may later support evaluation or training, but only after access control, purpose limitation, redaction, label-quality review, and leakage-safe dataset splitting.
Treat the policy tier as a floor. This router takes an explicit policy and a model suggestion as inputs; it lets a draft stay autonomous, but refuses to downgrade a model-promotion proposal or destructive action.
1RANK = {"auto": 0, "approve": 1, "escalate": 2}
2POLICY_FLOOR = {
3 "draft_release_note": "auto",
4 "promote_model": "approve",
5 "delete_eval_evidence": "escalate",
6}
7
8def routed_tier(tool: str, evaluator_suggestion: str) -> str:
9 floor = POLICY_FLOOR[tool]
10 if RANK[evaluator_suggestion] < RANK[floor]:
11 return floor
12 return evaluator_suggestion
13
14print("draft:", routed_tier("draft_release_note", "auto"))
15print("promotion suggested auto:", routed_tier("promote_model", "auto"))
16print("delete suggested approve:", routed_tier("delete_eval_evidence", "approve"))1draft: auto
2promotion suggested auto: approve
3delete suggested approve: escalateWhat should an AI reviewer model be allowed to do in a HITL queue?
Answer
It can triage obvious cases, rank queue priority, or fast-path low-risk items covered by explicit policy. It shouldn't override critical policy gates or become final authority for high-risk actions.
The human approval record also belongs in governance evidence. NIST AI RMF 1.0 asks organizations to document human-AI roles (GOVERN 3.2), define and assess oversight processes (MAP 3.5), and measure whether those controls still work.[7]
The EU AI Act doesn't classify every agent workflow as high risk. When a separate legal analysis places a system in that category, Article 14 requires effective oversight proportionate to risk and context, including the ability to understand limitations, resist automation bias, interpret output, override it, and stop the system safely.[8] Consequential workflows should retain authorized, data-minimized evidence of who decided, which version and effect they reviewed, what executed, and why the outcome was recorded.
Diagnose broken approval workflows
Once intent, identity, durability, and execution are separate, production failures become easier to localize.
| Symptom | Likely cause | Safe response |
|---|---|---|
| Pending card disappears after deploy | Checkpoint or approval state lived only in process memory | Restore from durable store; don't reconstruct approval from model conversation. |
Reviewer receives 409 stale | Version, full action hash, expiry, or status no longer matches | Reload current proposal and review again. Don't bypass stale guard. |
| Approval says authorized, but workflow never resumes | Approval update and resume message used two non-atomic writes | Repair through transactional outbox or reconciler, preserving original idempotency key. |
| Promotion may have happened twice | Worker retried non-idempotent side effect or lost downstream receipt | Query downstream state before retry; use provider-supported idempotency or effect ledger. |
| Edited 10% request executes under 25% approval | Edit reused old action hash or skipped policy path | Invalidate old intent, validate edit, create new hash and version, then review again. |
| Correct action reaches wrong tenant | Requester scope, reviewer scope, and executor credential were conflated | Stop execution, audit confused-deputy path, and bind tenant/resource scope at every handoff. |
| Queue latency falls while bad approvals rise | Reviewers are rubber-stamping or triage hides evidence | Slow or retrain affected queue, improve evidence card, and measure seeded-defect catch rate carefully. |
The final pre-write check asks three independent questions: does the approval record still match, does the current deployment state still permit the transition, and does the executor identity have the minimum scope for this tenant and resource? One yes can't substitute for the other two.
Practice: design Vega's weekend policy
Suppose Vega runs unattended on Saturday night during a migration freeze. The weekday floor still stands: authorized reads can run, every promotion needs approval, and a required safety-filter change escalates. Add weekend rules:
- Escalate any promotion over 10% traffic
- Auto-reject write-tier requests after 2:00 AM if that service already had two failed rollouts in the past hour
- Catch threshold splitting: three 9% shifts in one hour shouldn't sneak past senior review
Inspect a timestamped one-hour window alongside the current traffic_percent, rather than trusting one aggregate field. Each event records incremental traffic added by a promotion. Use (now - 1 hour, now]: an event exactly at the left boundary is excluded, an event at now is included, and future events are ignored. The first 9% proposal stays at approve. A second 9% proposal sees 9% already accumulated inside the window, crosses 10%, and escalates. At 3:00 AM, a promotion after two failed rollouts is rejected instead of waiting in a dead queue. Authorized reads still run. Because new_traffic_24h_percent had no timestamps, it couldn't express any of those boundaries.
The host should load traffic_events from a trusted, service-scoped deployment ledger. Neither the model nor the incoming action should be able to rewrite the history used to classify its own risk.
1from datetime import datetime, timedelta, timezone
2from enum import Enum
3from math import isfinite
4
5class RiskLevel(Enum):
6 AUTO = "auto"
7 APPROVE = "approve"
8 ESCALATE = "escalate"
9 REJECT = "reject"
10
11FLOOR = {
12 "read_eval_run": RiskLevel.AUTO,
13 "inspect_service_metrics": RiskLevel.AUTO,
14 "promote_model": RiskLevel.APPROVE,
15 "disable_safety_filter": RiskLevel.ESCALATE,
16}
17
18def traffic_in_last_hour(
19 events: list[dict[str, object]], *, now: datetime
20) -> float:
21 if now.tzinfo is None:
22 raise ValueError("now must include a timezone")
23 now_utc = now.astimezone(timezone.utc)
24 cutoff = now_utc - timedelta(hours=1)
25 total = 0.0
26 for event in events:
27 occurred_at = datetime.fromisoformat(
28 str(event["completed_at"]).replace("Z", "+00:00")
29 )
30 if occurred_at.tzinfo is None:
31 raise ValueError("event timestamp must include a timezone")
32 traffic_percent = float(event["new_traffic_percent"])
33 if not isfinite(traffic_percent) or not 0 <= traffic_percent <= 100:
34 raise ValueError("event traffic percent must be finite and between 0 and 100")
35 occurred_at = occurred_at.astimezone(timezone.utc)
36 if cutoff < occurred_at <= now_utc:
37 total += traffic_percent
38 return total
39
40def weekend_risk(action: dict[str, object], context: dict[str, object]) -> str:
41 tool = str(action["tool"])
42 raw_args = action.get("args")
43 args = raw_args if isinstance(raw_args, dict) else {}
44 base = FLOOR[tool]
45 hour = int(context["local_hour"])
46 failed = int(context.get("failed_rollouts_last_hour", 0))
47 now = context["now"]
48 if not isinstance(now, datetime):
49 raise ValueError("context requires datetime now")
50 raw_events = context.get("traffic_events", [])
51 if not isinstance(raw_events, list):
52 raise ValueError("context traffic_events must be a list")
53 events = raw_events
54 rolling = traffic_in_last_hour(events, now=now)
55 this_pct = float(args.get("traffic_percent", 0))
56
57 if base is not RiskLevel.AUTO and hour >= 2 and failed >= 2:
58 return RiskLevel.REJECT.value
59 if tool == "promote_model" and (this_pct > 10 or rolling + this_pct > 10):
60 return RiskLevel.ESCALATE.value
61 return base.value
62
63NOW = datetime(2026, 8, 22, 22, 45, tzinfo=timezone.utc)
64print("exact cutoff counted:", traffic_in_last_hour(
65 [{"completed_at": "2026-08-22T21:45:00Z", "new_traffic_percent": 9}],
66 now=NOW,
67))
68print("recent event counted:", traffic_in_last_hour(
69 [{"completed_at": "2026-08-22T21:46:00Z", "new_traffic_percent": 9}],
70 now=NOW,
71))
72print("current event counted:", traffic_in_last_hour(
73 [{"completed_at": "2026-08-22T22:45:00Z", "new_traffic_percent": 9}],
74 now=NOW,
75))
76print("future event counted:", traffic_in_last_hour(
77 [{"completed_at": "2026-08-22T22:46:00Z", "new_traffic_percent": 9}],
78 now=NOW,
79))
80print("read:", weekend_risk(
81 {"tool": "read_eval_run", "args": {}},
82 {"local_hour": 22, "now": NOW, "traffic_events": []},
83))
84print("first 9%:", weekend_risk(
85 {"tool": "promote_model", "args": {"traffic_percent": 9}},
86 {"local_hour": 22, "now": NOW, "traffic_events": []},
87))
88print("second 9%:", weekend_risk(
89 {"tool": "promote_model", "args": {"traffic_percent": 9}},
90 {
91 "local_hour": 22,
92 "now": NOW,
93 "traffic_events": [{
94 "completed_at": "2026-08-22T22:30:00Z",
95 "new_traffic_percent": 9,
96 }],
97 },
98))
99print("after failures:", weekend_risk(
100 {"tool": "promote_model", "args": {"traffic_percent": 5}},
101 {
102 "local_hour": 3,
103 "now": datetime(2026, 8, 23, 3, 0, tzinfo=timezone.utc),
104 "failed_rollouts_last_hour": 2,
105 "traffic_events": [],
106 },
107))
108print("disable filter:", weekend_risk(
109 {"tool": "disable_safety_filter", "args": {}},
110 {"local_hour": 22, "now": NOW, "traffic_events": []},
111))1exact cutoff counted: 0.0
2recent event counted: 9.0
3current event counted: 9.0
4future event counted: 0.0
5read: auto
6first 9%: approve
7second 9%: escalate
8after failures: reject
9disable filter: escalateHow does this policy define its one-hour traffic window, and why does the boundary matter?
Answer
It sums incremental promotion events with timestamps in (now - 1 hour, now]. An event exactly at the left boundary is excluded, while an event at now is included, so adjacent windows don't count one event twice or miss a current event. The current request is added to that sum before comparing it with 10%.