3 / 7 Evidence, not intent

interrupt() is a pause, not an approval

“A human was in the loop” is not the same claim as “a named person with authority reviewed this”.

Third in a series on building agents that survive regulatory review. Previously: deleting your approval rule to see whether a test goes red and why a checkpoint is not evidence.

We build systems; your counsel interprets the regulation. The obligations below are ones your compliance team already tracks.

The primitive, and what it’s for

LangGraph’s human-in-the-loop mechanism works like this. A node calls interrupt(). The graph state is checkpointed. Execution stops. Later, you resume with Command(resume=some_value), and the interrupted node returns that value.

It’s clean, it composes, and it solves the problem it was built for: stop and get an answer from outside before continuing. Approve a draft. Choose between two branches. Confirm an ambiguous entity match.

The failure isn’t in the primitive. It’s in what gets claimed on top of it — that because a human provided a value, human oversight happened.

Four questions, and what a resume value knows

Take a concrete case. Your agent proposes to close a customer account. It interrupts. Somebody resumes with True. The account closes.

Now, three months later:

Who approved this? The resume value is True. Whatever identity your web layer had is in your web layer’s logs, in a different system, correlated by a thread ID, with its own retention policy. It is not in the record of the decision.

What were they shown? The graph state at interrupt time contains whatever the graph state contains. The screen the approver actually looked at — the summary, the warnings, the balance, whether the customer’s open complaint was visible — was rendered by your UI. That rendering is not captured anywhere. Two approvers on the same interrupt may have seen different things, if the UI changed between them.

Could they have refused? Command(resume=False) returns false to the node. What the node does with false is application logic. Nothing structural prevents the code from treating a refusal as a retry, or a timeout as a default-yes. Refusal has to stick, and whether it sticks is a property of code somebody wrote in a hurry.

Did they understand it? No mechanism addresses this, and it’s the one that matters most in practice.

Rubber-stamping is the real failure mode

Here’s the uncomfortable finding that anyone operating an approval queue eventually makes.

Oversight degrades. Not through negligence — through volume. A person approving forty items a day develops a rhythm, and the rhythm is approval. The queue becomes friction to clear rather than a decision to make. The controls are all present; they’ve just stopped doing anything.

This is visible in data, and only in data. The tells:

  • Approval rate near 100%. If nothing is ever refused, the gate isn’t filtering. Either the agent never proposes anything questionable, in which case why is there a gate, or nobody is really looking.
  • Time-to-approve collapsing to seconds. Especially where the item takes longer than that to read.
  • Bimodal reading time. A healthy queue has some quick obvious ones and some slow hard ones. A rubber-stamped queue has only quick ones.
  • One approver for everything. Concentration means whatever that person’s rhythm is, that’s your control.

None of those are computable from a resume value, because none of the inputs were recorded. And they’re exactly what a reviewer asks about when they stop asking whether you have oversight and start asking whether it works.

What an approval record contains

Turning the four questions into fields. This is the shape we use; the specifics matter less than that each has a value written at the moment it was true.

Who. An identity, not a session. Resolved at approval time and stored in the decision record, not correlated from another system later. If the only place the name exists is an access log with a 30-day retention, you have 30 days of evidence.

What they were shown. Capture the rendered summary, not the raw state. The state is what the system had; the summary is what the human had, and they are different objects. Store it with the approval. If your UI changes next quarter, the record still shows what this person saw.

What was at stake. The action, the parameters, and the reason the policy required approval. “Approved: yes” is a fact about a click. “Approved: close account 4471, because balance exceeds the auto-close threshold” is a fact about a decision.

Refusal, and that it held. Denial must be a terminal state for that action, not an input to a retry. Test it: deny, then drive the run to completion, then assert the action never executed. Ours is test_a_denied_action_never_happens_afterwards. If you can’t write that test, refusal doesn’t structurally stick.

How long they took. One integer, and it’s the one that makes rubber-stamping measurable. Without it, you cannot answer whether your oversight is functioning, only whether it exists.

What happened on timeout. Every approval queue has items nobody touches. The default is a policy decision and it belongs in the record: expired-denied, escalated, or applied-default. Silent timeouts are where a control quietly inverts.

The sharper design point: approval is data, not a call

There’s a structural choice underneath this, and it’s the one that determines whether any of the above is even possible.

When approval is a blocking call — the process waits for a human — you have a process holding memory for hours or days, and the approval exists as a return value in a stack frame. That frame is not a record. If the process dies, the pending approval dies with it. If it lives, the fact of the approval exists only as long as the frame does, and what you persist afterwards is whatever you remembered to write.

When approval is data — an event appended to a log, which the agent reads on its next step — the run has no process at all while it waits. The pending approval is a row. The granted approval is another row, with the identity, the rendered summary, the elapsed time. The agent resuming is just a step that reads the log and finds an approval where there wasn’t one.

The evidentiary properties follow from that choice, not from discipline. You cannot forget to log the approval, because the log is how the agent learns the approval happened.

It also makes the operational questions tractable. How many approvals are pending right now, across all runs? With blocking calls that’s a question about processes. With events it’s a query.

Two channels, not one

A second point, briefly, because it’s a different failure.

Approval is involuntary: policy decided this action needs a signature. The agent doesn’t get a say.

There’s a second thing people call human-in-the-loop, which is the agent asking for help — it’s stuck, the request is ambiguous, the knowledge base has nothing. That’s voluntary, and it fails in the opposite direction: an agent that is confused is frequently confused in a way it cannot perceive. Asking a stuck model whether it’s stuck is unreliable precisely when it matters.

So the agent’s own escalation needs a partner: detectors watching observable symptoms. Three tool failures in a row. Two empty retrievals. The customer repeating themselves. Those fire on evidence rather than on self-report.

Keep them as separate channels with separate records. “The policy required a signature” and “the agent asked for help” are different events that a supervisor will want to distinguish, and collapsing them into one “human involved” flag loses the distinction permanently.

The exercise

Pull last month’s approvals. Compute three numbers:

  1. Approval rate. Anything above ~95% deserves an explanation. Either your gate is mis-scoped or nobody is reading.
  2. Median time-to-approve. Compare it to how long the item takes to read. If it’s shorter, you have your answer.
  3. Approver concentration. If one person handled most of it, your control has a single point of failure with a human attached.

Then check whether you can compute them. That’s the real test. Most systems discover at this point that time-to-approve was never recorded, and it cannot be backfilled — the same asymmetry as the previous post. A system built to capture a decision can always capture more. A system built to unblock execution cannot be taught to have captured something it didn’t.

Where this fails in our own work

Consistent with the last post, the honest column.

We record elapsed time but do not act on it. The data to detect rubber-stamping is in the log. Nothing alerts on it. Detecting a degrading control and telling somebody are different features, and we’ve built the first.

Authority is an identity string, not a verified role. We record who approved. We do not verify they were entitled to. Binding approvals to a role model owned by the client’s IAM is the right design; today it’s an integration you’d write, not something we provide.

No second signature. For high-value actions, some obligations want two independent approvers. Our policy can require approval; it cannot require two, from different people, neither of whom proposed the action. That’s a real gap and it’s on the list.

Last month’s changelog, for what it’s worth: the approval gate itself got harder to remove by accident, after a test discovered our explicit rule was redundant against a default nobody had documented.

The failure no guard catches

The failure no guard catches — an agent that confidently answers the wrong question, and passes every check you have.

Run these against your own stack.

We build the control layer this series describes for authorised firms operating under MiCA, DORA and the EU AI Act — per-request audit trails, approval records, exit readiness. Fifteen minutes on your architecture, no deck.

Book an architecture review Or read how AI Control works →