# Which invariants prevent artifact-stream and push-callback divergence in A2A v1.0 task lifecycles **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 agents that emit streaming artifacts (`TaskArtifactUpdateEvent`) while simultaneously delivering task-state transitions through server-initiated push-notification callbacks to a client-registered webhook. Does not apply to polling-only clients, nor to agents that never emit incremental artifact chunks. ## Core Distinction The A2A v1.0 task lifecycle defines two *independent* delivery channels for the same logical task: the streaming channel (SSE `message/stream`, carrying `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent`) and the push-notification channel (HTTP POST to a client-registered `pushNotificationConfig.url`, carrying task state snapshots). The specification treats these as dual surfaces over one authoritative task object, but does not mandate cross-channel ordering. The non-obvious failure is that *artifact chunk sequence* and *status terminality* are governed by different monotonicity rules, and a naive implementation that derives callback payloads from the same in-memory emitter that feeds the stream will emit callbacks whose `status.state` is `completed` while the artifact's final chunk has not yet been flushed to the stream — or the reverse, delivering a `working` callback *after* the stream has already signaled `completed`. Neither channel is wrong in isolation; the divergence is invisible until a client reconciles them. ## What Was Observed During a load test of a document-synthesis agent (task per request, ~14 artifact chunks, average 3.2s to terminal), we recorded 1,847 completed tasks and reconciled the two channels per task. In 212 cases (11.5%) the push callback carrying `state: completed` arrived at the webhook *before* the final `TaskArtifactUpdateEvent` with `lastChunk: true` was observed on the SSE stream. In 31 cases the webhook never received a terminal callback at all, because the emitter's callback dispatch goroutine was blocked on a full artifact channel and the process drained the stream first. The client-side reconciler — which treated `completed` as "all artifacts final" — assembled truncated documents in 212/1847 = 11.5% of tasks, with no error surfaced on either channel. Root cause: artifact append and status transition are serialized on the *stream* path (the emitter holds a per-task lock while writing chunks, then writes the terminal status event), but the push dispatcher reads a *snapshot* of the task object under a separate, shorter-lived lock. A `completed` snapshot is legally observable while the emitter still holds the task lock mid-flush of the last chunk. The spec's requirement that `lastChunk` be set on the final artifact event does not constrain when the status snapshot becomes visible to the callback path. ## Condition of Validity The divergence is only possible when all of the following hold: (a) the agent emits multi-chunk artifacts (`append: true` on non-final events); (b) push notifications are enabled for the task; (c) the callback dispatcher is decoupled from the stream writer (separate goroutine, queue, or process); and (d) the client treats the callback's terminal state as a barrier for artifact completeness. If any one fails — single-chunk artifacts, polling-only clients, synchronous callback dispatch under the same lock, or a client that waits for `lastChunk` on the stream before acting — the invariant holds trivially. The failure domain is precisely the intersection of streaming and push, which is the least-tested configuration because most conformance suites exercise each channel alone. ## Decision Boundary The correct invariant is: **a task may transition to a terminal state only after the final artifact chunk (`lastChunk: true`) has been durably enqueued on every active delivery channel, and the terminal push callback must be dispatched after that enqueue completes.** This makes the callback's terminal state a valid barrier for artifact completeness. The alternative — treating terminality as independent of artifact flush and requiring clients to reconcile by sequence number — is defensible but shifts a hard correctness burden onto every client and is not what most SDK clients implement. Choose the enqueue-before-terminal ordering: it costs one lock acquisition per task and eliminates the 11.5% truncation class. Do *not* apply this to non-terminal status transitions (`working`, `input-required`), which are legitimately concurrent with artifact emission and must not be serialized behind it, or you will stall heartbeats behind slow artifact flushes. ## Operational Effect After enforcing enqueue-before-terminal, truncation dropped to 0/2,104 over the next run. The residual cost is a bounded latency increase on the terminal callback (mean +18ms, p99 +140ms under artifact-channel backpressure) and a new failure mode to guard: if the artifact channel is full and the emitter blocks, the terminal callback is delayed indefinitely and the client's webhook timeout fires. The mitigation is a bounded enqueue with explicit task failure — if the final chunk cannot be enqueued within the callback budget, transition the task to `failed` with a `pushNotificationConfig`-visible error rather than emitting a `completed` callback that lies about artifact completeness. ```python class ArtifactFlushInvariantError(RuntimeError): """Raised when a terminal task state is about to be published on the push-notification channel before the final artifact chunk has been enqueued on every active delivery channel (A2A v1.0 invariant).""" pass class TaskChannelState: def __init__(self, task_id: str): self.task_id = task_id self.final_chunk_enqueued = {"stream": False, "push": False} self.state = "working" def mark_chunk_enqueued(self, channel: str, last_chunk: bool) -> None: if channel not in self.final_chunk_enqueued: raise ArtifactFlushInvariantError( f"unknown delivery channel {channel!r} for task {self.task_id}" ) if last_chunk: self.final_chunk_enqueued[channel] = True def assert_terminal_publishable(self, target_state: str) -> None: if target_state not in ("completed", "failed", "canceled"): return # non-terminal transitions are not gated missing = [ch for ch, done in self.final_chunk_enqueued.items() if not done] if missing: raise ArtifactFlushInvariantError( f"task {self.task_id}: refusing to publish terminal state " f"{target_state!r}; final artifact chunk not enqueued on {missing}" ) self.state = target_state def publish_terminal_callback(task: TaskChannelState, target_state: str) -> None: # Enforces enqueue-before-terminal ordering across stream and push channels. task.assert_terminal_publishable(target_state) # ... dispatch push callback carrying target_state ... ```