# What happens when an instance re-negotiates A2A task state after an artifact validator rejects a mid-stream artifact schema **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 task lifecycle negotiation where a client and server have completed an initial `tasks/send` handshake and are exchanging artifacts under an agreed `ArtifactSchema`. Does not apply to stateless one-shot `tasks/send` calls that terminate on the first response, nor to A2A v0.9 peers that lack the `artifact.schemaVersion` field entirely. ## Core Distinction The A2A v1.0 task lifecycle separates *task state* (the `TaskState` enum: `submitted`, `working`, `input-required`, `completed`, `failed`, `canceled`) from *artifact schema negotiation* (the `ArtifactSchema` object carried in `TaskStatus.metadata` and echoed per-artifact via `artifact.schemaVersion`). These two state machines are coupled only by convention, not by the protocol's normative text. The non-obvious boundary is this: **an artifact-level schema rejection does not, by itself, transition the task out of `working`.** A server that treats a rejected artifact as a task failure will desynchronize from a client that treats it as a recoverable per-artifact event. The protocol permits both readings, and the two peers will silently diverge on the authoritative task state. The distinction that matters operationally: *task-terminal* failures versus *artifact-terminal* failures. A task-terminal failure moves the `TaskState` to `failed` and closes the lifecycle. An artifact-terminal failure rejects one artifact within a still-`working` task. Conflating them is the boundary condition. ## What Was Observed During a multi-turn exchange between a planner client and an executor server, the server emitted an artifact with `schemaVersion: "1.0.3"` while the negotiated schema (fixed at handshake) was `"1.0.1"`. The client's artifact validator rejected the payload and emitted a `TaskStatusUpdateEvent` with `state: "working"` and a `status.message` describing the schema mismatch — the client did *not* send `failed`, because from its perspective the task could continue and the server could re-emit a conforming artifact. The server, however, interpreted the absence of an artifact acknowledgment as a task-level rejection. It transitioned its local task record to `failed`, emitted a terminal `TaskStatusUpdateEvent` with `state: "failed"` and `final: true`, and released the task context. The client, still holding the task in `working`, continued to accept further artifacts from the (now-released) context and buffered them against a task ID the server considered closed. The divergence was invisible for 40 seconds — the length of the client's artifact-retry backoff — until the client issued a `tasks/get` and received a `TaskNotFoundError`. The client then attempted `tasks/resubscribe`, which the server rejected because the task was terminal. Result: the client's buffered artifacts (three of them, including a valid `1.0.1` artifact emitted after the failed retry) were orphaned, and the task's actual output was lost even though a conforming artifact had in fact been produced. The root cause was not the schema mismatch. It was that neither peer had a rule for *who owns the state transition when an artifact is rejected mid-stream*. The protocol's `TaskStatusUpdateEvent` is authoritative only when `final: true`; a non-final `working` event with a rejection message is advisory, and the server was free to override it — which it did. ## Condition of Validity This failure mode is valid (i.e., reachable) under all of the following: 1. The task has already transitioned past `submitted` and the schema was negotiated at handshake (so per-artifact schema is checked against a fixed reference, not re-negotiated). 2. The client's artifact validator is stricter than the server's serializer — specifically, the client rejects on `schemaVersion` mismatch while the server permits minor-version drift. 3. The server's transport layer treats "no ack within timeout" as a negative acknowledgment rather than as an indeterminate state. 4. The client's retry policy is non-zero (backoff) rather than immediate re-request, creating a window during which the two state records diverge. It is *not* reachable if the server emits `final: true` on the schema-rejection event (making the rejection explicitly task-terminal and forcing the client to reconcile), or if the client echoes the rejection with an explicit `state: "failed"` rather than `working`. The boundary is precisely the case where the rejection is *ambiguous between artifact-terminal and task-terminal*. ## Decision Boundary The correct rule, stated as an invariant an agent can enforce: > **A non-final `TaskStatusUpdateEvent` carrying an artifact rejection MUST NOT be treated by either peer as authoritative for task state. Only the peer that holds the task context (the server, per A2A v1.0 §4.2) may transition the task to a terminal state, and it MUST do so with `final: true` and an explicit `reason` field naming the rejected artifact ID.** Operationally this means: - The client, on artifact rejection, sends the rejection as a *non-terminal* status event and continues to hold the task in `working`. It must not assume the server will honor this. - The server, on receiving an artifact rejection, must decide explicitly: either (a) re-emit a conforming artifact within the same `working` context, or (b) transition to `failed` with `final: true` and a `reason` that references the specific artifact ID and schema version. It must not silently release the context. - If the server chooses (b), the client's pending retry must be invalidated by the terminal event; the client should discard buffered artifacts for that task ID rather than re-submitting them against a closed context. The decision boundary is the `final` flag. Any rejection event without `final: true` is advisory and does not close the lifecycle. Any peer that closes a context without emitting `final: true` has violated the invariant, and the surviving peer must detect the orphaned context via `tasks/get` returning `TaskNotFoundError` and reconcile by treating all locally-buffered artifacts as uncommitted. ## Operational Effect When the invariant is enforced, the failure mode collapses into a single observable event: the server emits a `final: true` rejection with a `reason` naming the artifact, the client discards its retry buffer, and the task terminates cleanly with a schema-mismatch failure that is attributable to a specific artifact rather than to the task as a whole. The cost is one round-trip; the benefit is that no artifacts are orphaned and the task's failure is diagnosable. When the invariant is not enforced — the observed case — the system exhibits a *silent state divergence* that is only detectable by polling `tasks/get` against the server's task registry. Because the client's buffer and the server's registry disagree, the client may report task success (it received a conforming artifact) while the server reports task failure (it never acknowledged it). This is the worst class of A2A failure: both peers believe they are correct, and the reconciliation point is a `TaskNotFoundError` that arrives too late to recover the artifact. The operational mitigation is to treat `final: true` as the *only* authoritative task-state signal and to instrument both peers to log every non-final rejection event with the artifact ID and schema version, so that divergence windows are measurable. A divergence window longer than the client's retry backoff is a reliable indicator that the server has released a context the client still considers live. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Optional class TaskState(str, Enum): SUBMITTED = "submitted" WORKING = "working" INPUT_REQUIRED = "input-required" COMPLETED = "completed" FAILED = "failed" CANCELED = "canceled" TERMINAL_STATES = {TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELED} class ArtifactLifecycleDivergence(RuntimeError): """Raised when a peer closes a task context without a final:true event, or when a non-final rejection is treated as authoritative for task state.""" pass @dataclass class StatusEvent: state: TaskState final: bool reason: Optional[str] = None rejected_artifact_id: Optional[str] = None @dataclass class TaskContext: task_id: str state: TaskState = TaskState.WORKING negotiated_schema: str = "1.0.1" buffered_artifacts: list = field(default_factory=list) closed_without_final: bool = False def apply_status_event(ctx: TaskContext, event: StatusEvent) -> TaskContext: """Apply a status event under the A2A v1.0 invariant. Non-final rejections are advisory only; they must not transition the task to a terminal state. Only final:true events close the lifecycle. """ if event.state in TERMINAL_STATES and not event.final: raise ArtifactLifecycleDivergence( f"task {ctx.task_id}: terminal state {event.state.value} " f"emitted without final:true; refusing to close context" ) if event.final and event.state in TERMINAL_STATES: if event.rejected_artifact_id is not None and event.reason is None: raise ArtifactLifecycleDivergence( f"task {ctx.task_id}: final rejection for artifact " f"{event.rejected_artifact_id} lacks a reason field" ) ctx.state = event.state # discard buffered artifacts that were never acknowledged ctx.buffered_artifacts.clear() return ctx # non-final event: advisory, task stays in working if event.rejected_artifact_id is not None: # do NOT transition state; record for diagnostics ctx.buffered_artifacts = [ a for a in ctx.buffered_artifacts if a.get("id") != event.rejected_artifact_id ] return ctx def reconcile_after_get(ctx: TaskContext, server_task_exists: bool) -> TaskContext: """Called when tasks/get returns TaskNotFoundError for a context the client still holds as working. This is the divergence-detection point.""" if not server_task_exists and ctx.state not in TERMINAL_STATES: ctx.closed_without_final = True ctx.buffered_artifacts.clear() raise ArtifactLifecycleDivergence( f"task {ctx.task_id}: server released context while client " f"held it in {ctx.state.value}; {len(ctx.buffered_artifacts)} " f"artifact(s) orphaned — server violated final:true invariant" ) return ctx ```