TramAI - governed AI workflows for Java and Kotlin

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:

StateMeaning
PENDINGCreated, awaiting a human decision
APPROVEDAuthorized by a decider
DENIEDRejected by a decider
TIMED_OUTExpired 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:

  1. Suspend — a tool execution returns RequireApproval. The engine creates an approval with a cryptographically bound ApprovalBinding, persists the suspended invocation and continuation, and throws ApprovalSuspendedException carrying the ApprovalChallenge (approvalId, token, expiresAt) plus toolName, workflowRunId, toolCallId, and the continuation version.
  2. Wait — the approval sits in PENDING until a decider acts or the challenge expires. Nothing blocks a thread.
  3. 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.
  4. Resume — the caller issues a ResumeApprovalCommand (approval ID, expected approval version, expected continuation version, presented token, resumedBy) and calls resumeApprovalTyped<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.
  5. 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 outcome event 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:

OutcomeWorkflow resultNotes
SuspendedSuspendedForApprovalTerminal for this call — no thread is blocked; the approved-value lambda is not invoked
AlreadyApprovedCompleted(approvedValue)The lambda runs exactly once
AlreadyDeniedRejected(reason)Denial is a first-class business outcome, not a system failure
ExpiredExpired(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 Deny or 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 — DefaultApprovalGateCoordinator caps 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.