Skip to content
4 min read · 864 words

Gate durability and integration

A gate does not survive a process restart. This page shows what you must persist if your approval flow needs to survive the boring, inevitable reality of redeploys, what the observability bus emits, the seams gates plug into elsewhere in the loop, and the next places to read.

Gates covers the gate contract; Gate lifecycle covers settlement and suspension.

Durability is not the gate's problem

The TurnGate is an in-memory promise. It does not survive process restarts. The gate is gone after a redeploy. The operator clicks Approve, your resolver finds nothing live, and the turn that was waiting is already dead. Without your own durable mapping, that approval goes into the void — TurnGate.resolve called on a dead reference.

The fix is not in the ADK. The fix is your persistence layer:

  1. Subscribe to the turnGateOpen observability event and persist { gateId, turnId, reason, payload, schema, createdAt } (see TurnGate.id, TurnGate.payload, TurnGate.reason) from there. The ctx.waitFor(rawGate) call site cannot persist before returning — the call already returns the pending promise; the gate is open by the time control comes back to the caller. The observer is the only seam that runs synchronously at gate construction with the gate instance in hand, before any external resolver could race the write.
  2. When the process restarts, the in-flight turn is gone too — you do not "resume" a turn. The recovery flow is to start a new turn that knows about the pending external request, opens a fresh gate, and routes the original gateId to the new gate via your store.
  3. The operator-side UI / queue / webhook receiver always settles gates by looking up gateId in your store, finding the current live gate (if any), and calling resolve / reject on it. If no live gate exists for that gateId, the resolution must raise an error — a silent no-op discards an external resolution irrevocably, and you will never learn the gate was missed.

This is the line the ADK draws

Durability is application architecture. The ADK owns the in-memory promise; you own the rest. We do not ship a "durable gate" battery because durability semantics differ across every real deployment — your queue, your DB, your operator-presence model, your retry policy. Putting a battery here silently corrupts your approval flow: it assumes the wrong queue (approvals queue up in a FIFO a human never polls), the wrong retry policy (it retries on the wrong interval or stops retrying entirely on transient DB contention), and the wrong transaction model (the same approval fires twice because the battery's commit protocol doesn't match your store's isolation). A battery that guesses wrong about any of these settles gates on the wrong answer, lets operators click Approve and see nothing happen, or double-fires the same side effect — all without a single error.

Observability

turnGateOpen fires synchronously at construction time. The payload is the TurnGate instance itself — your observer sees TurnGate.id, TurnGate.turnId, TurnGate.reason, TurnGate.payload, TurnGate.createdAt, and TurnGate.isSettled (initially false).

turnGateClosed fires on settlement. The payload is a TurnGateClosedEvent: TurnGateClosedEvent.gateId, TurnGateClosedEvent.turnId, TurnGateClosedEvent.result ('resolved' | 'rejected' | 'aborted' | 'timeout'), TurnGateClosedEvent.settledAt.

Track latency by joining the two events on TurnGateClosedEvent.gateId. Track timeout rate by counting result === 'timeout'. Track operator response time by recording when your UI rendered the request and when turnGateClosed fired. Everything you need to operate gates lives on the observability bus — neither end is on the functional bus, because settling a gate is a decision made outside the agent's reasoning, not by it.

What plugs in around a gate

A gate is a primitive. The pieces that turn it into a feature are seams elsewhere in the loop:

  • Inside a Tool.handler (see Tool) — the dominant site. Gating happens in the handler so the side effect cannot run before the gate resolves. This is where RBAC, per-call approval, and external-system handoffs live.
  • In turnOutputPipeline — when the thing being gated is the model's own output rather than a tool call.
  • In dispatchOutputPipeline — when the decision needs to feed back into the next dispatch iteration.
  • Persistence behind the gate — your storage layer (Bring your own storage) tracks the gateId → live gate mapping so external resolvers can find it.
  • Observabilityobserve('turnGateOpen', …) and observe('turnGateClosed', …) on the observability bus.

The ADK gives you the primitive and the lifecycle. The composition is yours — deliberately. Human-in-the-loop is too tightly coupled to your operator workflow, your durability story, and your authorization model for a battery to ship sensible defaults. The seam is the contract; the feature is your code.

Where to go next

  • Tools — where most gates open. The handler is the contract surface that side effects pass through.
  • Turn Runner — TurnContext — where ctx.waitFor lives in the context surface.
  • Pipelines — which pipeline opens which kind of gate when the gate target is not a tool.
  • Events — full payload shape for turnGateOpen / turnGateClosed.
  • FailureE_INVALID_TURN_GATE_RESOLUTION, E_TURN_GATE_ABORTED, E_TURN_GATE_TIMEOUT, E_INVALID_INITIAL_TURN_GATE_VALUE.