TramAI

Approval Workflows

Experimental

The suspend/resume lifecycle is implemented and tested, but nested approval (approval-gated tool execution inside an already-suspended workflow) is explicitly blocked in v1. The API surface may change in minor releases.

Approval Workflows provide a human-in-the-loop gate: a tool execution or workflow step can suspend itself, create an approval challenge with cryptographic binding, wait for an external authorizer, and resume when the approval token is presented and validated.

Packages: dev.tramai.core.approval (SPIs and commands), dev.tramai.security.approval (DefaultApprovalGateCoordinator), dev.tramai.engine (SuspendedInvocationStore, resume orchestration)


What

The system implements a suspend/resume lifecycle with cryptographic integrity guarantees:

  1. Suspend — a tool execution triggers an approval gate. The engine calls ApprovalGateCoordinator.createApproval(CreateApprovalCommand) which validates identity fields, generates a secure random 256-bit token, creates an ApprovalRequest with a cryptographically-bound ApprovalBinding, and persists it to the store. The continuation is persisted via ApprovalContinuationStore with sensitive tool arguments wrapped in a SensitiveReplayEnvelope. The suspension produces an ApprovalChallenge containing the approval ID and raw token.
  2. PersistSuspendedInvocationStore stores safe invocation metadata (operation reference, tool reference, replay envelope digest, token budget snapshot) without raw tool arguments or approval tokens.
  3. Resume — an external authorizer presents the raw token. The engine calls ApprovalGateCoordinator.authorizeResume(AuthorizeResumeCommand) which re-validates the full binding (workflowRunId, toolName, argumentsDigest, policyVersion, workflowDigest), performs constant-time token comparison via MessageDigest.isEqual, validates version for optimistic concurrency, and atomically consumes via ApprovalStore.consumeApprovedOrReplay(). The continuation is claimed, the tool executes, and the approval completes.

Key Components

ComponentPackageRole
ApprovalGateCoordinator SPIdev.tramai.core.approvalCreates approvals, authorizes resume, validates tokens, manages lifecycle
DefaultApprovalGateCoordinatordev.tramai.security.approvalBuilt-in implementation with store, digesters, generators, validation
ApprovalStore SPIdev.tramai.core.approvalPersists and transitions approval requests
ApprovalContinuationStore SPIdev.tramai.core.approvalPersists continuation state for resume
SuspendedInvocationStore SPIdev.tramai.enginePersists safe invocation metadata and replay envelopes
Sha256ToolArgumentsDigesterdev.tramai.security.approvalComputes deterministic SHA-256 hash of tool arguments
ApprovalTokendev.tramai.core.approvalSecure random 256-bit token value class
ApprovalTokenGenerator SPIdev.tramai.core.approvalGenerates secure random tokens
ApprovalTokenDigester SPIdev.tramai.core.approvalSHA-256 digests tokens for storage
SecureRandomApprovalTokenGeneratordev.tramai.security.approvalDefault token generator
Sha256ApprovalTokenDigesterdev.tramai.security.approvalDefault token digester

Approval States

The approval lifecycle is governed by ApprovalStatus and transitions:

StateMeaning
PENDINGCreated, awaiting decision
APPROVEDAuthorized by a decider
DENIEDRejected by a decider
TIMED_OUTExpired without decision
COMPLETEDTool executed after approval

After approval, the status transitions through consumeApprovedOrReplay() which atomically marks the request as consumed.


When to Use

Use approval workflows when:

  • A tool execution requires human authorization before proceeding
  • You need a cryptographic binding between the approval token and the exact tool call context (workflow run ID, tool name, arguments digest, policy version, workflow digest)
  • You need audit events for every step of the approval lifecycle (suspended, resumed, completed, uncertain)
  • You need to suspend and resume workflow steps across process boundaries

Do not use approval workflows for:

  • Simple rate limiting or throttling
  • Authorization that does not require suspension (use the policy engine instead)
  • v1: Nested approval (approval-gated tools inside suspended workflows)

Quickstart

Configuration

// 1. Create stores
val approvalStore = InMemoryApprovalStore()

// 2. Create the approval coordinator
val coordinator = DefaultApprovalGateCoordinator(
    store = approvalStore,
    approvalIdGenerator = UuidApprovalIdGenerator,
    approvalTokenGenerator = SecureRandomApprovalTokenGenerator,
    approvalTokenDigester = Sha256ApprovalTokenDigester,
    decisionValidator = AllowAnyApprovalDecisionValidator,
    maxApprovalTtl = Duration.ofMinutes(15),
)

// 3. Wire into the builder
val tramai = Tramai {
    provider(openAiProvider, name = "openai", default = true)
    model("gpt-4o", "openai")
    approvalGateCoordinator(coordinator)
    approvalContinuationStore(continuationStore)
    toolArgumentsDigester(Sha256ToolArgumentsDigester)
}

Sovereign Builder

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

Suspend/Resume Lifecycle

Phase 1: Suspend

When a tool execution triggers an approval gate:

  1. Engine calls ApprovalGateCoordinator.createApproval(CreateApprovalCommand):
    • Validates all ID fields: no blanks, no control characters, max 256 chars, no surrounding whitespace
    • Validates actor ID via SafeActorIdPolicy
    • Validates expiresAt is in the future and within maxApprovalTtl
    • Generates a secure random 256-bit approval token via ApprovalTokenGenerator
    • Digests the token for storage via ApprovalTokenDigester
    • Creates ApprovalRequest with ApprovalBinding containing: workflowRunId, toolName, argumentsDigest, policyVersion, workflowDigest, approvalTokenDigest
    • Persists to ApprovalStore (throws ApprovalCreationException on store conflict)
    • Returns ApprovalChallenge(approvalId, token, expiresAt)
  2. Engine persists continuation via ApprovalContinuationStore.create(continuation, arguments)
  3. Engine persists safe metadata via SuspendedInvocationStore.create(metadata, replayEnvelope):
    • NO raw tool arguments — those stay in ApprovalContinuationStore
    • NO approval tokens
    • Stores: SuspendedInvocationMetadata with operation reference, tool reference, replay envelope digest, token budget snapshot, history size, security context
  4. An ApprovalSuspendedException is thrown with the ApprovalChallenge

Phase 2: Resume

When an external authorizer presents the approval token:

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("abc123"),
)

val result = runtime.resumeApproval(command)

The engine:

  1. Validates via ApprovalGateCoordinator.authorizeResume(AuthorizeResumeCommand):
    • Validates all ID fields and actor ID
    • Fetches existing ApprovalRequest from store
    • Re-validates full binding (workflowRunId, toolName, argumentsDigest, policyVersion, workflowDigest) — throws ApprovalBindingMismatchException on mismatch
    • Computes presented token digest and compares with stored digest using constant-time comparison (MessageDigest.isEqual) — throws ApprovalTokenRejectedException on mismatch
    • Validates status is APPROVED — throws ApprovalAuthorizationException
    • Validates version for optimistic concurrency
    • Validates expiry
    • Calls ApprovalDecisionValidator.validate(request, consumedBy)
    • Calls store.consumeApprovedOrReplay() for atomic transition — returns ApprovalConsumptionReceipt with replayed flag
  2. Claims the continuation from ApprovalContinuationStore.claimForExecution()
  3. Reveals the replay envelope via SuspendedInvocationStore.revealReplayEnvelope()
  4. Executes the tool with the original arguments
  5. Completes the approval — calls ApprovalContinuationStore.complete() and SuspendedInvocationStore.remove()

Token Validation

Token digest comparison uses constant-time comparison via MessageDigest.isEqual:

MessageDigest.isEqual(
    presentedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII),
    storedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII),
)

Token Management

Token Generation

SecureRandomApprovalTokenGenerator (default) generates 256-bit tokens using SecureRandom:

interface ApprovalTokenGenerator {
    fun generate(): ApprovalToken
}

Token Digest

Sha256ApprovalTokenDigester computes a SHA-256 digest of the token for storage:

interface ApprovalTokenDigester {
    fun digest(token: ApprovalToken): Sha256Digest
}

The digest is stored in the approval binding. The raw token is returned only in the ApprovalChallenge at creation time and must be stored externally for presentation at resume time.

Tool Arguments Digester

Sha256ToolArgumentsDigester computes a deterministic SHA-256 hash of tool arguments, bound into the approval challenge to prevent argument tampering.


Audit Events

The approval lifecycle emits events via AuditEngineApprovalLifecycleAuditEmitter (wired automatically in sovereign mode):

EventTrigger
suspendedTool execution was suspended pending approval
resumedApproval token was validated and tool execution resumed
completedTool execution completed successfully
uncertain outcomeApproval authorization succeeded but tool execution failed

In sovereign mode, the approval lifecycle emitter is wired automatically to the audit engine:

// Auto-wired in SovereignTramai.Builder.build():
val approvalLifecycleEmitter = AuditEngineApprovalLifecycleAuditEmitter(auditEngine)

Public Exception Types

All exceptions are in dev.tramai.core.exception:

ExceptionWhen
ApprovalSuspendedExceptionTool execution was suspended; carries ApprovalChallenge
ApprovalNotFoundExceptionApproval ID not found in store
ApprovalTokenRejectedExceptionPresented token does not match stored digest
ApprovalAuthorizationExceptionAuthorization failed (wrong status, version conflict, etc.)
ApprovalBindingMismatchExceptionBindings do not match (workflowRunId, toolName, arguments, etc.)
ApprovalCreationExceptionApproval could not be created (e.g., store conflict)
ConfigurationExceptionNested approval detected (blocked in v1)

Stores

ApprovalStore SPI

interface ApprovalStore {
    suspend fun create(request: ApprovalRequest): ApprovalRequest
    suspend fun get(approvalId: String): ApprovalRequest?
    suspend fun transition(
        approvalId: String,
        expectedVersion: Long,
        transition: ApprovalTransition,
    ): ApprovalRequest
    suspend fun consumeApprovedOrReplay(
        approvalId: String,
        expectedVersion: Long,
        presentedTokenDigest: Sha256Digest,
        consumedBy: String,
    ): ApprovalConsumptionReceipt
}

Built-in implementations:

  • InMemoryApprovalStore — for testing and simple deployments
  • FileApprovalStore — file-backed persistence (in tramai-persistence-file)

ApprovalContinuationStore SPI

interface ApprovalContinuationStore {
    suspend fun create(continuation: ApprovalContinuation, arguments: SensitiveToolArguments): ApprovalContinuation
    suspend fun get(approvalId: String): ApprovalContinuation?
    suspend fun claimForExecution(approvalId: String, expectedVersion: Long, claimedBy: String): ClaimedApprovalContinuation
    suspend fun complete(approvalId: String, expectedVersion: Long, completedBy: String): ApprovalContinuation
    suspend fun expire(approvalId: String, expectedVersion: Long): ApprovalContinuation
    suspend fun cancel(approvalId: String, expectedVersion: Long): ApprovalContinuation
    suspend fun findStaleClaimed(claimedBefore: Instant, limit: Int): List<ApprovalContinuation>
    suspend fun forceCancelClaimed(approvalId: String, expectedVersion: Long, cancelledBy: String, reasonCode: String): ApprovalContinuation
    suspend fun sweepExpired(): Int
}

SuspendedInvocationStore SPI

Persists safe invocation metadata (no raw arguments, no tokens) and sensitive replay envelopes:

interface SuspendedInvocationStore {
    suspend fun create(metadata: SuspendedInvocationMetadata, replayEnvelope: SensitiveReplayEnvelope)
    suspend fun get(approvalId: String): SuspendedInvocationMetadata?
    suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope?
    suspend fun remove(approvalId: String): SuspendedInvocationMetadata?
}

Built-in implementations:

  • InMemorySuspendedInvocationStore — for testing
  • FileSuspendedInvocationStore — file-backed (in tramai-persistence-file)

Token Budget Snapshots

When a tool execution is suspended, the engine captures a TokenBudgetSnapshot containing totalInputTokens, totalOutputTokens, totalInputCost, totalOutputCost, and warnIfExceeded. This snapshot is restored during resume to maintain continuity in token budget tracking.


SovereignTramaiRuntime Resume

For resume support in sovereign mode:

val runtime = tramai.runtime()

// Register services needed for resume (required after runtime restart)
runtime.registerService<InvoiceIntelligenceService>()

// Resume an approval-suspended tool execution
val result = runtime.resumeApprovalTyped<InvoiceAssessment>(command)

runtime.close()

SovereignTramaiRuntime wraps TramaiRuntime and exposes:

  • create(serviceType) — create a service proxy
  • registerService(serviceType) — register without creating a proxy (needed before resume)
  • resumeApproval(command) — resume execution
  • resumeApprovalTyped<R>(command) — typed overload

Limitations (v1)

  • No nested approvalConfigurationException is thrown if a tool execution inside an already-suspended workflow triggers another approval gate
  • No automatic expiry cleanup — expired approvals remain in the store until explicitly cleaned up via ApprovalContinuationStore.sweepExpired()
  • Max 15-minute TTLDefaultApprovalGateCoordinator enforces a maximum approval TTL (configurable via maxApprovalTtl, default 15 minutes)
  • No built-in UI — approval tokens must be presented programmatically; no web dashboard is included
  • Idempotent resume — replay protection is implemented via consumeApprovedOrReplay(), but exact-once semantics depend on the continuation store implementation
  • Process-local storesInMemorySuspendedInvocationStore does not survive JVM restart; FileSuspendedInvocationStore can be used for file-backed persistence

Minimum Configuration

// Minimum viable approval setup with in-memory stores
val coordinator = DefaultApprovalGateCoordinator(
    store = InMemoryApprovalStore(),
    approvalIdGenerator = UuidApprovalIdGenerator,
    approvalTokenGenerator = SecureRandomApprovalTokenGenerator,
    approvalTokenDigester = Sha256ApprovalTokenDigester,
    decisionValidator = AllowAnyApprovalDecisionValidator,
    maxApprovalTtl = Duration.ofMinutes(15),
)

Tramai {
    approvalGateCoordinator(coordinator)
    approvalContinuationStore(InMemoryApprovalContinuationStore())
    toolArgumentsDigester(Sha256ToolArgumentsDigester)
}

Next Steps