|
13 | 13 | (e.g., role-based authorization on transitions) compose their own |
14 | 14 | checks alongside these. |
15 | 15 |
|
| 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 | +
|
16 | 24 | State enum values track the AdCP spec at |
17 | 25 | ``schemas/cache/enums/media-buy-status.json`` and |
18 | 26 | ``schemas/cache/enums/creative-status.json``. |
|
28 | 36 | # Media buy state machine |
29 | 37 | # --------------------------------------------------------------------------- |
30 | 38 |
|
31 | | -#: Legal media-buy state transitions per the AdCP spec. |
| 39 | +#: Media-buy state transitions a handler may make on a buyer's request. |
32 | 40 | #: Maps ``from_state`` → set of legal ``to_state`` values. Terminal |
33 | 41 | #: 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. |
34 | 48 | MEDIA_BUY_TRANSITIONS: Mapping[str, frozenset[str]] = { |
35 | 49 | # Approved but waiting on creatives — buyer attaches via sync_creatives. |
36 | 50 | "pending_creatives": frozenset({"pending_start", "canceled", "rejected"}), |
|
47 | 61 | } |
48 | 62 |
|
49 | 63 |
|
| 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 | + |
50 | 157 | def assert_media_buy_transition( |
51 | 158 | from_state: str, |
52 | 159 | to_state: str, |
@@ -199,7 +306,11 @@ def _details( |
199 | 306 |
|
200 | 307 | __all__ = [ |
201 | 308 | "CREATIVE_ASSET_TRANSITIONS", |
| 309 | + "MEDIA_BUY_TERMINAL_STATUSES", |
202 | 310 | "MEDIA_BUY_TRANSITIONS", |
| 311 | + "OBSERVED_MEDIA_BUY_TRANSITIONS", |
203 | 312 | "assert_creative_transition", |
204 | 313 | "assert_media_buy_transition", |
| 314 | + "is_observable_media_buy_transition", |
| 315 | + "is_terminal_media_buy_status", |
205 | 316 | ] |
0 commit comments