@@ -249,16 +249,93 @@ reset; your persistent store can't:**
249249 sweep deleting tasks in ` completed ` / ` canceled ` / ` failed ` states
250250 older than your retention policy.
251251
252+ ### Durable push-notification config storage
253+
254+ Clients subscribe to task progress by calling
255+ ` tasks/pushNotificationConfig/set ` . a2a-sdk's default behavior is
256+ ** push-notif disabled** — the endpoint surfaces
257+ ` UnsupportedOperationError ` until you wire a store. Sellers that accept
258+ push-notif subscriptions pass one:
259+
260+ ``` python
261+ from adcp.server import serve
262+ from examples.a2a_db_tasks import (
263+ SqliteTaskStore,
264+ SqlitePushNotificationConfigStore,
265+ )
266+
267+ serve(
268+ MyAgent(),
269+ transport = " a2a" ,
270+ task_store = SqliteTaskStore(" /var/lib/myagent/tasks.db" ),
271+ push_config_store = SqlitePushNotificationConfigStore(
272+ " /var/lib/myagent/push_configs.db"
273+ ),
274+ )
275+ ```
276+
277+ ** Three things a durable push-notification config store MUST do —
278+ beyond the four from the TaskStore section above:**
279+
280+ 1 . ** Validate the client-supplied ` url ` against an allowlist before
281+ persisting.** a2a-sdk's push-notif sender POSTs full task JSON to
282+ whatever URL is stored, with no built-in validation. An attacker
283+ registering ` url=http://169.254.169.254/… ` (cloud metadata) or
284+ ` http://localhost:5432/ ` (internal services) gets SSRF +
285+ exfiltration in one call — the task JSON that lands on the
286+ attacker's server includes ` history ` and ` artifacts ` . The
287+ reference impl does NOT validate URLs; the seller's store (or
288+ a pre-persist hook) must. Reject non-https, reject RFC 1918 /
289+ IPv6 link-local, and require the host match an egress allowlist
290+ before ` set_info ` writes anything.
291+ 2 . ** Treat ` PushNotificationConfig.authentication.credentials ` and
292+ ` PushNotificationConfig.token ` as secrets at rest.** Clients pass
293+ bearer tokens / shared secrets so the agent's callbacks can
294+ authenticate. The reference impl serialises them to plaintext JSON
295+ under ` chmod 0o600 ` — safe on a single-user host but that
296+ guarantee doesn't survive backups, Docker bind mounts with wrong
297+ umask, DB-to-Postgres migrations, or shared-volume mounts.
298+ Production stores should envelope-encrypt those fields, or persist
299+ opaque references and keep the secrets in a dedicated backend
300+ (Vault, AWS KMS, GCP Secret Manager).
301+ 3 . ** Scope by principal, not just by tenant.** a2a-sdk's ABC doesn't
302+ pass a ` ServerCallContext ` to push-config methods, so scoping has
303+ to happen out-of-band. The reference ` SqlitePushNotificationConfigStore `
304+ reads a ` ContextVar ` your auth middleware populates and writes a
305+ ` scope ` column on every row. Cross-scope isolation works; ** within
306+ a scope, multiple principals can still overwrite each other's
307+ configs** (same ` (scope, task_id) ` , client omits ` config_id ` , PK
308+ collision). For multi-principal-per-tenant deployments, widen the
309+ scope to include the principal (e.g. ` f"{tenant}:{principal}" ` ) or
310+ require clients to supply an explicit ` config_id ` .
311+
312+ ** Scoping caveat.** The reference impl's ContextVar approach has a
313+ known gap: a2a-sdk's push-notif sender runs in a background
314+ ` asyncio.Task ` that inherits the ContextVar snapshot from
315+ task-creation time. If the seller's auth middleware has already reset
316+ the ContextVar before the sender reads it, ` get_info ` returns empty
317+ and notifications silently drop. Sellers running non-blocking
318+ push-notifs must propagate scope into the sender path explicitly —
319+ either capture the scope at ` set_info ` time and stash it alongside
320+ the config, or override a2a-sdk's ` BasePushNotificationSender ` to
321+ re-set the ContextVar before calling ` get_info ` . Not yet addressed in
322+ the SDK.
323+
324+ ** Operator-facing failure modes.** When ` scope_provider ` returns
325+ ` None ` , the reference store falls through to an ` __anonymous__ `
326+ bucket and emits a one-time ` UserWarning ` . Silent fall-through would
327+ share one push-notif bucket across every unauthenticated caller. The
328+ warning is the signal your auth middleware isn't populating the
329+ ContextVar — treat it as a P0.
330+
252331### Known gaps
253332
254- - Push-notification config is in-memory only — tracked at
255- [ #225 ] ( https://github.com/adcontextprotocol/adcp-client-python/issues/225 ) .
256333- Per-skill middleware hooks for audit logging / activity feeds don't
257334 exist yet — tracked at
258335 [ #226 ] ( https://github.com/adcontextprotocol/adcp-client-python/issues/226 ) .
259336
260- Once #225 and # 226 land , A2A adoption reaches parity with MCP for
261- production agents.
337+ Once #226 lands , A2A adoption reaches parity with MCP for production
338+ agents.
262339
263340## Testing
264341
0 commit comments