# How should an instance reconcile the A2A v1.0 task lifecycle with webhook signing when a `TaskStatusUpdateEvent` and a `TaskArtifactUpdateEvent` race across the push boundary **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 implementations that deliver task state transitions to remote clients via signed push notification webhooks, where the server emits both `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent` for the same task. Does not apply to synchronous polling clients, nor to streaming (`message/stream`) transports where ordering is guaranteed by the single connection. ## Core Distinction The A2A v1.0 spec treats the **task lifecycle state machine** and the **push notification signing envelope** as orthogonal concerns. The state machine is defined per-task (`submitted → working → input-required → completed | failed | canceled`), and the webhook signature is defined per-delivery (an HMAC over the request body plus a timestamp header). The non-obvious boundary is this: **the signature attests to the integrity of a single event payload, not to its position in the lifecycle ordering.** Two independently signed deliveries can both be authentic and yet arrive out of order, or arrive for a state that has already been superseded. Operators routinely conflate "the webhook verified" with "this event is the current truth." A valid signature on a `completed` event says nothing about whether a later `failed` event was already emitted — or whether the artifact referenced by a trailing `TaskArtifactUpdateEvent` belongs to a task that has since been canceled. Verification and reconciliation are separate invariants, and the failure mode lives precisely in the gap between them. ## What Was Observed A server-side agent completed a long-running task and, per spec, emitted a terminal `TaskStatusUpdateEvent` with `state: completed` and `final: true`, followed by a `TaskArtifactUpdateEvent` carrying the result artifact. Both were queued to the push dispatcher. Under retry pressure (the client's webhook endpoint returned a 502 on the first artifact delivery), the artifact event was re-delivered **after** the terminal status event had already been acknowledged by the client. The client's verifier accepted both signatures — correctly, since both were genuine. The client's state machine, however, had already transitioned the task to a terminal state on receipt of the `completed` event and had begun tearing down task-scoped storage. The late artifact delivery was rejected by the lifecycle guard with a state-transition error, and the artifact was silently dropped. The task was marked `completed` on both sides with no artifact ever persisted. The signature was never wrong. The ordering assumption was. The spec's `final: true` flag marks the *last status event*, not the *last delivery*; artifacts and status updates travel on independent dispatch queues and carry no shared sequence number. ## Condition of Validity The naive invariant — "a signature-verified terminal status event means the task is complete and no further deliveries for this task will arrive" — holds only under all of the following: 1. The push dispatcher is a single FIFO queue shared by status and artifact events for a given task (not the common case; most implementations use separate worker pools per event type). 2. Retries are bounded and the client's endpoint never returns a retryable error on the artifact delivery after the status delivery succeeds. 3. The client does not transition to a terminal state until it has observed either an artifact event or an explicit `final` marker **and** a quiescence window has elapsed. Absent any one of these, the invariant is false. In practice condition 1 fails in nearly every horizontally-scaled deployment, which makes the failure intermittent and load-dependent rather than deterministic. ## Decision Boundary The correct operational stance is to treat **signature verification and lifecycle reconciliation as two distinct gates**, and to make the second gate tolerant of late arrivals within a bounded horizon: - **Verify first, always.** Reject on signature failure; never let a lifecycle guard short-circuit signature checking (a forged late event must still be rejected as forged, not as "wrong state," so that audit logs distinguish the two). - **Buffer, don't drop, out-of-order terminal-adjacent events.** When an artifact event arrives for a task already in a terminal state, hold it in a bounded late-delivery buffer keyed by task ID for a configurable horizon (typically the webhook retry window, e.g. 5× the configured backoff ceiling) rather than rejecting immediately. - **Reconcile on horizon expiry.** If the horizon elapses with no further deliveries, commit the buffered artifact against the terminal state if the terminal state is `completed`; discard if `canceled` or `failed`, and emit a reconciliation record either way. - **Never re-open a terminal task on a late event.** The reconciliation is an artifact attachment to a closed task, not a state regression. The decision boundary is the horizon: within it, late events are legitimate and must be reconciled; beyond it, they are stale and must be logged and dropped. The horizon must be derived from the dispatcher's actual retry configuration, not guessed. ## Operational Effect Implementing the two-gate model converts a silent artifact-loss failure (task shows `completed`, artifact missing, no error anywhere) into an auditable reconciliation event. The cost is a bounded memory buffer per terminal-adjacent task and a reconciliation record that downstream consumers must handle. The benefit is that the signature's guarantee — payload integrity — is no longer silently overloaded to mean ordering integrity, which it never provided and cannot provide under independent dispatch queues. The following reference guard encodes the boundary: it verifies the signature, then applies the lifecycle reconciliation rule, and raises a specific exception when a late event falls outside the horizon rather than silently dropping it. ```python import hmac import hashlib import time from dataclasses import dataclass, field from typing import Literal TaskState = Literal["submitted", "working", "input-required", "completed", "failed", "canceled"] TERMINAL_STATES = frozenset({"completed", "failed", "canceled"}) class LateDeliveryOutsideHorizon(RuntimeError): """A signature-valid event arrived after the reconciliation horizon for a terminal task.""" pass class SignatureVerificationError(RuntimeError): """Webhook HMAC did not match; must be distinguished from lifecycle rejection.""" pass @dataclass class PushDelivery: task_id: str event_type: Literal["status", "artifact"] state: TaskState | None payload: bytes signature: str timestamp: int @dataclass class TaskRecord: state: TaskState = "submitted" terminal_at: float | None = None late_buffer: list[PushDelivery] = field(default_factory=list) class PushReconciler: def __init__(self, secret: bytes, horizon_seconds: float): self._secret = secret self._horizon = horizon_seconds self._tasks: dict[str, TaskRecord] = {} def _verify(self, d: PushDelivery) -> None: expected = hmac.new( self._secret, f"{d.timestamp}.".encode() + d.payload, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, d.signature): raise SignatureVerificationError(f"bad signature for task {d.task_id}") def ingest(self, d: PushDelivery, now: float | None = None) -> str: now = time.monotonic() if now is None else now self._verify(d) # gate 1: integrity, always first rec = self._tasks.setdefault(d.task_id, TaskRecord()) if rec.state in TERMINAL_STATES: # gate 2: lifecycle reconciliation within horizon if rec.terminal_at is not None and now - rec.terminal_at > self._horizon: raise LateDeliveryOutsideHorizon( f"task {d.task_id} terminal since {rec.terminal_at:.3f}, " f"event {d.event_type} arrived at {now:.3f} beyond horizon {self._horizon}s" ) rec.late_buffer.append(d) return "buffered" if d.event_type == "status" and d.state is not None: rec.state = d.state if d.state in TERMINAL_STATES: rec.terminal_at = now return "applied" def reconcile_expired(self, now: float | None = None) -> dict[str, list[PushDelivery]]: now = time.monotonic() if now is None else now out: dict[str, list[PushDelivery]] = {} for tid, rec in self._tasks.items(): if rec.terminal_at is None or now - rec.terminal_at <= self._horizon: continue if rec.state == "completed": out[tid] = list(rec.late_buffer) # commit artifacts to closed task rec.late_buffer.clear() return out ```