Human Approval for AI Agents
Human approval — human-in-the-loop (HITL) — is the mechanism that inserts a person into the execution path of an AI system before a sensitive action happens.
The critical distinction:
Human approval is a runtime state transition, not "ask a human in the prompt".
A prompt instruction ("ask the user before paying") is advice the model may or may not follow. A runtime approval gate suspends execution until an authorized person decides, and refuses to proceed otherwise. The system cannot proceed past the gate by improvising.
Why a State Transition, Not a Prompt Instruction
When an approval is implemented as a prompt instruction:
- The model may forget, misinterpret, or decide the user "already approved" based on context.
- There is no proof a human ever saw the request.
- There is no way to bind the approval to the exact call being authorized.
When approval is a runtime gate, the execution is physically suspended at the tool boundary, an approval challenge is created, and only a validated authorization token resumes it. The model has no path around the gate — it is not a participant in the decision.
The Approval Lifecycle
TramAI's approval lifecycle is governed by ApprovalStatus:
| State | Meaning |
|---|---|
PENDING | Created, awaiting a human decision |
APPROVED | Authorized by a decider |
DENIED | Rejected by a decider |
TIMED_OUT | Expired without a decision |
Every state except PENDING is terminal: the store allows no further transition, and a second decision on the same approval is rejected. Whether the tool then executed is tracked separately on the continuation, not on the approval status.
The flow:
- Suspend — a tool execution returns
RequireApproval. The engine creates an approval with a cryptographically boundApprovalBinding, persists the suspended invocation and continuation, and throwsApprovalSuspendedExceptioncarrying theApprovalChallenge(approvalId,token,expiresAt) plustoolName,workflowRunId,toolCallId, and the continuation version. - Wait — the approval sits in
PENDINGuntil a decider acts or the challenge expires. Nothing blocks a thread. - Decide — an external authorizer records the decision against the stored approval (
ApprovalStore.transition). The coordinator re-validates the full binding and compares the presented token digest with constant-time comparison; version conflicts and illegal transitions are rejected rather than applied. - Resume — the caller issues a
ResumeApprovalCommand(approval ID, expected approval version, expected continuation version, presented token,resumedBy) and callsresumeApprovalTyped<R>(command)on the runtime. The continuation is claimed exactly once, the original tool arguments are replayed from the replay envelope, and the tool executes. - Complete — the lifecycle emitter records completion. If authorization succeeded but the tool's outcome is unknown (for example an interruption after the continuation was claimed), the emitter records an
uncertain outcomeevent instead — the runtime never reports success it cannot prove.
Lifecycle audit goes through ApprovalLifecycleAuditEmitter: suspended, resumed, completed, uncertain outcome, suspension cancelled, and stale-claim events. Only safe metadata travels in them — IDs, tool and workflow names, timestamps, and argument digests, never raw arguments or tokens.
Cryptographic Binding
An approval is not a free-floating "yes". The ApprovalBinding ties the approval token to the exact execution context:
- workflow run ID
- tool name
- SHA-256 digest of the tool arguments
- policy version
- workflow digest
- approval token digest (the raw token is never stored)
This means an approval granted for one call cannot be replayed against a different call, different arguments, or a different policy version. The token itself is a 256-bit secure random value, stored only as a digest, and validated with constant-time comparison against timing attacks.
Example: Configuration
val clock = Clock.systemUTC()
val coordinator = DefaultApprovalGateCoordinator(
store = InMemoryApprovalStore(clock = clock),
approvalIdGenerator = UuidApprovalIdGenerator(),
approvalTokenGenerator = SecureRandomApprovalTokenGenerator(),
approvalTokenDigester = Sha256ApprovalTokenDigester(),
decisionValidator = AllowAnyApprovalDecisionValidator,
clock = clock,
maxApprovalTtl = Duration.ofMinutes(15),
)
val tramai = SovereignTramai.builder()
.profile(profile)
.modelRegistry(registry)
.auditStore(auditStore)
.provider(ollamaProvider, name = "ollama", default = true)
.model("llama3.2", "ollama")
.approvalGateCoordinator(coordinator)
.approvalContinuationStore(InMemoryApprovalContinuationStore(clock = clock))
.suspendedInvocationStore(invocationStore)
.toolArgumentsDigester(Sha256ToolArgumentsDigester())
.build()
When a governed tool execution hits the approval gate, the engine throws ApprovalSuspendedException with the ApprovalChallenge. The application surfaces that challenge to the authorizer.
The wiring types live in dev.tramai.security.approval.*; the contracts they implement (ApprovalStore, ApprovalContinuationStore, ApprovalGateCoordinator, ApprovalLifecycleAuditEmitter) live in dev.tramai.core.approval.*. The in-memory stores are for tests and examples. For durable suspension across restarts, use the file or JDBC stores in tramai-persistence-file / tramai-persistence-jdbc, wired by tramai-spring-boot-starter-sovereign-persistence-file or tramai-spring-boot-starter-sovereign-persistence-jdbc. Always pass an explicit Clock so expiry is deterministic and testable.
Example: Resume
// The engine threw ApprovalSuspendedException; `suspension` is the caught exception.
val stored = approvalStore.get(suspension.approvalId)
?: error("Missing approval ${suspension.approvalId}")
val approved = approvalStore.transition(
suspension.approvalId,
stored.version,
ApprovalTransition.Approve(decidedBy = "authorizer-user", comment = "Reviewed"),
)
val command = ResumeApprovalCommand(
approvalId = suspension.approvalId,
approvalExpectedVersion = approved.version,
continuationExpectedVersion = suspension.continuationVersion,
presentedToken = suspension.challenge.token,
resumedBy = "authorizer-user",
)
val result = runtime.resumeApprovalTyped<AssessmentResult>(command)
The two expected versions are optimistic-concurrency guards: a resume issued against a stale approval or a continuation already claimed by another resume is rejected, so a double-submitted resume cannot execute the tool twice.
The Approval Gateway (RC+ Stable)
For workflow code, the low-level store and command wiring above is hidden behind ApprovalGateway:
return approvalGateway.requestApproval(
subject = ApprovalSubject(input.claimId),
recommendation = ApprovalRecommendation(
type = "regulated-claim-triage",
summary = "High-risk claim requires medical review",
payload = mapOf("riskLevel" to "HIGH"),
),
requiredRole = ApproverRole("medical-reviewer"),
workflowRunId = WorkflowRunId(input.workflowRunId),
).toWorkflowResult { "approved-continue" }
requestApproval() persists the approval, suspended invocation, and continuation records, then returns immediately with one of four outcomes from ApprovalRequestResult:
| Outcome | Workflow result | Notes |
|---|---|---|
Suspended | SuspendedForApproval | Terminal for this call — no thread is blocked; the approved-value lambda is not invoked |
AlreadyApproved | Completed(approvedValue) | The lambda runs exactly once |
AlreadyDenied | Rejected(reason) | Denial is a first-class business outcome, not a system failure |
Expired | Expired(reason) | No decision arrived inside the window; the caller decides whether to start a new request |
The gateway contract, ApprovalRequestResult, and the toWorkflowResult mapper are RC+ Stable in 0.6.0. DefaultApprovalGateway and the REST/resume control planes are Preview — see approval workflows for the wiring and API stability for the tiers.
When to Use Human Approval
Use approval gates when:
- A tool execution can cause irreversible or high-impact side effects (payments, deletions, external sends).
- Policy requires demonstrable human oversight — the organization must prove a person authorized the action.
- You need audit events for every step of the approval lifecycle.
- You need to suspend and resume across process boundaries (durable continuation).
Do not use approval gates for:
- Rate limiting or throttling — use the policy engine's
Denyor application-level limits. - Authorization that does not require suspension — a plain policy decision is enough.
- Every single tool call — approvals add latency; gate the risky minority.
Limitations (0.6.0)
- No nested approval — a sibling tool call that needs approval while a workflow is already suspended cannot open a second gate. The runtime fails the execution deterministically (
NestedApprovalNotSupportedException) instead of stacking suspensions. - No built-in reviewer UI — approval challenges are presented programmatically. A Preview reviewer UI ships in the sovereign ops surfaces but is disabled by default, as is the Preview REST control plane (
tramai.sovereign.ops.rest-control-plane-enabled=true). A full approval inbox is future direction. - Expiry cleanup — expired approvals remain in the store until swept; expiry does not delete records.
- 15-minute default maximum TTL —
DefaultApprovalGateCoordinatorcaps approval TTL (maxApprovalTtl, default 15 minutes). The in-memory store applies the same cap to creation. - Not a correctness guarantee — approval records prove that a decision was requested, made by whom, and what the outcome was. They do not prove the decision was right, compliant, or well-informed.
Related Documentation
- Approval Workflows — full implementation reference
- Tool governance — REQUIRE_APPROVAL at the tool boundary
- Runtime governance — where approval sits in the enforcement chain
- AI agent governance — the category pillar
- EU AI Act technical controls — human oversight and evidence
- Sovereign Mode — composed runtime with approval wiring
- TramAI on GitHub
