Skip to content

a2a-rust v0.14.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 09:25
· 871 commits to main since this release
10f3435

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

  • tracing is a default feature of a2a-protocol-client,
    a2a-protocol-server and a2a-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 through tracing — a trace header the WebSocket binding
    cannot carry, a skipped webhook — were silent (audit O13). The maintainer
    chose default-on (ADR 0013). tracing was already in every default build's
    dependency graph through the HTTP stack; the feature adds
    tracing-attributes and syn. With no subscriber installed the cost is a
    level check per call site. Migration: none for most builds. To keep the old behaviour,
    depend with default-features = false (and re-add tls-rustls on the SDK
    if you want HTTPS).

  • default-features = false on a2a-protocol-sdk now 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 (plus tracing, above), and
    cargo tree -p a2a-protocol-sdk --no-default-features has no rustls,
    hyper-rustls or webpki-roots. Migration: a build that used
    default-features = false and still reached https:// agents was relying
    on the defect; add features = ["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 retryable Http error on JSON-RPC and HTTP+JSON, a
    non-retryable Transport("WebSocket connection closed") on WebSocket, and
    a non-retryable Protocol(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, as EventStream rules on every other
    binding), a unary call in flight fails with HttpClient (retryable), a
    connection that cannot be made is HttpClient, and a handshake the peer
    answers with an HTTP status is UnexpectedStatus { status, .. } — so a
    401 reaches BearerAuthInterceptor, 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-retryable Transport error. Over gRPC, a
    response that ends with no grpc-status trailer — what tonic reports for a
    truncated stream — ends a stream as IncompleteStream and fails a unary
    call with HttpClient, both retryable. Migration: code matching
    Transport(_) to detect a dropped WebSocket, or
    Protocol(e) with e.code == InternalError to detect a truncated gRPC
    stream, should match IncompleteStream { .. } / HttpClient(_), or ask
    is_retryable(). Not a wire change. The retry a retryable drop invites
    reconnects on the same WebSocketTransport (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, UNAUTHENTICATED is now
    ClientError::UnexpectedStatus { status: 401, .. } and PERMISSION_DENIED
    is UnexpectedStatus { status: 403, .. }, the shapes the JSON-RPC and REST
    bindings already report. They were Protocol(InvalidParams) on gRPC and
    Protocol(InternalError) on slimrpc, which told a caller with a revoked
    token to fix its request, and meant BearerAuthInterceptor never dropped a
    token a gRPC agent had refused: the next call sent it again. A peer's
    CANCELLED is now a non-retryable Protocol error (InternalError, or
    the A2A code its ErrorInfo names) instead of a retryable Timeout, so a
    call the peer abandoned is no longer re-sent. Migration: code matching
    Protocol(e) with e.code == InvalidParams to detect an authentication
    failure over gRPC should match UnexpectedStatus { status: 401 | 403, .. }.
    A peer's cancellation now reports is_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_card refuses 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.0 and 1-preview all count as
    major 1.

  • Graceful shutdown ends in-flight tasks before it drains connections.
    Server::serve_with_shutdown used 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::shutdown then 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, no CancelTask
    downstream. The order is now: stop accepting; let tasks finish on their own
    for up to ServeConfig::completion_grace (5 s); cancel the rest and give
    their executors task_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
    ending TASK_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
    cancel hook run, as CancelTask would. 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
    terminationGracePeriodSeconds default.

  • Every shipped TaskStore refuses to move a terminal task to a different
    state
    , atomically with the write, answering UnsupportedOperation with a
    TerminalStateConflict in 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,
    axum feature; audit N38), as its errors and RestDispatcher's responses
    already did. HTTP+JSON responses stay application/json, a deliberate
    deviation from spec §11.1's SHOULD for application/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, axum feature; audit N37). It answered {"error": "<text>"}; it
    now answers {"error": {"code", "status", "message", "details"}} through
    the REST dispatcher's own builders. A client that read error as a string
    from the adapter reads error.message. REST's own route and body errors
    gain the status name they lacked.
  • A refused credential answers 401/403, not 400 (server; audit N36,
    ADR 0014). The auth interceptors answered every refusal as
    InvalidRequest: HTTP 400 on both HTTP bindings, gRPC
    INVALID_ARGUMENT. Now a missing or wrong credential answers 401 with a
    WWW-Authenticate challenge (Bearer realm="a2a", or
    ApiKey header="x-api-key") and gRPC UNAUTHENTICATED; the AIP-193 body's
    status says UNAUTHENTICATED. JSON-RPC answers the same 401 with the
    body unchanged (-32600); batches stay 200. WebSocket is unchanged (the
    body alone). Migration: a client or alert that matched 400 for an
    auth failure matches 401/403.
  • The SLIMRPC binding's server answers a refused credential
    UNAUTHENTICATED or PERMISSION_DENIED
    (binding; audit N36). It mapped
    by error code alone, so the same refusal went out as INVALID_ARGUMENT and
    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.
  • SendMessage refuses a part whose mediaType the agent card does not
    declare, with ContentTypeNotSupportedError
    (server; audit N34). When
    the card declares input modes — defaultInputModes or any skill's
    inputModes — a part carrying an explicit mediaType outside 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 a mediaType are
    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-required is
    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 a stream_lagged error 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 WebSocketTransport whose socket has dropped reconnects
    (client; audit N18) instead of failing with a non-retryable Transport
    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 SubscribeToTask stream 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
    found cargo update -p a2a-protocol-client leaving 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) and tokio (RUSTSEC-2025-0023, unsound). The floors
    are now rustls >=0.23.45, ring 0.17.12, bytes 1.11.1, sqlx 0.8.1,
    time 0.3.47 and tokio >=1.44.2; each declares a rust-version at 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_complete and CallOutcome. after runs only
    on success and its error replaces the response, so no hook saw a failed
    or abandoned call: an interceptor that acquired something in before —
    an in-flight gauge, a concurrency slot, an open audit record — had no
    place to release it. on_complete is called once per call, in reverse
    order, on every interceptor whose before ran (including the one that
    refused the call), with Succeeded, Failed(&ServerError) — the error
    the caller is sent — or Cancelled, 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. Every RequestHandler method, on every binding, runs through it;
    calls made with ServerInterceptorChain::run_before and run_after
    directly do not. A new test drops a real JSON-RPC and HTTP+JSON
    connection mid-call and sees Cancelled in on_complete and cancelled
    in the rpc.server.call record — 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 (with with_history_length and
    with_tenant), and a2a_protocol_server::CancellationToken
    (audit
    N16). A custom store or test built a Task, and every GetTask caller a
    TaskQueryParams, with a full struct literal that breaks when a field is
    added; and an executor that stored its RequestContext::cancellation_token
    needed its own tokio-util dependency at a matching version to name the
    type. The re-export reaches the SDK as a2a_protocol_sdk::server::CancellationToken.

  • testing::ScriptedPeer (client feature testing): 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_shutdown and
    WebSocketDispatcher::serve_with_shutdown
    , with with_completion_grace,
    with_task_grace and with_drain_timeout on both dispatchers (defaults
    DEFAULT_COMPLETION_GRACE, DEFAULT_TASK_GRACE, DEFAULT_DRAIN_TIMEOUT).
    Both take a bound listener and a signal and return the same ServeReport as
    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 sent GOAWAY as 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 in release.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 .crate was 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.sh installs a pinned cargo-mutants 27.1.0,
    patched so that a function returning a Result alias (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.sh proves it fails when
    the push token stops reaching a Go webhook.

  • RequestHandler::finish_in_flight and RequestHandler::cancel_in_flight,
    returning InFlightReport; ServeConfig::completion_grace and task_grace
    (DEFAULT_COMPLETION_GRACE, DEFAULT_TASK_GRACE); ServeReport::tasks; and
    Dispatcher::request_handler, defaulted to None and overridden by the
    JSON-RPC and REST dispatchers. Call finish_in_flight yourself when
    something else owns the sockets — examples/deploy-agent does, behind Axum.

  • impl From<ClientError> for A2aError, so an executor that delegates can
    use ?. A downstream Protocol error passes through unchanged; exactly the
    errors is_retryable() accepts fail the task as Transient, everything
    else as Internal, with the client's text and source chain kept. The server
    now honours a class recorded with the new failure::set_error_class /
    failure::error_class, because no error code can express Transient.

  • Client stream liveness bounds: ClientConfig::stream_idle_timeout and
    stream_first_event_timeout (both 5 minutes by default, with builder
    setters), applied by A2aClient to 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 hang next() 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) and EventStream::with_max_event_size.

  • CallInterceptor::on_error and TokenProvider::invalidate, both
    no-op by default. BearerAuthInterceptor uses them so an OAuth2 token the
    agent answered with 401 is not sent again; the refused call still fails.
    On every binding: gRPC's UNAUTHENTICATED and a WebSocket handshake refused
    with 401 reach it as UnexpectedStatus { status: 401, .. } since the two
    entries under Breaking Changes.

  • OAuth2ClientCredentials::with_failure_backoff (1 s), Method::returns_empty,
    and push::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 through save, which is exactly what the send path
    did before, so a store that does not override it behaves as it always has.
    InMemoryTaskStore overrides it to extend a Vec in place.

    task.history is 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_history is 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.md has the per-stage attribution showing what
    still dominates.

  • Spans for every inbound call, on every binding (ADR 0013; audit O1, O2,
    O4). With the tracing feature — on by default — each call runs in one
    SERVER span named lf.a2a.v1.A2AService/{Method}, with the semantic
    conventions' rpc.system.name, rpc.method, and on failure
    rpc.status_code and error.type; HTTP+JSON spans add http.request.method
    and http.route. The executor, event processor, push delivery and SSE
    writer run in child spans, the executor's carrying a2a.task.id and
    a2a.context.id. With otel and a tracing-opentelemetry layer, an
    inbound traceparent is the span's remote parent and the traceparent sent
    downstream names that recorded span instead of an id nothing records.
    InboundTracePolicy::Drop records no span for the call or anything it
    spawns. Gate: crates/a2a-protocol-sdk/tests/observability_e2e/, which
    failed on main.

  • rpc.server.call.duration in OtelMetrics, and Metrics::on_rpc_call
    with RpcCall for 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.type cancelled)
    (audit O5, O11). Additive: the callback defaults to a no-op.

  • Connection statistics are reported. serve, serve_with_addr and
    Server::serve_with_shutdown now call Metrics::on_connection_pool_stats,
    which nothing called, so the four a2a.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::IncompleteStream before None.
    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_timeout bounds 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, and go-sdk-interop pins it.

  • Wire: an HTTP+JSON stream's error frame is a google.rpc.Status
    ({"error":{code,status,message,details}}), the error's data a flat
    google.protobuf.Struct detail. 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) and A2A-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 only scheme://host[:port]: webhook URLs often embed secrets.

  • StringList reads a2a-go's bare-array scopes and null as 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_delta is now overridden by SqliteTaskStore and
    PostgresTaskStore
    , not only by InMemoryTaskStore. Until now a SQL
    deployment got nothing from it — no regression, since the trait's default
    delegates to save, 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 save save_status_delta
    InMemoryTaskStore 8,499µs 639µs
    SqliteTaskStore 122,071µs 37,216µs
    PostgresTaskStore 287,287µs 120,104µs

    Both overrides update the state column that list filters on and the
    updated_at that §3.1.4 orders by, either of which is wrong in a way no
    round-trip through get would reveal. The SQLite override deliberately does
    not delete the artifact journal that its save deletes: save has 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.latency uses the semantic conventions' buckets (5 ms to
    10 s) and is deprecated in favour of rpc.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 its le labels see new ones.

Fixed

  • A continuation answering input-required at 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 for input-required —
    the state it had just been sent. The executor's writer now marks the turn
    parked when the latest state it writes is input-required or
    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 SubscribeToTask stream over SSE ends at subscribe_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 at input-required never 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 stayed working
    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 after hook no longer orphans a send's task
    (server; audit N28). after ran between spawning the executor and
    attaching what persists its events, so its error left a task the agent
    went on to complete at submitted in 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.

  • WebSocketTransport reconnects after its connection drops (client;
    audit N18). Once its socket dropped, every later call failed at once with
    a non-retryable Transport("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 by connect_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
    GetTask made 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 a stream_lagged error, as a lagging reader
    does on the server.

  • A WebSocket peer that stops reading is closed by the idle timeout
    (server; audit N30). With with_idle_timeout set, 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 its max_connections slot 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 with TaskStatus::new, which leaves timestamp empty, so agents
    built on it served statuses with no time: spec §5.6.1 wants ISO 8601 UTC
    timestamps, ListTasks is 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 toward max_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 in a2a-protocol-types: A2aError::unauthenticated,
    A2aError::permission_denied, A2aError::auth_rejection and
    AuthRejection/AuthRejectionKind, for custom interceptors to get the
    same statuses.

  • SendMessage checks 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 no method, a bare value such as 1, 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_watcher is 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: RestDispatcher answers an overload with 503 and an oversized
    body with 413
    , as the axum adapter always has, instead of 500 and
    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. OtelMetrics added 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/verb aliases; it routes only message/stream of them and refuses
    the rest with MethodNotFound, as its own test asserts (audit N19).

  • The book's Rust examples compile. 127 of its 130 ignored blocks now
    compile (the other 3 are slimrpc, 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), a match on the #[non_exhaustive]
    SendMessageResponse with no wildcard arm (E0004), an
    Arc<RateLimitInterceptor> passed where a ServerInterceptor is required
    (E0277), &*writer on a queue writer with no Deref (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's json). 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_binding it is always JSON-RPC.
    • CORS read as on by default; it is off until with_cors.
    • "sqlx::PgConnection is not Send + 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 adds http_body_util::Limited, as
      the REST dispatcher does.
    • The custom authentication interceptor compared tokens with a HashSet
      lookup and did not mark itself authenticates(); 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 and A2aRouter already answer /health and /ready,
      and the JSON-RPC dispatcher does not, which the page now says.

    .book-ignore-baseline now holds only the three slimrpc blocks.
    Documentation only; no crate changed.

  • The configuration reference's defaults are the defaults.
    book/src/reference/configuration.md gave with_executor_timeout a
    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) or idempotency_key_ttl (24
    hours), or for require_version_header (on) in DispatchConfig and
    GrpcConfig. crates/a2a-protocol-sdk/tests/book_defaults.rs now 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; a Message literal
    and matches 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 in crates/README.md — is
    checked against the manifest by scripts/check_feature_tables.py, which
    found 17 missing or wrong rows. The book and root README no longer say
    default-features = false removes 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 (9007199254740993 is 9007199254740992).
    Against V8's JSON.stringify over 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 (every m × 2^e with odd m below 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). The signing feature now enables
    serde_json's float_roundtrip, which makes float parsing correctly
    rounded, and slower, in every crate of a build that enables signing.

  • Server::serve_with_shutdown sees its signal at the connection
    ceiling.
    It waited for a connection permit before it looked at the
    signal, so with ServeConfig::max_connections set 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 as ClientError::Transport, which is not retryable, so
    through From<ClientError> for A2aError a task whose token fetch met a
    restarting identity provider failed as Internal rather than Transient.
    It is now ClientError::HttpClient, as the transports report the same
    error. A token endpoint that answers 429 or 5xx is still Transport; that
    is recorded as open (audit N2).

  • Replicas starting together against an empty PostgreSQL database all come
    up.
    CREATE TABLE IF NOT EXISTS races in PostgreSQL: two sessions that
    both find a table absent both create it, and the second fails with a
    duplicate key in pg_type or pg_class. Every schema-creating constructor
    — PostgresTaskStore::{new, from_pool, with_migrations}, the tenant-aware
    task store, both push-config stores and PostgresRateLimitCounter — ran its
    DDL unlocked (with_migrations included, because it creates
    schema_versions before 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.
    CancelTask on replica B wrote and answered Canceled, and replica A's
    executor, which never heard of it, then wrote Completed over 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. Custom TaskStores are not
    covered unless they apply store::refuses_write inside their writes.

  • Agent cards from a2a-go with securityRequirements parse. 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 no result and 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 in build() as in selection; and build() 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
    UnexpectedStatus or Serialization("unknown variant …"). The stream-lag
    signal survives the new server frame: A2aError::is_stream_lagged is 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_size with
    EventTooLarge instead of silently truncating it past twice the limit.

  • RequestHandler::shutdown counts the live queues it destroys.
    ShutdownReport::queues_force_destroyed was 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_id accepted only the single task find_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 a contextId and no taskId starts a new task
    (§3.4.3), one participant posting without a taskId forked 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: a contextId differing from that of the
    referenced task. §3.4.1 says a contextId "logically groups multiple Task
    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 taskId that names no existing task now returns TaskNotFound
    (§3.4.2) rather than InvalidParams, whether or not the context happens to
    hold some other task. The handler already returned TaskNotFound for 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 "receives Lagged(n) and skips
    missed events". The consumer does not resume: InMemoryQueueReader::read
    turns the lag into A2aError::stream_lagged, which streaming::sse writes
    as an event: error frame 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.py now writes the canonical, share URLs, titles
    and one description per page into the built HTML before deploy, and
    docs.yml fails the deploy if any page is wrong; print.html and
    404.html are noindex, the sitemap drops introduction.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 from Cargo.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.py proves 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 unmaintained paste
    (RUSTSEC-2024-0436). That advisory is now deny.toml's first ignore, with
    its justification: it is informational, paste is 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 on paste.
    No source change was needed; both examples' tests pass.
  • scripts/check_panic_paths.py counts unreachable!, which panics as
    panic! does and was invisible to the gate. The one in library code — the
    JSON-RPC dispatcher's batch branch, after an is_array() test that made
    it redundant — is gone: the branch now matches the array directly.
  • scripts/preflight.sh builds with CARGO_INCREMENTAL=0, as CI does.
    ci.yml does not set it, but both actions every CI job starts with export
    it (read at their pinned SHAs). Without it a --full run kept 16 GB of
    incremental state CI never builds and nearly filled a 38 GB allowance.
    prove_gates_fail.sh already 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 gRPC UNAUTHENTICATED and
    PERMISSION_DENIED mappings 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), every google.rpc.Code name 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 default ServerInterceptor::on_complete is 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-003 is 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 the ContentTypeNotSupportedError §3.1.1 requires.
    Evidence in docs/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-types README, 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-types README, every version: "zero unsafe blocks
    anywhere in this crate". From 0.12.0 the published crate's build.rs
    contains unsafe { std::env::set_var("PROTOC", …) }, as do the client's
    and server's; #![forbid(unsafe_code)] covers src/ only. The 0.12.0
    release notes counted four such build scripts in the repository; there
    are five.
  • a2a-protocol-client README, every version: documents
    resubscribe(), get_authenticated_extended_card() and
    ClientBuilder::with_transport(). None exists in any published version;
    the methods are subscribe_to_task, get_extended_agent_card and
    with_protocol_binding / with_custom_transport.
  • a2a-protocol-client and a2a-protocol-server READMEs, every
    version:
    the signing feature is described as agent card signature
    verification ("signing verification" on the server). In both crates it
    only forwards a2a-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 via cargo-mutants CI 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_basic and a2a_media_type_accepted for JavaScript,
    a2a_media_type_accepted for 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.