Why waitForEvent is not human-in-the-loop
Every workflow engine can pause. Pausing is the easy half. The hard half is everything between the pause and the resume — and that is the half you end up building yourself.
Every durable workflow engine — Inngest, Temporal, Trigger.dev, Hatchet — can suspend a run until an external event arrives. So a reasonable engineer looks at a human-approval requirement, finds step.waitForEvent() in the docs, and concludes the problem is solved in an afternoon.
The pause is real. It is also about five percent of the work. Everything that makes an approval trustworthy lives in the gap between the pause and the resume, and none of it is in the engine.
What does waitForEvent actually give you?#
A correlation ID and a timeout. Your workflow stops, and it will start again when something posts a matching event, or when the clock runs out.
That is genuinely hard infrastructure and the engines do it well. But notice what it does not say anything about:
- Who is allowed to send that event.
- What the person saw before they sent it.
- Whether one signature was enough.
- What happens when nobody sends it at all.
- Whether any of it can be reconstructed a year later, under audit.
The engine's contract ends at "a matching event resumed the run." Everything above is now yours.
The four things you end up building#
An interface that shows the decision#
The moment an agent is the thing proposing the action, a yes/no button is malpractice. The reviewer needs the model's actual output, the tool call it wants to make, and the records that change if it runs. Concretely: the issueRefund({ amount: 12400 }) call, the balance before and after, and the model's stated reasoning — on one screen, at decision time.
None of that is in the event payload. You will build the queue, the detail view, the rendering of arbitrary context, and the mobile layout, because approvals happen on phones.
Authorization on the resume path#
waitForEvent resumes on any matching event. Which means your approval endpoint is a public "make this workflow continue" button unless you put real authorization in front of it, and bind the approver's identity to the specific decision rather than to the event name.
Email action-links make this sharper. A signed link that approves a $12,400 payout with one click is a bearer token for money. It needs to be single-use, scoped to one decision, expiring, and unforgeable — and you have to log which one was redeemed.
Quorum#
"Two people from Finance must both approve" cannot be expressed as one event. You need to track partial state — who has signed off so far — reject duplicate votes from the same person, handle one approve and one reject, and only then emit the resume.
That state lives somewhere. The engine will not hold it for you, because from its perspective there is exactly one event and it has not arrived yet.
Escalation#
This is the one that bites in production, because it is invisible until it happens: a reviewer is on vacation, and a claim sits in a queue for nine days.
A timeout is not an escalation. A timeout fails the workflow. An escalation reroutes — after four hours with no quorum, widen to the whole Finance role; after twelve, page the on-call manager; after twenty-four, auto-reject and notify the customer. Each of those is a policy decision with a different blast radius, and every one of them is a new timer, a new notification path, and a new audit entry.
Note that this also means "the timeout" has two entirely different meanings that teams routinely conflate. There is auto-escalate in 3h 40m — nobody has acted, so widen the approver pool — and there is expires in 24h — nobody ever acted, so take the default action. Collapsing these into one number is how you end up either paging a VP over a $40 refund or silently approving a $40,000 one.
So when is waitForEvent the right answer?#
When the human is a teammate, the stakes are low, and the audit trail is a Slack thread. Internal tools, staging gates, "ping me before you deploy." Reach for the engine primitive, wire it to a Slack button, move on. Adding a system here is overhead with no return.
It stops being the right answer at the point where the decision is a decision of record — where someone will later ask who approved this, what they saw when they approved it, and what would have happened if they had not. That question has a compliance answer, not an engineering one, and a correlation ID does not supply it.
The actual dividing line#
waitForEvent treats the human as a signal source. Something out there will eventually emit an event; the workflow does not care what, or who, or why.
Human-in-the-loop treats the human as a decision-maker. The system owes them context before the decision, owes them authority scoped to their role, and owes the organization a record afterward.
Those are different problems. One is solved by durable execution. The other is solved by policy, presentation, and provenance — three things that have nothing to do with how your workflow engine persists state, which is exactly why your workflow engine does not ship them.
Ratifia is the second layer. It runs on top of the engine you already have, it never executes your code, and it turns the pause you already know how to write into a decision you can defend.