All concepts
ConceptDeep10 minSince 2.0.0
conceptstasksgovernancearchitectureasyncmcpadr-014

Governed async tasks: relay-with-governance, not an executor

Updated July 23, 2026

Governed async tasks: relay-with-governance, not an executor

A synchronous tool call is easy to reason about: the request goes in, the result comes back, and every layer that governed the call – identity, digest pin, egress policy, audit – got its turn on the way through. The call path is the governance path.

An MCP task breaks that. A tools/call returns almost immediately, but instead of a result the upstream answers it with a promise: a CreateTaskResult carrying a task_id and the assurance that the real work – a long report, a multi-minute crawl, a job that needs a human to say yes halfway through – will finish later. The result arrives on a tasks/get you poll minutes or hours after the call that started it.

That gap is where a gateway has to make a decision. It is, in our view, the most consequential design decision in the whole tasks feature – and the one it is easiest to get wrong by reflex. This page is about why Hangar answers it the way it does.

Two wrong answers, and the one we chose

Put a proxy in front of an upstream that emits tasks and you get three options.

Pass the handle through. Cheapest to build: the task_id the upstream minted travels back to the client untouched, and a later tasks/get is proxied blind. It is also the answer that quietly breaks. The gateway has no record that the task exists, so it cannot authorize the follow-up, cannot re-check the tool contract the async result will be validated against, and cannot tell a client polling a foreign tenant’s task_id from one polling its own. The honest version of pass-through is the one Hangar shipped first: refuse the handle outright with TaskRelayNotSupported rather than hand back a governance-blind promise. A clean refusal beats a dishonest relay.

Become the executor. The maximalist answer: the gateway creates tasks, owns a scheduler, stores results, runs a TTL garbage collector, arbitrates cancellation races, and bridges a worker thread back onto the main event loop. Now the gateway can govern everything – because it is now a job runner, with every job runner’s failure modes, and none of them are governance. This is a change of species. A proxy that runs your jobs is not a proxy; it is a job runner wearing a proxy’s name.

Hangar takes the third answer, and ADR-014 names it in the title: relay-with-governance, not executor. Hangar relays the task the upstream owns, and interposes governance on its lifecycle at the same proxy/store seam that already governs synchronous tools/call. It does not create tasks. It runs no scheduler, holds no results, crosses no worker → main-loop bridge.

The distinction is not a hedge. It is the whole thesis. Governance is worth having precisely because it is thin – it binds at the call path and nowhere else. The moment the gateway grows an execution engine, the governance it offers becomes contingent on that engine being correct, and “our audit trail is trustworthy as long as our job runner has no bugs” is not a claim worth making.

The failure mode that stops being possible

Here is the move that makes relay-with-governance more than a slogan.

The instant Hangar relays an upstream CreateTaskResult, and before the handle reaches the client, it does two things atomically: it writes a GovernedTaskStore entry keyed on the composite (target_server_id, task_id), and it emits a TaskCreated event onto the provenance chain. Registration and the provenance head happen in a single lock-held critical section – if the event publish fails, the whole registration rolls back and zero governed state survives. There is no window in which a governed task exists without a provenance head, and none in which a handle is live but untracked.

The consequence is structural, not defensive. A relayed task_id is always locally known. The dead-handle failure – client holds a task_id, follow-up tasks/get finds nothing, returns a misleading “not found” – cannot recur, because the rejection path that used to guard against it is replaced by a tracked record. We do not test our way to this property. We removed the state in which it could exist.

sequenceDiagram
    participant C as Client
    participant H as Hangar (relay seam)
    participant U as Upstream (task owner)
    C->>H: tools/call (long-running)
    H->>U: relay call
    U-->>H: CreateTaskResult(task_id)
    Note over H: register (target_server_id, task_id)<br/>+ emit TaskCreated -- one critical section
    H-->>C: task_id (now a governed object)
    C->>H: tasks/get(task_id)
    Note over H: authorize owner · sync upstream truth
    H->>U: relay tasks/get
    U-->>H: status
    H-->>C: governed snapshot

What the ledger holds – and what it refuses to hold

GovernedTaskStore is called a store, but the module’s own first sentence is a disclaimer: Hangar does not run tasks and never holds their results. It is a governance ledger, and the line it will not cross is the reason the executor question stays closed. Per relayed task it records exactly three things:

  • the owning identity – tenant plus principal – bound at relay time;
  • the tool digest pinned on the synchronous invoke path that spawned the task;
  • an upstream-truth Task snapshot: status and timestamps copied verbatim from the upstream, plus Hangar’s own local relay provenance.

That is the whole record. No result payload. No execution state. The relayed_at timestamp is Hangar’s own clock and is never surfaced as the upstream created_at – upstream truth is copied, not synthesized. When a client asks for state, the snapshot is refreshed by relaying tasks/get to the owner and copying the answer; an upstream error leaves the local snapshot untouched rather than fabricating a status.

Keep that boundary in view, because it is what every other property on this page is built on. A ledger that holds only who, which contract, and last known status has taken on no execution liability. It can be authoritative about governance precisely because it is authoritative about nothing else.

Authorization is one chokepoint, and it does not leak

Because task_id is unique only per upstream – two upstreams may legitimately mint the same one – every entry is keyed on the composite (target_server_id, task_id). But a client only ever sends a bare task_id. Resolving that to a composite key runs through find_owned_key, and it is fail-closed in a specific, deliberate way: a task_id you do not own is indistinguishable from one that does not exist. Both return None; both surface as the same INVALID_PARAMS “Task not found.” Denial never confirms existence, so the ledger is not a side channel for enumerating other tenants’ tasks.

Every public path – read or mutate – runs authorize first, through a single chokepoint that delegates to the ownership registry. An unattributed caller (no bound identity) collapses to TaskOwner(None, None) and can only ever reach unattributed entries; it can never reach a task owned by a real tenant. This is the same shape the synchronous path uses. The async surface did not invent a new authorization model – it reused the one that was already load-bearing.

The three serving handlers

On 2.0.0, a client follows up on a relayed task through four native tasks/* handlers, each fail-closed and upstream-truthful:

Handler What it does The governance in it
tasks/get Poll status, outcome inlined Authorize owner, relay to upstream, sync from verbatim upstream truth. Re-verify the pinned tool digest fail-closed before handing any payload over.
tasks/update Answer a mid-flight input request The governed consent seam (below): gate before the answer reaches the upstream, consume only on a confirmed relay.
tasks/cancel Best-effort cancel Retire the entry only on a confirmed upstream cancel; otherwise keep it with its true status. Acknowledges empty – cancellation is cooperative, so the ack must not claim an outcome.

tasks/result and tasks/list are not served: SEP-2663 removes both and Hangar answers -32601. The payload arrives inlined on the poll instead, and if your upstream still keeps it behind tasks/result, Hangar fetches it for you.

Two of these repay a closer look, because they are where async governance earns its keep.

Digest re-verification: killing the zombie

Pin a tool’s digest on a synchronous call and the guarantee is clean: the result you get was produced by the exact tool contract you authorized. Now stretch that call across minutes. Between the tools/call that mints the task and the poll that returns its outcome, the upstream tool can be redeployed, its schema can drift, and the async result can come back shaped by a contract the caller never saw.

So tasks/get re-verifies. Before any payload is handed over – inlined by a modern upstream, or fetched from an older one – _verify_pinned_digest recomputes the tool’s current digest and compares it to the one pinned when the task was born. The check guarded tasks/result until SEP-2663 removed that method; it moved with the payload rather than retiring alongside it, because tasks/get is now the only path by which a result reaches a caller. On drift – or when the current schema simply cannot be verified (the safe default is to treat unverifiable as drifted) – the task is failed, a DigestMismatchInTask provenance event is emitted, and an McpError propagates. The result is never handed over.

This closes what the prior ADR called the zombie: a task that completes against a tool contract nobody authorized, then hands back a result that can never be trusted and never quite dies. Digest drift fails the task – it does not merely refuse this one result. A permanently-unavailable-because-untrustworthy result is worse than an honest failure, so we make it an honest failure.

Some tasks pause mid-flight and ask for something – a confirmation, a missing parameter, a yes-or-no before they touch production. In MCP that surfaces as an input_required status. This is where async governance does something the synchronous path structurally cannot, and the distinction is worth stating precisely.

Synchronous L7 requireApproval fails closed. When the enforcement plane decides a synchronous tool call needs approval, there is no guaranteed back-channel at the invocation instant to go find a human – so the verdict is a fail-closed deny, not a hold. It is enforcement, not a queue. The call does not hang waiting for someone to click yes; it stops.

The async consent gate is different, because the task is already paused. A task in input_required is, by definition, suspended and waiting – and the client that is polling it is right there on a live session with a negotiated inputRequests map naming exactly what it needs. The client answers by driving an inbound tasks/update keyed on those names, and that update is the consent. Hangar does not ask; it governs an answer you volunteer.

Until the SEP-2663 realignment this was interactive: tasks/get elicited the downstream client over ctx.session and opened the gate only on an explicit accept. That belonged to the 2025-11-25 wire, which Hangar no longer serves. The governance did not weaken – the prompt is what went.

Consent is obtained before the gate opens – and the gate opens on nothing else. The primitive is a presence gate with a strict rule: it is opened only after a confirmed downstream accept, so there is no pre-decision race in which the answer relays ahead of the human’s yes.

Every other outcome is terminal and fail-closed. Walk the branches:

  • No elicitation capability negotiated. There is no back-channel to ask, so the decision is a fail-closed denial before any prompt is shown.
  • The client declines or cancels. A real no – the task is failed.
  • The elicitation raises for any reason. Any error at all is caught and treated as a denial. Ambiguity resolves closed, never open.
  • The consent entry was evicted (its bounded, TTL’d slot popped under pressure or on expiry). The binding fails closed – an expired or missing consent is never read as an implicit yes.

“Fail closed” here means one concrete thing: the task is moved to failed (a best-effort tasks/cancel is relayed upstream), and the now-failed snapshot is returned to the caller. A paused task is never left dangling in input_required waiting on a maybe. There is exactly one exception, and it fails in the safe direction: a transient upstream refusal while relaying an already-accepted answer discards the gate without consuming the single-use consent, so a retry re-elicits and completes rather than burning the task on a blip.

And every decision – accept or deny – is recorded as a TaskConsentDecided event on the task’s provenance chain, keyed by task_id and attributing the principal_id that was prompted. The consent 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 line in the provenance record. It is the one place in Hangar where governance stops and asks – and even there, it refuses to guess.

The provenance chain closes over async

String these events together and you get the point of the whole exercise. Every lifecycle transition of a relayed task emits an event keyed on task_id, carrying tenant_id and correlation_id:

TaskCreated          # emitted at relay time, before the handle reaches the client
TaskConsentDecided   # accept / decline, with principal attribution
TaskCompleted        # working -> completed, deduped to fire exactly once
TaskFailed           # incl. digest drift, evicted binding, consent denied
TaskCancelled        # only on confirmed upstream cancel
DigestMismatchInTask # the async supply-chain event

Six events, and the code path emits exactly these six. ADR-014’s Decision 3 also names a TaskInputRequired transition in the lifecycle set, but on 2.0.0 that event is defined and metered, not emitted – an input_required status is handled inline by the consent gate above, and the decision it produces is what lands on the chain as TaskConsentDecided. Every emitted transition of a relayed task is on that stream, so its governed lifecycle – who invoked it, under which pinned contract, and how each decision resolved – is reconstructable from the chain. This is the same forensic non-repudiation the synchronous call path already gives you – who invoked what, under which pinned contract, with what verdict – now extended to the one call-shape that used to be a blind spot. Async tasks were the last governed call-shape that was dark. Relay-with-governance is what turns them into a queryable provenance record without turning Hangar into the thing that runs them.

Name the liabilities we didn’t take on

The most useful way to understand a design is to enumerate what it refused, and ask which refusal, if reversed, changes the species. Relay-with-governance deliberately declines all of these:

  • No scheduler. Hangar never decides when work runs.
  • No result store. The payload lives upstream; the ledger holds none of it.
  • No TTL / GC correctness to own. Eviction of a still-live binding fails the entry closed (TaskFailed('evicted')) rather than letting it vanish silently – the safe direction, and the only GC-shaped concern the ledger touches.
  • No cancellation-race ownership. Cancel is best-effort relay; the entry retires only on a confirmed upstream cancel.
  • No worker → main-loop context bridge. Governance binds on the request path, synchronously, at the proxy/store seam. There is no background execution thread, so the “one bug and governance silently fails to bind” failure mode is excluded by construction, not merely tested against.

Reverse any one of them and you have started building an executor. That is the line ADR-014 draws, and the reason it draws it exactly here: a proxy can be trusted to govern because it does so little else. The governance is credible in proportion to how thin it is.

The modern protocol is what ships

The 2026-07-28 generation and the SEP-2663 reshape are served, not pending. Mid-flight input is resolved by an inbound tasks/update, tasks/result and tasks/list are gone, and a flat result carries ttlMs / pollIntervalMs / resultType.

This page used to describe that as forward-compatibility: handlers registered only while the SDK defined their types, so the served surface would track the negotiated protocol on its own. That mechanism does not work. The probes watch mcp_types, which carries the SEP-1686 generation 2026-07-28 removed from the core spec – a deliberately frozen region of a file otherwise under active edit. They were latches that could never trip: tasks/list was always served and tasks/update never was.

So the wire is vendored, tracking SEP-2663 and python-sdk#3005 rather than the SDK’s frozen types. The rule that came out of it generalises past Tasks: a capability probe is a hedge only when the probed module can still change.