← All articles
Giona Granchelli

Human-in-the-Loop Is Not a Button: Designing Durable AI Approval Workflows in Java

Learn why production AI approval requires suspension, durable state, replay-safe continuation, idempotency, and explicit approval outcomes—not just a confirmation dialog.

Architecture diagram showing an AI workflow suspending for human approval and safely resuming later

Giving a human the final say sounds simple.

An AI system proposes an action:

Refund customer €2,500

The application shows:

Approve? [Yes] [No]

Someone clicks Yes. Done.

That model works well in demos.

It becomes much harder when the approval arrives two hours later, the application has restarted, three workers are processing the same queue, the original model request no longer exists in memory, and retrying the wrong piece of code could execute the action twice.

At that point, human-in-the-loop is no longer a UI feature. It is a workflow durability problem.

A production approval system must suspend execution as data, persist enough state to continue safely, and resume exactly the operation that was approved.

For Java and Spring Boot teams building consequential AI workflows, that distinction matters.


The Demo Version of Human Approval

A simple implementation might look like this:

if (decision.requiresApproval()) {
    boolean approved = askHuman();

    if (approved) {
        paymentService.schedulePayment(payment);
    }
}

There is nothing inherently wrong with this code for a synchronous toy example. But it hides several assumptions:

  • The human must respond immediately.
  • The application process must remain alive.
  • The workflow state must remain in memory.
  • Only one execution path must exist.
  • The approval must still refer to the exact action originally proposed.
  • And the application must somehow guarantee that the side effect is not executed twice.

Real systems rarely give us all of those guarantees.


Approval Changes the Workflow Lifecycle

Consider an AI system reviewing an invoice. The model extracts the invoice, classifies the risk, and proposes:

schedulePayment(invoice = "INV-2026-1842", amount = 18400, currency = "EUR")

Runtime policy decides that this operation cannot execute automatically:

Tool request ──► Runtime policy ──► REQUIRE_APPROVAL

At that moment, normal execution must stop.

But stopping is not enough. The system must preserve enough information to answer:

  • What was being executed?
  • What action was proposed?
  • Which workflow requested it?
  • Which policy required approval?
  • What exactly is the human approving?
  • How should execution continue afterwards?

The workflow enters a durable state: RUNNING ──► REQUIRE_APPROVAL ──► SUSPENDED.

The important word is suspended: not sleeping, not polling inside the request, and not holding an application thread open.


Do Not Wait for Humans on Application Threads

A human may approve an operation in ten seconds or tomorrow morning. An application thread should not care.

Blocking an application thread (waitForHumanApproval()) for 4 hours is obviously undesirable.

A durable model is:

AI workflow ──► Approval required ──► Persist suspension ──► Return

                                         Time passes           │

                             Approval arrives ──► Load state ──► Resume workflow

The workflow is waiting conceptually. The application process is not.


What Must Be Persisted?

Saving approved = false is not sufficient. A durable approval workflow needs enough state to establish a secure relationship between the original execution and the future resume operation.

Conceptually, that means preserving:

  1. Approval Request: workflowRunId, subject, required approver role, recommendation, pending decision.
  2. Suspended Invocation: invocation identity, operation reference, replay identity.
  3. Continuation: resume metadata, arguments required to continue.

Approval must be bound to the execution it authorizes.


Approval Is Not Just Yes or No

A useful workflow model needs more than approved = true.

requestApproval()

       ├── SUSPENDED  ──► stop execution

       ├── APPROVED   ──► continue

       ├── DENIED     ──► terminate safely

       └── EXPIRED    ──► terminate safely

Explicit states prevent dangerous code such as if (!denied) { execute(); } where an expired or unresolved approval could accidentally behave like an approval.

Fail-closed state machines are much safer than ambiguous booleans.


Approval Must Describe What the Human Is Approving

If a reviewer sees “Approve payment?” and clicks yes, what exactly was approved? A payment to whom? For how much? Using which account? Was the operation changed after the approval request was created?

A useful approval request should carry a meaningful summary:

Action: Schedule invoice payment
Invoice: INV-2026-1842
Supplier: Example Components BV
Amount: €18,400
Risk: HIGH
Reason for approval: Payment exceeds automatic authority

The approval decision must remain tied to the execution context the reviewer actually evaluated.


The Hard Problem Is Resume

Creating an approval request is easy. Safely continuing afterwards is harder.

Suppose an approval is granted at 16:30. The original server process died at 15:50 and a new deployment is running.

A robust architecture reconstructs the suspended execution from durable state:

Human approves ──► Decision persisted ──► Resume request ──► Load suspended invocation ──► Revalidate binding ──► Continue

The runtime does not depend on the original Java call stack still existing in memory.


Exactly Once Is the Goal, Not the Default

If an approval endpoint gets called twice (double click, retried webhook, message broker redelivery), both paths executing paymentService.schedulePayment(...) could perform the side effect twice.

Approval workflows need idempotency and replay protection:

Approval granted ──┬──► Resume attempt A ──┐
                   │                       ├──► Durable state (1 wins) ──► Action executes ONCE
                   └──► Resume attempt B ──┘

Denial Must Be Terminal

Suppose a reviewer denies Transfer €18,400. A retry must not rediscover the old proposed action and execute it later.

The denial must become an explicit terminal workflow outcome (PENDING ──► DENIED ──► TERMINAL).

Likewise, an expired approval must not quietly become eligible for execution.


Approval Should Survive Restarts

A useful test for any human-approval architecture is simple:

Can I kill the application after suspension, restart it, and still make the correct approval decision and resume safely?

If the answer is no, the workflow is not truly durable.


Concurrency and Race Conditions

Two reviewers might attempt to decide the same approval concurrently, or two background workers might attempt to consume an approved continuation.

Durable state needs clear ownership semantics: exactly one decision transitions state, and exactly one worker continuation proceeds.


Human Approval Is Also an Audit Event

For consequential workflows, audit evidence must reconstruct the full timeline:

Model proposed payment ──► Policy required approval ──► Request created ──► Suspended ──► Reviewer approved ──► Resumed ──► Executed

Logging only the final tool call is not enough to explain how authority was granted.


Policy and Approval Should Remain Separate

Policy decides: Does this operation require approval? The approval system decides: Has the required human decision actually been granted?

Keeping them separate avoids hardcoding human-workflow mechanics into every business rule:

Policy evaluation ──► REQUIRE_APPROVAL ──► Approval workflow ──► APPROVED / DENIED / EXPIRED

The Model Should Not Approve Itself

If an LLM evaluates its own proposed payment and returns { "risk": "LOW", "approved": true }, that is a recommendation, not an authorization decision.

The authority boundary must remain outside the model:

Model ──► Recommendation ──► Runtime Policy ──► Human Authority ──► Side Effect

How TramAI Models Durable Approval

This lifecycle is one of the core problems TramAI is designed around:

Governed workflow ──► ApprovalGateway.requestApproval(...) ──► Persist state & continuation ──► SUSPENDED

Suspension does not mean keeping a thread alive. It is represented through persisted workflow state. When JDBC-backed sovereign stores are used, TramAI’s transactional approval path commits suspension records atomically.

(TramAI remains under active development. Its core sovereign approval golden path has a stronger stability boundary than surrounding control-plane features.)


What to Test

A production approval test suite should verify:

  1. Workflow suspends before side effects occur.
  2. Approval resumes the correct suspended execution.
  3. Denial never executes the protected action.
  4. Expiration remains fail-closed.
  5. Workflow survives process restarts while suspended.
  6. Duplicate approval/resume requests execute at most once.
  7. Concurrent decisions have one durable winner.
  8. Audit evidence reflects request, decision, suspension, resume, and execution.

None of these invariants require a live LLM to verify.


Human-in-the-Loop Should Be Rare and Intentional

The objective is not to put a human in every loop, but to place explicit human authority at boundaries where autonomous execution would exceed acceptable risk.

Low-risk operations remain automatic (ALLOW), forbidden operations are denied (DENY), and human attention is reserved for decisions where judgment is required (REQUIRE_APPROVAL).


Conclusion

Human-in-the-loop is not a button; it is a durable state transition in the execution lifecycle:

Probabilistic Recommendation ──► Deterministic Policy ──► REQUIRE_APPROVAL ──► Persisted Suspension ──► Human Decision ──► Replay-Safe Continuation ──► Side Effect

Once AI systems are allowed to trigger consequential actions, that distinction separates a demo from an enterprise architecture.


Building AI Workflows With Consequential Actions?

Constant Labs helps Java and Kotlin teams design governed AI workflows around real business processes, including tool authorization, durable approval, model routing, sensitive-data boundaries, recovery, and execution evidence.

Giona Granchelli

Giona Granchelli

• Author & Lead Engineer

Founder of Constant Labs & creator of TramAI. Specializes in production software architecture, Spring Boot, and private AI integrations for European businesses.