diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 20888871c..62faf2186 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -78,6 +78,8 @@ def create_media_buy( ) from adcp.decisioning.specialisms import ( AudiencePlatform, + CreativeAdServerPlatform, + CreativeBuilderPlatform, SalesPlatform, SignalsPlatform, ) @@ -109,6 +111,8 @@ def create_media_buy( "AudiencePlatform", "AuthInfo", "CollectionList", + "CreativeAdServerPlatform", + "CreativeBuilderPlatform", "DecisioningCapabilities", "DecisioningPlatform", "ExplicitAccounts", 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..48e9861c4 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -18,16 +18,36 @@ * :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``, ``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``, + ``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 +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", + "SalesPlatform", + "SignalsPlatform", +] diff --git a/src/adcp/decisioning/specialisms/creative.py b/src/adcp/decisioning/specialisms/creative.py new file mode 100644 index 000000000..77031aa72 --- /dev/null +++ b/src/adcp/decisioning/specialisms/creative.py @@ -0,0 +1,172 @@ +"""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:`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 +``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, 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, + SyncCreativesRequest, + SyncCreativesSuccessResponse, + ) + + +#: Per-platform metadata generic; matches ``RequestContext[TMeta]`` and +#: ``Account[TMeta]`` upstream. +TMeta = TypeVar("TMeta", default=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 sync_creatives( + self, + req: SyncCreativesRequest, + 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. + + 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"] 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..8b9959412 --- /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, + SyncCreativesRequest, + 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: SyncCreativesRequest, + 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. + + Same wire request type as the sales-* and creative-builder + archetypes use (``SyncCreativesRequest`` — shared spec shape). + """ + ... + + +__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..9f25b1098 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -23,6 +23,8 @@ from adcp.decisioning import ( AudiencePlatform, + CreativeAdServerPlatform, + CreativeBuilderPlatform, DecisioningCapabilities, DecisioningPlatform, SalesPlatform, @@ -39,16 +41,21 @@ 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 dx.SignalsPlatform is SignalsPlatform assert dx.AudiencePlatform is AudiencePlatform + assert dx.CreativeBuilderPlatform is CreativeBuilderPlatform + assert dx.CreativeAdServerPlatform is CreativeAdServerPlatform # ---- SignalsPlatform ---- @@ -314,3 +321,235 @@ def list_creatives(self, req, ctx): return {} assert isinstance(_SalesShim(), SalesPlatform) + + +# ---- CreativeBuilderPlatform ---- + + +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 {} + + # Minimal impl satisfies the wire-required set but lacks the + # optional Protocol methods → strict isinstance is False. + 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 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_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 ---- + + +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", + }