All concepts
ConceptCore7 minSince 2.0.0
conceptsconsenttasksasyncgovernance

Mid-flight consent for async tools

Updated July 28, 2026

Mid-flight consent for async tools

Most governance in Hangar is a verdict, not a conversation. Identity is checked, a digest is matched, an egress rule fires – and the call either proceeds or it does not. Nothing waits for a person. That is deliberate: a proxy that pauses to find a human on every hard call is a proxy that stops being fast, and stops being predictable.

There is exactly one place where that changes. An async task can pause mid-flight and ask for something – a confirmation, a missing parameter, a yes-or-no before it touches production – and surface that pause to the client as an input_required status. This is the one spot in the whole stack where governance stops enforcing and starts asking. The mid-flight consent gate is how it asks without ever guessing.

Why this one gate can be interactive

It is worth stating precisely why mid-flight consent is possible here and nowhere else, because the contrast is the whole point.

Synchronous L7 requireApproval fails closed. When the enforcement plane decides a synchronous tools/call needs approval, the call is blocked. There is no guaranteed back-channel at the invocation instant to go find a human, so the verdict is a hard, fail-closed deny – not a hold. It is a gate, not an approval queue: nothing prompts a person and waits for them to click yes. The call stops.

The async gate is different, because the task tells you what it wants. A task in input_required is suspended and waiting – and on the SEP-2663 wire its snapshot carries an inputRequests map naming exactly what is needed. The client answers by driving an inbound tasks/update keyed on those names. That update is the consent. Hangar does not ask; it governs an answer you volunteer.

So neither gate prompts a person. The synchronous one blocks; this one binds governance to a decision the client brought. What makes it a gate rather than a pass-through is the ordering.

What changed on 2026-07-28

Until the SEP-2663 realignment this page described something else, and it is worth being precise about it rather than quietly editing it away.

Hangar used to resolve a pause interactively: when tasks/get observed input_required on a 2025-11-25 session, it elicited the downstream client for consent over ctx.session and opened the gate only on an explicit accept. Every other branch – decline, cancel, no elicitation capability, any elicitation error – was a terminal fail-closed denial. That was, genuinely, the only interactive consent flow in the stack.

It belonged to the 2025-11-25 wire, and Hangar no longer serves tasks/* on that generation: SEP-2663 removed Tasks from the core spec and made them an extension, and the shapes the old flow used live on only in the SDK’s frozen SEP-1686 types. Serving them would be the same defect aimed the other way. So the elicitation path went with the wire it belonged to.

Nothing about the governance weakened. What was lost is the prompt.

The gate opens before the answer, and closes on nothing implicit

The primitive underneath is a presence gate, and the rule is an ordering:

  1. The tenant is authorized first, structurally above the gate. A foreign tenant is refused before anything opens or relays – and refused indistinguishably from a task that does not exist.
  2. The gate opens before your answer reaches the upstream.
  3. The single-use consent is consumed only after a confirmed relay.
  4. TaskConsentDecided records the decision with the principal that made it.

Step 3 is what makes a failure recoverable rather than destructive. A transient upstream refusal discards the gate without burning the consent and does not fail the task, so a retry re-drives the update and completes rather than losing the task to a blip.

An evicted consent slot still fails closed: under TTL pressure the binding is failed (TaskFailed('consent_unavailable')), because an expired or missing consent must never read as an implicit yes.

The acknowledgement to tasks/update is empty{"resultType": "complete"}. It changes nothing observable by itself; you poll tasks/get for the state that resulted. Answers naming an input request that was never issued are ignored rather than rejected, per the SEP.

Concurrency, without a double answer

A task under real load is polled more than once. The gate derives a deterministic input_key from the upstream’s pending input request(s) – stable across concurrent polls – so two clients answering the same pause map to the same gate key and the consent is consumed once. One pause, one recorded decision.

Every decision is a recorded line in the chain

Whichever way it goes, the outcome is written down. Each accept or deny becomes a TaskConsentDecided event on the task’s provenance chain, keyed by task_id, carrying the input_key and attributing the principal_id that was prompted. The gate itself stays deliberately minimal – it decides presence; the ledger owns the event bus and writes the record. Because consent is an additive layer (the relay is correct without it), it could be built and left dormant, then wired in the moment the relay seam went live.

That is the shape of the whole gate: a decision is taken first, the gate opens only on an explicit yes, and every path that isn’t an explicit yes terminates the task in the safe direction and leaves an attributed governance event behind it. It is the one place in Hangar where governance stops and asks – and even there, it refuses to guess.


Grounded in ADR-014 (relay-with-governance) and the mid-flight consent gate. Hangar relays and governs the task an upstream owns – it never runs it. Everything is MIT and self-hosted – no SaaS.