Approval Gate¶
An ApprovalGate is a human-in-the-loop checkpoint evaluated before a node runs. It is the third piece of the governed execution model, alongside ToolPolicy and StatePolicy.
When to use it¶
- Sensitive actions: pause before
payment.transfer,refund.process,user.delete. - Threshold-based pauses: ask a human when an amount exceeds a limit.
- Pre-flight review: pause the first time the graph hits a node so an operator can sanity-check the conversation so far.
The gate reuses the existing InterruptRequest + checkpoint mechanism: no new persistence layer, no new resume protocol.
The SPI¶
@FunctionalInterface
public interface ApprovalGate {
Decision check(String nodeName, AgentContext context);
// factories: requireFor(...), when(...), NONE
// composition: gate.and(other)
}
A gate that returns REQUIRE_APPROVAL causes the graph to:
- Build an
ApprovalRequest(nodeName, reason)payload. - Emit an
InterruptRequestwith reasonapproval.required:<node>. - Save a checkpoint via the configured
CheckpointStore. - Return an interrupted
AgentResultto the caller.
Pause / approve / resume¶
InMemoryCheckpointStore store = new InMemoryCheckpointStore();
AgentGraph graph = AgentGraph.builder()
.addNode("payment.transfer", transferAgent)
.approvalGate(ApprovalGate.requireFor("payment.transfer"))
.checkpointStore(store)
.build();
// First call — pauses on the guarded node
AgentResult paused = graph.invoke(ctx, "run-42");
if (paused.isInterrupted()) {
ApprovalRequest req = (ApprovalRequest) paused.interrupt().payload();
notifySlack("Approve transfer? node=" + req.nodeName() + " reason=" + req.reason());
}
// ... later, when the human clicks Approve ...
AgentResult resumed = graph.resumeWithApproval("run-42", "payment.transfer");
resumeWithApproval(runId, approvedNode, ...messages):
- Loads the checkpoint.
- Adds
approvedNodeto the internalApprovalGate.APPROVED_KEYset on the context. - Re-runs from where the gate paused. The default factories see the marker and let the node run.
Custom rule¶
ApprovalGate gate = ApprovalGate.when(
(node, ctx) -> {
if (!"payment.transfer".equals(node)) return false;
Double amount = ctx.get(AMOUNT);
return amount != null && amount > 1000.0;
},
"amount above 1000 requires approval");
Composition¶
ApprovalGate payments = ApprovalGate.requireFor("payment.transfer", "refund.process");
ApprovalGate deletes = ApprovalGate.when(
(node, ctx) -> node.startsWith("user.delete"), "user-deletion requires approval");
ApprovalGate combined = payments.and(deletes);
.and(other) triggers approval if either side requires it; the first reason wins.
Custom gates and the approval marker¶
The built-in factories (requireFor, when) check ApprovalGate.APPROVED_KEY to bypass an already-approved node on resume. A fully custom gate is free to ignore the marker, but then you must arrange a different bypass signal — typically a state key the operator sets via resumeWithApproval(..., messages) and which the gate inspects.
Default behaviour¶
AgentGraph.Builder.approvalGate(...) defaults to ApprovalGate.NONE. No interception happens — zero overhead when no gate is configured.
Relationship to other policies¶
| Concern | Policy / gate |
|---|---|
| What tools may run | ToolPolicy |
| What state may change | StatePolicy |
| What nodes need a human | ApprovalGate |
| What a run may spend | BudgetPolicy |
All four compose on the same graph. The ApprovalGate runs first (cheapest, no LLM call), then BudgetPolicy, then the node, then StatePolicy on the result.