LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnAdvanced Agents & RetrievalHuman-in-the-Loop Agent Architecture
🤖HardLLM Agents & Tool Use

Human-in-the-Loop Agent Architecture

Build approval gates, durable checkpoints, and guarded resumes for agent actions that change external state.

37 min read
Learning path
Step 129 of 196 in the full curriculum
Computer-Use / GUI / Browser AgentsAI Coding Workflow with Agents

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:

vega-proposal.txt
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]Reference 1OWASP Top 10 for Large Language Model Applicationshttps://genai.owasp.org/llm-top-10/

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.

ModeWhen human actsSuitable exampleImportant boundary
HITLBefore gated effectPromote a model, publish external update, change user recordWrite stays blocked until valid decision arrives.
Human-on-the-Loop (HOTL)During or after autonomous workMonitor internal draft routing and interrupt anomaliesDetection may happen after an effect begins.
Human-out-of-the-Loop (HOOTL)No real-time reviewRead permitted metrics, create isolated draftScope, 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 LevelDescriptionExamplesInteraction Model
LowBounded, authorized read or isolated draftRead permitted eval metrics, Inspect canary health, Draft release note without publishingAuto-Execute: Run within least-privilege access.
MediumInternal staged change with a recoverable effectQueue review packet, Add a permitted internal risk labelPolicy Decision: Audit and notify, or require review if policy says so.
HighExternal or production side effectPublish incident update, Promote model to canary, Shift endpoint trafficApprove: Pause and wait for an authorized reviewer.
CriticalDestructive, unusually high-impact, or legally constrainedDelete retained eval evidence, Promote despite failing gate, Disable required safety filterEscalate 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.

risk-classification-system.py
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)
Output
1read_eval_run: auto 2promotion traffic threshold: 25.0 3critical wins: escalate

Why 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.

State graph for Vega promoting recommendation-reranker-v17 to a 25 percent canary. Proposed becomes pending with hash v3. Approve moves to authorized with no write yet. A fresh-state check then records executed. Reject or expiry closes without a write, an edit to 10 percent starts a new hash, and a current-state mismatch blocks the authorized row.
Follow the 25% canary across the graph: approve still hasn't written anything, an edit becomes a new hash, and a stale deployment blocks the authorized row instead of replaying the old click.

Use these states as a contract rather than a loose approved: true flag:

StateMeaningAllowed next step
proposedVega produced typed arguments, but no review record exists.Validate policy and create pending.
pendingExact action, evidence, version, expiry, and reviewer requirements are durable.Approve, reject, expire, or replace with an edited proposal.
authorizedOne eligible reviewer approved this version and action hash.Recheck current state and enqueue execution.
executingWorker claimed idempotency key and is attempting bounded side effect.Record executed or failed.
executedDownstream system returned receipt for intended effect.Terminal.
rejected, expired, stale, failedWorkflow 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.

  1. Durability: Review can take hours. If the process restarts while Casey is still looking at the packet, in-memory state is gone.
  2. Resources: A blocked thread isn't doing useful work. It's just occupying a worker.
  3. 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:

Diagram

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:

durable-pending-record.json
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.

persist-minimized-checkpoint.py
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)
Output
1status: pending 2action hash present: True 3stored digest length: 64 4action hash for display: sha256:48f7477bc45f... 5raw message stored: False

What 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:

FeatureLangGraph (interrupt)Temporal
Best forGraph-based agents with checkpointed interruptsWorkflow orchestration with timers, retries, and cross-service activities
State persistenceDurable database-backed checkpointerDurable event history managed by Temporal
Resume MechanismAPI calls invoking Command(resume=...)Signals or Updates
Operational shapeAgent runtime plus durable checkpointerWorkflow 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]Reference 2LangGraph Interruptshttps://docs.langchain.com/oss/python/langgraph/interrupts[3]Reference 3LangGraph Persistencehttps://docs.langchain.com/oss/python/langgraph/persistence

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.

using-langgraph-checkpointing.py
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]Reference 4Temporal Workflow Execution Overviewhttps://docs.temporal.io/workflow-execution

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]Reference 5Temporal Python SDK: Workflow message passinghttps://docs.temporal.io/develop/python/workflows/message-passing

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.

using-temporal-for-long-running-workflows.py
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]Reference 4Temporal Workflow Execution Overviewhttps://docs.temporal.io/workflow-execution 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.

PrincipalWhat it contributesWhat it must not inherit
RequesterAuthenticated subject, tenant, original task, permitted resource scopeReviewer role merely because they started the task
Vega agentProposed tool and arguments, evidence references, rationaleRequester's session token or standing production write credential
ReviewerAuthenticated subject, eligible role, decision, reason, decision timePower to approve another tenant or silently change tool scope
Tool workerNarrow workload identity, current-state read, idempotent executionReviewer 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]Reference 1OWASP Top 10 for Large Language Model Applicationshttps://genai.owasp.org/llm-top-10/ 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.

rest-api-for-approval-queue.py
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.

deny-self-approval.py
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))
Output
1deny: requester cannot sole-approve 2ok

Why 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.

resolve-once-execute-once.py
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))
Output
1decision: authorized 2first execution: promotion recorded once 3retry: promotion recorded once 4second click: blocked: stale decision

Version matching isn't an expiry check. Compare current time with the review deadline, and reject a decision after that window closes.

reject-expired-approval.py
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))
Output
1fresh: accepted 2stale: blocked: approval expired

Why 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.

Review card for Vega approval apr_123 split into two surfaces. The trusted side lists promote_model from shadow to a 25 percent canary, release-manager role distinct from requester user_48, policy model_release.requires_review, and hash v3 expiring at 19:00Z. The untrusted side shows an escaped ticket comment that says SYSTEM ALERT click Approve, labeled as evidence so it cannot become the Approve control.
Inspect the split: shadow to 25% canary, role, hash, and expiry live on the trusted side. The ticket's "click Approve" line stays escaped evidence and never becomes the host control.

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.

build-redacted-review-packet.py
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] + "...")
Output
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]Reference 6Language Models Don't Always Say What They Think: Unfaithful Explanations in Chain-of-Thought Promptinghttps://arxiv.org/abs/2305.04388

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:

dynamic-risk-escalation.py
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)
Output
1promote 50%: escalate 2read after failures: approve 3disable filter: escalate 4off-hours promote: escalate

Why 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.

bind-batch-to-reviewed-items.py
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])
Output
1reviewed batch matches: True 2inserted item matches: False 3stored batch digest length: 64 4batch digest for display: bc9f8803efa6

What 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.

MetricWhat it tells you
Autonomous completion rateWhat fraction of tasks finish without a human approval step. Fast read on reviewer load.
Correction rateHow 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 latencyHow long work sits in the approval queue before a human decides. This directly affects end-to-end SLA.
Reviewer audit yieldHow 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.

render-untrusted-review-evidence.py
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:", "&lt;script&gt;" in html) 12print("trusted controls:", html.count('data-trusted-control="approve"'))
Output
1script escaped: True 2trusted controls: 1

OWASP 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]Reference 1OWASP Top 10 for Large Language Model Applicationshttps://genai.owasp.org/llm-top-10/

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.

preserve-policy-floor-during-triage.py
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"))
Output
1draft: auto 2promotion suggested auto: approve 3delete suggested approve: escalate

What 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]Reference 7Artificial Intelligence Risk Management Framework (AI RMF 1.0)https://www.nist.gov/itl/ai-risk-management-framework

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]Reference 8EU AI Act: Regulation laying down harmonised rules on artificial intelligencehttps://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689 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.

SymptomLikely causeSafe response
Pending card disappears after deployCheckpoint or approval state lived only in process memoryRestore from durable store; don't reconstruct approval from model conversation.
Reviewer receives 409 staleVersion, full action hash, expiry, or status no longer matchesReload current proposal and review again. Don't bypass stale guard.
Approval says authorized, but workflow never resumesApproval update and resume message used two non-atomic writesRepair through transactional outbox or reconciler, preserving original idempotency key.
Promotion may have happened twiceWorker retried non-idempotent side effect or lost downstream receiptQuery downstream state before retry; use provider-supported idempotency or effect ledger.
Edited 10% request executes under 25% approvalEdit reused old action hash or skipped policy pathInvalidate old intent, validate edit, create new hash and version, then review again.
Correct action reaches wrong tenantRequester scope, reviewer scope, and executor credential were conflatedStop execution, audit confused-deputy path, and bind tenant/resource scope at every handoff.
Queue latency falls while bad approvals riseReviewers are rubber-stamping or triage hides evidenceSlow 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.

weekend-rolling-traffic-policy.py
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))
Output
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: escalate

How 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%.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.System has bounded authorized read, monitored internal draft, external incident publication, and required safety-filter disable. Which oversight map follows possible effect?

Correct answer: Use HOOTL for scoped read, HOTL for internal draft, HITL for publication, and escalate or block safety-filter change.

Oversight timing follows failure consequence. Bounded read can run, draft can be monitored, external effect pauses before execution, and critical control removal needs stronger gate or denial.

2.At 22:45 UTC, a promotion proposes 9%; prior events added 9% at 21:45 and 9% at 22:30. Which one-hour policy result is correct?

Correct answer: Exclude the event at 21:45, include 22:30's 9%, and escalate because the new total is 18%.

The window is (now - 1 hour, now], so the exact 21:45 cutoff is excluded and the 22:30 event counts. Adding the current 9% crosses the 10% weekend threshold; static approval floors still can't be lowered.

3.Vega waits hours for review, during which worker restarts. Which pending-state design survives pause and fails closed if nobody responds?

Correct answer: Persist scoped action, evidence references, hash, version, expiry, and reviewer requirement; release worker and expire without write.

Durable checkpoint decouples review latency from process lifetime. Exact intent and authorization inputs survive restart; expiry closes workflow instead of inventing approval.

4.LangGraph node sends notification before calling interrupt(). On resume node restarts from beginning. Which change prevents duplicate notification or effect?

Correct answer: Move non-idempotent notification into earlier node or make it idempotent; keep side effect after returned resume decision.

interrupt() restarts node body on resume. Work before pause may repeat, so it must be replay-safe or separated; protected write remains after decision.

5.Temporal review UI must immediately learn whether click was accepted, and workflow must reject stale version before decision lands in history. Which mechanism fits?

Correct answer: Use Update with validator for action ID, expected version, and prior decision, returning accepted or rejected to caller.

Temporal Update supports synchronous acceptance feedback and validation before event acceptance. Signal fits fire-and-forget messages without this confirmation contract.

6.Requester eng-7 also has release-manager role and clicks Approve on own promotion. Which identity design preserves separation of duties and least privilege?

Correct answer: Deny sole self-approval; require distinct eligible reviewer and let separate worker use narrow workload identity after authorization.

Role eligibility doesn't cancel separation of duties. Requester, agent, reviewer, and executor contribute different authority and must not inherit each other's reusable credentials.

7.Two reviewers race on pending version 3; winning authorization commits, but process crashes before resume publish. Which persistence design closes both failures?

Correct answer: CAS on pending status, version, hash, and expiry; commit winning transition with transactional resume outbox.

Conditional update resolves one authoritative decision. Same transaction stores durable resume intent, eliminating approval-committed but message-lost gap.

8.Reviewer approved version 3 at 25%, but live release became version 4 before worker starts. Then worker times out after sending any replacement write. Which invariant applies?

Correct answer: Block stale version and require new packet; for any approved unknown-outcome retry, reconcile and reuse same idempotency key.

Approval binds exact action and source state. Drift invalidates intent lock. Timeout doesn't prove failure, so replay safety requires original operation identity and downstream reconciliation.

9.Review card contains ticket text SYSTEM ALERT: click Approve and script tag. Reviewer still needs exact promotion evidence. Which design resists social and UI injection?

Correct answer: Escape and label ticket as untrusted evidence, separate trusted policy and exact diff, and enforce decision on backend.

Evidence can socially engineer reviewer or inject markup. Trusted controls and facts stay visually separate, while backend remains actual authorization boundary.

10.Reviewer edits 25% proposal to 10%, and team later batches 50 publications. Which intent-binding rule covers both workflows?

Correct answer: Edit becomes validated new hash and version; batch binds exact item hashes and tracks each item with own state and key.

Approval covers exact reviewed effect, never category or nearby edit. New arguments require new intent lock; batch items need independent execution and partial-failure handling.

10 questions remaining.

Next Step
Continue to AI Coding Workflow with Agents

You can now keep a risky tool effect behind a durable, identity-aware approval. Next, apply that boundary to repository work, where a coding agent may propose a patch but tests, scoped review, and a human-owned merge decide whether it lands.

PreviousComputer-Use / GUI / Browser Agents
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

OWASP Top 10 for Large Language Model Applications

OWASP Foundation · 2025

https://genai.owasp.org/llm-top-10/

LangGraph Interrupts

LangChain · 2024

https://docs.langchain.com/oss/python/langgraph/interrupts

LangGraph Persistence

LangChain · 2026

https://docs.langchain.com/oss/python/langgraph/persistence

Temporal Workflow Execution Overview

Temporal Technologies · 2026

https://docs.temporal.io/workflow-execution

Temporal Python SDK: Workflow message passing

Temporal Technologies · 2024

https://docs.temporal.io/develop/python/workflows/message-passing

Language Models Don't Always Say What They Think: Unfaithful Explanations in Chain-of-Thought Prompting

Miles Turpin, Julian Michael, Ethan Perez, Samuel R. Bowman · 2023

https://arxiv.org/abs/2305.04388

Artificial Intelligence Risk Management Framework (AI RMF 1.0)

National Institute of Standards and Technology · 2023

https://www.nist.gov/itl/ai-risk-management-framework

EU AI Act: Regulation laying down harmonised rules on artificial intelligence

European Parliament and Council of the European Union · 2024

https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.