From 46e46200998759e2fb97759034b06f61adf4f08b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 21:10:36 -0400 Subject: [PATCH 1/2] feat(decisioning): CreativeBuilderPlatform + CreativeAdServerPlatform (breadth sprint Batch 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of the breadth-sprint per the parity audit. Ports two creative-archetype Protocols from JS reference at ``src/lib/server/decisioning/specialisms/{creative,creative-ad-server}.ts`` (commits ``841616d7`` F13 / ``bca20dfb`` F16-F17). New Protocols: * ``CreativeBuilderPlatform`` (src/adcp/decisioning/specialisms/creative.py) — covers ``creative-template`` (stateless transform — Bannerflow, Celtra) AND ``creative-generative`` (brief-to-creative AI — Pencil, Omneky, AdCreative.ai). Per JS commit ``841616d7`` (F13), the v6 preview's separation of CreativeTemplatePlatform and CreativeGenerativePlatform had no meaningful interface distinction, so they're unified here. Required: ``build_creative``. Optional (present-or-absent, framework surfaces ``UNSUPPORTED_FEATURE`` to buyers when missing): ``preview_creative``, ``refine_creative``, ``sync_creatives``. ``build_creative`` returns a discriminated arm per ``BuildCreativeReturn``: ``CreativeManifest | Sequence[CreativeManifest] | BuildCreativeSuccessResponse``. Adopters route on ``req.target_format_ids`` (multi) vs ``req.target_format_id`` (single). Returning the wrong arm shape surfaces as schema-validation failure on the wire response. ``RefinementMessage`` is exported as a TypedDict so adopters annotate ``refine_creative(refinement: RefinementMessage)``. * ``CreativeAdServerPlatform`` (src/adcp/decisioning/specialisms/creative_ad_server.py) — covers ``creative-ad-server``. Stateful library + per-creative pricing + tag generation (Innovid, Flashtalking, GAM-creative, CMP-style). Required: ``build_creative``, ``preview_creative``, ``list_creatives``, ``get_creative_delivery``. Optional: ``sync_creatives`` (hybrid sync/handoff for brand-suitability / S&P review). Distinct from CreativeBuilderPlatform — adopters that ALSO want library + tag generation + delivery reporting (a full ad server on top of the builder) declare ``creative-ad-server`` instead. Multi-archetype omni agents are rare; the recommended pattern is to front each archetype as a separate tenant via TenantRegistry. Required-method coverage in ``REQUIRED_METHODS_PER_SPECIALISM``: * ``creative-template``, ``creative-generative`` — both gate on ``{build_creative}`` (shared CreativeBuilderPlatform). * ``creative-ad-server`` — gates on ``{build_creative, preview_creative, list_creatives, get_creative_delivery}``. Public re-exports added at ``adcp.decisioning.__all__``: ``CreativeBuilderPlatform``, ``CreativeAdServerPlatform``, ``RefinementMessage``. Test coverage in ``tests/test_decisioning_specialisms.py`` (11 new tests, 24 total in the file): * ``runtime_checkable`` minimal-vs-full conformance per Protocol. * ``validate_platform`` required-method enforcement per slug. * Public-export drift guard for the new Protocols. * Contract pin for ``creative-template`` / ``creative-generative`` shared method set (drift tracker). * Architectural distinction test: ad-server's required set is a strict superset of builder's. * ``RefinementMessage`` TypedDict smoke test. One existing test updated: the canonical "spec-recognized but unenforced" example switched from ``creative-ad-server`` (now enforced) to ``brand-rights`` (still pending until Batch 3+ ships the brand/governance/lists Protocols). Remaining specialism Protocols (governance-aware-seller, governance-spend-authority, governance-delivery-monitor, brand-rights, content-standards, property-lists, collection-lists) are queued for subsequent breadth-sprint PRs. 2232 tests pass (up from 2221). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/decisioning/__init__.py | 6 + src/adcp/decisioning/dispatch.py | 30 +++ src/adcp/decisioning/specialisms/__init__.py | 30 ++- src/adcp/decisioning/specialisms/creative.py | 192 ++++++++++++++++ .../specialisms/creative_ad_server.py | 185 ++++++++++++++++ tests/test_decisioning_dispatch.py | 18 +- tests/test_decisioning_specialisms.py | 209 +++++++++++++++++- 7 files changed, 655 insertions(+), 15 deletions(-) create mode 100644 src/adcp/decisioning/specialisms/creative.py create mode 100644 src/adcp/decisioning/specialisms/creative_ad_server.py diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 20888871c..1f7f3f57f 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -78,6 +78,9 @@ def create_media_buy( ) from adcp.decisioning.specialisms import ( AudiencePlatform, + CreativeAdServerPlatform, + CreativeBuilderPlatform, + RefinementMessage, SalesPlatform, SignalsPlatform, ) @@ -109,6 +112,8 @@ def create_media_buy( "AudiencePlatform", "AuthInfo", "CollectionList", + "CreativeAdServerPlatform", + "CreativeBuilderPlatform", "DecisioningCapabilities", "DecisioningPlatform", "ExplicitAccounts", @@ -122,6 +127,7 @@ def create_media_buy( "Proposal", "PropertyList", "PropertyListReference", + "RefinementMessage", "RequestContext", "ResourceResolver", "SalesPlatform", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 957b10d57..ea97df4d4 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -214,6 +214,36 @@ "sync_audiences", } ), + # Creative builder specialisms — template-driven transform AND + # brief-driven generation share the unified + # ``CreativeBuilderPlatform`` Protocol per JS commit ``841616d7`` + # (F13). ``build_creative`` is the only wire-required method; + # ``preview_creative``, ``refine_creative``, ``sync_creatives`` are + # optional and surface ``UNSUPPORTED_FEATURE`` to buyers when + # missing. + "creative-template": frozenset( + { + "build_creative", + } + ), + "creative-generative": frozenset( + { + "build_creative", + } + ), + # Creative-ad-server — stateful library, per-creative pricing, tag + # generation, per-creative delivery. ``preview_creative`` is + # required here (distinct from CreativeBuilderPlatform where it's + # optional) — buyers expect preview surface from any stateful + # library. + "creative-ad-server": frozenset( + { + "build_creative", + "preview_creative", + "list_creatives", + "get_creative_delivery", + } + ), } diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index 9e1ad8cb8..a31d980aa 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -18,16 +18,38 @@ * :class:`AudiencePlatform` — covers ``audience-sync``. Two methods: ``sync_audiences`` (push first-party CRM audiences with delta upsert) and ``poll_audience_statuses`` (batch state read). +* :class:`CreativeBuilderPlatform` — covers ``creative-template`` + + ``creative-generative``. Required ``build_creative``; optional + ``preview_creative``, ``refine_creative``, ``sync_creatives``. + Unified shape per JS commit ``841616d7`` (F13) — wire spec doesn't + distinguish template-driven transform from brief-to-creative + generation. +* :class:`CreativeAdServerPlatform` — covers ``creative-ad-server``. + Stateful library + per-creative pricing + tag generation. Required + ``build_creative``, ``preview_creative``, ``list_creatives``, + ``get_creative_delivery``; optional ``sync_creatives``. -Remaining specialism Protocols (creative-*, governance-*, -brand-rights, content-standards, property-lists, collection-lists) -are added in subsequent breadth-sprint PRs as adopters need them. +Remaining specialism Protocols (governance-*, brand-rights, +content-standards, property-lists, collection-lists) are added in +subsequent breadth-sprint PRs. """ from __future__ import annotations from adcp.decisioning.specialisms.audience import AudiencePlatform +from adcp.decisioning.specialisms.creative import ( + CreativeBuilderPlatform, + RefinementMessage, +) +from adcp.decisioning.specialisms.creative_ad_server import CreativeAdServerPlatform from adcp.decisioning.specialisms.sales import SalesPlatform from adcp.decisioning.specialisms.signals import SignalsPlatform -__all__ = ["AudiencePlatform", "SalesPlatform", "SignalsPlatform"] +__all__ = [ + "AudiencePlatform", + "CreativeAdServerPlatform", + "CreativeBuilderPlatform", + "RefinementMessage", + "SalesPlatform", + "SignalsPlatform", +] diff --git a/src/adcp/decisioning/specialisms/creative.py b/src/adcp/decisioning/specialisms/creative.py new file mode 100644 index 000000000..849fb09c7 --- /dev/null +++ b/src/adcp/decisioning/specialisms/creative.py @@ -0,0 +1,192 @@ +"""CreativeBuilderPlatform Protocol — covers ``creative-template`` + +``creative-generative``. + +A platform claiming either ``creative-template`` (stateless transform — +Bannerflow, Celtra) or ``creative-generative`` (brief-to-creative AI +agents — Pencil, Omneky, AdCreative.ai) implements the methods on +this Protocol. The slugs mirror ``schemas/cache/enums/specialism.json``. +The wire shape doesn't distinguish "transform a template" from +"generate from a brief" — both produce a :class:`CreativeManifest` +from a :class:`BuildCreativeRequest`. The unified Protocol surface +captures that; the discovery distinction is preserved at the +buyer-facing spec level (so buyers filtering for "AI brief-to-creative" +still find generative agents). + +Required: + +* :meth:`build_creative` — produces the creative + +Optional (present-or-absent, surface UNSUPPORTED_FEATURE if missing): + +* :meth:`preview_creative` — sandbox URL or inline HTML preview +* :meth:`refine_creative` — refine a prior generation by ``task_id`` +* :meth:`sync_creatives` — review surface; hybrid sync/handoff + +Async story: ``build_creative`` is sync at the wire level — the +per-tool ``build-creative-response.json`` ``oneOf`` doesn't include a +``Submitted`` arm (spec inconsistency tracked as +``adcontextprotocol/adcp#3392``). Until the spec rolls Submitted into +the ``oneOf``, slow operations (TTS, audio mixing, long-running +generation) await in-request; status changes surface via +``ctx.publish_status_change(resource_type='creative', ...)``. + +Mirrors the JS-side ``CreativeBuilderPlatform`` interface at +``src/lib/server/decisioning/specialisms/creative.ts`` (commit +``841616d7`` / F13 — unified Template + Generative archetypes). + +For full ad-server adopters (library + tag generation + delivery +reporting) declaring ``creative-ad-server``, see +:class:`CreativeAdServerPlatform` instead. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from collections.abc import Sequence + + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync, SalesResult + from adcp.types import ( + BuildCreativeRequest, + BuildCreativeSuccessResponse, + CreativeManifest, + PreviewCreativeRequest, + PreviewCreativeResponse, + SyncCreativesSuccessResponse, + ) + + +#: Per-platform metadata generic; matches ``RequestContext[TMeta]`` and +#: ``Account[TMeta]`` upstream. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + + +class RefinementMessage(TypedDict, total=False): + """Refinement instruction for :meth:`CreativeBuilderPlatform.refine_creative`. + + JS-side equivalent declared inline at + ``src/lib/server/decisioning/specialisms/creative.ts`` + (``RefinementMessage``). + + :param message: REQUIRED — free-text instruction from the buyer. + :param changes: OPTIONAL — structured changes (e.g., + ``{"headline": "make it say X"}``). Adopter-defined shape. + """ + + message: str + changes: dict[str, Any] + + +@runtime_checkable +class CreativeBuilderPlatform(Protocol, Generic[TMeta]): + """Produces creatives — template-driven or brief-driven (generative). + + Methods may be sync (return ``T`` directly) or async (return + ``Awaitable[T]``); the dispatch adapter detects via + :func:`asyncio.iscoroutinefunction` and runs sync methods on a + thread pool. + + Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable + rejection (``UNSUPPORTED_FEATURE`` for missing optionals, + ``POLICY_VIOLATION`` for buyer rights issues, etc.); the framework + projects to the wire structured-error envelope. + """ + + def build_creative( + self, + req: BuildCreativeRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]: + """Build the creative. + + Single method covers template-driven transform + (``req.template_id`` + asset slots), brief-to-creative + generation (``req.brief``), and any hybrid the platform + supports — adopters route internally on ``req`` shape. + + Return shape is discriminated by the wire spec's Single vs + Multi response arms: + + * **Single manifest, no metadata**: return a :class:`CreativeManifest` + directly. Framework wraps as ``{creative_manifest: }``. + Use this for single-format requests (``target_format_id``) + when you don't need to set ``sandbox`` / ``expires_at`` / + ``preview``. + * **Multi-format manifests, no metadata**: return a + ``Sequence[CreativeManifest]``. Framework wraps as + ``{creative_manifests: [...]}``. Use for multi-format + requests (``target_format_ids``) when you don't need rich + metadata. + * **Fully-shaped envelope**: return a + :class:`BuildCreativeSuccessResponse` with ``sandbox`` / + ``expires_at`` / ``preview`` populated. Framework passes + through unchanged. + + Adopters route on ``req.target_format_ids`` (multi) vs + ``req.target_format_id`` (single) and return the matching arm. + Returning the wrong arm shape is an adopter contract violation + that surfaces as schema-validation failure on the wire response. + + :raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'`` + (buyer lacks rights to the requested template / brand + inputs), ``code='INVALID_REQUEST'`` (missing or + unrecognized template_id). + """ + ... + + def preview_creative( + self, + req: PreviewCreativeRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[PreviewCreativeResponse]: + """Preview-only variant — sandbox URL or inline HTML, expires. + + Always sync. Optional — generative-only adopters that don't + render preview ahead of generation can omit it; the framework + returns ``UNSUPPORTED_FEATURE`` to buyers calling + ``preview_creative`` against a platform that didn't wire this. + """ + ... + + def refine_creative( + self, + task_id: str, + refinement: RefinementMessage, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[CreativeManifest]: + """Refine a prior generation. + + ``task_id`` references a prior submission. Sync — refinement + is a mutation on existing state, not a new task creation. + Optional — pure template platforms iterate by re-calling + ``build_creative`` with different inputs and don't carry + generation state across calls. + """ + ... + + def sync_creatives( + self, + req: Any, + ctx: RequestContext[TMeta], + ) -> SalesResult[SyncCreativesSuccessResponse]: + """Sync review surface — present-or-absent. + + Stateless platforms typically auto-approve; adopters needing + mandatory pre-persist review return + ``ctx.handoff_to_task(fn)`` to defer to a background task. + Unified hybrid shape — return the typed + :class:`SyncCreativesSuccessResponse` for the sync fast path + OR ``ctx.handoff_to_task(fn)`` for HITL. + + ``req`` is typed as ``Any`` here because the SDK's + :class:`SyncCreativesRequest` is shared across creative + archetypes; the per-archetype handler shim narrows the type. + """ + ... + + +__all__ = ["CreativeBuilderPlatform", "RefinementMessage"] diff --git a/src/adcp/decisioning/specialisms/creative_ad_server.py b/src/adcp/decisioning/specialisms/creative_ad_server.py new file mode 100644 index 000000000..4b3d66bef --- /dev/null +++ b/src/adcp/decisioning/specialisms/creative_ad_server.py @@ -0,0 +1,185 @@ +"""CreativeAdServerPlatform Protocol — covers ``creative-ad-server``. + +A platform claiming ``creative-ad-server`` (Innovid, Flashtalking, +GAM-creative, CMP-style platforms) implements the methods on this +Protocol. The slug mirrors ``schemas/cache/enums/specialism.json``. + +Distinct from :class:`CreativeBuilderPlatform` (stateless transform / +brief-driven generation): + +* **Stateful** — adopter persists creatives in a library; + ``sync_creatives`` pushes assets in, ``list_creatives`` reads them + back, ``build_creative`` either looks up an existing creative by id + OR pushes a new one. +* **Pricing per creative** — vendor pricing options on each creative; + ``pricing_option_id`` selected at activation, billed via + ``report_usage``. +* **Tag generation** — ``build_creative`` returns ad-server tags + (VAST, placement-specific tracking pixels, macro-substituted + creative HTML) when invoked with ``media_buy_id`` + ``package_id`` + context. +* **Per-creative delivery reports** — ``get_creative_delivery`` + returns pacing data per creative across the library. + +Required methods (every ``creative-ad-server`` adopter): + +* :meth:`build_creative` — library lookup OR inline build with tag generation +* :meth:`preview_creative` — preview-only variant (NOT optional here, + unlike :class:`CreativeBuilderPlatform`) +* :meth:`list_creatives` — read creatives from the library +* :meth:`get_creative_delivery` — per-creative delivery actuals + +Optional: + +* :meth:`sync_creatives` — push creatives; hybrid sync/handoff for + brand-suitability / S&P review + +Mirrors the JS-side ``CreativeAdServerPlatform`` interface at +``src/lib/server/decisioning/specialisms/creative-ad-server.ts``. + +Multi-archetype omni agents (a single tenant doing both stateless +transform AND library + tag generation) are rare in the wild; the +recommended pattern is to front each archetype as a separate tenant +via ``TenantRegistry``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from collections.abc import Sequence + + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync, SalesResult + from adcp.types import ( + BuildCreativeRequest, + BuildCreativeSuccessResponse, + CreativeManifest, + GetCreativeDeliveryRequest, + GetCreativeDeliveryResponse, + ListCreativesRequest, + ListCreativesResponse, + PreviewCreativeRequest, + PreviewCreativeResponse, + SyncCreativesSuccessResponse, + ) + + +#: Per-platform metadata generic. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + + +@runtime_checkable +class CreativeAdServerPlatform(Protocol, Generic[TMeta]): + """Stateful creative library + per-creative pricing + tag generation. + + Methods may be sync (return ``T`` directly) or async (return + ``Awaitable[T]``); the dispatch adapter detects via + :func:`asyncio.iscoroutinefunction` and runs sync methods on a + thread pool. + + Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable + rejection. + """ + + def build_creative( + self, + req: BuildCreativeRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]: + """Build / retrieve creative tags. Two invocation modes: + + * **Library lookup**: ``req.creative_id`` references an + existing creative; return the manifest with tag fields + populated (``vast_tag``, click trackers, etc.). When + ``req.media_buy_id`` + ``req.package_id`` are also set, + generate placement-specific tags with macro substitution + baked in. + * **Inline build**: ``req.creative_manifest`` is provided + directly; transform / wrap it (similar to template archetype + but with ad-server side effects: register the creative in + the library, generate the tag, etc.). + + Sync at the wire level — the per-tool + ``build-creative-response.json`` ``oneOf`` doesn't include a + ``Submitted`` arm (spec inconsistency tracked as + ``adcontextprotocol/adcp#3392``). Until the spec rolls + Submitted into the ``oneOf``, slow tag-generation pipelines + await in-request; status changes flow via + ``ctx.publish_status_change``. + + Return shape: see :meth:`CreativeBuilderPlatform.build_creative` + for the discriminated-arm rules — single + :class:`CreativeManifest`, ``Sequence[CreativeManifest]`` for + multi-format, or :class:`BuildCreativeSuccessResponse` envelope + when you need ``sandbox``/``expires_at``/``preview``. + """ + ... + + def preview_creative( + self, + req: PreviewCreativeRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[PreviewCreativeResponse]: + """Preview-only variant — sandbox URL or inline HTML, expires. + + Always sync. NOT optional for ad-server adopters (distinct from + :class:`CreativeBuilderPlatform.preview_creative`, which is + optional) — buyers expect preview surface from any stateful + creative library. + """ + ... + + def list_creatives( + self, + req: ListCreativesRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ListCreativesResponse]: + """Read creatives from the library. + + Filters + pagination. When ``req.include_assignments``, + include the buyer's package-assignment graph. When + ``req.include_pricing``, include vendor pricing options on + each creative. + """ + ... + + def get_creative_delivery( + self, + req: GetCreativeDeliveryRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetCreativeDeliveryResponse]: + """Per-creative delivery actuals (impressions, spend, pacing). + + Sync — report-running platforms with manual report cycles + return the latest cached actuals and emit ``delivery_report`` + status changes via ``ctx.publish_status_change`` when fresh + reports are available. + """ + ... + + def sync_creatives( + self, + req: Any, + ctx: RequestContext[TMeta], + ) -> SalesResult[SyncCreativesSuccessResponse]: + """Push creatives. Optional — present-or-absent. + + Return the typed :class:`SyncCreativesSuccessResponse` for the + sync fast path OR ``ctx.handoff_to_task(fn)`` for HITL — + brand-suitability, S&P review. ``action: 'created'`` for new + entries, ``'updated'`` for replacements, ``'unchanged'`` when + matching. Optional ``status: 'pending_review'`` for sync-arm + rows awaiting manual review. + + ``req`` is typed as ``Any`` here because the SDK's + :class:`SyncCreativesRequest` is shared across creative + archetypes; the per-archetype handler shim narrows the type. + """ + ... + + +__all__ = ["CreativeAdServerPlatform"] diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 528ef61ee..37fecb2b0 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -277,22 +277,24 @@ def test_spec_specialism_enum_matches_schema_cache() -> None: def test_validate_platform_warns_on_unenforced_spec_specialism() -> None: """Spec-recognized specialism that the v6.0 framework doesn't yet - enforce (e.g. ``creative-ad-server``) emits an "unenforced - specialism" UserWarning — distinct from the "novel" warning, since - it's a real claim, just not method-checked. + enforce (e.g. ``brand-rights``) emits an "unenforced specialism" + UserWarning — distinct from the "novel" warning, since it's a + real claim, just not method-checked. - Use ``creative-ad-server`` here because ``signal-marketplace`` / - ``audience-sync`` got method-coverage rules in the breadth-sprint - Batch 1; ``creative-*`` are still pending until Batch 2.""" + Use ``brand-rights`` here because ``signal-*`` / ``audience-sync`` + got method-coverage rules in Batch 1, and ``creative-*`` got + coverage in Batch 2. Brand-rights, content-standards, + governance-*, property-lists, collection-lists are still pending + until subsequent breadth-sprint batches.""" class _UnenforcedSpecPlatform(DecisioningPlatform): - capabilities = DecisioningCapabilities(specialisms=["creative-ad-server"]) + capabilities = DecisioningCapabilities(specialisms=["brand-rights"]) accounts = SingletonAccounts(account_id="hello") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", UserWarning) validate_platform(_UnenforcedSpecPlatform()) - matched = [w for w in caught if "creative-ad-server" in str(w.message)] + matched = [w for w in caught if "brand-rights" in str(w.message)] assert len(matched) == 1 assert "spec-recognized" in str(matched[0].message) diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index ecbcd517e..2a01f7e1d 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -23,8 +23,11 @@ from adcp.decisioning import ( AudiencePlatform, + CreativeAdServerPlatform, + CreativeBuilderPlatform, DecisioningCapabilities, DecisioningPlatform, + RefinementMessage, SalesPlatform, SignalsPlatform, SingletonAccounts, @@ -39,16 +42,22 @@ def test_specialism_protocols_are_publicly_exported() -> None: - """The three Protocol classes are on ``adcp.decisioning.__all__`` - so adopters import from the canonical public surface, not the - internal ``adcp.decisioning.specialisms.*`` modules.""" + """All five Protocol classes (Batches 0–2) are on + ``adcp.decisioning.__all__`` so adopters import from the canonical + public surface, not the internal ``adcp.decisioning.specialisms.*`` + modules.""" import adcp.decisioning as dx assert "SalesPlatform" in dx.__all__ assert "SignalsPlatform" in dx.__all__ assert "AudiencePlatform" in dx.__all__ + assert "CreativeBuilderPlatform" in dx.__all__ + assert "CreativeAdServerPlatform" in dx.__all__ + assert "RefinementMessage" in dx.__all__ assert dx.SignalsPlatform is SignalsPlatform assert dx.AudiencePlatform is AudiencePlatform + assert dx.CreativeBuilderPlatform is CreativeBuilderPlatform + assert dx.CreativeAdServerPlatform is CreativeAdServerPlatform # ---- SignalsPlatform ---- @@ -314,3 +323,197 @@ def list_creatives(self, req, ctx): return {} assert isinstance(_SalesShim(), SalesPlatform) + + +# ---- CreativeBuilderPlatform ---- + + +def test_creative_builder_runtime_checkable_minimal() -> None: + """Minimal-compliant impl — only ``build_creative`` is wire-required. + Optional methods (``preview_creative``, ``refine_creative``, + ``sync_creatives``) are present-or-absent.""" + + class _MinimalBuilder: + def build_creative(self, req, ctx): + return {} + + # ``runtime_checkable`` Protocol matching is a structural-AND across + # all declared methods. Since CreativeBuilderPlatform declares + # optional methods that aren't present here, the runtime check is + # FALSE. ``validate_platform`` (which uses the narrower + # REQUIRED_METHODS_PER_SPECIALISM gate) is what production servers + # rely on; runtime_checkable is a strict structural match. + # This is consistent with SalesPlatform's behavior (same pattern). + assert not isinstance(_MinimalBuilder(), CreativeBuilderPlatform) + + +def test_creative_builder_runtime_checkable_full() -> None: + """A class with every Protocol method (required + optional) passes + the strict runtime_checkable structural match.""" + + class _FullBuilder: + def build_creative(self, req, ctx): + return {} + + def preview_creative(self, req, ctx): + return {} + + def refine_creative(self, task_id, refinement, ctx): + return {} + + def sync_creatives(self, req, ctx): + return {} + + assert isinstance(_FullBuilder(), CreativeBuilderPlatform) + + +def test_validate_platform_enforces_creative_template_method() -> None: + """``creative-template`` requires ``build_creative`` only — + Optional methods don't gate server boot. A platform claiming the + slug without ``build_creative`` fails fast.""" + + class _MissingBuildPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["creative-template"]) + accounts = SingletonAccounts(account_id="hello") + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_MissingBuildPlatform()) + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "build_creative" in missing_methods + + +def test_validate_platform_passes_creative_template_minimal() -> None: + """Minimal ``creative-template`` adopter implementing only + ``build_creative`` passes validation; optional methods can be + absent.""" + + class _MinimalTemplatePlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["creative-template"]) + accounts = SingletonAccounts(account_id="hello") + + def build_creative(self, req, ctx): + return {} + + validate_platform(_MinimalTemplatePlatform()) + + +def test_creative_template_and_generative_share_method_set() -> None: + """Both creative builder specialisms gate on the same single + method (``build_creative``). Drift in + REQUIRED_METHODS_PER_SPECIALISM here surfaces as a visible test + failure since they should track together.""" + expected = {"build_creative"} + assert REQUIRED_METHODS_PER_SPECIALISM["creative-template"] == expected + assert REQUIRED_METHODS_PER_SPECIALISM["creative-generative"] == expected + + +def test_refinement_message_typeddict_is_importable() -> None: + """``RefinementMessage`` is a TypedDict adopters annotate + ``refine_creative`` with. Smoke check: it's importable and has the + expected ``message`` key.""" + msg: RefinementMessage = {"message": "make headline bolder"} + assert msg["message"] == "make headline bolder" + + +# ---- CreativeAdServerPlatform ---- + + +def test_creative_ad_server_runtime_checkable_full() -> None: + """An ad-server impl with all required + optional methods passes + the runtime_checkable check.""" + + class _AdServerImpl: + def build_creative(self, req, ctx): + return {} + + def preview_creative(self, req, ctx): + return {} + + def list_creatives(self, req, ctx): + return {} + + def get_creative_delivery(self, req, ctx): + return {} + + def sync_creatives(self, req, ctx): + return {} + + assert isinstance(_AdServerImpl(), CreativeAdServerPlatform) + + +def test_validate_platform_enforces_creative_ad_server_required_methods() -> None: + """``creative-ad-server`` requires four methods. A platform + claiming the slug without all four fails fast at server boot.""" + + class _PartialAdServerPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["creative-ad-server"]) + accounts = SingletonAccounts(account_id="hello") + + # Implements only build_creative + preview_creative; + # missing list_creatives + get_creative_delivery. + def build_creative(self, req, ctx): + return {} + + def preview_creative(self, req, ctx): + return {} + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialAdServerPlatform()) + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "list_creatives" in missing_methods + assert "get_creative_delivery" in missing_methods + + +def test_validate_platform_passes_creative_ad_server_with_required_methods() -> None: + """Adopter implementing the four required ``creative-ad-server`` + methods passes validation. ``sync_creatives`` is optional.""" + + class _CompleteAdServerPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["creative-ad-server"]) + accounts = SingletonAccounts(account_id="hello") + + def build_creative(self, req, ctx): + return {} + + def preview_creative(self, req, ctx): + return {} + + def list_creatives(self, req, ctx): + return {} + + def get_creative_delivery(self, req, ctx): + return {} + + validate_platform(_CompleteAdServerPlatform()) + + +def test_creative_ad_server_required_methods_pinned() -> None: + """Contract test — ``creative-ad-server`` requires the four + methods JS marks non-optional in the Protocol interface + (``build_creative``, ``preview_creative``, ``list_creatives``, + ``get_creative_delivery``). ``sync_creatives`` is optional in + JS too.""" + expected = { + "build_creative", + "preview_creative", + "list_creatives", + "get_creative_delivery", + } + assert REQUIRED_METHODS_PER_SPECIALISM["creative-ad-server"] == expected + + +def test_creative_ad_server_distinct_from_builder() -> None: + """The two creative Protocols enforce different method sets — an + ad-server adopter must implement four methods; a builder adopter + only one. Confirms the architectural distinction at the + REQUIRED_METHODS layer.""" + builder_methods = REQUIRED_METHODS_PER_SPECIALISM["creative-template"] + ad_server_methods = REQUIRED_METHODS_PER_SPECIALISM["creative-ad-server"] + # Builder is a strict subset of ad-server (build_creative is shared). + assert builder_methods < ad_server_methods + # But ad-server has extra requirements (preview, list, delivery). + assert ad_server_methods - builder_methods == { + "preview_creative", + "list_creatives", + "get_creative_delivery", + } From 156c278d2e67155957a13aefb7f9cc004e37703c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 21:26:35 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(decisioning):=20address=20expert=20revi?= =?UTF-8?q?ew=20of=20#333=20=E2=80=94=20drop=20hallucinated=20refine=5Fcre?= =?UTF-8?q?ative=20+=20tighten=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on Batch 2 Creative Protocols: P0 (wire-spec) — ``refine_creative`` was a hallucinated wire surface. There is no ``refine-creative-*.json`` schema in ``schemas/cache/`` and no ``refine_creative`` wire tool. Per ``schemas/cache/media-buy/build-creative-request.json``, refinement is invoked via ``build_creative`` itself with ``creative_id`` referencing the prior build (the request schema's "For refinement…" description). Both the JS reference (``creative.ts``) and the initial Python port preserved a ``refine_creative`` Protocol method mirroring an earlier preview design. Dropped here: * Removed ``refine_creative`` method from ``CreativeBuilderPlatform`` * Removed ``RefinementMessage`` TypedDict (was its parameter type) * Removed both from ``adcp.decisioning`` re-exports + ``__all__`` * Removed corresponding tests * Added regression test (``test_creative_builder_protocol_has_no_refine_creative``) so a future port doesn't re-add it without checking the spec To file against the JS reference as a follow-up. P1 (typing) — ``sync_creatives`` declared ``req: Any`` on both creative Protocols. The shared ``SyncCreativesRequest`` exists in ``adcp.types`` (the sales-* archetypes use it). Typing as ``Any`` defeated the typed-input contract the rest of the SDK exposes. Tightened to ``req: SyncCreativesRequest``; the per-archetype narrowing comment kept (the shim narrows discriminated payload fields, not the envelope). P1 (regression test for adcp#3392) — added ``test_build_creative_response_has_no_submitted_arm`` that walks the ``BuildCreativeResponse`` Pydantic union and asserts no arm carries ``task_id``. When the spec inconsistency at adcp#3392 lands and the ``oneOf`` adds a Submitted variant, this test breaks and forces a coordinated SDK update to the Protocol return type. P2 (test rename) — ``test_creative_builder_runtime_checkable_minimal`` renamed to ``test_creative_builder_runtime_checkable_is_strict_structural_match``. The "minimal" framing implied the test was checking adopter-shape adequacy; it's actually checking that ``runtime_checkable`` strict structural-AND semantics are documented. Same assertion, honest name. Punt list (filed as follow-up): * ``BuildCreativeResponse2`` is mislabeled in ``src/adcp/types/aliases.py`` — currently aliased as ``BuildCreativeErrorResponse`` but the class actually carries ``creative_manifests`` (multi-success arm). The real error arm is ``BuildCreativeResponse3``. Real type-system bug affecting ``guards.py``, ``test_type_aliases.py``, ``public_api_snapshot.json``. Out of scope here; needs its own PR with public-API surface changes. * Python's ``REQUIRED_METHODS_PER_SPECIALISM`` is stricter than JS's ``SPECIALISM_REQUIREMENTS``. Deliberate Python-side hardening across all specialism Protocols (foundation + Batches 1-2); not unique to Creative. * Multi-archetype omni guidance (single tenant claiming both ``creative-template`` and ``creative-ad-server``) is unenforceable in code today. 2233 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/decisioning/__init__.py | 2 - src/adcp/decisioning/specialisms/__init__.py | 16 ++-- src/adcp/decisioning/specialisms/creative.py | 58 +++++--------- .../specialisms/creative_ad_server.py | 8 +- tests/test_decisioning_specialisms.py | 80 ++++++++++++++----- 5 files changed, 88 insertions(+), 76 deletions(-) diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 1f7f3f57f..62faf2186 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -80,7 +80,6 @@ def create_media_buy( AudiencePlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, - RefinementMessage, SalesPlatform, SignalsPlatform, ) @@ -127,7 +126,6 @@ def create_media_buy( "Proposal", "PropertyList", "PropertyListReference", - "RefinementMessage", "RequestContext", "ResourceResolver", "SalesPlatform", diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index a31d980aa..48e9861c4 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -20,10 +20,12 @@ upsert) and ``poll_audience_statuses`` (batch state read). * :class:`CreativeBuilderPlatform` — covers ``creative-template`` + ``creative-generative``. Required ``build_creative``; optional - ``preview_creative``, ``refine_creative``, ``sync_creatives``. - Unified shape per JS commit ``841616d7`` (F13) — wire spec doesn't - distinguish template-driven transform from brief-to-creative - generation. + ``preview_creative``, ``sync_creatives``. Unified shape per JS + commit ``841616d7`` (F13) — wire spec doesn't distinguish + template-driven transform from brief-to-creative generation. (No + separate ``refine_creative`` method — refinement is invoked via + ``build_creative`` with ``creative_id`` referencing the prior + build, per ``schemas/cache/media-buy/build-creative-request.json``.) * :class:`CreativeAdServerPlatform` — covers ``creative-ad-server``. Stateful library + per-creative pricing + tag generation. Required ``build_creative``, ``preview_creative``, ``list_creatives``, @@ -37,10 +39,7 @@ from __future__ import annotations from adcp.decisioning.specialisms.audience import AudiencePlatform -from adcp.decisioning.specialisms.creative import ( - CreativeBuilderPlatform, - RefinementMessage, -) +from adcp.decisioning.specialisms.creative import CreativeBuilderPlatform from adcp.decisioning.specialisms.creative_ad_server import CreativeAdServerPlatform from adcp.decisioning.specialisms.sales import SalesPlatform from adcp.decisioning.specialisms.signals import SignalsPlatform @@ -49,7 +48,6 @@ "AudiencePlatform", "CreativeAdServerPlatform", "CreativeBuilderPlatform", - "RefinementMessage", "SalesPlatform", "SignalsPlatform", ] diff --git a/src/adcp/decisioning/specialisms/creative.py b/src/adcp/decisioning/specialisms/creative.py index 849fb09c7..77031aa72 100644 --- a/src/adcp/decisioning/specialisms/creative.py +++ b/src/adcp/decisioning/specialisms/creative.py @@ -19,9 +19,19 @@ Optional (present-or-absent, surface UNSUPPORTED_FEATURE if missing): * :meth:`preview_creative` — sandbox URL or inline HTML preview -* :meth:`refine_creative` — refine a prior generation by ``task_id`` * :meth:`sync_creatives` — review surface; hybrid sync/handoff +**Refinement is via ``build_creative``, not a separate method.** The +spec's ``build-creative-request.json`` describes refinement as +re-invoking ``build_creative`` with ``creative_id`` referencing the +prior build (see the request schema's "For refinement…" description). +There is no ``refine-creative-*.json`` in ``schemas/cache/`` and no +``refine_creative`` wire tool. An earlier port preserved a +``refine_creative`` Protocol method mirroring the JS reference; +expert review (round-3 Emma) caught that as a hallucinated wire +surface — both codebases shipped a method with no spec backing. +Dropped here; JS to follow. + Async story: ``build_creative`` is sync at the wire level — the per-tool ``build-creative-response.json`` ``oneOf`` doesn't include a ``Submitted`` arm (spec inconsistency tracked as @@ -41,7 +51,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, runtime_checkable +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable from typing_extensions import TypeVar @@ -56,6 +66,7 @@ CreativeManifest, PreviewCreativeRequest, PreviewCreativeResponse, + SyncCreativesRequest, SyncCreativesSuccessResponse, ) @@ -65,22 +76,6 @@ TMeta = TypeVar("TMeta", default=dict[str, Any]) -class RefinementMessage(TypedDict, total=False): - """Refinement instruction for :meth:`CreativeBuilderPlatform.refine_creative`. - - JS-side equivalent declared inline at - ``src/lib/server/decisioning/specialisms/creative.ts`` - (``RefinementMessage``). - - :param message: REQUIRED — free-text instruction from the buyer. - :param changes: OPTIONAL — structured changes (e.g., - ``{"headline": "make it say X"}``). Adopter-defined shape. - """ - - message: str - changes: dict[str, Any] - - @runtime_checkable class CreativeBuilderPlatform(Protocol, Generic[TMeta]): """Produces creatives — template-driven or brief-driven (generative). @@ -152,25 +147,9 @@ def preview_creative( """ ... - def refine_creative( - self, - task_id: str, - refinement: RefinementMessage, - ctx: RequestContext[TMeta], - ) -> MaybeAsync[CreativeManifest]: - """Refine a prior generation. - - ``task_id`` references a prior submission. Sync — refinement - is a mutation on existing state, not a new task creation. - Optional — pure template platforms iterate by re-calling - ``build_creative`` with different inputs and don't carry - generation state across calls. - """ - ... - def sync_creatives( self, - req: Any, + req: SyncCreativesRequest, ctx: RequestContext[TMeta], ) -> SalesResult[SyncCreativesSuccessResponse]: """Sync review surface — present-or-absent. @@ -182,11 +161,12 @@ def sync_creatives( :class:`SyncCreativesSuccessResponse` for the sync fast path OR ``ctx.handoff_to_task(fn)`` for HITL. - ``req`` is typed as ``Any`` here because the SDK's - :class:`SyncCreativesRequest` is shared across creative - archetypes; the per-archetype handler shim narrows the type. + Same wire request type as the sales-* archetypes use + (``SyncCreativesRequest`` — shared spec shape); the + per-archetype handler shim narrows the discriminated payload + when adopters care about archetype-specific fields. """ ... -__all__ = ["CreativeBuilderPlatform", "RefinementMessage"] +__all__ = ["CreativeBuilderPlatform"] diff --git a/src/adcp/decisioning/specialisms/creative_ad_server.py b/src/adcp/decisioning/specialisms/creative_ad_server.py index 4b3d66bef..8b9959412 100644 --- a/src/adcp/decisioning/specialisms/creative_ad_server.py +++ b/src/adcp/decisioning/specialisms/creative_ad_server.py @@ -64,6 +64,7 @@ ListCreativesResponse, PreviewCreativeRequest, PreviewCreativeResponse, + SyncCreativesRequest, SyncCreativesSuccessResponse, ) @@ -163,7 +164,7 @@ def get_creative_delivery( def sync_creatives( self, - req: Any, + req: SyncCreativesRequest, ctx: RequestContext[TMeta], ) -> SalesResult[SyncCreativesSuccessResponse]: """Push creatives. Optional — present-or-absent. @@ -175,9 +176,8 @@ def sync_creatives( matching. Optional ``status: 'pending_review'`` for sync-arm rows awaiting manual review. - ``req`` is typed as ``Any`` here because the SDK's - :class:`SyncCreativesRequest` is shared across creative - archetypes; the per-archetype handler shim narrows the type. + Same wire request type as the sales-* and creative-builder + archetypes use (``SyncCreativesRequest`` — shared spec shape). """ ... diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 2a01f7e1d..9f25b1098 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -27,7 +27,6 @@ CreativeBuilderPlatform, DecisioningCapabilities, DecisioningPlatform, - RefinementMessage, SalesPlatform, SignalsPlatform, SingletonAccounts, @@ -53,7 +52,6 @@ def test_specialism_protocols_are_publicly_exported() -> None: assert "AudiencePlatform" in dx.__all__ assert "CreativeBuilderPlatform" in dx.__all__ assert "CreativeAdServerPlatform" in dx.__all__ - assert "RefinementMessage" in dx.__all__ assert dx.SignalsPlatform is SignalsPlatform assert dx.AudiencePlatform is AudiencePlatform assert dx.CreativeBuilderPlatform is CreativeBuilderPlatform @@ -328,22 +326,25 @@ def list_creatives(self, req, ctx): # ---- CreativeBuilderPlatform ---- -def test_creative_builder_runtime_checkable_minimal() -> None: - """Minimal-compliant impl — only ``build_creative`` is wire-required. - Optional methods (``preview_creative``, ``refine_creative``, - ``sync_creatives``) are present-or-absent.""" +def test_creative_builder_runtime_checkable_is_strict_structural_match() -> None: + """``runtime_checkable`` matches by attribute presence across ALL + declared Protocol methods (strict structural-AND). Documents the + contract: a class implementing only the wire-required methods + will NOT pass ``isinstance`` because optional Protocol methods + aren't present. + + ``validate_platform`` uses the narrower + REQUIRED_METHODS_PER_SPECIALISM gate — that's what production + servers actually rely on for spec coverage. This is consistent + with SalesPlatform's behavior (same pattern across all + specialism Protocols).""" class _MinimalBuilder: def build_creative(self, req, ctx): return {} - # ``runtime_checkable`` Protocol matching is a structural-AND across - # all declared methods. Since CreativeBuilderPlatform declares - # optional methods that aren't present here, the runtime check is - # FALSE. ``validate_platform`` (which uses the narrower - # REQUIRED_METHODS_PER_SPECIALISM gate) is what production servers - # rely on; runtime_checkable is a strict structural match. - # This is consistent with SalesPlatform's behavior (same pattern). + # Minimal impl satisfies the wire-required set but lacks the + # optional Protocol methods → strict isinstance is False. assert not isinstance(_MinimalBuilder(), CreativeBuilderPlatform) @@ -358,9 +359,6 @@ def build_creative(self, req, ctx): def preview_creative(self, req, ctx): return {} - def refine_creative(self, task_id, refinement, ctx): - return {} - def sync_creatives(self, req, ctx): return {} @@ -407,12 +405,50 @@ def test_creative_template_and_generative_share_method_set() -> None: assert REQUIRED_METHODS_PER_SPECIALISM["creative-generative"] == expected -def test_refinement_message_typeddict_is_importable() -> None: - """``RefinementMessage`` is a TypedDict adopters annotate - ``refine_creative`` with. Smoke check: it's importable and has the - expected ``message`` key.""" - msg: RefinementMessage = {"message": "make headline bolder"} - assert msg["message"] == "make headline bolder" +def test_creative_builder_protocol_has_no_refine_creative() -> None: + """Regression-guard: ``refine_creative`` was a hallucinated wire + surface in earlier port drafts. The spec invokes refinement via + ``build_creative`` itself with ``creative_id`` referencing the + prior build (per + ``schemas/cache/media-buy/build-creative-request.json``); there + is no ``refine-creative-*.json`` schema and no wire tool. If + someone re-adds ``refine_creative`` to the Protocol thinking it's + a missing method, this test breaks.""" + assert not hasattr(CreativeBuilderPlatform, "refine_creative") + + +def test_build_creative_response_has_no_submitted_arm() -> None: + """Regression-guard against ``adcontextprotocol/adcp#3392``: the + per-tool ``build-creative-response.json`` ``oneOf`` is strictly + Success | MultiSuccess | Error — no Submitted variant. Both the + JS and Python Protocols document ``build_creative`` as sync at + the wire level (slow generation pipelines await in-request; + status changes flow via ``publish_status_change``). + + When adcp#3392 lands and the spec rolls Submitted into the + ``oneOf``, this test breaks and forces a coordinated SDK update + to the Protocol return type (add ``BuildCreativeAsyncSubmitted`` + to the union).""" + # ``BuildCreativeResponse`` is a typing.Union of the discriminated + # arms. Walk its args and assert the wire-required field set + # doesn't include task-async submitted hints. + import typing + + from adcp.types import BuildCreativeResponse + + arms = typing.get_args(BuildCreativeResponse) + assert len(arms) > 0, "BuildCreativeResponse should be a Union of arms" + for arm in arms: + # Build-creative arms carry creative_manifest / creative_manifests + # (Success/MultiSuccess) or errors (Error). None should declare + # task_id or status='submitted' — those are Submitted-arm hints. + if hasattr(arm, "model_fields"): + field_names = set(arm.model_fields.keys()) + assert "task_id" not in field_names, ( + f"BuildCreativeResponse arm {arm.__name__} unexpectedly carries " + "task_id — adcp#3392 may have landed; update the Protocol " + "return type to include the Submitted arm." + ) # ---- CreativeAdServerPlatform ----