Gate lifecycle
Settlement and suspension — the mechanical pieces of a gate from open to close.
Gates covers the gate contract and minimum usable shape; Canonical gate applications covers the four use cases gates exist to support; Durability and integration covers observability, the seams gates plug into, and where to go next.
Settlement semantics
Four outcomes exist because "closed" is not enough information. Resolved is a deliberate external decision, and the value must pass the gate schema before it becomes the answer. Rejected is a deliberate veto, so there is no payload to validate; aborted is a turn-level force-close that does not wait for the resolver; timeout means no decision arrived before the SLA fired. You care which one happened because recovery, audit, retries, and operator blame are different for each case.
| Settlement | Triggered by | Promise outcome | Schema check? |
|---|---|---|---|
| Resolved | TurnGate.resolve(value). | Resolves with the (validated) value. | Yes — runs before settlement. Failed validation throws E_INVALID_TURN_GATE_RESOLUTION in the resolver's context; the gate stays open. |
| Rejected | TurnGate.reject(error). | Rejects with the supplied error. | No. |
| Aborted | Turn AbortController fires, or TurnGate.abort is called. | Rejects with E_TURN_GATE_ABORTED. | No. |
| Timed out | The optional timeout (ms) elapsed before any of the above. | Rejects with E_TURN_GATE_TIMEOUT. | No. |
Resolve is a signal, not the work
TurnGate.resolve(value) is the moment the awaiter wakes up — it is not the moment your operator's "Approve" handler runs its business logic. Whatever produced value happened earlier. Whatever acts on value happens in the middleware on the other side of await ctx.waitFor. Putting side effects inside the resolver is a category error.
A schema failure leaves the gate open
This is deliberate. A malformed approval is not an answer. The gate stays open, the operator retries, and bad data does not get fossilized as a settlement.
Surface resolver-side schema errors
Schema validation errors are thrown in the resolver's context, not the awaiter's. If the operator clicks Approve and your UI sends a bad payload, the ADK throws E_INVALID_TURN_GATE_RESOLUTION back at that resolver call and the gate stays open. Catch that error and show it to the operator, or they are clicking into a black hole.
What "suspends" actually means
A gate is not a background promise the turn politely steps around. The middleware pipelines are sequential: each middleware does its work, calls await next() to hand off to the next middleware, and resumes when the downstream pipeline returns. That means awaiting a gate before calling next() suspends the entire pipeline at that point — every middleware later in the same pipeline is waiting for the current one to return.
What that does and does not block:
- Within the same pipeline: every middleware downstream of the awaiter is blocked. If
turnInputPipeline[3]awaits a gate before callingnext(), thenturnInputPipeline[4..n]does not run, dispatch does not start, andturnOutputPipelinedoes not run until the gate settles. That is the normal pipeline behaviour, not a special gate property. If you intend the rest of the pipeline to run while the gate is open, await it afternext()(run-as-a-post-step pattern) or open the gate inside a tool handler instead. - Concurrent gates: if a middleware opens multiple gates via
Promise.all, they run concurrently — only the parent middleware is blocked, and only until all settle. - Tool handlers awaiting a gate: the handler blocks; the dispatch iteration that invoked it blocks; the dispatch loop blocks until the handler returns. The same pipeline rule applies — a gate inside a handler holds the iteration open.
- Event emission: synchronous. Events emitted before the await reach their listeners; listeners run on their own clock. A gate does not pause already-emitted events. The trap is emit-before-gate: observers see that event regardless of the gate outcome, and they may act on it before you even know whether the gated action is approved.
- Other turns on the same runner: unaffected. Each
run()call has its own pipeline. - The abort signal: still live. Aborting the turn rejects every open gate with
E_TURN_GATE_ABORTEDand unblocks the awaiter.
A turn can hold many concurrent open gates (one per pipeline that is currently mid-await). They settle independently. Aborting the turn aborts all of them.
Where you open the gate determines what it blocks
A gate awaited before next() in turnInputPipeline[0] blocks the whole turn from making progress until it settles. A gate awaited inside an turnOutputPipeline after next() blocks only the rest of the post-dispatch pipeline. A gate inside a tool handler blocks the dispatch iteration that called the tool. Choose the location based on what must stop while the gate is open. Get it wrong and you have a pipeline topology bug: work runs too early, waits too long, or observes state from the wrong side of the decision.
If you find yourself wanting to "let the turn keep going while a gate is open"
Use one of these shapes: (a) open the gate in a Promise.all alongside other work inside the same middleware, (b) move the gate to a later pipeline stage so earlier stages can complete first, or (c) open the gate from a tool handler so only that one dispatch iteration is held.
The wrong shape is using Promise.all when you actually need sequential gates, or opening the gate before next() when you meant to open it after.