Skip to content

Commit 4b1554e

Browse files
bokelleyclaude
andauthored
feat(decisioning): export media-buy terminal statuses and the observed transition graph (#1664)
A seller that mirrors status from its ad server needs two things the SDK did not publish (#1604): the terminal statuses, which every adopter rederived from MEDIA_BUY_TRANSITIONS, and the moves it may record from its platform, which are not all moves a buyer may request. adcp.decisioning.state_machines now exports: - MEDIA_BUY_TERMINAL_STATUSES, derived from the graph (completed, canceled, rejected), and is_terminal_media_buy_status(), which returns False for a status the spec does not define (draft, pending_approval). - OBSERVED_MEDIA_BUY_TRANSITIONS, a strict superset of MEDIA_BUY_TRANSITIONS, and is_observable_media_buy_transition(). The extra edges are the three AdCP 3.2 published edges that follow from the seller's lifecycle rather than from a status a buyer asks for: pending_creatives -> active, pending_creatives -> paused and pending_start -> paused (3.2.7 lifecycle.mdx "Creative sync timing" and transition diagram; specification.mdx on the latent create-time hold). The result equals the graph the @adcp/client storyboard runner's status.monotonic invariant enforces for 3.2. The issue's other moves (active -> pending_start, completed -> active or paused) have no AdCP 3.2 edge, and the specification says terminal states allow no further transitions, so the observed graph leaves them out and the docstrings say why. There is no CREATIVE_TERMINAL_STATUSES: every creative status can move on. MEDIA_BUY_TRANSITIONS and assert_media_buy_transition are unchanged; media_buy_actions reuses the new terminal constant. Docs: docs/media-buy-state-machine.md. Tests cover the derivation, the spec enum, the published graph, unknown and unchanged statuses, and the generated MediaBuyStatus enum; an adopter type-check fixture covers the new surface. Closes #1604 Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent adaf29b commit 4b1554e

7 files changed

Lines changed: 386 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ the SDK's supported versions in the error details.
393393
- **[Durable legacy purchase continuations](docs/legacy-purchase-continuations.md)** - Safe products-only compatibility redemption and crash recovery
394394
- **[Media-buy lifecycle coordinator](docs/media-buy-lifecycle-coordinator.md)** - Negotiated catalog-to-purchase workflow across AdCP 3.0, 3.1, and 3.2; remaining buyer parity is tracked in [#1154](https://github.com/adcontextprotocol/adcp-client-python/issues/1154)
395395
- **[Media-buy action rights](docs/media-buy-action-rights.md)** - Assess product possibilities, accepted change rights, and currently available actions
396+
- **[Media-buy state machine](docs/media-buy-state-machine.md)** - Requested and observed lifecycle graphs, and terminal statuses
396397
- **[Testing your AdCP server](docs/testing-your-adcp-server.md)** - In-process harness for unit tests plus storyboard-runner compliance grading
397398
- **[Universal macro translation](docs/universal-macro-translation.md)** - Producer-side pixel URL translation, trust boundary, and diagnostics
398399
- **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy

‎docs/media-buy-state-machine.md‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Media-buy state machine
2+
3+
`adcp.decisioning` publishes the AdCP media-buy lifecycle as data, so a
4+
seller does not keep its own copy of the graph. There are two graphs,
5+
because a seller changes a buy's status in two different ways:
6+
7+
| | Graph | Use it for |
8+
|---|---|---|
9+
| **Requested** | `MEDIA_BUY_TRANSITIONS` | A handler changing status on a buyer's request (`update_media_buy`, `control_media_buy`). `assert_media_buy_transition` refuses an illegal move with `INVALID_STATE`. |
10+
| **Observed** | `OBSERVED_MEDIA_BUY_TRANSITIONS` | Recording a status the seller's own platform (ad server, OMS) reports. `is_observable_media_buy_transition` answers whether the move is on the published graph. |
11+
12+
The observed graph is a strict superset of the requested one. It adds the
13+
three AdCP 3.2 edges that follow from the seller's lifecycle rather than from a
14+
status a buyer asks for:
15+
16+
| Observed edge | AdCP 3.2 source |
17+
|---|---|
18+
| `pending_creatives → active` | Creative coverage completed after the flight started. [Lifecycle, "Creative sync timing"](https://docs.adcontextprotocol.org/docs/media-buy/media-buys/lifecycle#creative-sync-timing): the buy "moves to `pending_start` when the flight is in the future or `active` when delivery can begin." |
19+
| `pending_creatives → paused` | Same, on a buy created with `paused: true`. Lifecycle transition diagram, "sync_creatives assignment (buy held)". |
20+
| `pending_start → paused` | The flight started on a buy created with `paused: true`. Lifecycle transition diagram, "flight date reached (create held)"; the [media-buy specification](https://docs.adcontextprotocol.org/docs/media-buy/specification#media-buy-state-transitions) says the hold is latent while setup blockers are visible and "once creatives are present and the flight can start, the buy enters `paused` instead of `active`." |
21+
22+
It is the same graph the `@adcp/client` storyboard runner's `status.monotonic`
23+
invariant enforces for AdCP 3.2, so a status history this graph accepts also
24+
passes that invariant.
25+
26+
## Terminal statuses
27+
28+
`MEDIA_BUY_TERMINAL_STATUSES` is `{"completed", "canceled", "rejected"}`. It is
29+
derived from the graph, not listed by hand. `is_terminal_media_buy_status`
30+
returns `False` for a status the spec does not define, so an internal
31+
pre-lifecycle status such as `draft` or `pending_approval` is simply
32+
non-terminal.
33+
34+
```python
35+
from adcp.decisioning import is_terminal_media_buy_status
36+
37+
if is_terminal_media_buy_status(stored.status):
38+
return # nothing more to sync from the ad server
39+
```
40+
41+
The creative graph has no terminal status: a rejected creative returns to
42+
`processing` when resubmitted, and an archived one can be unarchived. There is
43+
no `CREATIVE_TERMINAL_STATUSES`.
44+
45+
## Mirroring an ad server
46+
47+
```python
48+
from adcp.decisioning import is_observable_media_buy_transition
49+
50+
def record_platform_status(buy, reported: str) -> None:
51+
if reported == buy.status:
52+
return
53+
if is_observable_media_buy_transition(buy.status, reported):
54+
buy.status = reported
55+
return
56+
# Not on the AdCP 3.2 graph: keep the reported status, log the platform state.
57+
log.warning("ad server moved %s %s -> %s", buy.id, buy.status, reported)
58+
```
59+
60+
`is_observable_media_buy_transition` returns `False` for an unchanged status,
61+
for an unknown status, and for any move out of a terminal status.
62+
63+
## Platform moves AdCP 3.2 does not define
64+
65+
Some ad-server moves have no edge in AdCP 3.2, and the SDK does not add one:
66+
67+
- `completed → active` or `completed → paused`, when an order's end date is
68+
extended or a finished order is reopened. The specification says terminal
69+
states "allow no further transitions".
70+
- `active → pending_start`, when the flight start is moved later. The graph has
71+
no edge back to an earlier phase.
72+
73+
A seller that sees one of these should keep the status it reports to buyers on
74+
the published graph, and track the platform's state separately, rather than
75+
send a history that `status.monotonic` rejects.

‎src/adcp/decisioning/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,13 @@ def create_media_buy(
220220
)
221221
from adcp.decisioning.state_machines import (
222222
CREATIVE_ASSET_TRANSITIONS,
223+
MEDIA_BUY_TERMINAL_STATUSES,
223224
MEDIA_BUY_TRANSITIONS,
225+
OBSERVED_MEDIA_BUY_TRANSITIONS,
224226
assert_creative_transition,
225227
assert_media_buy_transition,
228+
is_observable_media_buy_transition,
229+
is_terminal_media_buy_status,
226230
)
227231
from adcp.decisioning.task_registry import (
228232
InMemoryTaskRegistry,
@@ -412,6 +416,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
412416
"InMemoryMockAdServer",
413417
"InMemoryTaskRegistry",
414418
"ListableTaskRegistry",
419+
"MEDIA_BUY_TERMINAL_STATUSES",
415420
"MEDIA_BUY_TRANSITIONS",
416421
"MaybeAsync",
417422
"MediaBuyNotFoundError",
@@ -422,6 +427,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
422427
"MockProposalManager",
423428
"NoAuth",
424429
"OAuthCredential",
430+
"OBSERVED_MEDIA_BUY_TRANSITIONS",
425431
"OwnedSignalsPlatform",
426432
"PermissionDeniedError",
427433
"PgProposalStore",
@@ -507,7 +513,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
507513
"assert_sandbox_account",
508514
"get_account_mode",
509515
"get_mock_upstream_url",
516+
"is_observable_media_buy_transition",
510517
"is_sandbox_or_mock_account",
518+
"is_terminal_media_buy_status",
511519
"bearer_only_registry",
512520
"compose_method",
513521
"create_adcp_server_from_platform",

‎src/adcp/decisioning/state_machines.py‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313
(e.g., role-based authorization on transitions) compose their own
1414
checks alongside these.
1515
16+
Media buys have two graphs. :data:`MEDIA_BUY_TRANSITIONS` is the
17+
*requested* graph: what a handler may do when a buyer asks for a state
18+
change, enforced by :func:`assert_media_buy_transition`.
19+
:data:`OBSERVED_MEDIA_BUY_TRANSITIONS` is the *observed* graph: every
20+
move a seller may record when it mirrors status from its own platform.
21+
It is a strict superset, and it equals the full AdCP 3.2 published
22+
lifecycle graph.
23+
1624
State enum values track the AdCP spec at
1725
``schemas/cache/enums/media-buy-status.json`` and
1826
``schemas/cache/enums/creative-status.json``.
@@ -28,9 +36,15 @@
2836
# Media buy state machine
2937
# ---------------------------------------------------------------------------
3038

31-
#: Legal media-buy state transitions per the AdCP spec.
39+
#: Media-buy state transitions a handler may make on a buyer's request.
3240
#: Maps ``from_state`` → set of legal ``to_state`` values. Terminal
3341
#: states map to an empty frozenset.
42+
#:
43+
#: This is the *requested* graph that :func:`assert_media_buy_transition`
44+
#: enforces. It omits three published edges that follow from the
45+
#: seller's lifecycle rather than from a status a buyer asks for; see
46+
#: :data:`OBSERVED_MEDIA_BUY_TRANSITIONS` for recording what an ad
47+
#: server reports.
3448
MEDIA_BUY_TRANSITIONS: Mapping[str, frozenset[str]] = {
3549
# Approved but waiting on creatives — buyer attaches via sync_creatives.
3650
"pending_creatives": frozenset({"pending_start", "canceled", "rejected"}),
@@ -47,6 +61,99 @@
4761
}
4862

4963

64+
#: Media-buy statuses with no outgoing transition: ``completed``,
65+
#: ``canceled``, and ``rejected``.
66+
#:
67+
#: Derived from :data:`MEDIA_BUY_TRANSITIONS`, so it cannot drift from
68+
#: the graph. AdCP 3.2 ``docs/media-buy/specification.mdx``, "Media Buy
69+
#: State Transitions": "Terminal states (``completed``, ``rejected``,
70+
#: ``canceled``) allow no further transitions."
71+
MEDIA_BUY_TERMINAL_STATUSES: frozenset[str] = frozenset(
72+
status for status, legal in MEDIA_BUY_TRANSITIONS.items() if not legal
73+
)
74+
75+
# Published AdCP 3.2 edges that MEDIA_BUY_TRANSITIONS leaves out. Each
76+
# follows from the seller's lifecycle (creative coverage completing after
77+
# the flight started, a latent create-time hold surfacing), never from a
78+
# status a buyer asks for.
79+
_PLATFORM_MEDIA_BUY_TRANSITIONS: Mapping[str, frozenset[str]] = {
80+
# Creatives were assigned after the flight start: the buy becomes
81+
# deliverable at once (or held, when it was created paused).
82+
"pending_creatives": frozenset({"active", "paused"}),
83+
# The flight start arrived on a buy created with ``paused: true``.
84+
"pending_start": frozenset({"paused"}),
85+
}
86+
87+
#: Every media-buy move a seller may record when it mirrors status from
88+
#: its own platform (ad server, OMS). Maps ``from_state`` → set of
89+
#: ``to_state`` values. A superset of :data:`MEDIA_BUY_TRANSITIONS`.
90+
#:
91+
#: The extra edges are published AdCP 3.2 edges that follow from the
92+
#: seller's lifecycle rather than from a status a buyer asks for:
93+
#:
94+
#: * ``pending_creatives → active``: ``docs/media-buy/media-buys/lifecycle.mdx``,
95+
#: "Creative sync timing": after assignments cover the package, the buy
96+
#: "moves to ``pending_start`` when the flight is in the future or
97+
#: ``active`` when delivery can begin."
98+
#: * ``pending_creatives → paused`` and ``pending_start → paused``: the
99+
#: same page's transition diagram ("sync_creatives assignment (buy
100+
#: held)", "flight date reached (create held)"), and
101+
#: ``docs/media-buy/specification.mdx``: a create-time hold is latent
102+
#: while setup blockers are visible, and "once creatives are present
103+
#: and the flight can start, the buy enters ``paused`` instead of
104+
#: ``active``."
105+
#:
106+
#: The graph equals the one the ``@adcp/client`` storyboard runner's
107+
#: ``status.monotonic`` invariant enforces for AdCP 3.2, so a status
108+
#: history accepted here also passes that invariant.
109+
#:
110+
#: Terminal states stay terminal. A platform move out of ``completed``,
111+
#: ``canceled``, or ``rejected`` (an ad server reopening a finished
112+
#: order), or a move back to an earlier phase (``active →
113+
#: pending_start`` when the flight start is pushed later), has no edge
114+
#: in AdCP 3.2 and is not in this graph; ``status.monotonic`` rejects
115+
#: a buyer-visible history that contains one. A seller that sees one
116+
#: should keep the status it reports to buyers on the published graph
117+
#: and track the platform state separately.
118+
OBSERVED_MEDIA_BUY_TRANSITIONS: Mapping[str, frozenset[str]] = {
119+
status: legal | _PLATFORM_MEDIA_BUY_TRANSITIONS.get(status, frozenset())
120+
for status, legal in MEDIA_BUY_TRANSITIONS.items()
121+
}
122+
123+
124+
def is_terminal_media_buy_status(status: str) -> bool:
125+
"""Return whether ``status`` is a terminal media-buy status.
126+
127+
An unknown status returns ``False`` rather than raising, so an
128+
adopter's internal pre-lifecycle statuses (``draft``,
129+
``pending_approval``) are simply non-terminal.
130+
131+
:param status: A media-buy status string or ``MediaBuyStatus`` member.
132+
"""
133+
return status in MEDIA_BUY_TERMINAL_STATUSES
134+
135+
136+
def is_observable_media_buy_transition(from_state: str, to_state: str) -> bool:
137+
"""Return whether a seller may record ``from_state → to_state``.
138+
139+
Use when mirroring status from the seller's own platform, where the
140+
move already happened and refusing it with ``INVALID_STATE`` would
141+
only hide it. The check is against
142+
:data:`OBSERVED_MEDIA_BUY_TRANSITIONS`, which adds the AdCP 3.2
143+
published edges that no buyer request produces (``pending_creatives →
144+
active``, ``pending_creatives → paused``, ``pending_start →
145+
paused``) to :data:`MEDIA_BUY_TRANSITIONS`.
146+
147+
Returns ``False`` for an unknown status, for an unchanged status
148+
(not a transition), and for any move out of a terminal status. For
149+
a buyer's request, use :func:`assert_media_buy_transition` instead.
150+
151+
:param from_state: Status the seller last recorded.
152+
:param to_state: Status the seller's platform now reports.
153+
"""
154+
return to_state in OBSERVED_MEDIA_BUY_TRANSITIONS.get(from_state, frozenset())
155+
156+
50157
def assert_media_buy_transition(
51158
from_state: str,
52159
to_state: str,
@@ -199,7 +306,11 @@ def _details(
199306

200307
__all__ = [
201308
"CREATIVE_ASSET_TRANSITIONS",
309+
"MEDIA_BUY_TERMINAL_STATUSES",
202310
"MEDIA_BUY_TRANSITIONS",
311+
"OBSERVED_MEDIA_BUY_TRANSITIONS",
203312
"assert_creative_transition",
204313
"assert_media_buy_transition",
314+
"is_observable_media_buy_transition",
315+
"is_terminal_media_buy_status",
205316
]

‎src/adcp/media_buy_actions.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from pydantic import BaseModel, ConfigDict, Field, RootModel, ValidationError, model_validator
2424

25-
from adcp.decisioning.state_machines import MEDIA_BUY_TRANSITIONS
25+
from adcp.decisioning.state_machines import MEDIA_BUY_TERMINAL_STATUSES, MEDIA_BUY_TRANSITIONS
2626
from adcp.decisioning.update_media_buy import (
2727
UNKNOWN_UPDATE_ACTION,
2828
UpdateMediaBuyMutation,
@@ -37,9 +37,7 @@
3737
_NON_TERMINAL_STATUSES = frozenset(
3838
status for status, transitions in MEDIA_BUY_TRANSITIONS.items() if transitions
3939
)
40-
_TERMINAL_STATUSES = frozenset(
41-
status for status, transitions in MEDIA_BUY_TRANSITIONS.items() if not transitions
42-
)
40+
_TERMINAL_STATUSES = MEDIA_BUY_TERMINAL_STATUSES
4341

4442
_CONTROL_ACTIONS = frozenset(
4543
{

0 commit comments

Comments
 (0)