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
COMPLETEDThe tool executed after approval

The flow:

  1. Suspend — a tool execution triggers RequireApproval. The engine creates an approval with a cryptographically bound ApprovalBinding, persists the continuation, and throws ApprovalSuspendedException carrying the challenge.
  2. Wait — the approval sits in PENDING until a human acts or it expires.
  3. Decide — an external authorizer presents the approval token. The engine re-validates the full binding, compares the presented token digest using constant-time comparison, and atomically transitions the approval to APPROVED or DENIED.
  4. Resume — on APPROVED, the continuation is claimed, the original tool arguments are replayed from the sensitive replay envelope, and the tool executes.
  5. Complete — the execution finishes and the approval moves to COMPLETED.

Every transition emits audit events: suspended, resumed, completed, and uncertain outcome when authorization succeeded but tool execution failed.

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 coordinator = DefaultApprovalGateCoordinator(
    store = InMemoryApprovalStore(),
    approvalIdGenerator = UuidApprovalIdGenerator,
    approvalTokenGenerator = SecureRandomApprovalTokenGenerator,
    approvalTokenDigester = Sha256ApprovalTokenDigester,
    decisionValidator = AllowAnyApprovalDecisionValidator,
    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(continuationStore)
    .suspendedInvocationStore(invocationStore)
    .toolArgumentsDigester(Sha256ToolArgumentsDigester)
    .build()

When a governed tool execution hits the approval gate, the engine throws ApprovalSuspendedException with the ApprovalChallenge (approval ID + raw token + expiry). The application surfaces that challenge to the authorizer.

Example: Resume

val command = AuthorizeResumeCommand(
    approvalId = challenge.approvalId,
    expectedVersion = 0L,
    presentedToken = challenge.token,
    consumedBy = "authorizer-user",
    workflowRunId = "run-123",
    toolName = "myTool",
    argumentsDigest = Sha256ToolArgumentsDigester.digest(args),
    policyVersion = "1.0",
    workflowDigest = Sha256Digest.of("sha256:abc..."),
)

val result = runtime.resumeApprovalTyped<AssessmentResult>(command)

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 (v1)

  • No nested approval — a tool execution inside an already-suspended workflow cannot trigger another approval gate (blocked explicitly in v1).
  • No built-in UI — approval tokens are presented programmatically. A preview reviewer UI exists in the sovereign ops surfaces but is disabled by default; a full approval inbox is roadmap direction.
  • Expiry cleanup — expired approvals remain in the store until swept.
  • 15-minute maximum TTLDefaultApprovalGateCoordinator caps approval TTL (configurable via maxApprovalTtl).