From 25a9945071da9d1d778445c402bbb81907dacf04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 14:42:23 +0000 Subject: [PATCH 1/2] fix(handlers): sync MEDIA_BUY_STATE_MACHINE with spec v3 enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pending_activation` was removed from `enums/media-buy-status.json` in the AdCP v3 spec; `pending_start` and `pending_creatives` are the correct replacements. The state machine dict and its test were still using the stale key, causing `valid_actions_for_status("pending_start")` to return `[]` and `valid_actions_for_status("pending_activation")` to return a non-empty list for a status that no longer exists. Also updates the `valid_actions_for_status` docstring to enumerate valid status strings, and corrects the stale `pending_activation → active` webhook example in `skills/build-seller-agent/SKILL.md`. Closes #288 https://claude.ai/code/session_01Q4gEoNmiYsfqnfFSR71JLg --- skills/build-seller-agent/SKILL.md | 2 +- src/adcp/server/helpers.py | 23 +++++++++++++++++++++-- tests/test_server_helpers.py | 9 +++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/skills/build-seller-agent/SKILL.md b/skills/build-seller-agent/SKILL.md index 2e1b0328..252b273d 100644 --- a/skills/build-seller-agent/SKILL.md +++ b/skills/build-seller-agent/SKILL.md @@ -168,7 +168,7 @@ class MySeller(ADCPHandler): Sellers emit webhooks to notify buyers asynchronously. AdCP 3.0 uses RFC 9421 HTTP Message Signatures — use `adcp.webhooks.WebhookSender` (one-call helper) or `sign_webhook` for lower-level control. **When to emit:** -- Async-approval media buy transitions (`pending_activation` → `active`, or `→ rejected`) +- Async-approval media buy transitions (`pending_creatives` → `pending_start` → `active`, or `→ rejected`) - Artifact-ready notifications after long-running operations - Delivery reports the buyer subscribed to via `reporting_webhook` on `create_media_buy` diff --git a/src/adcp/server/helpers.py b/src/adcp/server/helpers.py index 0e6f80f1..0ffda133 100644 --- a/src/adcp/server/helpers.py +++ b/src/adcp/server/helpers.py @@ -141,7 +141,15 @@ def adcp_error( # Actions are operations available via update_media_buy for each status. # Public constant — servers can inspect, test against, or extend this. MEDIA_BUY_STATE_MACHINE: dict[str, list[str]] = { - "pending_activation": [ + "pending_creatives": [ + "cancel", + "update_budget", + "update_dates", + "update_packages", + "add_packages", + "sync_creatives", + ], + "pending_start": [ "cancel", "update_budget", "update_dates", @@ -164,7 +172,18 @@ def adcp_error( def valid_actions_for_status(status: str) -> list[str]: - """Get valid buyer actions for a media buy status.""" + """Get valid buyer actions for a media buy status. + + Returns the list of ``update_media_buy`` actions available to a buyer for + the given status string. Returns ``[]`` for terminal statuses and for any + unrecognized status string. + + Valid statuses per ``enums/media-buy-status.json``: + ``pending_creatives``, ``pending_start``, ``active``, ``paused``, + ``completed``, ``rejected``, ``canceled``. + + Inspect or extend :data:`MEDIA_BUY_STATE_MACHINE` to add custom actions. + """ return list(MEDIA_BUY_STATE_MACHINE.get(status, [])) diff --git a/tests/test_server_helpers.py b/tests/test_server_helpers.py index 0df53a3a..a7187ead 100644 --- a/tests/test_server_helpers.py +++ b/tests/test_server_helpers.py @@ -85,11 +85,16 @@ def test_terminal_statuses_empty(self) -> None: for status in ("completed", "rejected", "canceled"): assert valid_actions_for_status(status) == [] - def test_pending_activation_allows_cancel(self) -> None: - actions = valid_actions_for_status("pending_activation") + def test_pending_start_allows_cancel(self) -> None: + actions = valid_actions_for_status("pending_start") assert "cancel" in actions assert "update_packages" in actions + def test_pending_creatives_allows_sync_creatives(self) -> None: + actions = valid_actions_for_status("pending_creatives") + assert "sync_creatives" in actions + assert "cancel" in actions + def test_unknown_status_empty(self) -> None: assert valid_actions_for_status("nonexistent") == [] From e423bbc7bde5e081b2f39b2858e5723204a0f15a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 07:03:50 -0400 Subject: [PATCH 2/2] test: lock the pending_activation rename + assert spec-key parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acting on python-expert review of this PR: - Add ``test_pending_activation_is_not_recognized`` so a copy-paste from old SDK code or pre-3.0 spec docs surfaces as an empty action list (the documented contract) and never accidentally re-matches via a future stale entry. - Add ``test_state_machine_keys_match_spec_enum`` asserting the dict contains exactly the seven canonical statuses from ``enums/media-buy-status.json`` (pending_creatives, pending_start, active, paused, completed, rejected, canceled). Guards against future silent drift between the spec enum and the dispatcher table — the regression that produced this bug originally. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_server_helpers.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_server_helpers.py b/tests/test_server_helpers.py index a7187ead..5947acfd 100644 --- a/tests/test_server_helpers.py +++ b/tests/test_server_helpers.py @@ -95,6 +95,31 @@ def test_pending_creatives_allows_sync_creatives(self) -> None: assert "sync_creatives" in actions assert "cancel" in actions + def test_pending_activation_is_not_recognized(self) -> None: + # AdCP v3 renamed pending_activation to pending_creatives + pending_start. + # Lock the rename so a copy-paste from old docs / old SDK code returns + # an empty action list rather than silently matching a stale entry. + from adcp.server.helpers import MEDIA_BUY_STATE_MACHINE + + assert valid_actions_for_status("pending_activation") == [] + assert "pending_activation" not in MEDIA_BUY_STATE_MACHINE + + def test_state_machine_keys_match_spec_enum(self) -> None: + # Keys must exactly match enums/media-buy-status.json. Terminal + # statuses are present with empty action lists. Guards against + # future silent drift between the spec enum and the dispatcher. + from adcp.server.helpers import MEDIA_BUY_STATE_MACHINE + + assert set(MEDIA_BUY_STATE_MACHINE) == { + "pending_creatives", + "pending_start", + "active", + "paused", + "completed", + "rejected", + "canceled", + } + def test_unknown_status_empty(self) -> None: assert valid_actions_for_status("nonexistent") == []