Repository navigation
a2a-rust v0.14.0
Cadence exception: 0.13.0 came out on 2026-09-20, but this release can't wait until October. It fixes problems people are running into now. A dropped send could leave a task wedged or stuck in working (N26, N27). The client never let go of a revoked credential (N36). The official conformance suites also found several interoperability failures.
Breaking Changes
-
tracingis a default feature ofa2a-protocol-client,
a2a-protocol-serveranda2a-protocol-sdk. A default build used to
compile every log call and failure report in the crates to nothing, so an
agent logged nothing however its subscriber was set up, and paths that
report only throughtracing— a trace header the WebSocket binding
cannot carry, a skipped webhook — were silent (audit O13). The maintainer
chose default-on (ADR 0013).tracingwas already in every default build's
dependency graph through the HTTP stack; the feature adds
tracing-attributesandsyn. With no subscriber installed the cost is a
level check per call site. Migration: none for most builds. To keep the old behaviour,
depend withdefault-features = false(and re-addtls-rustlson the SDK
if you want HTTPS). -
default-features = falseona2a-protocol-sdknow removes what the
SDK's defaults enable. The SDK took the client with the client's own
defaults, so rustls stayed in an SDK built without default features,
contrary to its manifest (audit K1). The SDK now takes the client and
server without their defaults and forwards its own: a default build
enables exactly what it did (plustracing, above), and
cargo tree -p a2a-protocol-sdk --no-default-featureshas no rustls,
hyper-rustls or webpki-roots. Migration: a build that used
default-features = falseand still reachedhttps://agents was relying
on the defect; addfeatures = ["tls-rustls"]. -
A peer that goes away mid-call reads the same on every binding. Found
by driving each binding against a scripted peer that cuts streams off
(audit N13): the same event — the connection or stream ending before a
final event — was a retryableHttperror on JSON-RPC and HTTP+JSON, a
non-retryableTransport("WebSocket connection closed")on WebSocket, and
a non-retryableProtocol(InternalError)on gRPC, so retry and resume
logic, and the failure class an executor reports, depended on the binding.
Now, over WebSocket, a stream in flight when the socket drops ends as
IncompleteStream(retryable, asEventStreamrules on every other
binding), a unary call in flight fails withHttpClient(retryable), a
connection that cannot be made isHttpClient, and a handshake the peer
answers with an HTTP status isUnexpectedStatus { status, .. }— so a
401 reachesBearerAuthInterceptor, which drops the refused token, as it
already did over HTTP and gRPC. A frame over the size cap, or one that
breaks the protocol, stays a non-retryableTransporterror. Over gRPC, a
response that ends with nogrpc-statustrailer — what tonic reports for a
truncated stream — ends a stream asIncompleteStreamand fails a unary
call withHttpClient, both retryable. Migration: code matching
Transport(_)to detect a dropped WebSocket, or
Protocol(e)withe.code == InternalErrorto detect a truncated gRPC
stream, should matchIncompleteStream { .. }/HttpClient(_), or ask
is_retryable(). Not a wire change. The retry a retryable drop invites
reconnects on the sameWebSocketTransport(audit N18; see Fixed). -
gRPC status codes map to what the caller has to do about them. Over the
gRPC transport and the slimrpc binding,UNAUTHENTICATEDis now
ClientError::UnexpectedStatus { status: 401, .. }andPERMISSION_DENIED
isUnexpectedStatus { status: 403, .. }, the shapes the JSON-RPC and REST
bindings already report. They wereProtocol(InvalidParams)on gRPC and
Protocol(InternalError)on slimrpc, which told a caller with a revoked
token to fix its request, and meantBearerAuthInterceptornever dropped a
token a gRPC agent had refused: the next call sent it again. A peer's
CANCELLEDis now a non-retryableProtocolerror (InternalError, or
the A2A code itsErrorInfonames) instead of a retryableTimeout, so a
call the peer abandoned is no longer re-sent. Migration: code matching
Protocol(e)withe.code == InvalidParamsto detect an authentication
failure over gRPC should matchUnexpectedStatus { status: 401 | 403, .. }.
A peer's cancellation now reportsis_retryable() == false, so a retry
policy stops re-sending it with no change to the caller's code. Neither is
a wire change; both change the client's reading of one. -
ClientBuilder::from_cardrefuses a card with no interface for A2A
protocol major 1. Such a card used to be accepted with a warning and then
fail at the first call with whatever the wire produced — a v0.3 endpoint
answers-32601 method not found. The error now lists what the card offers.
Versions are read leniently: empty,v1.0and1-previewall count as
major 1. -
Graceful shutdown ends in-flight tasks before it drains connections.
Server::serve_with_shutdownused to drain first, and an open SSE stream is
a connection that does not close until its task ends: the drain waited out
its 15 s,RequestHandler::shutdownthen cancelled tokens without waiting
for any executor, and a delegating executor's downstream tasks outlived the
process. Reproduced with a coordinator delegating to two a2a-go workers:
exit after 16 s,abandoned: 1, no terminal event upstream, noCancelTask
downstream. The order is now: stop accepting; let tasks finish on their own
for up toServeConfig::completion_grace(5 s); cancel the rest and give
their executorstask_grace(10 s) to cancel what they delegated and write a
terminal event; then drain. With the completion window at zero, the same run
exits in 0.02 s with both Go workers cancelled and the upstream stream
endingTASK_STATE_CANCELED; with the default window it first waits up to
5 s for the delegation to finish on its own (not re-measured). An
executor that returns without a terminal state after shutdown has its
cancelhook run, asCancelTaskwould. The trade: a task that outlives
the completion window is now cancelled where it used to get the whole drain
window; the three phases are bounded by 30 s together, a Kubernetes
terminationGracePeriodSecondsdefault. -
Every shipped
TaskStorerefuses to move a terminal task to a different
state, atomically with the write, answeringUnsupportedOperationwith a
TerminalStateConflictin its data. Code that "reopens" a finished task
through a shipped store now gets that error. See Fixed for why.
Behaviour Changes
Changes in what a caller observes for the same input, with no change to any
type or signature — the entries a careful adopter has to read at every bump,
and which cargo semver-checks cannot see. Listed here as well as under
their own heading. An adopter asked for this section after 0.12.1's change to
what a new task on an existing context inherits (#130) reached them filed
under Fixed; that entry did say it changed the wire shape, but not where
someone scanning for such changes would look.
- The axum adapter's successful responses carry
A2A-Version(server,
axumfeature; audit N38), as its errors andRestDispatcher's responses
already did. HTTP+JSON responses stayapplication/json, a deliberate
deviation from spec §11.1's SHOULD forapplication/a2a+json: the
official Go SDK's client cannot read an error labelled that way. - The axum adapter's error bodies are AIP-193, as
RestDispatcher's are
(server,axumfeature; audit N37). It answered{"error": "<text>"}; it
now answers{"error": {"code", "status", "message", "details"}}through
the REST dispatcher's own builders. A client that readerroras a string
from the adapter readserror.message. REST's own route and body errors
gain thestatusname they lacked. - A refused credential answers
401/403, not400(server; audit N36,
ADR 0014). The auth interceptors answered every refusal as
InvalidRequest: HTTP400on both HTTP bindings, gRPC
INVALID_ARGUMENT. Now a missing or wrong credential answers401with a
WWW-Authenticatechallenge (Bearer realm="a2a", or
ApiKey header="x-api-key") and gRPCUNAUTHENTICATED; the AIP-193 body's
statussaysUNAUTHENTICATED. JSON-RPC answers the same401with the
body unchanged (-32600); batches stay200. WebSocket is unchanged (the
body alone). Migration: a client or alert that matched400for an
auth failure matches401/403. - The SLIMRPC binding's server answers a refused credential
UNAUTHENTICATEDorPERMISSION_DENIED(binding; audit N36). It mapped
by error code alone, so the same refusal went out asINVALID_ARGUMENTand
a client never dropped a revoked token there, the N36 defect the gRPC
dispatcher had already been fixed for. Found auditing the book on
2026-09-25. Migration: as for gRPC above. SendMessagerefuses a part whosemediaTypethe agent card does not
declare, withContentTypeNotSupportedError(server; audit N34). When
the card declares input modes —defaultInputModesor any skill's
inputModes— a part carrying an explicitmediaTypeoutside them is
refused (-32005 over JSON-RPC, HTTP 400 over HTTP+JSON), as spec §3.1.1
requires; it used to reach the executor. Parts without amediaTypeare
not checked, and nothing is enforced without a card or when the card
declares no modes. Migration: declare every media type the agent
accepts on its card, or call
RequestHandlerBuilder::allow_undeclared_input_modes()to keep the old
behaviour while the card is corrected.- A continuation sent the moment a task reaches
input-requiredis
admitted (server; audit N21). It used to be refused as "already being
processed" whenever the executor that parked the task had not yet returned
— see Fixed. - A WebSocket stream the caller reads more slowly than the agent writes
ends with astream_laggederror once 64 frames are waiting (client;
audit N29). It used to be held back without loss, which stalled every
other call on the socket — see Fixed.ClientError::is_stream_lagged
identifies it; resubscribe to continue. - A call on a
WebSocketTransportwhose socket has dropped reconnects
(client; audit N18) instead of failing with a non-retryableTransport
error. Code that matched that error to detect a dead transport now sees
the call succeed, or fail retryably if the reconnect does. - A WebSocket request whose connection ends is cancelled (server;
audit N30). It used to run on after its peer had gone, which is how a
peer that stopped reading held its connection open; a request on the HTTP
bindings is dropped the same way when its client disconnects. - An idle
SubscribeToTaskstream over SSE now ends at
subscribe_max_idle(server; audit N25). It used to stay open for as
long as its client did — see Fixed. A client that relied on the
stream staying open resubscribes, which §3.5.2 supports.
Security
- The published manifests no longer admit dependency versions with RustSec
advisories (audit N24). A consumer's cargo keeps whatever version its own
lockfile holds as long as our requirement admits it, so a fix that only
moved this repository's lockfile never reached them — an adopter on 0.12.1
foundcargo update -p a2a-protocol-clientleaving rustls 0.23.43 in place,
inside RUSTSEC-2026-0285. Measured against the RustSec database on
2026-09-24, the requirements admitted affected releases under seven
advisories:rustls(RUSTSEC-2024-0336, RUSTSEC-2024-0399,
RUSTSEC-2026-0285),ring(RUSTSEC-2025-0009),bytes
(RUSTSEC-2026-0007),sqlx(RUSTSEC-2024-0363),time
(RUSTSEC-2026-0009) andtokio(RUSTSEC-2025-0023, unsound). The floors
are nowrustls >=0.23.45,ring 0.17.12,bytes 1.11.1,sqlx 0.8.1,
time 0.3.47andtokio >=1.44.2; each declares arust-versionat or
below 1.88, and both lockfiles already held newer versions, so neither
changed. Effect on consumers: upgrading to this release moves any of
those dependencies still below its new floor. A new CI gate,
scripts/check_advisory_floors.py, fails when a normal or build
dependency of a published crate admits an affected, published version;
cargo deny, which reads our lockfile, could not see this and was green
throughout.
Added
-
ServerInterceptor::on_completeandCallOutcome.afterruns only
on success and its error replaces the response, so no hook saw a failed
or abandoned call: an interceptor that acquired something inbefore—
an in-flight gauge, a concurrency slot, an open audit record — had no
place to release it.on_completeis called once per call, in reverse
order, on every interceptor whosebeforeran (including the one that
refused the call), withSucceeded,Failed(&ServerError)— the error
the caller is sent — orCancelled, when the call's future is dropped
unanswered. It returns(), so it cannot change the response. The
default does nothing, so existing interceptors compile and behave as
before. EveryRequestHandlermethod, on every binding, runs through it;
calls made withServerInterceptorChain::run_beforeandrun_after
directly do not. A new test drops a real JSON-RPC and HTTP+JSON
connection mid-call and seesCancelledinon_completeandcancelled
in therpc.server.callrecord — the first test of the premise that
N26, N27 and N28 rest on: hyper drops a request's future when its client
disconnects. -
Task::new,TaskQueryParams::new(withwith_history_lengthand
with_tenant), anda2a_protocol_server::CancellationToken(audit
N16). A custom store or test built aTask, and everyGetTaskcaller a
TaskQueryParams, with a full struct literal that breaks when a field is
added; and an executor that stored itsRequestContext::cancellation_token
needed its owntokio-utildependency at a matching version to name the
type. The re-export reaches the SDK asa2a_protocol_sdk::server::CancellationToken. -
testing::ScriptedPeer(client featuretesting): a loopback A2A
peer that stalls, cuts off, mis-frames or refuses every call on purpose,
over JSON-RPC, HTTP+JSON, WebSocket and gRPC, for testing code that calls
agents — a coordinator has to survive the worker it delegates to, and a
well-behaved test server never produces these failures. This crate's own
tests drive every binding against every script with it
(tests/scripted_peer_tests.rs), which is how N13 above was found. Adds no
dependency; not for production builds. -
GrpcDispatcher::serve_with_shutdownand
WebSocketDispatcher::serve_with_shutdown, withwith_completion_grace,
with_task_graceandwith_drain_timeouton both dispatchers (defaults
DEFAULT_COMPLETION_GRACE,DEFAULT_TASK_GRACE,DEFAULT_DRAIN_TIMEOUT).
Both take a bound listener and a signal and return the sameServeReportas
Server::serve_with_shutdown, in the same order: stop accepting, end the
tasks in flight, then drain. Until now neither dispatcher took a signal, so
a gRPC or WebSocket deployment had to assemble the sequence by hand or
orphan its delegations on shutdown (audit S8). Two differences follow from
the transports: gRPC connections are sentGOAWAYas soon as accepting
stops, so they take no new calls during the completion window; and each
WebSocket connection, once the tasks have ended, finishes the requests it
has read and is closed with a Close frame. The gRPC server's own error is
returned rather than discarded. -
Release gates for what a tag publishes (
scripts/check_release_tree.py,
four steps inrelease.yml). A release now fails if the tagged tree has
entries under[Unreleased], if any packaged file changed after the
release's own notes were last edited, if a patch release or a second
breaking minor in one calendar month carries### Breaking Changes, or if a
packaged.cratewas built from another commit or a dirty tree.v0.13.0
would have failed three of them (audit N7, N10). The cost is ordering:
release notes must be the last edit before the tag, and the next breaking
release cannot be tagged before October 2026. Not a change to any crate. -
The mutation gate grades function bodies it never could.
scripts/install_cargo_mutants.shinstalls a pinned cargo-mutants 27.1.0,
patched so that a function returning aResultalias (A2aResult<T>,
ClientResult<T>) or a boxed future (Pin<Box<dyn Future<Output = T>>>,
every object-safe async trait method here) gets a "replace the body" mutant
that compiles. Stock 27.1.0 generated none that did for 346 functions, so
whether their effects were tested at all had never been graded (audit N4,
N9, N11). The survivors the patched build found are killed by tests on
this branch. The install verifies the crate's checksum, applies the patch
exactly, and refuses a binary without it. -
A Go SDK interop gate in CI (
go-sdk-interop,scripts/go_sdk_interop.sh).
The official Go SDK's client against this server, and this client against an
a2a-go server, over JSON-RPC, HTTP+JSON and gRPC. Before it, a2a-go ran in CI
only as a server driven by the in-repo TCK, which does not use
a2a-protocol-client. Against the unfixed tree it was red for five of the
defects fixed below (S2, S7, T1, C8 and C9 in the 2026-09-22 audit's
numbering); its C10 check could not run there, because the T1 failure
stopped card resolution first.scripts/prove_gates_fail.shproves it fails when
the push token stops reaching a Go webhook. -
RequestHandler::finish_in_flightandRequestHandler::cancel_in_flight,
returningInFlightReport;ServeConfig::completion_graceandtask_grace
(DEFAULT_COMPLETION_GRACE,DEFAULT_TASK_GRACE);ServeReport::tasks; and
Dispatcher::request_handler, defaulted toNoneand overridden by the
JSON-RPC and REST dispatchers. Callfinish_in_flightyourself when
something else owns the sockets —examples/deploy-agentdoes, behind Axum. -
impl From<ClientError> for A2aError, so an executor that delegates can
use?. A downstreamProtocolerror passes through unchanged; exactly the
errorsis_retryable()accepts fail the task asTransient, everything
else asInternal, with the client's text and source chain kept. The server
now honours a class recorded with the newfailure::set_error_class/
failure::error_class, because no error code can expressTransient. -
Client stream liveness bounds:
ClientConfig::stream_idle_timeoutand
stream_first_event_timeout(both 5 minutes by default, with builder
setters), applied byA2aClientto every stream on every binding, and TCP
keepalive (60 s idle, 15 s interval, 4 probes) on every client connection. A
stream whose server sent one event and then went silent while holding the
connection open used to hangnext()forever. Any bytes reset the idle
bound, SSE keep-alive comments included; on gRPC and WebSocket, which carry
no heartbeat the client can see, it bounds the gap between events. -
Resuming a broken stream:
ClientError::IncompleteStream { last_event_id, detail },EventStream::last_event_id()and
A2aClient::subscribe_to_task_from(id, last_event_id), which sends
Last-Event-ID. Verified against this repository's JSON-RPC and REST
dispatchers. gRPC and WebSocket streams carry no ids. -
ClientConfig::max_event_size(16 MiB) andEventStream::with_max_event_size. -
CallInterceptor::on_errorandTokenProvider::invalidate, both
no-op by default.BearerAuthInterceptoruses them so an OAuth2 token the
agent answered with401is not sent again; the refused call still fails.
On every binding: gRPC'sUNAUTHENTICATEDand a WebSocket handshake refused
with 401 reach it asUnexpectedStatus { status: 401, .. }since the two
entries under Breaking Changes. -
OAuth2ClientCredentials::with_failure_backoff(1 s),Method::returns_empty,
andpush::webhook::notification_token, which reads the push token under
either header name. -
TaskStore::save_appending_history, which persists a task whose history
grew by the messages a turn added, without the caller shipping the history
it has already stored. Additive: the default reads the record, appends,
trims and writes it back throughsave, which is exactly what the send path
did before, so a store that does not override it behaves as it always has.
InMemoryTaskStoreoverrides it to extend aVecin place.task.historyis ignored by this method — the appended messages come
from their own parameter. That is deliberate: reusing the field for the tail
would put the caller back in possession of the whole conversation, which is
the cost being removed.max_historyis a parameter rather than a constant
in the store, because retention is the handler's policy and a store
inventing its own cap would silently disagree with the rest of the server.Measured on one box, 1,400 sequential posts to one channel: 3,962µs →
2,748µs per send at the 1,024-message history cap, and growth across the run
3.2x → 2.1x. It does not make a send O(1);
docs/swarm-scale-findings.mdhas the per-stage attribution showing what
still dominates. -
Spans for every inbound call, on every binding (ADR 0013; audit O1, O2,
O4). With thetracingfeature — on by default — each call runs in one
SERVERspan namedlf.a2a.v1.A2AService/{Method}, with the semantic
conventions'rpc.system.name,rpc.method, and on failure
rpc.status_codeanderror.type; HTTP+JSON spans addhttp.request.method
andhttp.route. The executor, event processor, push delivery and SSE
writer run in child spans, the executor's carryinga2a.task.idand
a2a.context.id. Withoteland atracing-opentelemetrylayer, an
inboundtraceparentis the span's remote parent and thetraceparentsent
downstream names that recorded span instead of an id nothing records.
InboundTracePolicy::Droprecords no span for the call or anything it
spawns. Gate:crates/a2a-protocol-sdk/tests/observability_e2e/, which
failed onmain. -
rpc.server.call.durationinOtelMetrics, andMetrics::on_rpc_call
withRpcCallfor any other exporter: every call's duration with the
conventions' buckets (otel::RPC_DURATION_BUCKETS), method, and the status
its binding answered with — including calls the handler never sees (a body
that is not JSON-RPC, an unknown method, an HTTP+JSON request refused on its
parameters) and calls whose peer went away (error.typecancelled)
(audit O5, O11). Additive: the callback defaults to a no-op. -
Connection statistics are reported.
serve,serve_with_addrand
Server::serve_with_shutdownnow callMetrics::on_connection_pool_stats,
which nothing called, so the foura2a.server.pool.*instruments the book
catalogues were never emitted (audit O10). The gRPC and WebSocket
dispatchers' own listeners do not report them yet.
Changed
-
A client stream that ends before its final event yields
ClientError::IncompleteStreambeforeNone. A final event is a
Message, or a task in a terminal or interrupted state (§3.1.2, §11.7).
Before, a stream cut off mid-task was indistinguishable from one that
finished, and a half-written last frame was dropped silently. -
stream_connect_timeoutbounds only establishing a stream; the first
event has its own 5-minute bound. The 30 s connect timeout used to bound the
first event too, which cut off agents — a2a-go among them — that send headers
and then think. If you shortened the connect timeout to fail fast, also set
with_stream_first_event_timeout. -
The JSON-RPC client reads a streaming call's pre-stream error in either
shape: the plain JSON-RPC error body this server sends, or one SSE frame
carrying it, which is what a2a-go's server sends. Both become the same
ClientError::Protocol. The server's own shape is unchanged: a plain JSON
200, which the official a2a-tck requires (STREAM-SUB-003/004). a2a-go
v2.5.0's client reads streaming answers only as SSE, so it still loses
these errors. That is a2a-go's to fix, andgo-sdk-interoppins it. -
Wire: an HTTP+JSON stream's error frame is a
google.rpc.Status
({"error":{code,status,message,details}}), the error'sdataa flat
google.protobuf.Structdetail. a2a-go's REST client could not decode the
old bare{code,message}frame. This client decodes all three shapes. -
Push notifications carry the token under both
X-A2A-Notification-Token
(Python SDK) andA2A-Notification-Token(a2a-go), the same value in each.
A Go webhook read no token before. This reverses 0.8's removal of the
unprefixed name, which is a2a-go's rather than this SDK's invention. Push
logs now show onlyscheme://host[:port]: webhook URLs often embed secrets. -
StringListreads a2a-go's bare-array scopes andnullas well as the
spec's{"list":[...]}, which is still the only form written. -
A streaming client's terminal frame waits for the store's ruling (at most
the queue's write timeout, 5 s) and carries the stored state. See Fixed. -
save_status_deltais now overridden bySqliteTaskStoreand
PostgresTaskStore, not only byInMemoryTaskStore. Until now a SQL
deployment got nothing from it — no regression, since the trait's default
delegates tosave, but no win either, and neither the trait docs nor the
findings report said so. Measured on one box, 100 status events on a channel
holding 200 messages, medians of five runs:store savesave_status_deltaInMemoryTaskStore8,499µs 639µs SqliteTaskStore122,071µs 37,216µs PostgresTaskStore287,287µs 120,104µs Both overrides update the
statecolumn thatlistfilters on and the
updated_atthat §3.1.4 orders by, either of which is wrong in a way no
round-trip throughgetwould reveal. The SQLite override deliberately does
not delete the artifact journal that itssavedeletes:savehas just
rewritten the document with every part in it, a status delta has not, so
those rows are still the only record of appended parts. -
a2a.server.latencyuses the semantic conventions' buckets (5 ms to
10 s) and is deprecated in favour ofrpc.server.call.duration, to be
removed no earlier than the next minor release. It kept the SDK's default
boundaries, sized for milliseconds, so every call under 5 s landed in one
bucket (audit O5). Dashboards that read itslelabels see new ones.
Fixed
-
A continuation answering
input-requiredat once is admitted (server;
audit N21). A blocking send returns when the task reaches an interrupted
state, and a stream delivers that state, before the executor that wrote
it has returned; admission refused any send while that executor's token
was live, with an error telling the caller to wait forinput-required—
the state it had just been sent. The executor's writer now marks the turn
parked when the latest state it writes isinput-requiredor
auth-required, and admission waits up to
HandlerLimits::executor_drain_timeout(default 5 s) for a parked turn's
executor to finish. A send into a task still working is refused at once,
as before, and one still beside a live executor past the bound is refused
exactly as before, so two executors never run for one task. -
An idle
SubscribeToTaskstream over SSE ends atsubscribe_max_idle
(server; audit N25). The bound was measured inside a future that each SSE
keep-alive discarded, so with the defaults — keep-alive 30 s, bound 300 s
— a subscription to a task parked atinput-requirednever ended and
polled the store for as long as its client stayed.InMemoryQueueReader::read
is now cancel-safe across the reattach wait. -
A send whose client goes away mid-commit no longer wedges its task
(server; audit N26). A request dropped between claiming its idempotency
key and spawning its executor — a client timing out during a slow store
write — kept the task's queue lease and cancellation token for the life
of the process, so every later continuation was refused as "already being
processed", and a retry carrying the same idempotency key waited on a
task that did not exist. What the send took is now released when its
future is dropped, and only what it took: a continuation dropped while it
waits for the previous turn leaves that turn's executor cancelable. -
A blocking send whose client goes away still records the task's
outcome (server; audit N27). The collector that persists a blocking
send's events ran in the request's future, so when a client timed out on a
slow agent the executor finished into nothing: the task stayedworking
in the store, and no later push notification was sent. The collection now
runs on its own task, which shutdown waits for, and the request awaits it. -
A failing interceptor
afterhook no longer orphans a send's task
(server; audit N28).afterran between spawning the executor and
attaching what persists its events, so its error left a task the agent
went on to complete atsubmittedin the store. It now runs once the
response exists.ServerInterceptor::after's documentation said it runs
even when the handler fails; no method ever did that, and it now says so. -
WebSocketTransportreconnects after its connection drops (client;
audit N18). Once its socket dropped, every later call failed at once with
a non-retryableTransport("WebSocket connection closed"), so a
long-lived client had to be rebuilt by hand after any server restart. The
next call now reconnects to the same endpoint with the same configuration,
bounded byconnect_timeout, one reconnect for concurrent callers; a call
that races the old connection's close fails as retryable. Streams already
open keep the connection they were opened on. -
An unread WebSocket stream no longer stalls other calls on the same
socket (client; audit N29). The transport's single reader waited for
room in a stream's 64-frame buffer before reading anything else, so a
GetTaskmade before reading an open stream timed out once the agent had
sent more than 64 events. The reader no longer waits: a stream whose
buffer overflows ends with astream_laggederror, as a lagging reader
does on the server. -
A WebSocket peer that stops reading is closed by the idle timeout
(server; audit N30). Withwith_idle_timeoutset, a peer that stopped
reading while being streamed to was never closed: the stream's send held
the sink's lock, the keepalive waited for that lock, and so did the
closing handshake, so the connection and itsmax_connectionsslot were
held for as long as the peer liked. The keepalive no longer waits, the
connection's requests are cancelled when the peer is gone, and the close
handshake is bounded at 1 s. -
A gRPC stream the client cancels releases its subscription at once
(server; audit N31), rather than at the task's next event. -
The agent card's poll watcher retries a card that failed to parse
(server; audit N32). It recorded the file's mtime even when the reload
failed, so on a filesystem with coarse timestamps a fix written in the
same second as a half-written file was never loaded. -
Every task status carries a timestamp (server; audit N35).
EventEmitter::status— the helper executors are pointed to — built its
status withTaskStatus::new, which leavestimestampempty, so agents
built on it served statuses with no time: spec §5.6.1 wants ISO 8601 UTC
timestamps,ListTasksis ordered by them (§3.1.4), and ACTS
DM-SERIAL-001 (a MUST) failed. The event queue now stamps any status
update or task snapshot the executor left without one, before it is
persisted or streamed; a timestamp the executor set is kept. The stamp
counts towardmax_event_size, so a status event within a few dozen
bytes of that limit may now be refused. -
The two HTTP+JSON dispatchers answer the same failure identically
(server; audit N37) — see Behaviour Changes. Found while fixing N36. -
The axum adapter's successes carry
A2A-Version(server; audit N38)
— see Behaviour Changes. Found while examining ACTS REST-CT-001. -
This SDK's client recovers from a revoked or rotated token against this
SDK's server (server; audit N36). The client drops a cached token on
401; the server never sent one, so the refused token was re-sent on every
call until the provider's cache expired, on every binding. See Behaviour
Changes. New ina2a-protocol-types:A2aError::unauthenticated,
A2aError::permission_denied,A2aError::auth_rejectionand
AuthRejection/AuthRejectionKind, for custom interceptors to get the
same statuses. -
SendMessagechecks message parts' media types against the agent
card (server; audit N34) — see Behaviour Changes for what is
refused and how to opt out. Found by the ACTS conformance suite
(CORE-SEND-004, a MUST); the official Rust SDK has the same gap open as
a2aproject/a2a-rs#270. -
JSON that is not a JSON-RPC Request object is answered Invalid Request
(-32600), not Parse error (-32700) (server, JSON-RPC and WebSocket; audit
N33). A body with nomethod, a bare value such as1, an empty batch
[]and a batch item that is not a request were all answered -32700,
which JSON-RPC 2.0 reserves for a body that is not JSON; its own examples
answer these -32600. Found by the ACTS conformance suite (CORE-ERR-006, a
MUST). A client that matched on -32700 for these cases now sees -32600. -
The agent card's poll watcher sees a change made just after it starts
(audit N23). It read the file's baseline mtime inside its own task, on
the blocking pool, so a card rewritten before that read landed became the
baseline and was never loaded. The baseline is now read when
spawn_poll_watcheris called. -
A WebSocket stream outlives the client that opened it, as streams on
the HTTP and gRPC bindings do (audit N22). Dropping the client, or the
WebSocketTransport, aborted the connection's reader while the stream
still held its own sender, so the stream went silent: no later event, and
no end until the idle bound (five minutes by default) reported a
Timeout. The stream now holds the connection open until it is dropped. -
Wire:
RestDispatcheranswers an overload with503and an oversized
body with413, as the axum adapter always has, instead of500and
400. The two HTTP+JSON dispatchers had separate mappings, so the same
overload told a client of one to retry and a client of the other not to
(audit N20). One mapping now serves both. -
The pool counters count each connection once.
OtelMetricsadded the
cumulative totals in each report to its counters, so every report
re-counted every earlier connection (audit O10). -
The WebSocket dispatcher's documentation said it routes the v0.3
method/verbaliases; it routes onlymessage/streamof them and refuses
the rest withMethodNotFound, as its own test asserts (audit N19). -
The book's Rust examples compile. 127 of its 130
ignored blocks now
compile (the other 3 areslimrpc, outside the workspace), and 79 of those
also run; the other 48 would call a network or a database. Most needed only
hidden#lines supplying what the page established in prose. Five could not have compiled as shown — each confirmed by
compiling the original with only its missing context added: a client
moved into two calls (E0382), amatchon the#[non_exhaustive]
SendMessageResponsewith no wildcard arm (E0004), an
Arc<RateLimitInterceptor>passed where aServerInterceptoris required
(E0277),&*writeron a queue writer with noDeref(E0614), and
ClientBuilder::new("…".into())(E0283). Others named what does not exist
(send_streaming_message(),AuthInterceptor::new,RetryInterceptor)
or needed a feature the page did not mention (hyper-util's
server-auto,tracing-subscriber'sjson). And compiling them contradicted the prose around them:AgentExecutor::cancel's default was documented as refusing with
TaskNotCancelable; it has cancelled since 0.7.- The builder was said to choose the transport from the URL; without
with_protocol_bindingit is always JSON-RPC. - CORS read as on by default; it is off until
with_cors. - "
sqlx::PgConnectionis notSend + Sync" — it is. A single connection
is still the wrong choice, because every call then waits on its lock. - The body-size pitfall's fix checked only
size_hint(), which a chunked
body leaves unbounded; the page now addshttp_body_util::Limited, as
the REST dispatcher does. - The custom authentication interceptor compared tokens with a
HashSet
lookup and did not mark itselfauthenticates(); the page now points
fixed token lists at the constant-time built-ins, and its example
records the caller and sets the marker. - The production page's health-check example could not type-check; the
REST dispatcher andA2aRouteralready answer/healthand/ready,
and the JSON-RPC dispatcher does not, which the page now says.
.book-ignore-baselinenow holds only the threeslimrpcblocks.
Documentation only; no crate changed. -
The configuration reference's defaults are the defaults.
book/src/reference/configuration.mdgavewith_executor_timeouta
default of None; the builder sets one hour, so a task can be failed by a
bound the page said was not there (audit S12). It also had no row for
TaskStoreConfig::max_events_per_task(512) oridempotency_key_ttl(24
hours), or forrequire_version_header(on) inDispatchConfigand
GrpcConfig.crates/a2a-protocol-sdk/tests/book_defaults.rsnow reads
each defaults table on the page and compares it with the struct's real
Default, in both directions: a documented value that differs, a row that
names no field, and a field with no row all fail. Against the page as it
was, it reports exactly those five. Documentation only. -
The four crate READMEs — each crate's crates.io page — compile. Seven
of their eight Rust blocks did not: the client's documented
resubscribe(),get_authenticated_extended_card()and
ClientBuilder::with_transport(), none of which exist; aMessageliteral
andmatches that the types'#[non_exhaustive]rejects; a server prelude
that is the SDK's. Each README is now a doctest of its crate
(#[cfg(doctest)] #[doc = include_str!("../README.md")]), and its feature
table — like the two in the book and the one incrates/README.md— is
checked against the manifest byscripts/check_feature_tables.py, which
found 17 missing or wrong rows. The book and root README no longer say
default-features = falseremoves rustls from the SDK; it does not yet
(audit K1). -
Agent-card signing canonicalizes numbers the way RFC 8785 says. Three
deviations, each found by the RFC's own vectors, now in
crates/a2a-protocol-types/tests/rfc8785_vectors.rs: a double exactly
halfway between two shortest renderings took the upper one, not the even
one (1424953923781206.2, Appendix B note 4); a card parsed from text
could read a float one ULP off, because serde_json's default parser is not
correctly rounded (5 of Appendix B's 24 values); and an integer beyond
2^53 was written with all its digits rather than as the double JCS
defines every number to be (9007199254740993is9007199254740992).
Against V8'sJSON.stringifyover the same 1,000,077 random doubles, the
canonicalizer disagreed 1,474 times from the bits and 237,518 times from
the text; it now disagrees 0 times on either, and 0 times over 8,552,446
structured ones (everym × 2^ewith oddmbelow 4096), which is where
the fix's own first version was caught wrong at 2^-24. What it trades: a
signature made by 0.13.0 or earlier over a card holding an affected number
does not verify here, and the reverse — though neither verified against
any other conforming implementation, which is the correction (a
specification fix,STABILITY.md§2). Thesigningfeature now enables
serde_json'sfloat_roundtrip, which makes float parsing correctly
rounded, and slower, in every crate of a build that enablessigning. -
Server::serve_with_shutdownsees its signal at the connection
ceiling. It waited for a connection permit before it looked at the
signal, so withServeConfig::max_connectionsset and every slot held by a
stream that only shutdown could end, shutdown never began: the test that
pins it timed out after 20 s with the only slot held. The signal is now
watched while waiting for the permit as well as for the peer. The new gRPC
and WebSocket shutdown paths are built the same way and tested for it. -
An unreachable OAuth2 token endpoint is a transient failure. A refused
or dropped connection to the token endpoint, or to the OIDC discovery
document, surfaced asClientError::Transport, which is not retryable, so
throughFrom<ClientError> for A2aErrora task whose token fetch met a
restarting identity provider failed asInternalrather thanTransient.
It is nowClientError::HttpClient, as the transports report the same
error. A token endpoint that answers 429 or 5xx is stillTransport; that
is recorded as open (audit N2). -
Replicas starting together against an empty PostgreSQL database all come
up.CREATE TABLE IF NOT EXISTSraces in PostgreSQL: two sessions that
both find a table absent both create it, and the second fails with a
duplicate key inpg_typeorpg_class. Every schema-creating constructor
—PostgresTaskStore::{new, from_pool, with_migrations}, the tenant-aware
task store, both push-config stores andPostgresRateLimitCounter— ran its
DDL unlocked (with_migrationsincluded, because it creates
schema_versionsbefore it can lock it), so a multi-replica deployment's
first start could crash a replica. Measured on 16.13 with 48 constructors
against a fresh database: 2 failed on the first attempt; 0 of 48 in each of
20 consecutive runs after. Each constructor's DDL now runs in one transaction
holding a transaction-scoped advisory lock, the same key for every store, so
they serialize against each other and against the migration runner. The
trade: schema creation is serialized across the whole database — a
cold start of N replicas does its DDL one replica at a time — and the key,
the bytes of"a2a_schm", is one an application's own advisory locks on
the same database should avoid. -
A task another replica canceled no longer ends
Completed.
CancelTaskon replica B wrote and answeredCanceled, and replica A's
executor, which never heard of it, then wroteCompletedover it: every
store applied status writes unconditionally.tests/cross_replica_cancel/
reproduced it on the in-memory, SQLite and PostgreSQL stores. The refused
write is now how A learns: it cancels its executor's token, stops persisting
and pushing its events, answers a blocking caller with the stored task, and
pushes the stored terminal status once. The trade: A learns at its
executor's next write, not immediately, and non-terminal frames emitted in
between still reach a client streaming from A. CustomTaskStores are not
covered unless they applystore::refuses_writeinside their writes. -
Agent cards from a2a-go with
securityRequirementsparse. The reverse
direction is a2a-go's to fix: v2.5.0 rejects the spec's shape. The interop
gate pins that rejection and fails when it stops holding. -
Deleting a push config on an a2a-go agent reports success. a2a-go answers
JSON-RPC with noresultand HTTP+JSON with an empty 200; both are now
accepted, for Empty-result methods only. -
Interface selection skips other protocol majors, so an a2a-go agent's
v0.3 endpoint listed first is no longer chosen; binding names match
case-insensitively inbuild()as in selection; andbuild()falls back to
the card's next interface when the chosen one cannot be built. -
HTTP+JSON streaming errors decode to the A2A error they carry, from a
non-2xx answer and from inside a 200 stream, where both used to surface as
UnexpectedStatusorSerialization("unknown variant …"). The stream-lag
signal survives the new server frame:A2aError::is_stream_laggedis true
across a REST stream from this server. -
OAuth2 refresh failures are shared, not serialised. Every caller waiting
on a refresh gets its outcome, failure included, from one request. Five
callers with 1 s timeouts used to fail at 1, 2, 3, 4 and 5 s. A caller
cancelled mid-refresh hands the attempt to the next waiter. -
Short-lived OAuth2 tokens are cached for at least half their lifetime. A
token living 30 s or less was refetched on every call. -
The SSE parser refuses a line that outgrows
max_event_sizewith
EventTooLargeinstead of silently truncating it past twice the limit. -
RequestHandler::shutdowncounts the live queues it destroys.
ShutdownReport::queues_force_destroyedwas a hard-coded 0, so a shutdown
that cut live streams reported itself graceful. -
A message naming a live task in its own context is no longer refused.
resolve_task_idaccepted only the single taskfind_task_by_context
returns — the most recently updated non-terminal one — and rejected every
other, including tasks that were live and in the same context. Since a
message carrying acontextIdand notaskIdstarts a new task
(§3.4.3), one participant posting without ataskIdforked the channel,
the fork became the context's canonical task, and every other participant
was locked out of the original permanently with
InvalidParams: message task_id does not match task found for context.§3.4.3 permits continuing "a specific task" by
taskId, and mandates
rejecting exactly one mismatch: acontextIddiffering from that of the
referenced task. §3.4.1 says acontextId"logically groups multipleTask
objects". The implementation treated a context as holding one. It now
resolves against the task the message actually names and refuses only a
genuine cross-context reference. -
A
taskIdthat names no existing task now returnsTaskNotFound
(§3.4.2) rather thanInvalidParams, whether or not the context happens to
hold some other task. The handler already returnedTaskNotFoundfor that
input when the context was empty; the only thing deciding between the two
answers was whether an unrelated task existed nearby. -
The in-memory event queue's documentation described the wrong lag
behaviour. It said a slow SSE consumer "receivesLagged(n)and skips
missed events". The consumer does not resume:InMemoryQueueReader::read
turns the lag intoA2aError::stream_lagged, whichstreaming::ssewrites
as anevent: errorframe before closing the stream. A lagging tail gets a
contiguous prefix and an announced end, never a silent hole — the stronger
of the two behaviours, and the one a reader can recover from. Documentation
only; no code changed.
Internal
- The documentation site serves each page's own canonical URL. Every
page's HTML declared the site root as its canonical and carried two
identical site-wide descriptions; a script rewrote them after load, which
Google advises against for canonicals (Search Console reported
introduction.html as "Duplicate without user-selected canonical").
scripts/seo_postprocess.pynow writes the canonical, share URLs, titles
and one description per page into the built HTML before deploy, and
docs.ymlfails the deploy if any page is wrong;print.htmland
404.htmlarenoindex, the sitemap dropsintroduction.html(a copy of
the root), the benchmark dashboard gains a canonical and description, and
the JSON-LD's MSRV (it said 1.93) is read fromCargo.toml. - STABILITY.md's one-breaking-minor-a-month rule gains a declared
exception for fixes adopters cannot wait for. A release's notes may
carry**Cadence exception:** <reason>;check_release_tree.py cadence
then accepts a second breaking minor in the month and prints the reason,
and refuses the line without one. Breaking changes in a patch release are
still refused.prove_workflow_gates_fail.pyproves the reasonless case
fails. - The genai examples move to genai 0.6.5 (
genai-a2a-agent,
incident-response; supersedes Dependabot's #128). 0.12 kept them on 0.5
because every 0.6.x depends on the unmaintainedpaste
(RUSTSEC-2024-0436). That advisory is nowdeny.toml's first ignore, with
its justification: it is informational,pasteis a compile-time
proc-macro, and it reaches only these two examples, not a published
crate. genai 0.7.0-beta.24 was considered and still depends onpaste.
No source change was needed; both examples' tests pass. scripts/check_panic_paths.pycountsunreachable!, which panics as
panic!does and was invisible to the gate. The one in library code — the
JSON-RPC dispatcher's batch branch, after anis_array()test that made
it redundant — is gone: the branch now matches the array directly.scripts/preflight.shbuilds withCARGO_INCREMENTAL=0, as CI does.
ci.ymldoes not set it, but both actions every CI job starts with export
it (read at their pinned SHAs). Without it a--fullrun kept 16 GB of
incremental state CI never builds and nearly filled a 38 GB allowance.
prove_gates_fail.shalready turned it off; its comment saying CI did not
is corrected.- The mutants the release PR's in-diff mutation run found surviving are
now caught by tests: stamping a task snapshot's status timestamp (N35;
only status updates were tested), the gRPCUNAUTHENTICATEDand
PERMISSION_DENIEDmappings for a refused credential (N36; their tests
lived in the SDK crate, which the server crate's mutation run does not
execute), admitting a send when the task's only token is already
cancelled (N21), everygoogle.rpc.Codename the REST dispatcher's
plain error responses carry (N37; eight of its eleven arms were
untested), and the agent-card poll watcher warning once about a card
that stays broken (N32; in a test binary of its own, because a
thread-local capture in the unit-test binary saw 0 warnings in some
full-suite runs once another test had cached the callsite as
uninterested). The defaultServerInterceptor::on_completeis marked
#[mutants::skip]: it is an empty future, and so is the mutant (ADR
0006). Tests only; no behaviour changed. - The official TCK's
CORE-SEND-003is baselined as a defect in the
suite: it declares no expected error, so it demands that a message part
with an unsupported media type be accepted, and it began failing when N34
started answering theContentTypeNotSupportedError§3.1.1 requires.
Evidence indocs/official-tck-findings.md§22.
Correction, 2026-09-24: claims that were not true when published
An audit of every public claim from 0.6.0 onward — the READMEs inside the
published .crate files, the crate documentation, the book, the GitHub
release notes and this file — found claims their own artifacts contradict.
Those in the repository are corrected in this release. Those in published
artifacts cannot be changed: crates.io and docs.rs go on serving them for
each version below until the next release replaces the current one. Each
was checked against the published .crate for that version.
a2a-protocol-typesREADME, 0.7.0 to 0.13.0: "A2A_VERSION—
"1.0.0"". The constant is"1.0"in every one of those versions.a2a-protocol-typesREADME, every version: "zerounsafeblocks
anywhere in this crate". From 0.12.0 the published crate'sbuild.rs
containsunsafe { std::env::set_var("PROTOC", …) }, as do the client's
and server's;#![forbid(unsafe_code)]coverssrc/only. The 0.12.0
release notes counted four such build scripts in the repository; there
are five.a2a-protocol-clientREADME, every version: documents
resubscribe(),get_authenticated_extended_card()and
ClientBuilder::with_transport(). None exists in any published version;
the methods aresubscribe_to_task,get_extended_agent_cardand
with_protocol_binding/with_custom_transport.a2a-protocol-clientanda2a-protocol-serverREADMEs, every
version: thesigningfeature is described as agent card signature
verification ("signing verification" on the server). In both crates it
only forwardsa2a-protocol-types/signing; neither crate signs or
verifies anything on its own. This is a security-relevant capability
claim, and it was false.- The book's introduction, as deployed: "zero surviving mutants
enforced viacargo-mutantsCI gate" (the gate covers only functions a
pull request changes; the weekly sweep has recorded survivors), "the
entire codebase is free of unsafe code", "no panics on any caller input or
I/O failure" (no gate measures that), "exhaustive pattern matching"
(protocol enums are#[non_exhaustive]), "zero-cost abstractions" (the
extension points are trait objects and boxed futures), and "all defaults
… are overridable". Each is rewritten to what its evidence supports. - 0.7.0 release notes: "Official-SDK interop is now proven, both
directions … 20/20 on both JSON-RPC and REST for every one". The 20/20
for the JavaScript and Java SDKs was scored after skipping checks they
fail (list_tasks_basicanda2a_media_type_acceptedfor JavaScript,
a2a_media_type_acceptedfor Java); the entry mentions that--skip
exists but not that it was used on them. The reverse direction was the
Python SDK's client only. - 0.8.0 release notes: "MUST compatibility 100%" and an empty
conformance baseline. True of what that run graded; four MUSTs have failed
against the suite's stale v1.0.0 table since A2A v1.0.1, and the figure
to quote is 88 of 114 passing and 4 failing. - 0.12.1, raised by an adopter: the change to what a new task on an
existing context inherits (#130) was filed under Fixed. The entry
said it changed the wire shape, but not where someone scanning for such
changes looks; Behaviour Changes above exists for that. CITATION.cff: "A complete Rust implementation" — rewritten to what
is measured.