Human-in-the-loop approval§
awaitSignaldeadline + escalationchooseeffect
The problem§
An expense report needs a manager's decision. The manager might act in a minute or a week — or never. Holding a worker thread (or any process resource) for that wait is absurd; forgetting the "or never" case is how requests rot in queues forever.
The topology§
Workflow.define("expense-approval")
.step("submit")
.awaitSignal("manager-approval", Duration.ofHours(48),
esc -> esc.step("auto-escalate")) // runs only if the deadline passes
.choose(
Case.when("was-escalated", b -> b.effect("notify-director")),
Case.otherwise("approved-path", b -> b.step("pay-out")))
.build();
The handlers§
@Handlers("expense-approval")
class ExpenseHandlers {
public Expense submit(Expense e) { return e.withState("PENDING_APPROVAL"); }
// deadline branch: nobody acted within 48h
public Expense autoEscalate(Expense e) { return e.escalated(true); }
public boolean wasEscalated(Expense e) { return e.isEscalated(); } // choose guard
public void notifyDirector(Expense e) { mail.director(e); } // effect: no state change
public Expense payOut(Expense e) { return e.withState("PAID"); }
}
Delivering the decision is one client call, from any process that knows the instance id:
client.signal(instanceId, "manager-approval", Map.of("decision", "approved", "by", "sam"));
Why this shape§
- The instance parks; nothing is held. A signal wait is a token at rest in the database — no worker, no thread, no lease. A million parked approvals cost a million rows, not a million threads.
- The deadline is part of the topology. "48 hours, then escalate" is visible in the graph and survives every restart — the timer is server-side and leader-managed, firing exactly once even across failover.
- Exactly one of delivery or escalation happens. The flow rejoins after the wait either way,
so the downstream
choosereads whichever field the branch that ran actually set. No race between the human and the clock. - The signal payload merges into the context — the one place external input enters state — so
pay-outcan readdecisionandbylike any other field.
Variations§
- Chain waits for multi-level approval: manager → finance, each with its own deadline.
- The escalation branch can itself
awaitSignal(director approval with a stricter deadline). - The ops console can deliver signals manually — useful for support interventions — and shows every parked wait on the instance trace.