ADR-285: A2A push has an inbox and a bounded outbox
- Status
-
Accepted
- Date
-
2026-11-10
- Authors
-
Netresearch DTT GmbH
Context
A2A push notifications are HTTP POSTs of a Stream to a webhook the
client registered, by Create or embedded in
Send (sections 3.1.7 and 4.3.3 of the specification). The sender must
try at least once, may retry with backoff, should time out within 10 to 30
seconds; the receiver must answer 2xx and should tolerate duplicates.
nr-llm is a sender in the server role and a receiver in the client role. As a receiver it needs a public, authenticated endpoint it does not have (ADR-280 covers the server endpoint only). As a sender it must not call a webhook from a request (ADR-283), and every target is an outside URL (ADR-279).
Task has an optional token and an
authentication. An example in the specification used to send the token as
X-; a2aproject/A2A#1594 asked about that header, a
maintainer answered that credentials belong in Authorization, and
a2aproject/A2A PR #1793, merged on 2026-05-23, aligned the examples. The
v1.0.1 prose read for this decision contains no such header.
Decision
Sender: an outbox written with the event. A task event (ADR-282) and one delivery job per active config are written in the same transaction. A worker delivers. Before each attempt it reads the config again, checks the target through the address guard, loads the credential and checks the delivery deadline; a deleted config starts no attempt.
Sender: bounded and classified attempts. POST of a bare Stream
with Content-, HTTPS only, 15 seconds timeout. A
2xx acknowledges. A timeout, 429 or 502/503/504 retries with exponential
backoff and jitter, at most eight attempts. A 401 or 403 marks the config
"authentication failed", visible in the backend, and pauses it. A target the
guard refuses, or 404/410, stops the config. A redirect is a failed attempt, not
a new target. A request already sent when the config is deleted may still
arrive; that is documented and tested, not prevented.
Sender: Bearer in Authorization only. The first supported scheme is
a Bearer credential from authentication. token is stored and returned
but never turned into a header. Changing a config is creating a new one and
deleting the old one; there is no update method. Credentials are stored as
sealed references and masked in the backend, in audit rows and in what Get
and List return; a2a- 1.3.0 returns them in clear. Every target
passes the address guard, which a2a- 1.3.0 without a configured
validator does not do: in stage 0 it posted to a private Docker address.
Receiver: its own endpoint and credential per registration. Below the
site's configured A2A base URL, an endpoint accepts callbacks for registrations
nr-llm created. Each registration gets a random callback credential, handed to
the remote in authentication and stored hashed, as inbound client tokens
are. A callback without the right Authorization: Bearer is answered 401
and stores nothing. X- is ignored: a2a-
1.3.0 sends it beside Authorization (measured in stage 0), and it neither
authenticates nor refuses. The body is a plain Stream and arrives
without A2A-; the receiver requires neither.
Receiver: registration in a fixed order. Without an embedded config, the
client sends, stores the acknowledged remote task id, creates the local
registration, creates the remote one, and calls Get once to close the
gap. With an embedded config, the pending local registration is written before
the message is sent.
Receiver: a bounded inbox, and push is only a signal. A valid callback is
stored durably, within a bound per registration and in total (over it, 429),
before the endpoint answers 2xx; 2xx means received. A callback that arrives
before the remote task id is known is kept against the pending registration. A
worker reconciles by Get as the owning actor; the body itself decides
nothing. A duplicate or late callback never resolves a dependency twice
(ADR-281), and polling stays the repair path.
Consequences
- ●● No request makes an outbound webhook call, and no webhook can make nr-llm act on content it did not fetch itself.
- ● A paused or stopped config is visible instead of retrying for ever.
- ◑ A second public endpoint exists; its security review is part of stage 5b.
- ◑ Push shortens the wait but does not replace polling.
- ✕ Two more tables and a delivery worker to operate.
Net Score: +3
Alternatives considered
Deliver webhooks from the request that changed the state. Rejected: it makes a request wait for a foreign server and loses the delivery when that request dies.
Act on the callback body. Rejected: the body is the remote's claim; the
state the run continues with is what Get returns to an authenticated
call.
Send the token as a header. Rejected: the specification defines no
such header, and its maintainers said credentials go in Authorization.