Approval Workflows
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:
- 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 anApprovalRequestwith a cryptographically-boundApprovalBinding, and persists it to the store. The continuation is persisted viaApprovalContinuationStorewith sensitive tool arguments wrapped in aSensitiveReplayEnvelope. The suspension produces anApprovalChallengecontaining the approval ID and raw token. - Persist —
SuspendedInvocationStorestores safe invocation metadata (operation reference, tool reference, replay envelope digest, token budget snapshot) without raw tool arguments or approval tokens. - 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 viaMessageDigest.isEqual, validates version for optimistic concurrency, and atomically consumes viaApprovalStore.consumeApprovedOrReplay(). The continuation is claimed, the tool executes, and the approval completes.
Key Components
| Component | Package | Role |
|---|---|---|
ApprovalGateCoordinator SPI | dev.tramai.core.approval | Creates approvals, authorizes resume, validates tokens, manages lifecycle |
DefaultApprovalGateCoordinator | dev.tramai.security.approval | Built-in implementation with store, digesters, generators, validation |
ApprovalStore SPI | dev.tramai.core.approval | Persists and transitions approval requests |
ApprovalContinuationStore SPI | dev.tramai.core.approval | Persists continuation state for resume |
SuspendedInvocationStore SPI | dev.tramai.engine | Persists safe invocation metadata and replay envelopes |
Sha256ToolArgumentsDigester | dev.tramai.security.approval | Computes deterministic SHA-256 hash of tool arguments |
ApprovalToken | dev.tramai.core.approval | Secure random 256-bit token value class |
ApprovalTokenGenerator SPI | dev.tramai.core.approval | Generates secure random tokens |
ApprovalTokenDigester SPI | dev.tramai.core.approval | SHA-256 digests tokens for storage |
SecureRandomApprovalTokenGenerator | dev.tramai.security.approval | Default token generator |
Sha256ApprovalTokenDigester | dev.tramai.security.approval | Default token digester |
Approval States
The approval lifecycle is governed by ApprovalStatus and transitions:
| State | Meaning |
|---|---|
PENDING | Created, awaiting decision |
APPROVED | Authorized by a decider |
DENIED | Rejected by a decider |
TIMED_OUT | Expired without decision |
COMPLETED | Tool 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:
- 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
expiresAtis in the future and withinmaxApprovalTtl - Generates a secure random 256-bit approval token via
ApprovalTokenGenerator - Digests the token for storage via
ApprovalTokenDigester - Creates
ApprovalRequestwithApprovalBindingcontaining:workflowRunId,toolName,argumentsDigest,policyVersion,workflowDigest,approvalTokenDigest - Persists to
ApprovalStore(throwsApprovalCreationExceptionon store conflict) - Returns
ApprovalChallenge(approvalId, token, expiresAt)
- Engine persists continuation via
ApprovalContinuationStore.create(continuation, arguments) - Engine persists safe metadata via
SuspendedInvocationStore.create(metadata, replayEnvelope):- NO raw tool arguments — those stay in
ApprovalContinuationStore - NO approval tokens
- Stores:
SuspendedInvocationMetadatawith operation reference, tool reference, replay envelope digest, token budget snapshot, history size, security context
- NO raw tool arguments — those stay in
- An
ApprovalSuspendedExceptionis thrown with theApprovalChallenge
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:
- Validates via
ApprovalGateCoordinator.authorizeResume(AuthorizeResumeCommand):- Validates all ID fields and actor ID
- Fetches existing
ApprovalRequestfrom store - Re-validates full binding (
workflowRunId,toolName,argumentsDigest,policyVersion,workflowDigest) — throwsApprovalBindingMismatchExceptionon mismatch - Computes presented token digest and compares with stored digest using constant-time comparison (
MessageDigest.isEqual) — throwsApprovalTokenRejectedExceptionon mismatch - Validates status is
APPROVED— throwsApprovalAuthorizationException - Validates version for optimistic concurrency
- Validates expiry
- Calls
ApprovalDecisionValidator.validate(request, consumedBy) - Calls
store.consumeApprovedOrReplay()for atomic transition — returnsApprovalConsumptionReceiptwithreplayedflag
- Claims the continuation from
ApprovalContinuationStore.claimForExecution() - Reveals the replay envelope via
SuspendedInvocationStore.revealReplayEnvelope() - Executes the tool with the original arguments
- Completes the approval — calls
ApprovalContinuationStore.complete()andSuspendedInvocationStore.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):
| Event | Trigger |
|---|---|
suspended | Tool execution was suspended pending approval |
resumed | Approval token was validated and tool execution resumed |
completed | Tool execution completed successfully |
uncertain outcome | Approval 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:
| Exception | When |
|---|---|
ApprovalSuspendedException | Tool execution was suspended; carries ApprovalChallenge |
ApprovalNotFoundException | Approval ID not found in store |
ApprovalTokenRejectedException | Presented token does not match stored digest |
ApprovalAuthorizationException | Authorization failed (wrong status, version conflict, etc.) |
ApprovalBindingMismatchException | Bindings do not match (workflowRunId, toolName, arguments, etc.) |
ApprovalCreationException | Approval could not be created (e.g., store conflict) |
ConfigurationException | Nested 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 deploymentsFileApprovalStore— file-backed persistence (intramai-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 testingFileSuspendedInvocationStore— file-backed (intramai-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 proxyregisterService(serviceType)— register without creating a proxy (needed before resume)resumeApproval(command)— resume executionresumeApprovalTyped<R>(command)— typed overload
Limitations (v1)
- No nested approval —
ConfigurationExceptionis 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 TTL —
DefaultApprovalGateCoordinatorenforces a maximum approval TTL (configurable viamaxApprovalTtl, 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 stores —
InMemorySuspendedInvocationStoredoes not survive JVM restart;FileSuspendedInvocationStorecan 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
- DLP — add DLP to your approval-gated tools
- Artifact Verification — verify model integrity
- Evidence Packs — generate deployment evidence
