Upgrading Between Minor Versions
The newest minor boundary this page covers is 0.14 → 0.15, whose breaking
section is ## [0.15.0] in
CHANGELOG.md.
(A patch such as 0.14.1 breaks no API, so it has no section of its own here.
Its security fix has one migration: PathSegmentTenantResolver now reads only
the request URL, so on gRPC and the Axum adapter, which carry no tenant in
the URL, it resolves none, and the request is served from the default tenant
or, under require_resolved_tenant(), refused. Use
BearerTokenTenantResolver, or HeaderTenantResolver behind a proxy that
sets the header and strips any inbound copy; the [0.14.1] entry in
CHANGELOG.md has the advisory.) A test,
book-tests/tests/upgrading_guide.rs, fails when the changelog calls a
release from 0.7 on breaking and this page has no ## X.Y → X.Z section for
its boundary (A2A v1.0.1 §1.4: migration guidance MUST be provided when
introducing major breaking changes).
This page is the migration guide the changelog is not. One section per minor
boundary that broke something or deprecated something you may use, newest
first; each names what breaks, shows the code before and after, and says when
the fix is a rename and when it is not. Every Rust "after" block on this page
is compiled against the current crates by a2a-book-tests; the "before"
blocks are fenced as text because they no longer compile, which is the
point.
The policy
STABILITY.md (adopted 2026-09-09) is the commitment. What it says, condensed:
- SemVer 2.0.0, in the
0.y.zsense. A minor release may contain breaking changes; a patch release may not. The four crates (a2a-protocol-types,-client,-server,-sdk) are versioned in lockstep and released together, so you bump all four at once. - What counts as breaking: whatever
cargo-semver-checkssays, plus any change to the JSON, gRPC or WebSocket bytes produced for a given input, or to which requests are rejected. Removing or renaming a public item, adding a required trait method, adding a variant or field to something that is not#[non_exhaustive], raising the MSRV, and changing what a default feature enables are all minor bumps, never patches. - The one patch-level exception: a correction to a demonstrated deviation from the A2A specification, shown by the official TCK, the ITK or a reference SDK. The changelog entry names the requirement it corrects.
- Deprecate first. The old item stays for at least one minor release with
#[deprecated(since = "X.Y.0", note = "...")]pointing at the replacement. Removal is no earlier than the following minor. The exception is a security fix that cannot be made without removing an unsafe surface. - Batch. At most one breaking minor per calendar month, unless the
release's changelog section carries a
**Cadence exception:**line giving a reason it cannot wait (0.14.0 does). Non-breaking fixes ship whenever they are ready. - Label. Every breaking change is listed under a
### Breaking Changesheading for that release, with the migration in the same entry. The GitHub release notes are extracted from that section. - Prove it. CI runs
cargo-semver-checksagainst the previous release on every pull request. A finding on a change meant to be compatible is a bug in the change. - The MSRV is part of the API. Raised only in a minor, only for a language
or standard-library feature that earns it or to adopt a new major version of
a direct dependency (0.15.0: 1.88 to 1.94, for
sqlx0.9), and never because a transitive dependency moved. - 1.0 is cut when two consecutive minors ship with no breaking change and
none pending in
ROADMAP.md; the official TCK reports no MUST failure outside the baselined, upstream-acknowledged rows;cargo-semver-checksruns on all published crates with all features; and the MSRV has been stable for six months. At 1.0 the deprecation window becomes two minors.
How to read a release's breaking section
Open the release's ## [x.y.z] heading in CHANGELOG.md and look for the
### Breaking Changes heading first. It is the first subsection when it
exists, and each bullet carries its own migration. Three things to know
about the older entries, because the heading was not always spelled the same
way:
- 0.9.0 used
### Breakingrather than### Breaking Changes. - 0.11.0 has no breaking heading at all. Its three breaking changes are
wire-format corrections under
### Changed, each prefixed BREAKING (wire format), because they change bytes on the wire without changing any Rust signature — which is exactly the class of changecargo-semver-checkscannot see. - 0.8.0 put its breaking changes under
### Removed(three scheduled deletions) and### Changed(two wire-format changes), with aWARNINGadmonition at the top for the one that can cause an outage.
From 0.12.0 onward, every breaking change is under ### Breaking Changes,
including ones also listed elsewhere; the 0.12.0 and 0.13.0 sections do this.
How to check your own code
If you publish a library that re-exports or wraps these crates, run
cargo-semver-checks
on your crate after bumping the dependency:
cargo install cargo-semver-checks --locked
cargo semver-checks
It reports which of your public items changed shape because a type you re-export changed shape. It is the tool this project's CI uses on its own crates.
If you are building an application, cargo-semver-checks has nothing to
say to you: it compares API surfaces, and an application has none. The honest
tool is the compiler, then your tests:
- Bump all four crates to the same version in
Cargo.toml. cargo build --all-targets. A change to a signature, a field or a type that your code names is a compile error; fix those from the section for your boundary. A change of behaviour, value or wire format compiles silently (theTaskStatediscriminants, a status code, a stricter check), so read the whole section for your boundary, not only the errors.cargo buildagain and read the warnings. Deprecations arrive as warnings one minor before they become errors, and each note names the replacement.#![deny(deprecated)]in your crate root turns them into errors if you would rather deal with them now.cargo test. The wire-format changes (0.8's card shape, 0.11's status codes and pushContent-Type) do not touch the compiler at all; only a test that asserts the old bytes, or a peer that does, will notice.- If a gateway, WAF or proxy sits in front of your agent, check its rules against the wire-format items for your boundary. They are the ones that fail outside your process.
What makes future additions non-breaking
Two conventions in these crates decide whether a new field costs you a compile error:
- A struct marked
#[non_exhaustive]cannot be built with a struct literal from outside its crate — not even with..Default::default(). You build it fromDefaultand set fields withwith_*methods. When such a struct gains a field, your code does not change.GrpcTransportConfigbecomes one in 0.12;ServeConfigalready is. - A struct that is not
#[non_exhaustive]breaks any literal that names every field the moment a field is added. A literal that ends in..Default::default()keeps compiling, and so does thewith_*form. Since 0.12 no configuration struct is in this group: STABILITY.md §4 lists the seventeen that carry the attribute (RetentionPolicyjoined them in 0.13). What remains here is most of the spec's wire types ina2a-protocol-types, which are deliberately built with literals.
So the habit that costs nothing today and saves a compile error later: build
configuration with Default::default() and with_* setters, and never with
a literal that names every field. Every setter on this page exists in the
current source.
Enums follow the same rule: ClientError, and the protocol enums that can
grow with the specification, are #[non_exhaustive], so a match on one
already carries a wildcard arm and a new variant does not break it.
0.14 → 0.15
These come from making the SDK follow the A2A 1.0 specification where it did
not, and from moving to current releases of sqlx, OpenTelemetry and SLIM.
CHANGELOG.md lists 26 breaking items under ### Breaking Changes and about a
hundred behaviour changes beside them. Each of the 26 has a subsection below;
a few need nothing from you but a newer toolchain or an AssertUnwindSafe.
The behaviour changes an existing deployment is most likely to trip over are
among the subsections too, and the last one points to the rest.
The MSRV is 1.94, and the SQL stores use sqlx 0.9
The minimum supported Rust version goes from 1.88 to 1.94 in all four crates
and in the SLIMRPC binding, so a toolchain older than 1.94 no longer builds
them. STABILITY.md §5 lets a minor release raise it to adopt a new major
version of a direct dependency, and this is that case: sqlx 0.9 needs 1.94,
and its pool types are in the SQL stores' public constructors
(SqliteTaskStore::from_pool takes a SqlitePool,
PostgresTaskStore::from_pool a PgPool, and so do the other SQL stores). A
crate that builds one of those pools itself moves to sqlx 0.9 with this
release; with two sqlx majors in the build, its pool is a different type
from the one the store names, and the call stops compiling. A store that opens
its own pool from a URL needs only the toolchain.
# 0.14
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
# 0.15
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
sqlx 0.9 also refuses a statement built at run time unless it is marked
sqlx::AssertSqlSafe; the library's own such statements are marked and built
from static text, so only a store of your own that formats SQL needs the same.
The two helpers the changelog lists as now taking &'static str
(pg_schema::apply, the SQLite add_applied_seq) are crate-internal, so
nothing you call changed.
save_appending_history takes a HistoryLimit
A TaskStore that overrides save_appending_history takes
limit: HistoryLimit where it took max_history: usize, and trims with
limit.trim(&mut history), which bounds bytes as well as messages (release
audit F2). A caller that passed a count passes
HistoryLimit::new(count, usize::MAX) for the old behaviour; the handler
passes TASK_HISTORY_LIMIT, 1,024 messages and 16 MiB.
a2a.server.latency is gone; units are UCUM
Dashboards and alerts on a2a.server.latency (Prometheus
a2a_server_latency_seconds) read a2a.server.request.duration
(a2a_server_request_duration_seconds) instead: the same measurement under
the conventions' name, with the conventions' current buckets, 0.1 ms to 100 s
(a2a.server.latency used 5 ms to 10 s; the other two call-duration
histograms moved too, see
Dashboards, alerts and rate-limit tables).
A stored series' le labels and quantiles mix both sets until the old series
ages out. It was deprecated in 0.14.0. Units of counts are brace-annotated
({request}); the other Prometheus names are unchanged.
A signed agent card declares the budget extension
The builder adds https://a2a-rust.com/extensions/budget/v1 to every card,
as it adds the failure taxonomy, and a signed card cannot be edited without
breaking its signature, so build() refuses a signed card that does not
declare it. Add it to capabilities.extensions (required: false) before
signing. An unsigned card needs nothing.
TenantLimits::max_stored_tasks is gone
The field, TenantLimits::with_max_stored_tasks and
TenantLimitsBuilder::max_stored_tasks are removed. Nothing ever read them
(see 0.9 → 0.10); a build that
used them stops compiling where it used to warn. Give the tenant its own
TaskStoreConfig with TenantAwareInMemoryTaskStore::with_tenant_override
instead.
Telemetry is the otlp feature; otel is on by default
On a2a-protocol-server directly, Telemetry, the Langfuse preset and
init_otlp_pipeline need features = ["otlp"]; otel now means the
instrumentation alone (OtelMetrics, the span bridge) and is a default
feature of the server and the client. On a2a-protocol-sdk both are default
and nothing changes. A build that must not link the OpenTelemetry API sets
default-features = false.
init_otlp_pipeline and init_otlp_pipeline_with_endpoint take Option<&str>
Wrap the service name in Some. It now names the service only when
OTEL_SERVICE_NAME (or service.name in OTEL_RESOURCE_ATTRIBUTES) does
not; a deployment that set both used to get the argument and now gets the
environment's value. None leaves the name to the environment and the SDK's
unknown_service default.
Cards and push configs that would leak or drop a credential are refused
build() and every card handler refuse a card with a credential in one of its
URLs (user information, or a query parameter named like a secret such as
token or api_key); move it out of the card. Creating a push config whose
authentication names a scheme but carries no credentials is refused with
InvalidParams on authentication.credentials; leave authentication out
for an unauthenticated webhook.
A card whose OAuth or OpenID Connect URLs are not URLs is refused
a2a.proto says each OAuth flow URL "MUST be in the form of a URL", and that
oauth2_metadata_url requires TLS. AgentCard::validate_security_schemes,
which build() and every card handler run (the static, dynamic and hot-reload
handlers, and per-caller extended cards), now refuses a flow URL, refresh URL
or openIdConnectUrl that is not scheme://authority..., and an
oauth2MetadataUrl that is not https. The deprecated implicit and password
flows' URLs are checked only when given, and must be https (the proto: "The
OAuth2 standard requires the use of TLS"). A card that carried a relative URL
built before and does not now; write the absolute one. A client runs the same
check on a card it receives (see
The client refuses what a2a.proto does not allow).
// 0.14 built; 0.15 refuses
{"oauth2SecurityScheme": {"flows": {"clientCredentials": {"tokenUrl": "/oauth/token", "scopes": {}}}}}
{"oauth2SecurityScheme": {"flows": {"clientCredentials": {"tokenUrl": "https://idp.example/oauth/token", "scopes": {}}}}}
An authenticator needs a scheme declared in the public card
Spec §13.3: agents "MUST authenticate requests using one of the schemes
declared in the public AgentCard.securitySchemes". build() now refuses an
authenticating interceptor (bundled or custom) behind a public card that
declares no securitySchemes, and a scheme declared on the extended card but
not, the same, on the public one. Declare the scheme your interceptor checks
in the public card: an httpAuthSecurityScheme with scheme Bearer for
BearerTokenAuthInterceptor or JwtAuthInterceptor, an
apiKeySecurityScheme naming the header for ApiKeyAuthInterceptor, an
mtlsSecurityScheme for MutualTlsAuthInterceptor
(Authentication
shows one), and repeat any extended-card scheme in the public card.
A card that demands authentication needs an interceptor that enforces it
Spec §3.3.2: "Servers MUST reject requests with invalid or missing
authentication credentials"; §7.4 and §13.3 add that a server authenticates
every request by its declared requirements, with one of the schemes the public
card declares. build() now refuses two configurations that built before,
with InvalidParams naming the fix:
- A card, or a skill, whose
securityRequirementsask for a credential when no registered interceptor authenticates. Such a handler served every request without the credential its card demanded. Register an authenticating interceptor (BearerTokenAuthInterceptor,ApiKeyAuthInterceptor,JwtAuthInterceptor, or your own that returnstruefromauthenticates()), or, when a gateway, mesh or sidecar in front of the process checks the credential, say so withRequestHandlerBuilder::authenticated_upstream(). A requirement list that contains an empty requirement ({}, optional authentication) asks for nothing, and needs neither. - An authenticating interceptor that checks none of the schemes the public card
declares, so a client following the card could never pass it. The new
provided method
ServerInterceptor::checks_schemetakes a&SecuritySchemeand answersOption<bool>: it is how an interceptor says which it checks. The bundled authenticators answer it, and a custom one that keeps the defaultNoneis trusted to check one of them.
The card and the interceptors must agree has the rules in full. Before, then after, for a card that asks for a bearer token that a gateway checks:
// 0.14: built, and served every request without checking the token
RequestHandlerBuilder::new(agent).with_agent_card(card_with_bearer_requirements).build()?;
use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _q| async { Ok(()) }); fn main() -> Result<(), Box<dyn std::error::Error>> { let card: AgentCard = serde_json::from_value(serde_json::json!({ "name": "my-agent", "description": "An example agent", "version": "1.0.0", "supportedInterfaces": [{ "url": "http://localhost:3000", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }], "capabilities": {}, "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "skills": [{ "id": "echo", "name": "Echo", "description": "Echoes text", "tags": ["echo"] }], "securitySchemes": { "bearer": { "httpAuthSecurityScheme": { "scheme": "Bearer" } } }, "securityRequirements": [{ "schemes": { "bearer": {} } }] }))?; let handler = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card) .authenticated_upstream() // the gateway in front checks the token .build()?; drop(handler); Ok(()) }
A push config the sender cannot authenticate is not delivered
Spec §13.2: agents MUST send the credentials authentication specifies.
HttpPushSender::send used to deliver with no Authorization header when a
stored config named a scheme other than Bearer or Basic, or no
credentials; it now refuses the delivery with InvalidParams. Creation
already refused such configs, so only one stored another way is affected.
sign_agent_card is deprecated, and refuses None
It takes key_id: Option<&str>, and in 0.14 None wrote a protected
header without kid, which spec §8.4.2 requires; it now returns
InvalidParams for None. It is removed in 0.16. Call
sign_card_es256(card, key, key_id), whose key_id is a &str; with a key
id it signs what sign_agent_card did, apart from the two changes in the
next subsection. For Ed25519, sign_card_ed25519.
A card signed by 0.14 may no longer verify
Spec §8.4.1 rule 1: the signed bytes are the card in protobuf field presence,
which leaves out a field holding its default value unless the field is
REQUIRED or declared optional. Canonicalization now does that. It drops an
empty capabilities.extensions, an extension's required: false or empty
description, an interface's empty tenant, and empty
securityRequirements; before, they were signed, so a card carrying one
canonicalized differently from the specification's own worked example. A
signature made by 0.14 over such a card therefore does not verify here, and
one made here does not verify under 0.14. A card with none of these fields is
unaffected. The protected header also carries typ: "JOSE" now (§8.4.2). The
server never signs the card it serves, so sign it again where you do, with
sign_card_es256 or sign_card_ed25519, and serve the new signature:
// 0.14: the signature covers the bytes 0.14 canonicalized
let signature = sign_agent_card(&card, pkcs8, Some("my-key"))?;
card.signatures = Some(vec![signature]);
#![allow(unused)] fn main() { use a2a_protocol_types::agent_card::AgentCard; use a2a_protocol_types::error::A2aResult; use a2a_protocol_types::signing::sign_card_es256; fn resign(card: &mut AgentCard, pkcs8: &[u8]) -> A2aResult<()> { let signature = sign_card_es256(card, pkcs8, "my-key")?; card.signatures = Some(vec![signature]); Ok(()) } }
ConvertError and SseParseError are #[non_exhaustive]
Build a ConvertError with ConvertError::new(field, reason), and give a
match on SseParseError a wildcard arm.
serve::Server is no longer UnwindSafe
With tls-rustls (a default feature of the SDK, not of a2a-protocol-server
alone), Server holds the TLS acceptor that Server::with_tls sets, built on
rustls' server configuration, which implements neither UnwindSafe nor
RefUnwindSafe; neither does Server now. cargo-semver-checks reports it
as auto_trait_impl_removed. Only code that moved a Server across
std::panic::catch_unwind notices, and the migration is the one the
InMemoryQueueWriter item in
0.11 → 0.12
gives:
// 0.14
let outcome = std::panic::catch_unwind(move || drop(server));
#![allow(unused)] fn main() { use std::panic::{AssertUnwindSafe, catch_unwind}; use a2a_protocol_server::serve::Server; fn drop_guarded(server: Server) -> bool { catch_unwind(AssertUnwindSafe(move || drop(server))).is_ok() } }
HTTP+JSON answers application/a2a+json unless asked otherwise
A response to a request that names no JSON type in Accept and sends no
application/a2a+json body is labelled application/a2a+json (§11.1's
SHOULD), on both RestDispatcher and the axum router; it was
application/json. A client that reads only application/json keeps getting
it by sending Accept: application/json. A request that asks only for
text/event-stream still gets application/json errors, so that released
a2a-go can read them. A JSON request body no longer decides the response
type on its own: an application/json body with no Accept is answered as
application/a2a+json.
A parked subscription stays open until its task finishes
HandlerLimits::subscribe_max_idle is an Option<Duration> and None by
default: a SubscribeToTask stream to a task parked in input-required or
auth-required runs until the task reaches a terminal state or the client
goes, as §3.1.6 requires. It used to end after five minutes with no terminal
state, and the client resubscribed. with_subscribe_max_idle(d) still sets a
bound and takes a Duration as before; code that read the field compares
against Some(d) now. A bound trades conformance for reclaiming subscriptions
whose clients are connected but no longer reading; a client that has gone is
noticed without one (the field's rustdoc says how, per binding).
TaskState numbers its variants as the proto does
TaskState is now generated from a2a.proto (spec §1.4, ADR 0016), and each
variant's discriminant is its proto number. TaskState::Completed as i32 was
5 (its place in the old declaration) and is now 3, as TASK_STATE_COMPLETED
is in the proto and in the prost enum. Only code that casts a TaskState to
an integer sees a difference; TaskState::ALL lists the variants as before.
The wire form, "TASK_STATE_COMPLETED", is unchanged.
build() refuses an agent card the spec does not allow
Spec §5.7: a required array MUST contain at least one element.
RequestHandlerBuilder::build now runs AgentCard::validate, which checks
defaultInputModes, defaultOutputModes, skills, and every skill's tags,
and the card's description and version, which a2a.proto marks REQUIRED,
as well as the name and interfaces it checked before. It also refuses an empty
REQUIRED string of an interface (url, protocolBinding, protocolVersion),
of the provider (organization, url), of a skill (id, name,
description) and of a signature (protected, signature). A card missing
any of them used to be served as it was. Add a description and a skill:
#![allow(unused)] fn main() { use a2a_protocol_sdk::prelude::*; let url = "http://localhost:3000"; let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc(url)) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])); assert!(card.validate().is_ok()); }
AgentCard::new now starts both mode lists at ["text/plain"], so a card
built with it only needs the description and the skill. build() also runs
AgentCard::validate_security_schemes, which refuses a security scheme whose
REQUIRED string is empty, such as an API key with no name (and an OAuth or
OpenID Connect URL that is not a URL; see
that subsection).
A DynamicAgentCardHandler whose producer
returns an invalid card answers 500, HotReloadAgentCardHandler refuses
to reload one and keeps serving the previous card, and
StaticAgentCardHandler::new returns InvalidParams for one.
The client refuses what a2a.proto does not allow
Spec §5.7: implementations SHOULD reject a message missing a field the proto
marks REQUIRED. The client now holds what it receives to the rules the server
holds its own executors to, on every binding. A task with an empty id, a
status in TASK_STATE_UNSPECIFIED, a message without a messageId, role or
parts, an artifact without an artifactId or parts, a status or artifact
update without its taskId or contextId, and a push config without a url
or with an authentication that has no scheme, fail the call with
ClientError::InvalidAgentResponse, which names the call and the field; on a
stream, the event ends the stream with that error. They used to be returned as
if valid. Over gRPC such a field absent on the wire (proto3's "" or 0), or
a card's absent capabilities, is refused by the conversion where it used to
read as its zero value (below),
and any answer the conversion refuses is now InvalidAgentResponse rather
than Transport. Match InvalidAgentResponse to tell a non-conformant agent
from a transport fault; ClientError is #[non_exhaustive], so the new
variant breaks no match.
An agent card fetched by resolve_agent_card, fetch_card_from_url or
CachingCardResolver, or returned by get_extended_agent_card, must pass
AgentCard::validate and AgentCard::validate_security_schemes, the checks
build() runs above; the deprecated implicit and password OAuth flows'
URLs must also be https (the proto: "The OAuth2 standard requires the use of
TLS"). A refused extended card is not adopted. ClientBuilder::from_card does
not validate the card it is given, but no longer selects an interface whose
protocolVersion is empty.
Two requests change on the wire: list_tasks sends status_timestamp_after
in UTC with a Z (a value with an offset is rewritten; one that is not ISO
8601 is refused, unsent, with InvalidParams), and an inline push config's
task_id is no longer sent with SendMessage, as the proto asks.
The gRPC conversion refuses an absent REQUIRED field
proto3 reads an absent string as "", an absent enum as 0 and an absent
message as None. The TryFrom<pb::…> conversions of a2a-protocol-types
(the proto feature, which the gRPC binding uses) used to turn each into its
zero value, so a field the same JSON could not omit converted without it. They
now return a ConvertError whose reason is missing required field for
those fields: a task's id and its status's state, a message's message_id
and role, an artifact's artifact_id, a status or artifact update's
task_id and context_id, a card's name, description, version and
capabilities (no longer defaulted to the empty set), every interface's url,
protocol_binding and protocol_version, the provider's organization and
url, a skill's id, name and description, a signature's protected and
signature, an API key's name, an HTTP scheme's scheme, an
openIdConnectUrl, and the URLs of the authorization-code, client-credentials
and device-code flows.
A gRPC server answers a request carrying one INVALID_ARGUMENT with a
google.rpc.BadRequest violation naming the field; it used to reach the
handler, which refused most of them with the same code. A gRPC client reports
a response the conversion refuses as InvalidAgentResponse (above). Code that
calls the conversions itself gets the ConvertError where it got a value:
#![allow(unused)] fn main() { use a2a_protocol_types::proto as pb; use a2a_protocol_types::task::Task; // A peer that never set the task's `id` sends proto3's `""`. let err = Task::try_from(pb::Task::default()).unwrap_err(); assert_eq!(err.field, "task.id"); assert_eq!(err.reason, "missing required field"); }
Push configs the server cannot honour are refused at creation
A push config with an empty or scheme-less url, or with an authentication
scheme other than Bearer or Basic, answers InvalidParams. The second used to
be stored and then delivered with no credentials at all.
Push needs a push sender
Spec §3.3.4: when push is not supported, every push-config operation MUST
answer PushNotificationNotSupported. A handler with no push sender now
answers that to Get, List and Delete as well as Create; they used to run
against the store. And build() refuses an agent card that declares
capabilities.pushNotifications with no sender, since every push-config
operation would refuse what the card advertises. Wire one with
.with_push_sender(HttpPushSender::new()), or declare pushNotifications
false.
ClientBuilder::from_card follows the card's interface order
Spec §8.3.2: a client MUST select the first interface it supports, preferring
earlier entries in the card's list. from_card used to prefer JSON-RPC
wherever the card listed it; it now takes the card's first interface for
protocol 1.x and falls back down the list past one it cannot build. A card
listing HTTP+JSON before JSONRPC now gets HTTP+JSON. To keep the old
choice, rank the bindings yourself:
use a2a_protocol_sdk::prelude::*; fn main() -> Result<(), ClientError> { let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::rest("http://localhost:3001")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])); let client = ClientBuilder::from_card_preferring(&card, &["JSONRPC".into()])?.build()?; drop(client); Ok(()) }
ClientConfig::preferred_bindings and its setter are deprecated: nothing
reads them. Use from_card_preferring to rank, and
ClientBuilder::chosen_interface to see what was chosen.
A client built from a card checks the card's capabilities
Spec §3.3.4: a client SHOULD check the agent card before an operation that
needs an optional capability. A client from ClientBuilder::from_card (or
A2aClient::from_card) now does: streaming and resubscription need
capabilities.streaming, the push-config methods and a send carrying an
inline push config need pushNotifications, and get_extended_agent_card
needs extendedAgentCard, each set to true. Otherwise the call returns
ClientError::CapabilityNotDeclared and nothing is sent.
A conformant agent refuses the same calls, so code that already handled
UnsupportedOperationError or PushNotificationNotSupportedError from such
an agent should handle this error the same way. For an agent that streams or
takes push configs without declaring it, opt out:
use a2a_protocol_sdk::prelude::*; fn main() -> Result<(), ClientError> { let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3001")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])); let client = ClientBuilder::from_card(&card)? .without_capability_checks() .build()?; drop(client); Ok(()) }
The CLI's equivalent is --no-capability-check. A client built for a bare
endpoint with ClientBuilder::new has no card and checks nothing.
A client built from a card refuses a required extension it did not ask for
a2a.proto, AgentExtension.required: "If true, the client must understand and
comply with the extension's requirements". When the card marks an extension
required: true and with_extensions does not ask for its URI,
ClientBuilder::build now fails with ClientError::Protocol carrying code
ErrorCode::ExtensionSupportRequired, whose message names the extension; it
used to build, and the agent refused every call. Ask for the extension, or
call skip_required_extension_check() when an interceptor of yours sends the
A2A-Extensions header instead. A client built with ClientBuilder::new has
no card and is unaffected.
use a2a_protocol_sdk::prelude::*; use a2a_protocol_types::extensions::AgentExtension; fn main() -> Result<(), ClientError> { const CONSENT: &str = "https://ext.example/consent/v1"; let mut consent = AgentExtension::new(CONSENT); consent.required = Some(true); let mut card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3001")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])); card.capabilities.extensions = Some(vec![consent]); let client = ClientBuilder::from_card(&card)? .with_extensions([CONSENT]) .build()?; drop(client); Ok(()) }
build() requires an agent card
Spec §8.1: an A2A server MUST make an agent card available.
RequestHandlerBuilder::build now refuses a handler without one. Before, it
built, answered /.well-known/agent-card.json with 404, and skipped every
check a card would drive: streaming and push-notification capabilities, input
modes, required extensions. Give it the card your agent publishes:
use a2a_protocol_sdk::prelude::*; struct MyAgent; agent_executor!(MyAgent, |_ctx, _q| async { Ok(()) }); fn main() -> Result<(), Box<dyn std::error::Error>> { let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])) .with_streaming(true); let handler = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card) .build()?; drop(handler); Ok(()) }
Declare what the agent does: with a card, a streaming call to an agent that
does not declare streaming is refused, as the spec requires. A handler whose
card is published by something else, or one that is not a public A2A server
(a test), keeps the old behaviour with .without_agent_card().
An authenticating interceptor isolates tasks by caller
Spec §13.1: results are scoped to the caller's authorization boundaries. A
handler with an authenticating interceptor now gives each caller it
identifies (a labelled token or key, a JWT's sub, or a custom interceptor's
set_caller_identity) a partition of its own, and another caller's task is
TaskNotFound. Callers it does not name keep sharing the tenant's records, so
a deployment on unlabelled tokens or keys sees no change in what each caller
can reach; its default stores do become the tenant-aware in-memory ones.
Their capacity (with_task_store_config, 10,000 tasks by default) now applies
to each caller's partition, up to 1,000 partitions; supply a
TenantAwareInMemoryTaskStore with your own TenantStoreConfig to bound the
total differently.
Two configurations that built before now fail build(), each naming its
remedy: an authenticating interceptor with a task or push-config store you
supplied that does not partition by tenant (use the TenantAware* stores),
and an approval gate that wants an approver other than the requester,
including the one Profile::Auditable installs (or let requesters approve
with ApprovalGate::allowing_self_approval()). An explicit
isolate_tasks_by_caller() without an authenticating interceptor still fails,
as before. Where callers are meant to see each other's tasks, say so:
fn card() -> a2a_protocol_types::agent_card::AgentCard { use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard, AgentInterface, AgentSkill}; AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])) .with_capabilities(AgentCapabilities::none().with_streaming(true)) .with_security_schemes(std::collections::HashMap::from([( "bearer".to_owned(), a2a_protocol_types::security::SecurityScheme::Http( a2a_protocol_types::security::HttpAuthSecurityScheme { scheme: "Bearer".to_owned(), description: None, bearer_format: None, }, ), )])) } use a2a_protocol_sdk::prelude::*; use a2a_protocol_sdk::server::InMemoryTaskStore; struct MyAgent; a2a_protocol_sdk::server::agent_executor!(MyAgent, |_ctx, _q| async { Ok(()) }); fn main() -> Result<(), Box<dyn std::error::Error>> { let handler = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card()) .with_task_store(InMemoryTaskStore::new()) .with_interceptor(BearerTokenAuthInterceptor::with_labelled_tokens([ ("token-a", "alice"), ("token-b", "bob"), ])) .share_tasks_across_callers() .build()?; drop(handler); Ok(()) }
A tenant needs stores that isolate tenants
A request that resolves to a non-empty tenant, named by the client or derived
by a TenantResolver, is now refused with UnsupportedOperation (-32004 on
JSON-RPC, 400 on HTTP+JSON) unless both the task store and the push-config
store answer true from the new isolates_tenants(). The default stores
ignore the tenant, so such a request used to be served from records every
tenant shared: on 0.14.1 tenant B could get, list, subscribe to and cancel
tenant A's task. The six TenantAware* stores answer true, the default
stores false, and the built-in wrappers what the store they wrap answers.
Requests without a tenant are unaffected. Configure a TenantAware* task
store; an unset push-config store then defaults to
TenantAwareInMemoryPushConfigStore. Where tenants only key per-tenant limits
over records you mean every tenant to share, say so with
RequestHandlerBuilder::accept_unisolated_tenants(). A custom TaskStore or
PushConfigStore that partitions by tenant must override isolates_tenants()
to return true, and a wrapper store must forward it.
// 0.14: a request naming tenant "acme" was served from the shared records
RequestHandlerBuilder::new(agent).with_agent_card(card).build()?;
use a2a_protocol_sdk::prelude::*; use a2a_protocol_server::TenantAwareInMemoryTaskStore; struct MyAgent; agent_executor!(MyAgent, |_ctx, _q| async { Ok(()) }); fn main() -> Result<(), Box<dyn std::error::Error>> { let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])); // Each tenant gets records of its own. let isolated = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card.clone()) .with_task_store(TenantAwareInMemoryTaskStore::new()) .build()?; // Or: tenants key limits over records every tenant shares. let shared = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card) .accept_unisolated_tenants() .build()?; drop((isolated, shared)); Ok(()) }
CachingCardResolver trusts the card's max-age
Spec §8.6.2: clients SHOULD honor RFC 9111 when fetching agent cards.
CachingCardResolver::resolve used to send a request on every call; it now
returns the stored card without one while the card is fresh, for its
Cache-Control: max-age (or Expires). A card the agent changes is seen
once the stored one goes stale, which against this SDK's servers is up to
agent_card_max_age (default 3600 s) later. To see a change at once, as
before, build the resolver with .with_always_revalidate(true), or lower
the server's max-age. with_options now also starts the resolver with an
empty cache, so a clone given other options no longer shares its original's
stored or extended card.
Push webhook timeouts: 10 s per request, 30 s per delivery
HttpPushSender's request timeout goes from 30 s to 10 s and
HandlerLimits::push_delivery_timeout from 5 s to 30 s, both inside the
10-30 s that spec §4.3.3 recommends. The old pair let one attempt of the
sender's three run; now all three start and two always finish. A webhook
that needs more than 10 s per request needs HttpPushSender::with_timeout.
Because the delivery bound now equals push_delivery_budget, an event's
deliveries run all at once instead of 17 at a time; to bound how many are in
flight, raise push_delivery_budget or lower the delivery bound.
Only A2A-Version: 1.0 is served
Spec §3.6.2: a request is processed with the semantics of the version it
asks for, and a server that does not support that version MUST answer
VersionNotSupportedError. This server implements 1.0 only, so a request
naming 1.1, or a bare 1, is now refused with an error that says it
supports 1.0; a patch number is still ignored (1.0.1 is 1.0). It used to
accept any 1.x and serve it as 1.0, which is also what the official Python
SDK does. Every client in this SDK, and every peer the conformance suites
run against, sends 1.0.
The Axum adapter (A2aRouter) now enforces the version, and the request media
type, as RestDispatcher does. A request that names no version, in the
A2A-Version header or the A2A-Version query parameter, is
VersionNotSupportedError (unless the DispatchConfig given to
A2aRouter::with_config has with_require_version_header(false)), and a
request body that is neither application/json nor application/a2a+json is
ContentTypeNotSupportedError. The agent card, /health and /ready stay
versionless. A client that called the router without the header must now send
it.
OpenTelemetry 0.33 for the otel and otlp features
opentelemetry, opentelemetry_sdk, opentelemetry-otlp and the rest of the
family move from 0.32 to 0.33.1, and tracing-opentelemetry from 0.33 to
0.34.0. Types from them appear in Telemetry's API, so move your own
dependencies on these crates to the same versions. Export now retries a
failed batch (four attempts by default), which is opentelemetry-otlp's new
default, and shutdown reports only exports that were lost after retrying,
on gRPC as well as OTLP/HTTP.
SLIMRPC is built on agntcy-slim-rpc 3.4
a2a-protocol-slimrpc goes from 0.6.0 to 0.7.0. It requires 0.15 of the
server, client and types crates, and moves its SLIM requirements to the line
agntcy-slim-rpc 3.4.1 needs: agntcy-slim-rpc 2.3 → 3.4, agntcy-slim-auth
0.15 → 0.17, agntcy-slim-datapath 0.18 → 0.19, agntcy-slim-service
0.12 → 0.13 and agntcy-slim-config 0.16 → 0.17; agntcy-slim-session stays
at 0.7. They are public dependencies: SlimRpcServer::from_app,
SlimRpcTransport::from_app, SlimRpcMulticast::from_app and the builders'
with_identity take SLIM types, so pass types from the same SLIM versions.
The binding's own code needed no change. Over the wire, SLIM 3.x sessions are
MLS and verify the peer's identity token on the request that sets one up; a
peer on the 2.x line was not tried against this binding. The binding is
outside the root workspace and versioned on its own, as in
0.12 → 0.13.
# 0.14, bindings/a2a-protocol-slimrpc 0.6.0
a2a-protocol-slimrpc = "0.6"
agntcy-slim-rpc = "2.3"
# 0.15, bindings/a2a-protocol-slimrpc 0.7.0
a2a-protocol-slimrpc = "0.7"
agntcy-slim-rpc = "3.4"
A stream that falls behind catches up instead of ending
A reader slower than the agent used to end with streamLagged as soon as the
broadcast queue overwrote an event it had not read. It now reads what it
missed from the task's event log and goes on (spec §3.5.2), on every store that
keeps a log, which is every built-in one. A test that provokes streamLagged
with a small with_event_queue_capacity no longer sees it there; also bound
the log below what the stream misses
(TaskStoreConfig::with_max_events_per_task) and, since an attached stream
now holds what it owes past that bound, leave it no room to
(with_max_held_events_factor(Some(1))). Lowering
HandlerLimits::subscribe_replay_limit no longer provokes it either: since
0.15.0 the limit is the size of each read of the log, and a longer replay is
read in pages (next subsection).
A replay longer than subscribe_replay_limit is read in pages
Spec §3.5.2: each stream MUST receive the same events in the same order.
HandlerLimits::subscribe_replay_limit (1,000 by default) bounds each read of
the task's event log now, not the replay. A stream that lagged by more than the
limit used to end with streamLagged, and a Last-Event-ID resume that
needed more than the limit stopped at it and went on live, past the positions
between the replay's end and where its receiver attached, with no error. Both
now read the log a page at a time, from where each page ended, delivering each
page before the next is read, and go to the live channel only after the last
page. The same holds for a turn a SubscribeToTask stream missed between
polls. A page the log cannot supply is still reported as streamLagged, once,
after what it did supply.
Memory stays bounded by the limit, but a client that resumes with
Last-Event-ID: 0 now receives the task's whole retained history on one
connection, where it received at most one page. A proxy, a client buffer or a
test that assumed the old cap sees more frames; the limit still sets the size
of each read, and with_subscribe_replay_limit sets it:
#![allow(unused)] fn main() { use a2a_protocol_server::HandlerLimits; let limits = HandlerLimits::default().with_subscribe_replay_limit(500); let _ = limits; }
ListTasks takes a pageSize from 1 to 100
A2A's proto bounds page_size to 1 through 100, and the server now refuses
anything else with InvalidParams (400 on HTTP+JSON). It used to clamp a
larger size to 1000 and read 0 as the default of 50. A client paging through
every task should ask for 100 at most; leaving pageSize out still lists 50.
The conformance kit's cancel check is cancel_succeeds
The conformance feature's cancel_emits_terminal check is renamed
cancel_succeeds; a CI step that greps for the old name finds nothing. It
failed a cancel hook that wrote no terminal state, because such a hook left
the task's streams without one. The handler now writes Canceled after such a
hook (spec §3.1.2), so the check passes it, and fails a hook that errors,
panics or overruns.
GetExtendedAgentCard with no extended card configured
A card that declares capabilities.extendedAgentCard and a handler built
without with_extended_agent_card now answers GetExtendedAgentCard with
ExtendedAgentCardNotConfiguredError (-32007), as §3.1.11 and §13.3
require; it used to serve the public card in the extended card's place. To
serve the same content as before, pass the public card to
with_extended_agent_card too:
use a2a_protocol_sdk::prelude::*; use a2a_protocol_sdk::server::RequestHandlerBuilder; struct MyAgent; agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) }); fn main() { let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])) .with_capabilities(AgentCapabilities::none().with_extended_agent_card(true)); let handler = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card.clone()) .with_extended_agent_card(card) .allow_unauthenticated_extended_card() .build() .unwrap(); let _ = handler; }
allow_unauthenticated_extended_card() is there because the extended card
here is the public card, so nothing secret is served. Spec §13.3 requires the
extended card endpoint to be authenticated, and by default a handler whose
card declares extendedAgentCard refuses to serve it unless an interceptor
authenticates; an extended card with more content than the public one needs
an authenticating interceptor instead.
A handler built with without_agent_card() and no extended card answers
UnsupportedOperationError (-32004) where it answered -32007: nothing on
it declares the capability. Both are HTTP 400.
An appended artifact chunk needs a part
An executor's TaskArtifactUpdateEvent with append: true and no parts is
refused by the event queue (InvalidParams), as one without append already
was: a2a.proto's Artifact.parts "Must contain at least one part" and makes
no exception for a chunk. An executor that ended a streamed artifact with an
empty lastChunk: true chunk sets lastChunk: true on the chunk carrying the
last part instead.
Request-field errors are ServerError::InvalidField
The handler's own checks of a request field (ids, a message's role, parts and
metadata, a push config's fields, ListTasks filters, REST query
parameters) return ServerError::InvalidField { field, message }, which
answers as InvalidParams does plus a google.rpc.BadRequest detail naming
the field. They returned ServerError::InvalidParams(message). The code, the
HTTP status and the invalid_params metric label are unchanged, and the
response gains the BadRequest detail on JSON-RPC, HTTP+JSON, WebSocket and
gRPC. The SendMessage id checks now name the field by its wire path, in the
violation's field and in the message: message.messageId,
message.contextId and message.taskId (they named message.id,
context_id and task_id), and message.referenceTaskIds, which was not
checked before. A client or test that matched the old strings matches the new
ones. A match or matches! on the variant is affected, and still compiles.
Match either variant, or the code:
#![allow(unused)] fn main() { use a2a_protocol_server::ServerError; use a2a_protocol_types::error::ErrorCode; fn is_invalid_params(err: &ServerError) -> bool { matches!(err, ServerError::InvalidParams(_) | ServerError::InvalidField { .. }) || err.to_a2a_error().code == ErrorCode::InvalidParams } let err = ServerError::InvalidField { field: "message.parts".into(), message: "message must contain at least one part".into(), }; assert!(is_invalid_params(&err)); }
An error's data detail is a ProtoJSON Any
The JSON-RPC, HTTP+JSON and WebSocket bindings carry an error's data as a
google.protobuf.Struct detail in the error's details array. Its fields
were written beside @type; they are now under value, as ProtoJSON writes
an Any holding a Struct:
{"@type": "type.googleapis.com/google.protobuf.Struct", "value": {"streamLagged": 6}}
A2aError::is_stream_lagged and dropped_event_count read both shapes, as
does the REST client's stream decoder. Code that read a detail's fields
directly, or a client in another language that reads them beside @type,
reads value instead.
The REST client sends application/a2a+json
The HTTP+JSON client labels a request body application/a2a+json, as §11.1
asks, and a non-streaming call asks for
application/a2a+json, application/json (a streaming call still asks for
text/event-stream). A GET or DELETE has no body and now no Content-Type.
a2a-java 1.3.0.Final and older refuse a request labelled anything but
application/json (ContentTypeNotSupportedError); 1.4.0.Final accepts it. A
client that must reach an older a2a-java server uses its JSON-RPC interface.
A SLIMRPC multicast lag is is_stream_lagged
A multicast member that fell behind ends with the typed streamLagged error,
as a lagging stream does on the JSON-RPC, HTTP+JSON, WebSocket and gRPC
bindings, so ClientError::is_stream_lagged() and dropped_event_count()
read it. Code that matched the old message text ("event(s) dropped")
matches is_stream_lagged() instead. A unicast SLIMRPC stream's lag is
still the server's error text, without the marker.
A JSON-RPC notification gets no reply
A JSON-RPC request with no id member is a notification, and "the Server
MUST NOT reply to a Notification, including those that are within a batch
request" (JSON-RPC 2.0 §4.1). The server ran one and answered it with
"id": null; it now runs it and answers nothing: 204 No Content over HTTP
for a notification alone, a batch's array without the notifications' answers
(204 when every member is one), and no frame over WebSocket. A streaming
method sent as a notification is not run, as in a batch: a stream is its
only answer. A client that sent requests without an id and read the reply
sends an id. "id": null is a call, and is still answered.
A task snapshot cannot move a task back to Submitted
An executor's Task snapshot that changes the task's state is held to the
transitions a status update is. For one moving a task back to Submitted,
the background processor marks the task failed, and a blocking send answers
InvalidStateTransition (an InvalidParams error), as each does for such a
status update. One leaving a terminal state is refused. A snapshot in the
task's current state is stored as before.
ClientRequest gains query_params
An API key that the agent's card places in the query string is sent there
now, so a request reaches the transports with query parameters as well as
headers. ClientRequest has a new public field, query_params, and a struct
literal that names every field no longer compiles. Build the request with
ClientRequest::new, which leaves it empty:
#![allow(unused)] fn main() { use a2a_protocol_client::ClientRequest; let mut req = ClientRequest::new("GetTask", serde_json::json!({ "id": "t-1" })); assert!(req.query_params.is_empty()); req.query_params.push(("api_key".into(), "k".into())); }
A custom Transport still compiles: the new send_request_with_query and
send_streaming_request_with_query methods have defaults, which send a call
with no query parameters as before and refuse one with them
(ClientError::InvalidAuthScheme). Override both if your transport has a URL
to put them in. gRPC, WebSocket and the SLIMRPC binding refuse such a call.
A caller is named by how it authenticated
The bundled interceptors record bearer:<label>, api-key:<label> and
jwt:<issuer>:<sub> where they recorded the bare label or sub, so one sub
from two issuers is two callers. Two things follow here, and a third is the
next subsection.
An approver allowlist, or anything else that compares against an identity,
names the new form. auth::caller_id builds it:
#![allow(unused)] fn main() { use a2a_protocol_server::approval::ApprovalGate; use a2a_protocol_server::auth::caller_id; let gate = ApprovalGate::new().with_approvers([ caller_id::bearer("alice"), caller_id::jwt(Some("https://idp.example"), "bob"), ]); let _ = gate; }
Records a SQLite or PostgreSQL tenant-aware store keeps under caller
isolation are keyed by the old name. Move them once, before serving with
0.15, or each caller's tasks and webhooks are not found. The old key does not
say how the caller authenticated, so you give the map; the helpers cover a
deployment that used one authenticator. migrate_caller_keys exists on the
SQLite and PostgreSQL stores (the sqlite or postgres feature); the
in-memory stores keep nothing across a restart and need no migration:
#![allow(unused)] fn main() { async fn example() -> Result<(), Box<dyn std::error::Error>> { use a2a_protocol_server::store::caller_keys; use a2a_protocol_server::store::tenant_sqlite_store::TenantAwareSqliteTaskStore; let tasks = TenantAwareSqliteTaskStore::new("sqlite:tasks.db").await?; let moved = tasks.migrate_caller_keys(caller_keys::bearer_labels()).await?; println!("{} partitions, {} rows", moved.partitions, moved.rows); Ok(()) } }
Run it on the push-config store too, with its cipher set: a sealed config's
context names its partition, and the migration seals it again under the new
one. For several authenticators, pass your own Fn(&str) -> Option<String>
returning the new identity of each old name, or None to leave it.
A JWT with no sub is refused under caller isolation
A JWT with no sub authenticates and names nobody. Under the default caller
isolation it used to be served the tenant's shared records, which handed an
anonymous token every unattributed task. It is now refused with
PermissionDenied, unless callers share tasks
(RequestHandlerBuilder::share_tasks_across_callers()), which keeps the shared
records for it. A JWT issuer that omits sub needs one added, or the handler
needs share_tasks_across_callers() as in
An authenticating interceptor isolates tasks by caller.
JwtValidator refuses an HS256 secret shorter than 32 bytes
RFC 7518 §3.2 requires an HS256 key at least as long as the hash output, so
with_hs256_secret (the auth-jwt feature) now takes a secret of at least
MIN_HS256_SECRET_LEN (32) bytes. It took any length, down to one byte, and a
guessable secret let anyone who guessed it mint tokens the server accepted. A
shorter one fails closed: the call logs an error and stores nothing, so
every HS256 token is rejected. A deployment configured with a short secret
therefore stops accepting HS256 tokens on upgrade, with a log line to say why.
Move to a secret of at least 32 bytes, and use try_with_hs256_secret, which
returns the error so the process can refuse to start. RS256 and ES256 keys are
not affected.
// 0.14: any length was stored
let validator = JwtValidator::new().with_hs256_secret(b"hunter2".to_vec());
fn main() -> Result<(), Box<dyn std::error::Error>> { use a2a_protocol_server::auth::jwt::JwtValidator; assert!(JwtValidator::new().try_with_hs256_secret(b"hunter2".to_vec()).is_err()); // Load yours from a secret store; it must be at least 32 bytes. let secret = b"0123456789abcdef0123456789abcdef".to_vec(); let validator = JwtValidator::new().try_with_hs256_secret(secret)?; let _ = validator; Ok(()) }
What an executor may write is checked
The event queue every executor writes through now holds what it writes to
rules the server already held a client to. A write that breaks one returns
InvalidParams naming the field, before the event is stored or sent:
- A message with no
messageId, role or parts, or inROLE_UNSPECIFIED; an artifact with noartifactIdor parts; a status inTASK_STATE_UNSPECIFIED. These are refused wherever they sit: in the event itself, or inside a status update or a task snapshot. - A status, artifact or task event that names another task or context than the running one. An empty task or context id gets the running task's.
- What the gRPC binding cannot carry: a
rawpart that is not base64 (one in URL-safe or unpadded base64 is kept in standard padded form), an integer beyond ±2^53 in metadata or adatapart, andmetadatathat is not a JSON object. Over JSON they were stored and served, and the task then answeredINTERNALover gRPC.
Five more things change for an executor and the streams it feeds:
- A
Messagewritten after the task stream began is streamed as aTaskStatusUpdateEventthat keeps the state the turn last wrote and carries the message instatus.message. A follow-up (a message naming the task'staskId) answered with aMessagegets a task stream, aTaskand then that update, and a blocking follow-up gets theTaskwith the message as its status message. A new task's first-eventMessageis still a direct reply. - A streaming send whose first event is a
Messageis the message-only stream §3.1.2 describes: that oneMessage, then the stream closes. It used to lead with aTasksnapshot, so the stream was[Task, Message]. An executor that wants a task stream and narrates first writes a status update before its firstMessage. - Events written after the terminal status are not streamed, and a stream ends once the terminal event is delivered.
- A non-append artifact update for an id the task already holds replaces that artifact, in the blocking and background paths alike; it stored a second one.
RequestContext::activated_extensions()lists only the extensions the card declares, in request order. An executor that read an undeclared one no longer finds it: declare it on the card. Without a card the list passes through.
A client that read a mid-task Message event reads status.message on the
status update instead:
#![allow(unused)] fn main() { use a2a_protocol_types::{Message, StreamResponse}; fn narration(event: &StreamResponse) -> Option<&Message> { match event { // 0.14 also delivered a mid-task `StreamResponse::Message`. StreamResponse::StatusUpdate(update) => update.status.message.as_ref(), _ => None, } } }
Dashboards, alerts and rate-limit tables
-
Error labels.
a2a.server.errors'errorlabel and the audit trail'scallrecords saidprotocolfor every A2A error a binding sent as it was. They now sayunauthenticatedorpermission_deniedfor a refused credential, and the error code's label (version_not_supported,content_type_not_supported, ...) for any other. -
Histogram buckets.
rpc.server.call.duration,rpc.client.call.durationanda2a.server.request.durationuse the semantic conventions' current buckets, 0.1 ms to 100 s, where they used 5 ms to 10 s. The boundaries are part of a series' identity, so a stored histogram's quantiles across the upgrade mix the two sets until the old series ages out. -
The executor's span is
invoke_agent {card name}where it wasa2a.execute. An alert or query on the old name matches the new one, or the handler keeps the old span:#![allow(unused)] fn main() { use a2a_protocol_server::RequestHandlerBuilder; fn old_span_name(builder: RequestHandlerBuilder) -> RequestHandlerBuilder { builder.with_agent_span_conventions(false) } } -
error.typeonrpc.server.call.durationand the server span marks a server failure only, by each binding's conventions: every JSON-RPC error code; a 5xx but not a 4xx on HTTP+JSON;UNKNOWN,DEADLINE_EXCEEDED,UNIMPLEMENTED,INTERNAL,UNAVAILABLEandDATA_LOSSon gRPC. It was set for every status but success.rpc.status_codestill carries every status, so a dashboard counting client errors reads that instead. -
Rate-limit keys.
RateLimitInterceptorkeys areid:<identity>,ip:<address>,tenant:<id>andanon; an identity was the bare key. A caller no interceptor identified shared one"anonymous"bucket, and with no trusted proxy hops it is now keyed by its peer address (an IPv6 address by its /64). ARateLimitCountertable sees the new spellings, and rows keyed the old way age out with their window. -
Trace propagation. With
oteland a recordingtracing-opentelemetrylayer, the client sends atraceparenton every call, naming its own span, so the server span a call causes is the child of the client's. Opt out withClientBuilder::with_trace_propagation(false).
Error codes some clients may match on
- A rate-limit refusal from
RateLimitInterceptoris HTTP429withRetry-Afterand gRPCRESOURCE_EXHAUSTED, not500/INTERNAL. The JSON-RPC code is still-32603. - On WebSocket, errors carry their A2A code and typed
data: a lagged stream is-32603with thestreamLaggedmarker, and an over-capacity request-32603, where both were-32000. - SLIMRPC status codes follow the SLIMRPC specification's table:
JSONParseErrorisINTERNAL,ContentTypeNotSupportedErrorUNIMPLEMENTED,VersionNotSupportedErrorFAILED_PRECONDITION. - A credential refused over WebSocket is still
-32600, now with anErrorInfofroma2a-rust.com(UNAUTHENTICATEDwith the challenge aswww-authenticatemetadata, orPERMISSION_DENIED; the binding specification §6). The SDK's client reports it asClientError::UnexpectedStatuswith status401or403, as over HTTP and gRPC; code that matchedClientError::ProtocolwithErrorCode::InvalidRequestfor it matchesUnexpectedStatusinstead.
Also in 0.15, not breaking
- Deprecated. Besides
sign_agent_cardandClientConfig::preferred_bindings(above):HotReloadAgentCardHandler::updateis deprecated fortry_update, which checks the card asreload_from_jsondoes and refuses an invalid one withInvalidParams, leaving the current card in place.OAuthFlows::ImplicitandOAuthFlows::Passwordare deprecated because the A2A 1.0 proto marks both; they stay, parse and serialize as before (spec §1.4 keeps deprecated definitions until the next major version), and code that names them gets a warning. - Several replicas sharing a store need session affinity. Without every turn of a task routed to one replica, two replicas can run the same task at once, and a continuation saved on one can overwrite what the other's turn wrote meanwhile. One replica, or several with affinity, is the supported topology for 0.15; see Running More Than One Replica.
serveandserve_with_addrcut off a slow peer. They run onServerunderServeConfig::default(), with a 30-second header-read timeout and a 75-second idle timeout (traffic in either direction counts, so an SSE stream with its 30-second keep-alive stays open). Their signatures are unchanged; for a ceiling, TLS or a graceful drain, useServerdirectly.- A header sent as several lines is one field. Every binding joins the
lines in order with
", "("; "forcookie), where each kept the last.A2A-Extensionson two lines activates both; a field that takes one value, such asAuthorization, reaches the interceptors as both values joined, so a bearer token sent on two lines is refused. - A
Content-Typeis read as a media type: the type and subtype before any;parameter, compared without regard to case.Application/JSONis accepted;application/json-rpcandapplication/json-patch+json, which a prefix match served, are refused withContentTypeNotSupportedError. - On HTTP+JSON the path's id is the request's. A body's
idortaskIdno longer replaces the path's, an empty path segment reaches its handler as an empty id and isInvalidParams, and the Axum router reads the body of:canceland:subscribe(a malformed one is400). - JSON-RPC follows three more rules of JSON-RPC 2.0. An
idthat is not a string, a number ornullis-32600; omittedparamsread as{}; andSendStreamingMessageorSubscribeToTaskinside a batch isUnsupportedOperationError(-32004), not-32602. - The WebSocket binding no longer routes
message/stream, the one v0.3 method name it still served; it is-32601, as on every other binding. - SLIMRPC refuses a call that declares no
A2A-VersionwithVersionNotSupportedError, as the HTTP and gRPC bindings do by default.SlimRpcServer::with_require_version(false)restores the old behaviour. - ListTasks follows the proto.
statusTimestampAfterincludes its bound ("greater than or equal to"), where every store excluded it, andtotalSizeis the number of tasks the filters match before pagination. - Listing or deleting the push configs of an unknown task is
TaskNotFound(404on HTTP+JSON,-32001on JSON-RPC,NOT_FOUNDon gRPC); it answered an empty list and success. Deleting a config that is already gone from a task that exists still succeeds.
These are the ones a gateway, a test or a client is most likely to meet. The
rest are under ### Behaviour Changes in the ## [0.15.0] section of
CHANGELOG.md.
0.13 → 0.14
0.14.0 makes a failure read the same on every binding, and a refused credential
answer the status a client acts on. CHANGELOG.md lists seven breaking items and
ten behaviour changes (two more are listed under Fixed); the SLIMRPC binding's
has its own section at the end.
None of them is a compile error: the first two change what a build contains,
and the rest change what a match on an error, a status check or a gateway
rule sees, which only a test, a peer or a run notices.
tracing is a default feature
Of a2a-protocol-client, a2a-protocol-server and a2a-protocol-sdk (ADR
0013). A default build used to compile every log call to nothing, so an agent
logged nothing whatever subscriber it installed. With no subscriber the cost is
a level check per call site. Nothing to do for most builds. To keep the old,
silent build, depend with default-features = false, and on the client or SDK
add back features = ["tls-rustls"] if you dial https://.
default-features = false on the SDK removes rustls
The SDK used to take the client with the client's own defaults, so rustls stayed
in an SDK built without default features. A build that did that and still
reached https:// agents was relying on the defect:
a2a-protocol-sdk = { version = "0.15", default-features = false, features = ["tls-rustls"] }
A peer that goes away reads the same on every binding
The same event — a connection or stream ending before its final event — used to
be 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. Now, on WebSocket and gRPC as on the HTTP
bindings, a stream cut off ends with ClientError::IncompleteStream, a unary
call in flight fails with HttpClient, and a WebSocket handshake refused with an
HTTP status is UnexpectedStatus. A frame over the size cap, or one that breaks
the protocol, stays a non-retryable Transport error.
// 0.13
Err(ClientError::Transport(_)) => reconnect(), // WebSocket drop
Err(ClientError::Protocol(e)) if e.code == ErrorCode::InternalError => resume(), // gRPC cut
Match the new variants, or ask is_retryable(), which is true for both:
#![allow(unused)] fn main() { use a2a_protocol_client::ClientError; fn peer_went_away(err: &ClientError) -> bool { matches!(err, ClientError::IncompleteStream { .. } | ClientError::HttpClient(_)) } }
gRPC UNAUTHENTICATED and PERMISSION_DENIED are 401 and 403
Over the gRPC transport and the SLIMRPC binding's client they are now
ClientError::UnexpectedStatus { status: 401, .. } and { status: 403, .. },
the shapes JSON-RPC and REST already reported; they were
Protocol(InvalidParams) on gRPC and Protocol(InternalError) on SLIMRPC.
BearerAuthInterceptor now drops a token a gRPC agent refused. A peer's
CANCELLED is a non-retryable Protocol error rather than a retryable
Timeout, so a retry policy stops re-sending a call the peer abandoned.
#![allow(unused)] fn main() { use a2a_protocol_client::ClientError; fn credential_refused(err: &ClientError) -> bool { // was: ClientError::Protocol(e) if e.code == ErrorCode::InvalidParams matches!(err, ClientError::UnexpectedStatus { status: 401 | 403, .. }) } }
ClientBuilder::from_card refuses a card with no interface for protocol major 1
It used to accept such a card with a warning and 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: v1.0
and 1-preview both count as major 1. (An empty one counted too until 0.15; it
no longer does.) If a peer's card is wrong but the endpoint speaks 1.0, build
from the URL with ClientBuilder::new.
Graceful shutdown ends in-flight tasks before it drains connections
Server::serve_with_shutdown used to drain first, and an open SSE stream does
not close until its task ends, so the drain waited out its 15 s and delegated
tasks outlived the process. The order is now: stop accepting; let tasks finish
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 (drain_timeout, 15 s). The trade: a task that outlives the
completion window is now cancelled where it used to get the whole drain window.
If your tasks need longer:
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_server::ServeConfig; let config = ServeConfig::default().with_completion_grace(Duration::from_secs(30)); let _ = config; }
The gRPC and WebSocket dispatchers gain their own serve_with_shutdown with the
same three setters.
Shipped TaskStores refuse to move a terminal task to another state
Atomically with the write, answering UnsupportedOperation with a
TerminalStateConflict in its data
(a2a_protocol_server::store::TerminalStateConflict::from_error recognises it).
Code that reopened a finished task through a shipped store gets that error; send
the follow-up as a new task instead. A custom store is not covered unless it
applies store::refuses_write inside its writes.
Behaviour changes
No type or signature changed for these; each changes what a caller observes.
-
A refused credential answers
401/403, not400(ADR 0014). The auth interceptors used to answer every refusal asInvalidRequest:400on both HTTP bindings,INVALID_ARGUMENTon gRPC. A missing or wrong credential now answers401with aWWW-Authenticatechallenge (Bearer realm="a2a", orApiKey header="x-api-key", naming the configured header), and gRPCUNAUTHENTICATED; the AIP-193 body'sstatussaysUNAUTHENTICATED. JSON-RPC answers the same401with its body unchanged (-32600); a batch stays200. WebSocket, which has no status per call, sends the body alone. Update any client, test or alert that matched400for an auth failure. A custom authenticating interceptor gets the same statuses by returning:#![allow(unused)] fn main() { use a2a_protocol_types::AuthRejectionKind; use a2a_protocol_types::error::A2aError; let err = A2aError::unauthenticated("authentication required", "Bearer realm=\"a2a\""); assert_eq!( err.auth_rejection().map(|r| r.kind()), Some(AuthRejectionKind::Unauthenticated) ); let _forbidden = A2aError::permission_denied("not permitted"); } -
A message part whose
mediaTypethe card does not declare is refused withContentTypeNotSupportedError(-32005 over JSON-RPC, HTTP400over HTTP+JSON), onSendMessageandSendStreamingMessage, when the card declares input modes —defaultInputModesor any skill'sinputModes. Parts without amediaTypeare not checked. Declare every type the agent accepts, or keep the old behaviour while the card is corrected:#![allow(unused)] fn main() { use a2a_protocol_server::RequestHandlerBuilder; fn lenient(builder: RequestHandlerBuilder) -> RequestHandlerBuilder { builder.allow_undeclared_input_modes() } } -
The axum adapter's error bodies are AIP-193, as
RestDispatcher's are:{"error": {"code", "status", "message", "details"}}instead of{"error": "<text>"}. A client that readerroras a string readserror.message. -
The axum adapter's successful responses carry
A2A-Version, as its errors already did. HTTP+JSON responses stayapplication/jsonon purpose: the official Go SDK's client cannot read an error labelledapplication/a2a+json. (0.15 changed this; see HTTP+JSON answersapplication/a2a+jsonunless asked otherwise.) -
JSON that is not a JSON-RPC Request object is answered
-32600(Invalid Request), not-32700(Parse error), over JSON-RPC and WebSocket: a body with nomethod, a bare value such as1, an empty batch[], a batch item that is not a request. A client that matched-32700for these sees-32600. (Listed under Fixed in the changelog.) -
Every task status carries a timestamp. The event queue stamps any status update or task snapshot the executor left without one; a timestamp the executor set is kept. The stamp counts toward
max_event_size, so an event within a few dozen bytes of the limit may now be refused. (Listed under Fixed.) -
A continuation sent the moment a task reaches
input-requiredis admitted instead of refused as "already being processed" while the executor that parked it is still returning; admission waits up toHandlerLimits::executor_drain_timeout(5 s) for it. -
An idle
SubscribeToTaskstream over SSE ends atHandlerLimits::subscribe_max_idle(5 min). It used to stay open as long as its client did. Resubscribe, or raise the bound withwith_subscribe_max_idle. (0.15 removed the default bound; see A parked subscription stays open until its task finishes.) -
A WebSocket request whose connection ends is cancelled on the server, as a request on the HTTP bindings already was when its client disconnected.
-
WebSocket client: a stream the caller reads more slowly than the agent writes ends with a
stream_laggederror once 64 frames are waiting (ClientError::is_stream_lagged); resubscribe to continue. It used to be held back, which stalled every other call on the socket. -
WebSocket client: a call on a
WebSocketTransportwhose socket dropped reconnects to the same endpoint with the same configuration, instead of failing with a non-retryableTransporterror. Streams already open keep the connection they were opened on.
Two changes to interceptors are additive but worth reading:
ServerInterceptor::after now runs once the response exists, so a failing one
no longer orphans a send's task (listed under Fixed), and the new, defaulted on_complete(ctx, outcome) is
called on every call whose before ran — with CallOutcome::Succeeded,
Failed(&ServerError) or Cancelled — which is where to release something
before acquired, since after runs only on success.
SLIMRPC binding: a refused credential answers UNAUTHENTICATED / PERMISSION_DENIED
bindings/a2a-protocol-slimrpc's server sent INVALID_ARGUMENT for a credential
an interceptor refused, as the gRPC dispatcher did before ADR 0014, so a
client's BearerAuthInterceptor never dropped a revoked token there. It now
answers UNAUTHENTICATED for A2aError::unauthenticated and PERMISSION_DENIED
for A2aError::permission_denied, which this SDK's client reads as 401 and
403. An ordinary InvalidRequest keeps INVALID_ARGUMENT.
0.12 → 0.13
0.13.0 makes the event log the record and spends it on stream resumption.
Nine breaking items. The first is the one most code will meet; the third is
a rename you fix at the read site; the last five were found by an audit after
the first three were written down, and two of them are attributes or types
that only bite a match or a binding; the other three change behaviour. The
ninth shipped in 0.13.0 but was listed under [Unreleased] until 2026-09-23;
its section is the last below.
RequestContext is #[non_exhaustive], and gains call_context
Building one with a struct literal from outside a2a-protocol-server stops
compiling. cargo semver-checks check-release -p a2a-protocol-server --baseline-version 0.12.1 ran 196 checks in the run recorded when this was
written and failed one, struct_marked_non_exhaustive on this type. That run
was not exhaustive: the PurgeReport rename below makes
struct_pub_field_missing fire too. The added field is not a second finding:
once a struct is #[non_exhaustive] an added field is no longer separately
observable.
// 0.12 — a struct literal, from outside the crate
let ctx = RequestContext {
message,
task_id,
context_id,
stored_task: None,
metadata: None,
cancellation_token: CancellationToken::new(),
};
new takes the three fields with no sensible default; everything else has a
with_*:
#![allow(unused)] fn main() { use a2a_protocol_server::RequestContext; use a2a_protocol_types::{Message, TaskId}; let ctx = RequestContext::new( Message::user_text("msg-1", "3 + 5"), TaskId::new("task-1"), "ctx-1".to_owned(), ) .with_metadata(serde_json::json!({ "tier": "gold" })); let _ = ctx; }
with_stored_task and with_call_context are the other two in 0.13; later
releases add more with_* setters. Reading a
RequestContext is unaffected — the fields are still public, so an executor
that only reads ctx.message needs no change. The attribute is deliberate
rather than incidental: this type grows, and every previous growth would have
broken a literal nobody writes.
EventQueueReader::read yields a StreamEvent, not a StreamResponse
The event now travels the queue with the position it holds in the task's log:
StreamEvent { seq: Option<u64>, event: StreamResponse }. Reattached::Channel
and the persistence channel carry the same type. Only code that matched the
result of read() directly is affected.
// 0.12
while let Some(Ok(StreamResponse::StatusUpdate(update))) = reader.read().await {
handle(update);
}
Reach through .event for a value, or .map(|e| e.event) for the Result:
#![allow(unused)] fn main() { use a2a_protocol_server::{EventQueueReader, InMemoryQueueReader}; use a2a_protocol_types::StreamResponse; async fn drain<R: EventQueueReader>(reader: &mut R) { while let Some(Ok(ev)) = reader.read().await { if let StreamResponse::StatusUpdate(update) = ev.event { // `ev.seq` is the number the store wrote and the SSE `id:` carries. println!("{:?} {:?}", ev.seq, update.status.state); } } } let _ = drain::<InMemoryQueueReader>; }
StreamEvent is itself #[non_exhaustive], so destructure it with .. or —
as above — reach through .seq and .event.
seq is None for frames the server synthesized rather than the agent
emitting — the SubscribeToTask snapshot, and the terminal frame the reattach
hook builds from stored state. Those are not in the log, so they carry no id:
and a resuming client's offset is unaffected by having seen them. It is a type
change rather than an accessor because the position has to be the same number
the store wrote, and the only way to guarantee that is to assign it once and
carry it.
PurgeReport::journal_orphans_deleted is now orphan_rows_deleted
The retention sweep reclaims two side tables now, not one — the artifact journal and the event log — so the old name described half of what the number counts. Renamed rather than kept and widened: a field whose name names one of its two sources is read as the count for that source.
// 0.12
tracing::info!(orphans = report.journal_orphans_deleted, "retention sweep");
Rename it at the read site. The meaning is unchanged for anyone who had only the journal:
#![allow(unused)] fn main() { use a2a_protocol_server::store::PurgeReport; fn summarize(report: &PurgeReport) -> String { format!( "{} tasks, {} orphan side-table rows, complete={}", report.tasks_deleted, report.orphan_rows_deleted, report.complete, ) } let _ = summarize(&PurgeReport::default()); }
It is still normally zero: both side tables carry an ON DELETE CASCADE, so a
non-zero count means rows outlived their task — which happens on a SQLite
pool handed to from_pool without foreign_keys=ON.
IdempotencyClaim and KeyError are #[non_exhaustive]
Both are enums a caller matches on. A match from outside the defining crate
now needs a wildcard arm — and should name it, because a future variant
reaching a silent catch-all is how a new outcome gets folded into the wrong
one.
IdempotencyClaim is also re-exported from a2a_protocol_server::store now,
beside RecordedEvent. It is the return type of a TaskStore method, so an
out-of-tree store has to name it, and it was reachable only at
store::task_store::IdempotencyClaim.
use a2a_protocol_server::store::IdempotencyClaim; use a2a_protocol_types::task::TaskId; fn describe(claim: IdempotencyClaim) -> String { match claim { IdempotencyClaim::Claimed => "create the task".to_owned(), IdempotencyClaim::Replay(id) => format!("return {id}"), IdempotencyClaim::Conflict { held_by } => format!("refuse; held by {held_by}"), // Required from 0.13. Naming it beats a silent fallthrough. other => format!("unhandled claim outcome: {other:?}"), } } fn main() { let _ = describe(IdempotencyClaim::Replay(TaskId::new("t"))); }
FailureClass::ALL is a slice
It was [FailureClass; 5], so the length was in the type and the sixth
variant would have broken every caller that bound it — the break
#[non_exhaustive] on the enum exists to prevent.
use a2a_protocol_types::failure::FailureClass; fn tokens() -> Vec<&'static str> { // was: for c in FailureClass::ALL FailureClass::ALL.iter().map(|c| c.as_str()).collect() } fn main() { assert!(!tokens().is_empty()); }
build() refuses a signed agent card it would have to edit
The builder advertises the extensions this server can honour by appending to
capabilities.extensions, and a card signature covers everything except
signatures — so appending to a card that is already signed leaves the served
card canonicalizing to bytes nobody signed, and every client that verifies it
fails while this side reports nothing.
Declare the extensions before signing. A card that already declares them builds and is served byte-identical to what was signed.
use a2a_protocol_types::extensions::AgentExtension; use a2a_protocol_types::failure::FAILURE_EXTENSION_URI; use a2a_protocol_types::idempotency::IDEMPOTENCY_EXTENSION_URI; fn example(card: &mut a2a_protocol_types::agent_card::AgentCard) { // Before signing, not after: card.capabilities.extensions = Some(vec![ AgentExtension::new(IDEMPOTENCY_EXTENSION_URI), AgentExtension::new(FAILURE_EXTENSION_URI), ]); } fn main() {}
Declare IDEMPOTENCY_EXTENSION_URI only when the store you configure reports
supports_idempotency(); FAILURE_EXTENSION_URI is advertised on every
server. From 0.15 the builder also advertises BUDGET_EXTENSION_URI on every
card (see
A signed agent card declares the budget extension),
LEASE_EXTENSION_URI when a lease store is configured and
APPROVAL_EXTENSION_URI when an approval gate is; a signed card must declare
each of them.
A keyed message/send is retried only against a peer that advertises it
RetryTransport treated any valid key in Message.metadata as making the
send retryable. The key rides in a field A2A defines as free-form, and the
extension is not part of A2A v1.0, so a conformant peer from another SDK
ignores it and runs the send — and the retry starts a second task.
ClientBuilder::from_card reads the advertisement and enables it for you.
For a client built on a bare endpoint, assert it only for a peer you know
implements the extension:
use a2a_protocol_client::ClientBuilder; fn example() -> Result<(), a2a_protocol_client::error::ClientError> { let client = ClientBuilder::new("http://localhost:8080") .with_peer_honouring_idempotency(true) .build()?; let _ = client; Ok(()) } fn main() {}
message.id is validated at ingress
It is checked like context_id and task_id now — non-empty after trimming,
and within HandlerLimits::effective_max_message_id_length(), which is
max_id_length but never below 36, the length of a UUID, so tightening
max_id_length does not refuse conformant clients. It was checked nowhere, so
an empty messageId was accepted, stored, and echoed in task history, where
every empty id collides with every other.
No code change is needed unless you were sending empty or very long message
ids. If you generate them, uuid::Uuid::new_v4().to_string() is what the
examples use.
RetentionPolicy and PurgeReport are #[non_exhaustive]
The ninth item. RetentionPolicy was missed by 0.12's conversion of the
configuration structs, and PurgeReport is marked for the same reason: both
gain fields (idempotency_key_max_age, idempotency_keys_deleted) that would
otherwise have broken a literal. It shipped in 0.13.0, but the changelog listed
it under [Unreleased] until 2026-09-23, so the 0.13.0 release notes do not
mention it.
Build a policy with new and the setters, and read a report's fields rather
than destructuring it without ..:
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_server::store::RetentionPolicy; let policy = RetentionPolicy::new(Duration::from_secs(7 * 24 * 3600)) .with_batch_size(500); let _ = policy; }
Also in 0.13, not breaking
TaskStoregains nine methods, all defaulted. Six for the event log (supports_event_log,append_event,last_event_seq,read_events,earliest_event_seq,event_log_covers) and three for idempotency (supports_idempotency,claim_idempotency_key,release_idempotency_key). A custom store keeps compiling, and the defaults report no support rather than a successful claim or an empty log — so forgetting to implement them cannot quietly produce a send that runs twice or a resumption that silently skips events.bindings/a2a-protocol-slimrpcmoves to0.5.0, pinned to0.13. It is outside the root workspace and versioned independently, so this is not a break of the four published crates; it bumps in this release rather than after it because the binding depends on the workspace bypathas well as by version, and^0.12stops resolving the moment the crates read 0.13.0. See its chapter.
0.11 → 0.12
0.12.0 is the deliberate breaking minor before the two clean minors that
STABILITY.md §7 requires, so every "this struct should have been
#[non_exhaustive]" fix lands here rather than one per release. Five items;
the fourth is the one most code will meet.
HandlerLimits gains two fields
push_delivery_budget (default 30 s) and executor_drain_timeout (default
5 s) are new. A literal naming every field stops compiling with
"missing fields":
// 0.11 — a full struct literal
let limits = HandlerLimits {
max_id_length: 2048,
max_metadata_size: 1_048_576,
max_cancellation_tokens: 10_000,
// … every other field …
};
Use Default and the setters. Every field has one (fourteen in 0.12):
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_server::HandlerLimits; let limits = HandlerLimits::default() .with_max_id_length(2048) .with_push_delivery_budget(Duration::from_secs(30)) // new in 0.12 .with_executor_drain_timeout(Duration::from_secs(5)); // new in 0.12 let _ = limits; }
A literal that ended in ..HandlerLimits::default() was not broken by the
new fields — but it is by the fourth item below, which makes the struct
#[non_exhaustive]; the setter form above is the one that survives both.
Hand the result to RequestHandlerBuilder::with_handler_limits as before.
WebSocketTransportConfig (client) gains max_pending_requests
Default 64. The same rule: a full literal breaks, Default plus setters does
not, and every field has a setter.
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_client::WebSocketTransportConfig; let config = WebSocketTransportConfig::default() .with_connect_timeout(Duration::from_secs(5)) .with_request_timeout(Duration::from_secs(30)) .with_max_pending_requests(64); // new in 0.12 let _ = config; }
Hand it to WebSocketTransport::connect_with_config(endpoint, config) as
before. The new bound is why the next item exists.
ClientError gains TooManyPendingRequests { limit }
Returned up front when a WebSocket connection already has limit requests
awaiting responses. The enum is #[non_exhaustive], so an exhaustive match
already had a wildcard arm and nothing stops compiling. If you branch on
retryability, ClientError::is_retryable() returns true for it: room
appears as responses arrive.
#![allow(unused)] fn main() { use a2a_protocol_client::ClientError; fn describe(err: &ClientError) -> String { match err { ClientError::TooManyPendingRequests { limit } => { format!("{limit} requests already in flight on this connection; retry") } other => other.to_string(), } } }
Fifteen configuration structs are #[non_exhaustive]
Server: HandlerLimits, DispatchConfig, CorsConfig, GrpcConfig,
CacheConfig, PushRetryPolicy, RateLimitConfig, TaskStoreConfig,
TenantStoreConfig, PerTenantConfig, TenantLimits. Client:
ClientConfig, RetryPolicy, WebSocketTransportConfig,
GrpcTransportConfig. (ServeConfig already was; the wire types in
a2a-protocol-types are deliberately not on the list — a literal is how the
spec's data types are meant to be built.) None of them will gain a field at
your expense again.
The rule is the same for all fifteen: a struct literal of any shape —
..Default::default() included — is an error outside the crate, and every
public field has a with_<field> setter. Before:
// 0.11
let limiter = RateLimitInterceptor::new(RateLimitConfig {
requests_per_window: 100,
window_secs: 60,
..RateLimitConfig::default()
})?;
After — start from Default (or the documented constructor:
CorsConfig::new/permissive, CacheConfig::with_max_age) and chain the
setters for the fields you set:
#![allow(unused)] fn main() { use a2a_protocol_server::{RateLimitConfig, RateLimitInterceptor}; fn example() -> Result<(), a2a_protocol_server::ServerError> { let limiter = RateLimitInterceptor::new( RateLimitConfig::default() .with_requests_per_window(100) .with_window_secs(60), )?; let _ = limiter; Ok(()) } }
Some setters did not exist before this release and were added for it, so a
missing one is a 0.11 build, not a missing field: RateLimitConfig's four,
TaskStoreConfig's four (with_max_capacity, with_task_ttl,
with_eviction_interval, with_max_page_size), CorsConfig's four,
TenantStoreConfig::with_per_tenant/with_max_tenants,
PerTenantConfig::with_default/with_overrides/with_override,
TenantLimits::with_* for each of its fields (its builder() still works),
DispatchConfig::with_require_version_header, and ten on ClientConfig.
The setters for Option fields take the Option, so None is spelled
where the literal spelled it:
#![allow(unused)] fn main() { use a2a_protocol_server::TaskStoreConfig; let no_ttl = TaskStoreConfig::default() .with_max_capacity(Some(50_000)) .with_task_ttl(None); let _ = no_ttl; }
GrpcTransportConfig also gained bare_address_scheme and, under the
grpc-tls feature, tls_config; every field has a setter there too:
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_client::GrpcBareAddressScheme; use a2a_protocol_client::transport::grpc::GrpcTransportConfig; let config = GrpcTransportConfig::default() .with_timeout(Duration::from_secs(30)) .with_connect_timeout(Duration::from_secs(5)) .with_max_message_size(4 * 1024 * 1024) .with_stream_channel_capacity(64) .with_bare_address_scheme(GrpcBareAddressScheme::HttpsExceptLoopback); // new in 0.12 let _ = config; }
with_tls_config(tonic::transport::ClientTlsConfig) is its sixth setter,
behind grpc-tls.
InMemoryQueueWriter (server) is no longer UnwindSafe / RefUnwindSafe
It now holds the Arc<dyn Metrics> it reports dropped persistence events
through, and a trait object without those bounds removes the auto-impls.
Only code that wrapped a writer in std::panic::catch_unwind or
AssertUnwindSafe can notice. The handler that owns the writer never
required either bound. There is no migration beyond AssertUnwindSafe if you
are in that position.
Also in 0.12, not breaking
- MSRV lowered from 1.93 to 1.88; edition 2024. Lowering is not a break.
- Dependency majors: opentelemetry / opentelemetry_sdk / opentelemetry-otlp
0.32, tokio-tungstenite 0.30, rig-core 0.42 in the example. If your crate
depends on one of these directly and holds a type the SDK hands back
(
init_otlp_pipelinereturnsopentelemetry_sdk'sSdkMeterProvider), align your version with the SDK's; STABILITY.md §6 says re-exported dependency types carry their own stability.
0.10 → 0.11
No Rust signature changed. Three wire-format corrections, each labelled
BREAKING (wire format) under ### Changed, plus one addition worth
acting on. Nothing here is a compile error; all of it can fail a test, a
gateway rule or a peer.
Six §5.4 error mappings corrected
ErrorCode::http_status() and ErrorCode::grpc_status() keep their
signatures and return different values for six codes. The SDK had implemented
a stale vendored copy of the specification's table. Upstream did not amend the
document in place, as this entry previously said: the corrections shipped as
the tagged patch release v1.0.1 on 2026-05-28, and A2A's v1.0.0 tag
still carries the superseded table.
| A2A error | HTTP was → is | gRPC was → is |
|---|---|---|
TaskNotCancelableError | 409 → 400 | — |
ContentTypeNotSupportedError | 415 → 400 | — |
InvalidAgentResponseError | 502 → 500 | — |
PushNotificationNotSupportedError | — | UNIMPLEMENTED → FAILED_PRECONDITION |
UnsupportedOperationError | — | UNIMPLEMENTED → FAILED_PRECONDITION |
VersionNotSupportedError | — | UNIMPLEMENTED → FAILED_PRECONDITION |
The Axum adapter carried a second, hand-written copy of the table that had
drifted three more places (its TaskNotCancelable and
InvalidStateTransition answered 409, its PushNotSupported answered
501; all are 400). It now defers to ErrorCode::http_status().
PayloadTooLarge (413) and Overloaded (503) remain adapter-specific
because A2A has no error code for either.
What to change: any client that matches on the old status, any test that asserts it, any gateway rule keyed on it. The current values, as the crate returns them:
#![allow(unused)] fn main() { use a2a_protocol_types::ErrorCode; assert_eq!(ErrorCode::TaskNotCancelable.http_status(), 400); // was 409 assert_eq!(ErrorCode::ContentTypeNotSupported.http_status(), 400); // was 415 assert_eq!(ErrorCode::InvalidAgentResponse.http_status(), 500); // was 502 assert_eq!(ErrorCode::UnsupportedOperation.grpc_status(), "FAILED_PRECONDITION"); // was UNIMPLEMENTED }
If you grade against the official a2aproject/a2a-tck, expect two MUST
failures — HTTP_JSON-STATUS-001 and GRPC-ERR-002. That suite vendors the
same stale table; both rows are baselined with a reproduction in
docs/official-tck-findings.md §20.
Push notifications are delivered as application/a2a+json
§4.3.3 specifies the A2A media type for the webhook POST; the sender used
application/json. A receiver that matches the header exactly — a framework
body parser keyed on application/json, a gateway rule, a WAF — stops
accepting deliveries until it also accepts application/a2a+json. The body
is unchanged, and any +json structured-suffix parser already handles it.
The value is exported:
#![allow(unused)] fn main() { use a2a_protocol_types::A2A_CONTENT_TYPE; assert_eq!(A2A_CONTENT_TYPE, "application/a2a+json"); }
Advertise the WebSocket binding by URI (recommended, not required)
WEBSOCKET_BINDING_URI (https://a2a-rust.com/bindings/websocket/v1) is new
in a2a-protocol-types. §5.8 says a custom binding SHOULD be identified
by a URI rather than a bare name. AgentInterface::protocol_binding is a
free-form string the crates never match on, so nothing breaks either way — but
a card advertising the bare "WEBSOCKET" should move, and a reader should
accept both while cards in the wild carry the old spelling.
#![allow(unused)] fn main() { use a2a_protocol_types::{A2A_VERSION, AgentInterface, WEBSOCKET_BINDING_URI}; let interface = AgentInterface { url: "wss://agent.example.com:3002".into(), protocol_binding: WEBSOCKET_BINDING_URI.into(), // was "WEBSOCKET" protocol_version: A2A_VERSION.into(), tenant: None, }; let _ = interface; }
0.9 → 0.10
No breaking change. One deprecation, with a compiler warning at every use site naming the replacement.
TenantLimits::max_stored_tasks is deprecated
It named a cap on stored tasks and nothing ever read it: it sits on
PerTenantConfig, which the handler holds, and a store is constructed
independently and handed to the builder, so a store never saw it. The working
equivalent gives a named tenant its own TaskStoreConfig, and therefore its
own max_capacity:
#![allow(unused)] fn main() { use a2a_protocol_server::{TaskStoreConfig, TenantAwareInMemoryTaskStore}; let store = TenantAwareInMemoryTaskStore::new().with_tenant_override( "acme", TaskStoreConfig::default().with_max_capacity(Some(500)), ); let _ = store; }
The override applies to a partition created after the store is built; a tenant that already has one keeps it. The field was deprecated rather than removed until 0.15.0, which removes it.
The same release corrected two pieces of documentation that read as if
something was enforced when it was not: PerTenantConfig and TenantLimits
now state that no code in the request path reads any of the five per-tenant
limits (data isolation runs through the tenant-aware stores' partitioning,
not through those limits), and ClientConfig::max_response_size now states
that it does not reach a transport supplied via with_custom_transport —
WebSocketTransportConfig::max_message_size is the setting that applies
there. Neither is a change in behaviour; both may be a change in what you
believed.
0.8 → 0.9
Two items under ### Breaking.
executor_timeout defaults to one hour
It was unbounded. An executor that never returned pinned its task, its event
queue and its cancellation token for the life of the process, and with
max_cancellation_tokens at 10,000, enough of them eventually stopped the
handler accepting work. A task that trips the ceiling now fails visibly, as a
Failed task with a timeout error.
If a task of yours legitimately exceeds an hour, either raise the ceiling or
restore unbounded execution explicitly with the new
without_executor_timeout(). The constant is
a2a_protocol_server::builder::DEFAULT_EXECUTOR_TIMEOUT.
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_server::RequestHandlerBuilder; fn six_hours(builder: RequestHandlerBuilder) -> RequestHandlerBuilder { builder.with_executor_timeout(Duration::from_secs(6 * 3600)) } fn as_in_0_8(builder: RequestHandlerBuilder) -> RequestHandlerBuilder { builder.without_executor_timeout() } }
The changelog's own advice: a task genuinely running longer than an hour should be using push notifications rather than holding an executor and a stream open.
shutdown() and shutdown_with_timeout() return ShutdownReport
Both returned () and discarded the executor-cleanup timeout with no log and
no return value, which made a hung cleanup indistinguishable from a clean
drain. The type is #[must_use], so an existing handler.shutdown().await;
still compiles and warns rather than errors. Only a let () = … binding, or
a function whose return type was () and ended with the call, breaks.
// 0.8
handler.shutdown_with_timeout(Duration::from_secs(10)).await;
#![allow(unused)] fn main() { use std::time::Duration; use a2a_protocol_server::{RequestHandler, ShutdownReport}; async fn stop(handler: &RequestHandler) { let report: ShutdownReport = handler .shutdown_with_timeout(Duration::from_secs(10)) .await; if !report.is_graceful() { eprintln!( "shutdown incomplete: {} queue(s) force-destroyed, executor cleanup completed: {}", report.queues_force_destroyed, report.executor_cleanup_completed ); } } }
Until 0.14, queues_force_destroyed was always 0 for shutdown(), whatever
it cut off; it now counts the live queues it destroyed.
executor_cleanup_completed == false means the on_shutdown hook was
abandoned, not that it failed — it may still be running.
0.7 → 0.8
A deliberate breaking release: three deprecations announced in 0.7 come out,
on the schedule their notes named, and two card fields change shape on the
wire. Also the release that brought the four crates back into lockstep —
a2a-protocol-server alone had been at 0.8.0 while the others sat at 0.7.0.
If you pinned them separately, move all four to 0.8.
If you serve gRPC to 0.6 clients, upgrade order matters — get it wrong and it is an outage, not a degradation. Move gRPC clients onto the canonical
lf.a2a.v1.A2AServicefirst, then upgrade servers. Unaffected: JSON-RPC, REST and WebSocket deployments, and any gRPC deployment already on the canonical binding, which has been the default since 0.7 and is what the official Go, Python and Java SDKs speak.
The grpc-legacy-json feature and the JSON-tunnel gRPC service are removed
Releases before 0.7 tunneled JSON inside a protobuf bytes envelope on a
non-standard service, a2a.v1.A2aService, served alongside the canonical
binding behind an off-by-default feature so that 0.6 clients survived a
rolling upgrade. The service is no longer registered on the listener, so a
0.6 client's calls fail at the gRPC layer and fall back to nothing.
Gone with it: the feature on a2a-protocol-server and a2a-protocol-sdk,
dispatch/grpc/service.rs, GrpcDispatcher::into_legacy_service, the
LegacyA2aServiceServer / LegacyGrpcServiceImpl re-exports, the JSON codec
helpers (encode_json / decode_json / reader_to_grpc_stream), the
a2a.v1 proto and its build step.
# 0.7
a2a-protocol-server = { version = "0.7", features = ["grpc", "grpc-legacy-json"] }
# 0.8
a2a-protocol-server = { version = "0.8", features = ["grpc"] }
There is no in-process migration for a client on the old service: it moves
to lf.a2a.v1.A2AService. GrpcDispatcher::serve and into_service are
unchanged for everyone already on it.
with_event_queue_write_timeout and with_write_timeout are removed
RequestHandlerBuilder::with_event_queue_write_timeout and
EventQueueManager::with_write_timeout, deprecated no-ops since 0.7.
Event-queue writes to the broadcast side never block — a slow
streaming consumer receives an explicit lag error on its reader rather than
exerting backpressure on the executor — so neither setter ever did anything.
Delete the call. To bound a slow consumer, size the queue and handle the lag error on the reader:
// 0.7
let handler = RequestHandlerBuilder::new(agent)
.with_event_queue_write_timeout(Duration::from_secs(5))
.build()?;
#![allow(unused)] fn main() { use a2a_protocol_server::RequestHandlerBuilder; fn sized(builder: RequestHandlerBuilder) -> RequestHandlerBuilder { builder.with_event_queue_capacity(1024) } }
The two public setters are gone. DEFAULT_WRITE_TIMEOUT (5 s) is still
exported — 0.8 kept it, and a then-dead field on InMemoryQueueWriter, rather
than change that constructor's arity — and since 0.10 it is enforced: a write
that cannot reach the persistence channel within it fails with an error naming
the stalled processor.
The bare a2a-notification-token header leaves the default CORS allow-list
x-a2a-notification-token is canonical and stays; the unprefixed spelling was
this SDK's own pre-0.7 name. A browser-hosted webhook receiver behind the same
CORS policy that still reads the bare header can restore it —
CorsConfig::allow_headers is a plain String and always was, so this
changes a default, not a capability:
#![allow(unused)] fn main() { use a2a_protocol_server::CorsConfig; let mut cors = CorsConfig::new("https://console.example.com"); cors.allow_headers.push_str(", a2a-notification-token"); let _ = cors; }
Hand it to JsonRpcDispatcher::with_cors or RestDispatcher::with_cors as
before.
AgentCard.url is no longer emitted
It is the v0.3 top-level URL. The v1.0 AgentCard has no url;
supportedInterfaces replaced it, and emitting it made the card fail the
specification's own JSON schema. The field is still parsed, so a card
from a v0.3 peer still loads. To publish an agent's address, populate
supported_interfaces (a Vec<AgentInterface>; the 0.11 section above shows
one).
{ "name": "…", "url": "https://agent.example.com/rpc", "supportedInterfaces": [ … ] }
becomes
{ "name": "…", "supportedInterfaces": [ { "url": "https://agent.example.com/rpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" } ] }
A consumer that read url from the raw JSON needs the interface list
instead.
AgentCard.securitySchemes is emitted in the v1.0 wire shape
SecurityScheme is a protobuf oneof, so its ProtoJSON encoding is a
single-key object naming the arm. The SDK emitted the v0.3 OpenAPI-style form.
The bytes on /.well-known/agent-card.json change:
{ "type": "apiKey", "in": "header", "name": "X-API-Key" }
becomes
{ "apiKeySecurityScheme": { "location": "header", "name": "X-API-Key" } }
Both encodings are accepted on input (the v1.0 form under either the
json_name or the proto field-name spelling of the arm, and the v0.3 form),
and a v0.3 scheme normalises to the v1.0 form on re-emission.
ApiKeySecurityScheme emits location, with in kept as an alias.
Deserialization is strictly more permissive than before; only a consumer
reading the card's raw JSON keys rather than parsing it with an A2A
implementation notices.
0.6 → 0.7
The boundary that costs a consumer of a2a-protocol-types alone the most:
the only two changes between 0.7 and 0.12 that stop such code compiling are
both here. (0.13 and 0.15 add more for that crate, such as FailureClass::ALL
and, with the proto feature, a ConvertError literal.) Neither appears under
a ### Breaking Changes heading — 0.7.0 has no such heading — and both sit
under ### Changed in a release with sixteen subsections, so they are not
findable by chance.
JsonRpcRequest.id is the three-state JsonRpcRequestId
JsonRpcRequest.id was Option<serde_json::Value>, which cannot tell an
absent id member from an explicit "id": null. JSON-RPC 2.0 does: the
first is a notification, the second is a call. It is now an enum with all
three states, so "id": null stops collapsing into a notification on
round-trip.
Responses did not change. JsonRpcId is still
Option<serde_json::Value>, and it is still what JsonRpcSuccessResponse.id
and JsonRpcErrorResponse::new take. Code that threaded a request id
straight into a response stops compiling:
// 0.6 — the request id went directly into the response
fn error_response(id: Option<serde_json::Value>, error: JsonRpcError) -> JsonRpcErrorResponse {
JsonRpcErrorResponse::new(id, error)
}
to_response_id() is the bridge. It maps Absent and Null alike to
None, mirroring the specification's rule for a request whose id could not
be determined, so the bytes on the response are what they were before:
#![allow(unused)] fn main() { use a2a_protocol_types::jsonrpc::{JsonRpcError, JsonRpcErrorResponse, JsonRpcRequestId}; fn error_response(id: &JsonRpcRequestId, error: JsonRpcError) -> JsonRpcErrorResponse { JsonRpcErrorResponse::new(id.to_response_id(), error) } let replied = error_response( &JsonRpcRequestId::Value(serde_json::json!(1)), JsonRpcError::new(-32600, "Invalid Request"), ); assert_eq!(replied.id, Some(serde_json::json!(1))); // Both non-value states answer with a null id, as they did before. let notification = error_response( &JsonRpcRequestId::Absent, JsonRpcError::new(-32600, "Invalid Request"), ); assert_eq!(notification.id, None); }
In practice a server threads the request id through one or two local helpers like the one above, so changing those signatures is usually the entire migration; every call site that passes an id into them keeps compiling untouched.
The new capability is worth a second look while you are there. A server can
now see JsonRpcRequestId::Absent and decline to answer at all, which is
what JSON-RPC 2.0 requires for a notification — is_absent() is provided for
exactly that. Migrating with to_response_id() alone preserves the old
behaviour of replying to everything; it does not adopt the new one.
AuthenticationInfo.credentials and TaskPushNotificationConfig::task_id are Option<String>
Both were required, which rejected valid cross-SDK payloads at parse time — a
push configuration nested in SendMessageConfiguration, before the task it
will belong to exists, legitimately carries no task id. Both are now
Option<String>, matching the canonical schema.
The struct literal is the obvious break:
// 0.6
let auth = AuthenticationInfo {
scheme: "bearer".to_string(),
credentials: "my-token".to_string(),
};
#![allow(unused)] fn main() { use a2a_protocol_types::push::AuthenticationInfo; let auth = AuthenticationInfo { scheme: "bearer".to_string(), credentials: Some("my-token".to_string()), }; let _ = auth; }
The non-obvious break is the one to search for. Any format! that
interpolated credentials stops compiling for want of Display, and rustc
suggests {:?} in its note — which builds an Authorization header reading
Bearer Some("my-token"). unwrap_or_default() sends an empty bearer token
instead. Neither is right: omit the header when there is nothing to send.
// 0.6 — and note that neither `{:?}` nor `unwrap_or_default()` is the fix
request.header("Authorization", format!("Bearer {}", auth.credentials))
#![allow(unused)] fn main() { use a2a_protocol_types::push::AuthenticationInfo; let auth = AuthenticationInfo { scheme: "bearer".to_string(), credentials: None }; let header = auth .credentials .as_deref() .map(|credentials| format!("Bearer {credentials}")); // No credentials configured, so no header at all — not an empty one. assert!(header.is_none()); }
On the server side, a standalone CreateTaskPushNotificationConfig carrying
no task id is now refused with a structured invalid-params error rather than
a parse error, and every push-config store guards the missing routing key
explicitly. A handler of your own that forwards task_id onward has the same
decision to make: an absent id is a malformed request, not a default.
Also in 0.7, changing what parses rather than what compiles
Three tightenings reject input that previously got through. A Part carrying
more than one of text / raw / url / data now fails deserialization
instead of silently taking the first match. A JsonRpcResponse carrying both
result and error is rejected per JSON-RPC 2.0 §5 instead of being read as
a success with the error discarded, and a mistyped result now surfaces the
real type error rather than an opaque "no variant matched". And RFC 8785
canonicalization for signing now sorts object keys by UTF-16 code units
(§3.2.3) and formats doubles per ECMAScript Number::toString (§3.2.2),
which changes the signature computed over any card containing
supplementary-plane keys — a card signed under 0.6 and verified under 0.7 can
disagree.
Also in 0.7, on the server and client
This is where the JSON tunnel went behind grpc-legacy-json and
with_event_queue_write_timeout / with_write_timeout became deprecated
no-ops, all three slated for the 0.8 removals above. It is also where a
consumer that falls behind the broadcast ring started receiving a marked
streamLagged error and a closed stream instead of a silently skipped gap,
where max_concurrent_streams gained a default cap of 1024 (previously
unlimited, an unauthenticated denial-of-service vector), and where
RateLimitInterceptor::new became fallible and stopped trusting a
client-supplied X-Forwarded-For unless RateLimitConfig::trusted_proxy_hops
is set.
Older boundaries
Before 0.8 the changelog was the migration guide. It still is for these; only
0.5 → 0.6 breaks nothing at compile time (0.4 → 0.5 changes the TaskStore
signatures, and 0.3 → 0.4 the wire format, PartContent and OAuthFlows),
and the 0.6 → 0.7 boundary has a full section above:
- 0.5 → 0.6 (
## [0.6.0] - 2026-06-10): noBreakingheading, and nothing that fails to compile — no public API signature changed, which is what makes this the easiest boundary to cross without noticing. A types-only consumer gets a clean build and a different service. What moved is the bytes on the wire and which requests are accepted, both breaking under the policy above, so read### Changedand### Fixedfor this release rather than looking for a section that is not there.Task.historyis populated for the first time — nothing had ever appended to it — sotasks/getnow returns real history subject tohistoryLength, capped at 1,024 messages with the oldest dropped first, whilemessage/sendresponses and streaming snapshots omit it unlessSendMessageConfiguration.historyLengthasks; response sizes move in both directions. A droppedmessage/streamconnection no longer cancels the task: work runs to completion and clients reattach withSubscribeToTask, so anything that relied on disconnect-kills-task must now callCancelTaskexplicitly.Working → Workingbecame a valid transition, so an executor emitting more than one progress note is no longer markedTASK_STATE_FAILEDby the background processor behind a stream that already saidCompleted— if you worked around that split-brain, the workaround can go. Continuations carry artifacts, metadata and history forward instead of overwriting them, with only the status returning toSubmitted.tasks/resubscribejoinsSubscribeToTaskandtasks/subscribeas a routed alias. And on the client, a JSON-RPC error answeringmessage/streamsurfaces asClientError::Protocolcarrying the original code, instead of ending the stream with zero events and no error, so code that read an empty stream as "nothing happened" now sees the failure it was missing. - 0.4 → 0.5 (
## [0.5.0] - 2026-04-02,### Breaking Changes):TaskStore::save()andTaskStore::insert_if_absent()take&Taskinstead of an ownedTask. - 0.3 → 0.4 (
## [0.4.0] - 2026-03-31,### Breaking Changes): the move to the A2A v1.0.0 wire format. Clients and servers on the old wire format need that section in full.