# Which invariants prevent silent task-state divergence when A2A v1.0 Agent Cards are re-signed mid-negotiation **Date of Record:** 2026 November 11 **Scope:** Applies to A2A v1.0 peers that re-sign Agent Cards during an active task lifecycle (key rotation, capability advertisement change, or metadata refresh) while a task is in a non-terminal state. Does not apply to fully static deployments where the Agent Card is immutable for the lifetime of every task, nor to A2A v0.x which lacks signed capability negotiation. ## Core Distinction The Agent Card is not a configuration document; it is a *signed assertion of capability* bound to a specific key, a specific `capabilities` set, and a specific `protocolVersion`. The non-obvious boundary is this: **re-signing an Agent Card is a capability-set mutation event, not a metadata refresh.** A2A v1.0 permits a peer to fetch a card at any time, but the task state machine's `capabilities` snapshot is captured at `task.create` and MUST remain frozen for the task's lifetime. Divergence arises when an operator rotates a signing key or edits `capabilities` and assumes in-flight tasks are unaffected. They are not: the receiving agent re-validates the card against the *current* signature on every `task.status` poll if it does not pin the snapshot, and a mid-flight capability removal can cause the state machine to reject a legal transition that was valid at `task.create` time. ## What Was Observed During a key-rotation drill, an orchestrator re-signed its Agent Card at T+40s of a long-running `input-required` task. The card's `capabilities.streaming` flag was flipped from `true` to `false` in the same commit (unrelated cleanup). The remote peer, which re-fetched the card on each poll rather than pinning the create-time snapshot, then refused the subsequent `task.sendStreamingMessage` transition with `UnsupportedCapability`. The task sat in `input-required` indefinitely — neither `completed` nor `failed`, because the transition was rejected *before* the state machine could record a terminal state. The signature itself validated fine; the failure was a capability-set drift, not a cryptographic one. A second observation: when the *key* rotated but `capabilities` did not, peers that validated signatures against a cached JWKS without honoring the card's `signature.keyId` field accepted the new signature under the old key material for up to the JWKS cache TTL — a window during which two distinct keys both validated, violating the single-key-per-card invariant. ## Condition of Validity The invariant "a task's capability set is immutable for its lifetime" holds **only if** the receiver pins the full signed card (including `keyId` and `capabilities`) at `task.create` and validates all subsequent transitions against that pinned snapshot. It is **invalid** if the receiver re-fetches and re-validates the live card per transition. The signature-validity invariant ("exactly one key validates a given card at a given instant") holds only if JWKS cache TTL is bounded below the key-rotation overlap window, or if the card's `keyId` is treated as authoritative and stale keys are rejected immediately. ## Decision Boundary - If a card re-sign changes **only** `signature`/`keyId` and `capabilities` is byte-identical: treat as transparent; in-flight tasks proceed. - If `capabilities` changes at all: the change applies **only to tasks created after the new card's `issuedAt`**. In-flight tasks retain the old snapshot. If the peer cannot honor the old capability (e.g., streaming endpoint torn down), it MUST transition the task to `failed` with reason `capability_withdrawn`, never silently reject the transition. - If `protocolVersion` changes: all in-flight tasks are invalid; fail them explicitly. ## Operational Effect Pinning the create-time snapshot converts a silent hang (task stuck in `input-required`, no terminal state, no error surfaced to the caller) into a deterministic terminal outcome. The cost is that a withdrawn capability cannot be revoked mid-task — the peer must serve it until the task terminates or fail the task explicitly. This is the correct trade: an observable `failed` is strictly better than an unobservable hang, and the state machine's guarantee that every task reaches a terminal state is preserved. ```python class CapabilitySnapshotMismatch(RuntimeError): """Raised when a task transition violates the pinned create-time capability snapshot.""" pass class AgentCardSnapshot: def __init__(self, key_id: str, capabilities: frozenset, protocol_version: str): self.key_id = key_id self.capabilities = capabilities self.protocol_version = protocol_version def validate_transition(snapshot: AgentCardSnapshot, live_card: dict, required_capability: str, current_state: str) -> None: if snapshot.protocol_version != live_card["protocolVersion"]: raise CapabilitySnapshotMismatch( f"protocolVersion changed mid-task: pinned={snapshot.protocol_version} " f"live={live_card['protocolVersion']}; task in {current_state} must fail explicitly" ) if required_capability not in snapshot.capabilities: raise CapabilitySnapshotMismatch( f"capability {required_capability!r} absent from pinned snapshot; " f"transition illegal at create time" ) if required_capability not in live_card["capabilities"]: # Capability withdrawn mid-task: fail explicitly, never hang. raise CapabilitySnapshotMismatch( f"capability {required_capability!r} withdrawn mid-task " f"(pinned keyId={snapshot.key_id}, live keyId={live_card['signature']['keyId']}); " f"transition to failed with reason=capability_withdrawn" ) if live_card["signature"]["keyId"] != snapshot.key_id: # Key rotated but capability intact: transparent, proceed. return ```