Skip to content

a2a-rust v0.15.0

Latest

Choose a tag to compare

@github-actions github-actions released this 25 Oct 14:20
· 292 commits to main since this release
v0.15.0
e4fc3bc

These are the sections to read before upgrading. The full notes for this release are in CHANGELOG.md at this tag.

Known Limitations

  • 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 (the_single_writer_refusal_does_not_cross_replicas), and a
    continuation saved on one can overwrite what the other's turn wrote
    meanwhile: the stored task loses it, though a subscriber still receives
    the event (tests/stale_write_position). Compare-and-set task writes and
    cross-replica single-writer admission would close it; they are planned,
    not built. One replica, or several with affinity, is the supported
    topology for 0.15 (Running More Than One Replica).

Breaking Changes

  • serve::Server is no longer UnwindSafe or RefUnwindSafe with
    tls-rustls
    (server; on by default in the SDK). The field holding
    Server::with_tls's configuration carries rustls' server configuration,
    which implements neither. Code that sent a Server across
    std::panic::catch_unwind wraps it in AssertUnwindSafe. Found by
    cargo-semver-checks 0.51.0 against 0.14.1 (auto_trait_impl_removed),
    the one break that run reported which this list did not name.

  • The client refuses an agent's object that a2a.proto does not allow
    (ClientError::InvalidAgentResponse).
    Spec §5.7: implementations SHOULD
    reject messages missing a REQUIRED field. On every binding (JSON-RPC,
    HTTP+JSON, WebSocket, gRPC) the tasks get_task/cancel_task/list_tasks
    return, what send_message returns, every streamed event, and the push
    configs the push-config calls return are checked against
    a2a_protocol_types::required: a task with an empty id, a status in
    TASK_STATE_UNSPECIFIED, a message with no messageId/role/parts, an
    artifact with no artifactId or parts, a status/artifact update with no
    taskId/contextId, a push config with no url or authentication.scheme
    now fail the call with the new ClientError::InvalidAgentResponse(String)
    naming the call and field (not retryable; converts to ErrorCode::InvalidAgentResponse).
    A refused stream event ends the stream. They used to be returned as if valid.
    What integrators see: calls to a non-conformant agent that used to succeed
    now fail; match InvalidAgentResponse to tell this from a transport fault.
    ClientError is #[non_exhaustive], so adding the variant breaks no match.
    Crates: a2a-protocol-client, a2a-protocol-types.
    Tests: crates/a2a-protocol-client/tests/received_validation/ (json.rs,
    grpc.rs, cards.rs).

  • Agent cards a client receives must pass AgentCard::validate and
    validate_security_schemes.
    resolve_agent_card, fetch_card_from_url,
    CachingCardResolver and get_extended_agent_card refuse, with
    InvalidAgentResponse, a card the server would refuse to publish (no
    description, no skills, empty provider.url, a skill with no name, a
    signature with no signature, an empty protocolVersion, a security
    scheme with an empty REQUIRED string, ...). A refused extended card is not
    adopted (its capabilities and security are not acted on).
    ClientBuilder::from_card does not validate the card it is handed.
    Crates: a2a-protocol-client. Tests:
    cards.rs a_discovered_card_that_breaks_a_publishing_rule_is_refused,
    an_extended_card_that_breaks_a_publishing_rule_is_refused_and_not_adopted.

  • The gRPC conversion refuses an absent REQUIRED field. TryFrom<pb::…>
    now returns ConvertError { reason: "missing required field" } for
    Task.id, TaskStatus.state (0), Message.message_id/role (0),
    Artifact.artifact_id, TaskStatusUpdateEvent/TaskArtifactUpdateEvent
    task_id/context_id, AgentCard.name/description/version/
    capabilities (no longer defaulted to the empty set), AgentInterface
    url/protocol_binding/protocol_version, AgentProvider, AgentSkill
    id/name/description, AgentCardSignature fields, apiKey.name,
    httpAuth.scheme, openIdConnectUrl and the authorization-code,
    client-credentials and device-code flows' URLs — the cases where the same
    JSON without the key does not parse. A server answers such a gRPC request
    INVALID_ARGUMENT with a BadRequest violation (it used to reach the
    handler, which refused most of them with the same code). A client reports a
    response its conversion refuses as InvalidAgentResponse (it was
    Transport). Crates: a2a-protocol-types, a2a-protocol-client.
    Tests: crates/a2a-protocol-types/src/proto/convert/required_tests.rs,
    card.rs agent_card_missing_capabilities_is_refused,
    tests/proptest_proto_convert.rs unspecified_state_and_role_are_refused.

  • An authenticating interceptor needs a scheme declared in the public
    card, and an extended-card scheme must also be in the public card

    (server; spec §13.3). §13.3: agents "MUST authenticate requests using one
    of the schemes declared in the public AgentCard.securitySchemes".
    RequestHandlerBuilder::build compared the interceptors with the schemes
    of the public and extended cards pooled, and passed when none were
    declared, so a public card declaring nothing built behind any
    authenticator, and a scheme declared only on the extended card passed for
    the public card's. It now refuses, with InvalidParams naming §13.3:
    (1) any authenticating interceptor (bundled or custom) behind a public
    card that declares no securitySchemes; (2) a scheme the extended card
    declares that the public card does not declare under the same name with
    the same definition; (3) as before, an authenticator whose
    checks_scheme answers Some(false) to every scheme, now of the public
    card only. A custom authenticator that answers None (the trait default)
    is still trusted to check one of the declared schemes: only its author
    knows what it checks, and refusing it would refuse every custom
    authenticator ever written. Without a public card (without_agent_card)
    the extended card's schemes are checked as before.
    Migration: declare the scheme your interceptor checks in the card you
    pass to with_agent_card: for BearerTokenAuthInterceptor or
    JwtAuthInterceptor, securitySchemes: {"bearer": {"httpAuthSecurityScheme": {"scheme": "Bearer"}}} (in Rust,
    .with_security_schemes(HashMap::from([("bearer".into(), SecurityScheme::Http(HttpAuthSecurityScheme { scheme: "Bearer".into(), description: None, bearer_format: None }))]))); for
    ApiKeyAuthInterceptor, an apiKeySecurityScheme naming its header; for
    MutualTlsAuthInterceptor, a mtlsSecurityScheme; and repeat every
    extended-card scheme in the public card. The ITK agent now declares its
    bearer scheme in every pass (it guards the extended card in every pass),
    and the SLIMRPC binding's tests declare mtlsSecurityScheme for
    SlimPeerAuthInterceptor::required(). Tests:
    auth::declared_tests::an_authenticator_behind_a_public_card_declaring_no_scheme_is_refused,
    auth::declared_tests::a_scheme_declared_only_on_the_extended_card_is_refused.

  • The MSRV is 1.94, and the SQL stores use sqlx 0.9 (server, SDK,
    SLIMRPC binding; 2026-10-23). sqlx 0.9 needs Rust 1.94, and its pool
    types are in the SQL stores' public constructors (SqliteTaskStore,
    PostgresTaskStore and the rest take a SqlitePool / PgPool), so an
    integrator building one from its own pool moves to sqlx 0.9 with this
    release. STABILITY.md §5 now lets a minor release raise the MSRV to
    adopt a direct dependency's major version, which is this case; it was
    folded into 0.15.0 so that the stores' move does not need a second
    breaking release. sqlx 0.9 refuses a statement built at run time unless
    it is vouched for; the seventeen such statements in the library (the
    ListTasks filters, retention, caller-key erasure, the audit key lookup,
    the SQLite schema helpers and the several-part artifact append; counted
    with grep -rn AssertSqlSafe outside tests) are built from static text,
    &'static str names and integer positions only, and each is marked
    sqlx::AssertSqlSafe with that reason. pg_schema::apply and the SQLite
    add_applied_seq helper now take &'static str, so a run-time string
    cannot reach them.

  • Every agent card declares the budget extension (server; ADR 0023).
    https://a2a-rust.com/extensions/budget/v1 joins the failure taxonomy
    as an extension the builder adds to every card, never as required. A
    card that carries a signature but does not declare it is refused at
    build(), as for any extension the builder would have to add to signed
    bytes. Migration: declare it on the card, then sign the result.

  • TenantLimits::max_stored_tasks is removed (server), with
    TenantLimits::with_max_stored_tasks and
    TenantLimitsBuilder::max_stored_tasks. Deprecated since 0.10.0 and
    0.12.0 as "never enforced": the field sat on PerTenantConfig, which a
    store never sees, so nothing read it. Migration: give the tenant its own
    TaskStoreConfig::max_capacity with
    TenantAwareInMemoryTaskStore::with_tenant_override. The deprecations
    made in 0.15.0 stay until 0.16.

  • TaskStore::save_appending_history takes a HistoryLimit, not a
    message count
    (server; release audit F2). The limit bounds bytes as well
    as messages. A store that overrides the method trims with
    limit.trim(&mut history), and a caller that passed n passes
    HistoryLimit::new(n, usize::MAX) to keep the old meaning.

  • OpenTelemetry instrumentation is on by default; export is otlp
    (server, client, SDK; decided 2026-10-14, the exporter's place settled
    2026-10-18). The server's otel feature is now the instrumentation only
    (OtelMetrics, the spans' OpenTelemetry bridge), through the OpenTelemetry
    API (opentelemetry, tracing-opentelemetry), and is a default feature
    of the server, the client and the SDK: a process that installs a provider
    gets the SDK's spans and metrics with nothing to enable, and one that
    installs none records to nothing. Telemetry, the Langfuse preset and
    init_otlp_pipeline moved to a new otlp feature, with the SDK and
    exporter dependencies; it is default in a2a-protocol-sdk only.
    Migration: a direct dependency on a2a-protocol-server that used
    Telemetry asks for features = ["otlp"]; on the SDK nothing changes.

  • The metric catalogue follows the OpenTelemetry conventions (server,
    otel; decided 2026-10-15). a2a.server.latency, deprecated in 0.14.0, is
    removed: read a2a.server.request.duration (Prometheus
    a2a_server_request_duration_seconds), the same histogram under the
    conventions' name, or rpc.server.call.duration. Units are UCUM: a count
    is brace-annotated ({request}, {connection}, {error}, {queue},
    {delivery}, {response}). Rendered through opentelemetry-prometheus
    0.33.1 the other metric names are unchanged (measured 2026-10-18).

  • init_otlp_pipeline and init_otlp_pipeline_with_endpoint take
    service_name: Option<&str>
    (server, otel). The environment now wins:
    OTEL_SERVICE_NAME, or service.name in OTEL_RESOURCE_ATTRIBUTES, names
    the service, and the argument only when they do not; None leaves it to
    the environment and the SDK's unknown_service default. The &str
    overwrote the environment, against the OpenTelemetry environment-variable
    specification. Migration: wrap the name in Some. Tests:
    otel::pipeline::tests.

  • ConvertError and SseParseError are #[non_exhaustive] (types,
    client; release audit F26). A match on SseParseError needs a wildcard
    arm, and a ConvertError is built with the now-public
    ConvertError::new(field, reason) rather than a struct literal, so a
    variant or field added later is not a breaking change.

  • A push config whose authentication carries no credentials is
    refused
    (server; spec §4.3.3: a delivery MUST carry the credentials its
    config specifies). CreateTaskPushNotificationConfig answers
    InvalidParams naming authentication.credentials when they are absent,
    empty or whitespace; such a config was stored and every delivery went out
    with no Authorization, or with an empty Bearer . Leave
    authentication out for an unauthenticated webhook. Test:
    push_config_credentials.

  • A card carrying a credential in a URL is refused when published
    (server; spec §14 "The Agent Card SHOULD NOT include sensitive
    credentials", §13.3 an extended card no "unmasked credentials"). build(),
    the static, dynamic and hot-reload card handlers and per-caller extended
    cards refuse a card any of whose URLs carries user information
    (user:pass@host) or a query parameter named like a secret (token,
    access_token, api_key, apikey, auth, client_secret, key,
    password, secret, sig, signature; case-insensitive), naming the field. New
    public agent_card::card_credentials(&card) lists them. Tests:
    file_url_ssrf::build_refuses_a_card_carrying_a_credential,
    agent_card::lint::tests::a_credential_in_any_card_url_is_found_and_refused.

  • A card whose OAuth or OpenID Connect URLs are not URLs is refused when
    published
    (types/server; a2a.proto: each OAuth flow URL "MUST be in the
    form of a URL", and oauth2_metadata_url "TLS is required").
    validate_security_schemes, which build() and every card handler run,
    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. A client applies the same check to a card it fetches. Tests:
    security::url_tests.

  • A client built from a card refuses to build when the card marks an
    extension required that it does not ask for
    (client; a2a.proto
    AgentExtension.required: "the client must understand and comply").
    build() returns ExtensionSupportRequired naming the extension; ask for
    it with with_extensions, or call skip_required_extension_check() when
    an interceptor sends the A2A-Extensions header. A client built for an
    endpoint, not a card, is unaffected.

  • build() refuses a card whose declared authentication the
    interceptors do not enforce
    (server; spec §3.3.2 "Servers MUST reject
    requests with invalid or missing authentication credentials", §7.4 "MUST
    authenticate every incoming request based on ... its declared
    authentication requirements", §13.3 "MUST authenticate requests using one
    of the schemes declared in the public AgentCard"). Two configurations that
    built now fail with ServerError::InvalidParams, naming the fix: a card
    (or a skill) with securityRequirements when no interceptor authenticates,
    which served every request without the credential the card demanded; and
    an authenticating interceptor that checks none of the card's declared
    schemes. RequestHandlerBuilder::authenticated_upstream() accepts the
    first when a gateway in front of the process authenticates. A requirement
    list containing an empty requirement ({}, optional authentication) asks
    for nothing. New provided trait method
    ServerInterceptor::checks_scheme(&SecurityScheme) -> Option<bool>
    (default None, not checked); the bundled authenticators answer it.
    Tests: auth::declared_tests, and tests/auth_every_method.rs, which
    calls every method in Method::ALL on both HTTP bindings without a
    credential and with a wrong one and requires 401 each time.

  • A replay longer than subscribe_replay_limit is read in pages
    (server; spec §3.5.2 "each stream MUST receive the same events in the
    same order"). The limit now bounds each read of the event log, not the
    replay. A stream that lagged by more than the limit ended with
    streamLagged; it now reads the log a page at a time, from where each
    page ended, before it reads the live channel again. The same holds for a
    turn a SubscribeToTask stream missed between polls, and for a
    Last-Event-ID resume, which had a silent gap: the replay stopped at the
    limit and the stream went on live, past the positions between the
    replay's end and where its receiver attached, with no streamLagged
    (event_log_tests pinned [snapshot, 1, 2] for a log of five); a
    resume whose first read the log had not caught up with within
    subscribe_replay_catchup skipped the same way, and now reads on while
    the log moves forward (found by an independent review). A page the log
    cannot supply is still reported as streamLagged, after what it did
    supply, once, and the final state of a finished task follows the last
    page. Memory stays bounded by the limit, but a client resuming with
    Last-Event-ID: 0 now receives the whole retained history on one
    connection, read a page at a time as it is delivered, where it received
    at most one page before.

  • HTTP+JSON responds application/a2a+json when the request states no
    preference
    (server; spec §11.1 "Content-Type: application/a2a+json SHOULD
    be used for requests and responses"). RestDispatcher and the axum router
    answered application/json unless Accept preferred the A2A type or the
    body was labelled with it. Now Accept still decides when it names either
    JSON type, an application/a2a+json body is still answered in kind, a
    request asking only for text/event-stream gets application/json
    (released a2a-go v2.6.0 sends exactly that on its streaming calls and
    decodes an error body only under application/json), and everything else,
    including a request with no Accept, gets application/a2a+json.
    scripts/go_sdk_interop.sh passes both directions on every binding. A
    report asking a2a-go to read application/a2a+json errors is drafted,
    held, in docs/upstream/.

  • A SubscribeToTask stream has no idle bound by default (server;
    spec §3.1.6 "The stream MUST terminate when the task reaches a terminal
    state"). HandlerLimits::subscribe_max_idle is Option<Duration>,
    None by default; it was a Duration of five minutes, after which a
    stream to a task still parked in input-required ended with no terminal
    state. with_subscribe_max_idle sets Some and keeps its signature.
    Every binding already ends a stream whose client has gone (SSE keep-alive,
    the WebSocket read loop, gRPC's closed channel, SLIMRPC's session close),
    so the bound is needed only to reclaim subscriptions whose clients are
    still connected. A bound too large to add to the clock (Duration::MAX)
    panicked inside the stream's poll; it is now no bound. A store that keeps
    failing while a stream waits is warned about once per run of failures,
    not at every poll (see the upgrading guide).

  • SLIMRPC: the binding is built on agntcy-slim-rpc 3.4.1
    (a2a-protocol-slimrpc; audit register R13). Its six SLIM requirements
    move to the line 3.x requires: 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, agntcy-slim-config 0.16 → 0.17,
    agntcy-slim-session 0.7 (locked 0.7.10 → 0.7.21). They are public
    dependencies: SlimRpcServer::from_app, SlimRpcTransport::from_app,
    SlimRpcMulticast::from_app and the builders' with_identity take SLIM
    types, so a caller passes types from the same SLIM versions. The binding's
    own code compiled unchanged. 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 it.

  • TaskState's discriminants are its proto numbers (types; spec §1.4,
    ADR 0016). TaskState is generated from a2a.proto now, and
    TaskState::Completed as i32 is 3, as TASK_STATE_COMPLETED is in the
    proto and the prost enum; it was 5, its place in the hand-written
    declaration. The wire form and TaskState::ALL are unchanged.

  • ClientRequest has a new public field, query_params (client;
    a2a.proto APIKeySecurityScheme.location: "Valid values are "query",
    "header", or "cookie""; audit register R67). The client refused an API
    key that goes in the query string, because a request reached the
    transports with headers only. An interceptor now adds query parameters
    to query_params, and the JSON-RPC and HTTP+JSON transports append them
    to the request URL, percent-encoded, after any query the route builds
    itself; a retried call sends them on every attempt. Debug prints their
    names, never their values. A struct literal that names every field of
    ClientRequest no longer compiles: build it with ClientRequest::new,
    or add query_params: Vec::new(). This field is why the next release
    is 0.15.0 rather than 0.14.2.

    The Transport trait gains send_request_with_query and
    send_streaming_request_with_query, with defaults, so a custom
    transport still compiles. The default sends a call with no query
    parameters as before and refuses one with them, with
    ClientError::InvalidAuthScheme, rather than send it without its
    credential. gRPC, WebSocket and the SLIMRPC binding have no query string
    and refuse the same way.

  • A caller is named by how it authenticated (server; spec §13.1,
    RFC 7519 §4.1.2; audit register R12). The bundled interceptors record
    bearer:<label>, api-key:<label> and jwt:<issuer>:<sub> (the issuer
    percent-encoded) where they recorded the bare label or sub. A sub is
    unique only within its issuer, so one sub from two accepted issuers was
    one caller: one task partition, one rate-limit budget, one approver. The
    new identity is what caller isolation, the rate limiter, the audit actor,
    ApprovalRequest.requested_by and ApprovalGate::with_approvers see.
    An approver allowlist of bare names matches nobody: write it with
    auth::caller_id, e.g. caller_id::bearer("alice"). Code that compares
    CallContext::caller_identity() against a label must compare against the
    qualified form. Stored partitions keep their old keys until migrated:
    run migrate_caller_keys once on each SQLite or PostgreSQL tenant-aware
    task and push-config store (see Added), or each caller's tasks and
    webhooks are not found under its new identity. A pending approval stored
    before the upgrade names its requester bare; the four-eyes check still
    refuses that requester, under any issuer.

  • A JWT with no sub is refused under caller isolation (server; audit
    register R12). It authenticated and named nobody, and under the default
    isolation was served the tenant's shared records. It is now refused with
    PermissionDenied unless callers share tasks
    (share_tasks_across_callers).

Deprecated

  • signing::sign_agent_card (types; spec §8.4.2), for
    sign_card_es256(card, key, key_id), whose key_id is a &str. Its
    key_id: Option<&str> let a signature's protected header omit kid,
    which §8.4.2 requires, so it now refuses None with InvalidParams
    instead of writing a header without one: a specification correction
    (STABILITY.md §2), which is the only change for a caller passing a key
    id. Kept for the one minor release STABILITY.md §3 requires and removed
    in 0.16.0. Test: tests/sign_agent_card_deprecated.rs.
    (sign_agent_card_ed25519, its Ed25519 twin, was added and removed within
    0.15.0 and never published; sign_card_ed25519 is the Ed25519 signer.)

  • HotReloadAgentCardHandler::update, for try_update (server;
    spec §5.7: arrays marked as required MUST contain at least one element).
    update stored any card; try_update checks it as reload_from_json
    checks a reloaded one (AgentCard::validate and
    validate_security_schemes) and refuses with InvalidParams, leaving
    the current card in place.

  • ClientConfig::preferred_bindings and ClientConfig::with_preferred_bindings
    (client). Nothing reads them once ClientBuilder::from_card follows the
    card's interface order (spec §8.3.2); setting them changed nothing a caller
    could see. Rank bindings with ClientBuilder::from_card_preferring, and see
    the choice with ClientBuilder::chosen_interface. Kept for one minor
    release, as STABILITY.md requires.

  • OAuthFlows::Implicit and OAuthFlows::Password (types), as the A2A
    1.0 proto marks both fields deprecated. They remain, parse and serialize
    as before (spec §1.4 keeps deprecated definitions until the next major
    version); code that names them now gets a deprecation warning.

Security

  • TaskPushNotificationConfig's Debug prints the webhook's origin, not
    its URL
    (types; spec §13.2, §13.4). A webhook URL routinely carries the
    receiver's secret in its path, query or userinfo; {:?} printed it whole,
    into logs and panic messages, while the server's and client's own log
    lines already printed only scheme://host[:port]. Tests:
    push::tests::debug_redacts_secrets_but_keeps_presence (which asserted
    the full URL and now asserts its absence),
    push::tests::debug_url_never_echoes_what_it_cannot_cut.

  • rsa is no longer in any lockfile (RUSTSEC-2023-0071, Marvin timing
    side channel, no fixed release). It was in Cargo.lock only through
    sqlx-mysql 0.8, a driver no feature enables, and osv-scanner.toml
    waived it; sqlx 0.9 does not bring it, so the waiver is removed. CI's
    Go toolchain moves from 1.26.9 to 1.26.10 for GO-2026-6809
    (crypto/x509), which govulncheck reported reachable in all three Go
    interop modules under itk/; with 1.26.10 it reports none.

  • tokio requires 1.54.1; lockfiles move off this week's advisories
    (client, server, SDK, SLIMRPC binding). tokio 1.54.0, yanked for a use-after-free race in time::Interval on
    the multi-threaded runtime (GHSA-5wjr-9356-4wc8; the server's halt sync
    uses Interval), is replaced by 1.54.1 in the workspace, SLIMRPC, fuzz
    and ITK lockfiles. rustls-webpki 0.103.15 (RUSTSEC-2026-0382, distrusted
    emSign intermediates) is replaced by 0.103.16 wherever it was locked, and
    the SDK-comparison harness's webpki-root-certs 1.0.9 (RUSTSEC-2026-0383)
    by 1.0.10. The SLIMRPC lockfile's ctutils 0.4.3 (RUSTSEC-2026-0385,
    mask generation and u128 comparisons that could branch on secret values)
    becomes 0.4.4; it arrives through the SLIM crates' p256 and ml-kem,
    and no other lockfile holds it. Its crypto-bigint 0.7.5
    (RUSTSEC-2026-0387, operations meant to be constant-time that could
    branch on secret values) becomes 0.7.6; it arrives through
    agntcy-slim-auth's p256, and no other lockfile holds it either. In
    CI's hash-locked Python tools, certifi 2026.7.22 (GHSA-6q48-6c7w-9gw2)
    becomes 2026.10.19 and uv 0.13.1 (GHSA-hq85-5cpj-mr62) becomes 0.13.5,
    for the same emSign root. The tokio requirement rises from 1.44.2 to
    1.54.1, so a downstream lockfile that already holds 1.54.0 must update
    (crates.io has yanked it, so a new resolution never picked it; a
    lockfile written between 2026-10-19 and the yank could). 1.54.1 needs
    Rust 1.85, under this crate's 1.88.

  • webpki-roots requires 1.0.10 (client, server; RUSTSEC-2026-0381,
    affecting 1.0.0 to 1.0.9). The lockfiles already resolved 1.0.10, except
    the SDK-comparison bench harness, which is updated; the manifests admitted
    the affected versions to a downstream build that resolves its own.
    check_advisory_floors.py found it.

  • A keyed audit digest has one spelling (server, audit and
    encryption). AuditLog::digest_matches decoded the hex of a stored
    hmac-sha256:<kid>:<hex> with u8::from_str_radix, which also takes
    uppercase digits and a leading +, so a record whose digest had a digit
    uppercased (or 0f written +f) still matched. Nothing could be forged
    this way, since the tag bytes compared were the same, but a changed record
    matched. It now takes lowercase digits only, which is all the SDK writes.
    Found by the new sealed_audit_value fuzz target. Test:
    hex_round_trips_and_refuses_what_is_not_hex.

  • A control character in a client's id is refused (server; spec §14
    "MUST sanitize user-provided content"). validate_id refused only empty
    and over-long ids, so "a\r\nb" as a message, context, task or reference
    task id was stored, and GetTask, CancelTask and SubscribeToTask
    logged the requested id with %, writing a line of the client's choosing
    into the server's log. The push-config taskId and id (create, get,
    list, delete, and inline in SendMessage) and the ListTasks contextId
    filter never reached validate_id at all. Each now answers
    InvalidParams naming the field — -32602 on JSON-RPC, 400 on
    HTTP+JSON — for any char::is_control character (C0, DEL, C1), as the
    idempotency key already did; a tenant is refused likewise, not only for
    U+001F. An inline push config is checked before the task is created. The
    three entry logs and JSON-RPC's dispatch log now record the id and method
    with ?, escaped, since they run before validation. Letters outside ASCII
    still pass. Tests: control_chars_in_ids, handler::ids::tests.

  • JSON-RPC authenticates a call before reading its params (server;
    docs/handoff.md "Open engineering"). A call whose params did not
    deserialize was answered -32602, naming the field at fault, before the
    interceptor chain saw it, so a caller with no credential got the parameter
    schema where a well-formed call got 401. Such a call is now admitted
    first: one the chain refuses gets the refusal (401/403 with its
    challenge, -32600 in the body; 200 inside a batch), and one it admits
    gets the -32602 it always got. Interceptors now see these calls, with no
    task id, and on_complete reports them Failed with the -32602; a rate
    limiter counts them and an audit interceptor records them. HTTP+JSON, gRPC
    and WebSocket followed the same day (Behaviour Changes, "A malformed
    request without a credential is refused as unauthenticated"). Test:
    jsonrpc_auth_before_params.

  • A card-signing key whose x5c certificate has expired is not used
    (types, signing; spec §8.4.3 "Expired or revoked keys MUST NOT be used
    for verification"; RFC 7517 §4.7). Jwk had no x5c member, so serde
    dropped it and a key whose certificate had expired still verified a card.
    Jwk gains x5c: Option<Vec<String>>, and Jwks::candidates (so
    verify_agent_card_with_jwks, verify_card_with_jwks,
    verify_card_json_with_jwks and the client's signed-card fetch) uses a
    key that carries one only when its first certificate holds the same key
    (algorithm, curve and key bits from the parsed SubjectPublicKeyInfo)
    and the verification time is within its notBefore..notAfter,
    inclusive. An empty chain, an entry that is not standard base64 or not
    exactly one DER certificate leaves the key unused; the refusal reads as
    a missing key. Keys without x5c are unchanged. Not checked: the chain
    to a trust anchor, the certificates' signatures, key usage, and
    revocation (CRL/OCSP); the set itself is the caller's trusted source.
    New dependency of the signing feature: x509-parser 0.18
    (MIT OR Apache-2.0, already in the lockfile through rcgen), default
    features off. Tests: signing::keys::x5c::tests,
    signing::keys::x5c::spki_tests, and
    a_fetched_key_whose_certificate_has_expired_is_not_used in the
    client's jwks_fetch_tests.

  • SLIMRPC: the binding has no advisory waivers (a2a-protocol-slimrpc).
    With agntcy-slim-auth 0.17.5 the lock takes mls-rs-crypto-awslc
    0.25.1, which pins aws-lc-rs =1.18.1, so rustls resolves to 0.23.46
    (from 0.23.43) and RUSTSEC-2026-0285, -0346, -0348 and -0349 no longer
    apply; agntcy-slim-config 0.17.5 and regorus 0.12.0 replace
    serde_yaml, so unsafe-libyaml (RUSTSEC-2024-0449) is gone. The five
    waivers are removed from the binding's deny.toml and the six entries
    from its osv-scanner.toml; cargo-deny 0.20.2 and osv-scanner 2.6.0 report
    nothing without them. deny.toml allows CDLA-Permissive-2.0 again, as
    the root does, for webpki-root-certs, which the SLIM 3.x tree brings.
    Since 2026-10-19 it carries one again: mls-rs-crypto-awslc 0.25.1 was
    yanked with no stated defect, and 0.25.0 would bring the four rustls
    advisories back; the binding's deny.toml explains the entry. Update the
    binding's lockfile by package name, never with a blanket cargo update,
    which cannot keep a yanked version and downgrades rustls to 0.23.43.
    Since 2026-10-23 it has none again: agntcy-slim-mls 0.5.1 accepts
    mls-rs-crypto-awslc 0.26.0, so a plain cargo update takes
    agntcy-slim-rpc 3.4.6 and the unyanked 0.26.0 with rustls staying at
    0.23.46, and cargo-deny reports the entry as no longer encountered.

  • rustls 0.23.46 is the floor (client, server, sdk). RustSec published
    four rustls advisories on 2026-10-14, all patched in 0.23.46
    (GHSA-w44r-93j7-qv28, an unauthenticated ServerHello downgrading later
    connections to classical key exchange; GHSA-c8m5-c952-6cm4, Debug of
    client session values printing TLS secrets; GHSA-vp88-798v-h67f, buffered
    data sent to the outer server after an ECH rejection; GHSA-h2v8-394m-p7vv,
    Brotli certificate decompression, only with rustls's brotli feature).
    The published manifests required >=0.23.45, so a consumer could keep an
    affected rustls; they now require >=0.23.46, and the root and both
    benchmark-harness lockfiles take 0.23.46. check_advisory_floors.py found
    it: 8 requirement/advisory pairs, 0 after the change. The SLIMRPC binding
    stays on 0.23.43 for RUSTSEC-2026-0285's reason (aws-lc-rs), and waives
    the three that reach 0.23.43 in its osv-scanner.toml; cargo-deny matches
    waivers only by RUSTSEC id, which these advisories do not have yet.

  • The client's debug output names headers and JSON keys, never their
    values
    (client). ClientRequest, ClientResponse, CardFetchOptions and
    WebSocketTransportConfig derived Debug, so {req:?} in an interceptor
    printed the Authorization an auth interceptor had added, a
    CardFetchOptions printed the bearer token its own example sets, and a
    push-config call's request or response printed the webhook's token and
    credentials. They print header names, sorted, and the top-level keys of
    params and result now, as the server's CallContext does; read a value
    from its field when you want one. The fields' names in that output changed
    with it: extra_header_names, header_names, param_keys, result_keys.

  • A failed push delivery is logged with the webhook's origin, not its
    URL
    (server). The background delivery's failure and timeout warnings
    logged the whole webhook URL, so a secret in its path or query
    (/hooks/<token>, ?sig=) went into every log pipeline the server
    feeds. HttpPushSender's own log lines already named the origin only.

  • SLIMRPC: a caller's :-prefixed metadata key is dropped
    (a2a-protocol-slimrpc). A key beginning with : names what a server
    records itself, ":peer-addr" (the connection's address, for
    CallContext::peer_addr) and ":path", and the SLIMRPC server records
    neither, but it passed a caller's own through as request headers. A caller
    could therefore set the address AuthFailureThrottle charges its failed
    attempts to, and lock out the HTTP client at that address when one handler
    serves both. Its ":path" gave no more than the path an HTTP caller
    already chooses. Such keys are dropped now, beside SLIMRPC's own.
    ":peer-addr" is new in this release; ":path" was passed through by
    the binding's 0.6.0.

  • A card signature naming a critical JWS extension is refused (types;
    spec §8.4: card signatures are JWS "as defined in RFC 7515", whose
    §4.1.11 makes a JWS invalid when its crit names an extension the
    recipient does not understand). signature_header, which
    verify_agent_card and the JWK Set verifiers read, ignored crit, so a
    signature whose protected header carried "crit": ["exp"], "exp": …
    verified, its expiry unenforced. This verifier understands no extension,
    so any crit, in the protected header or the unprotected one (where RFC
    7515 forbids it), now fails verification.

  • SLIMRPC binding: RUSTSEC-2024-0449 waived, with its chain recorded
    (bindings/a2a-protocol-slimrpc/deny.toml). unsafe-libyaml is
    unmaintained (an informational advisory, not a vulnerability) and reaches
    only the binding, as serde_yaml under agntcy-slim-config and regorus.
    Every agntcy-slim-config release through 0.17.2 still requires
    serde_yaml, so no bump available here removes it; the entry names the
    release that would. The four published crates do not contain it.

  • Code-scanning findings on main addressed (OSV-Scanner, Scorecard,
    CodeQL; reproduced locally with the same tools before and after).

    • Test-peer dependencies: the Python peers floor anyio>=4.14.2
      (CVE-2026-63374, CVE-2026-64847) and h11>=0.16.0 (CVE-2025-43859);
      the JavaScript echo agent moves uuid to ^11.1.1 (GHSA-w5hq-g745-h8pq)
      and gains a lockfile. The Java peer manages the Netty and Jackson
      artifacts the advisories name directly as well as through BOMs (Maven
      already resolved the fixed versions; OSV's source mode did not). A
      recursive osv-scanner over the tree finds no advisories.
    • Every Docker base image is pinned by digest; the Python and JavaScript
      agent images install from hash-locked files (requirements.lock,
      npm ci); CI's pip install steps install from hash-locked files under
      .github/requirements/. Scorecard Pinned-Dependencies: 23 findings to 0.
    • osv.yml's rust job runs the scanner inline so Scorecard sees its SARIF
      upload: Token-Permissions 7 findings to 4 (the four remaining
      contents: write grants create releases and push the benchmark
      dashboard and the nightly prerelease, which need them).
    • CodeQL rust/cleartext-logging: four jwt.rs test assertions no longer
      format secret-derived values into their failure messages.
    • CodeQL Python code quality: 45 findings in the gate and probe scripts
      fixed (unclosed files, silent except blocks, unused imports).
  • Stored secrets are wiped on drop (zeroize, already in the
    dependency graph through rustls, so no new crate). This covers the HS256
    secret held by JwtValidator, including one refused as too short, and the
    client secret held by OAuth2ClientCredentials. The copies a request
    makes while it is in flight are not wiped: ring's HMAC key, the token
    request body, and hyper's buffers. The documentation on each field says
    so.

  • A server whose stores cannot isolate tenants now refuses requests that
    name one.
    The default RequestHandlerBuilder store ignored the tenant
    field, so a request carrying one was served from records every tenant
    shares: on 0.14.1, tenant B could get, list, subscribe to and cancel
    tenant A's task (five of five probes,
    docs/sdk-comparison-2026-10-02.md §5.1). TaskStore and
    PushConfigStore gain isolates_tenants(), false by default and true
    on the six TenantAware* stores. A request that resolves to a non-empty
    tenant — named by the client or derived by a resolver — is refused with
    UnsupportedOperation unless both stores answer true. Requests without
    a tenant are unaffected.

  • JwtValidator refuses an HS256 secret shorter than 32 bytes (server,
    auth-jwt feature). RFC 7518 §3.2 requires an HS256 key at least as long
    as the hash output; with_hs256_secret accepted any length, down to one
    byte, so a guessable secret let anyone who guessed it mint tokens the
    server accepted. with_hs256_secret now fails closed for a short secret:
    it logs an error and stores nothing, so every HS256 token is rejected. The
    new try_with_hs256_secret returns an error instead, for refusing the
    secret at startup, and MIN_HS256_SECRET_LEN names the bound. A
    deployment configured with a short secret stops accepting HS256 tokens on
    upgrade and must move to a secret of at least 32 bytes. RS256 keys under
    2048 bits were already refused by ring. The book's example, which taught
    a 13-byte secret, and examples/genai-agent now use
    try_with_hs256_secret.