diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 50a3cab22..cb63a0def 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -78,9 +78,13 @@ def create_media_buy( ) from adcp.decisioning.specialisms import ( AudiencePlatform, + BrandRightsPlatform, CampaignGovernancePlatform, + CollectionListsPlatform, + ContentStandardsPlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, + PropertyListsPlatform, SalesPlatform, SignalsPlatform, ) @@ -111,8 +115,11 @@ def create_media_buy( "AdcpError", "AudiencePlatform", "AuthInfo", + "BrandRightsPlatform", "CampaignGovernancePlatform", "CollectionList", + "CollectionListsPlatform", + "ContentStandardsPlatform", "CreativeAdServerPlatform", "CreativeBuilderPlatform", "DecisioningCapabilities", @@ -128,6 +135,7 @@ def create_media_buy( "Proposal", "PropertyList", "PropertyListReference", + "PropertyListsPlatform", "RequestContext", "ResourceResolver", "SalesPlatform", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index c0577a43e..8a130b249 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -273,6 +273,56 @@ "get_plan_audit_logs", } ), + # Brand-rights — identity discovery + licensing for branded + # inventory. Three required methods, all sync. ``acquire_rights`` + # has 3-arm discriminated success union (acquired / pending / + # rejected) — rejection-as-data, not AdcpError. + "brand-rights": frozenset( + { + "get_brand_identity", + "get_rights", + "acquire_rights", + } + ), + # Content-standards — brand safety policies, content adjacency + # rules, per-creative compliance. Six required methods (CRUD + + # calibration + delivery validation); analyzer reads + # (``get_media_buy_artifacts``, ``get_creative_features``) are + # optional and surface ``UNSUPPORTED_FEATURE`` to buyers when + # missing. + "content-standards": frozenset( + { + "list_content_standards", + "get_content_standards", + "create_content_standards", + "update_content_standards", + "calibrate_content", + "validate_content_delivery", + } + ), + # Property-lists / Collection-lists — list-publishing specialisms + # with parallel CRUD shapes. Each has 5 required methods (create, + # update, get, list, delete) on its respective list type. Tokens + # are scoped per-seller for revocation; compromise-driven + # revocation MUST trigger the delete path. + "property-lists": frozenset( + { + "create_property_list", + "update_property_list", + "get_property_list", + "list_property_lists", + "delete_property_list", + } + ), + "collection-lists": frozenset( + { + "create_collection_list", + "update_collection_list", + "get_collection_list", + "list_collection_lists", + "delete_collection_list", + } + ), } diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index 25ec30f04..001a9de6b 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -39,26 +39,49 @@ does NOT implement this Protocol; it integrates WITH a platform that does. That slug stays unenforced until sync_governance handler shim wiring lands for sales adopters. +* :class:`BrandRightsPlatform` — covers ``brand-rights``. Required + ``get_brand_identity``, ``get_rights``, ``acquire_rights`` (3-arm + discriminated success union: acquired / pending / rejected). +* :class:`ContentStandardsPlatform` — covers ``content-standards``. + 6 required CRUD + calibration + validation methods; 2 optional + analyzer reads (``get_media_buy_artifacts``, + ``get_creative_features``). +* :class:`PropertyListsPlatform` — covers ``property-lists``. + Standard 5-method CRUD with fetch-token issuance. +* :class:`CollectionListsPlatform` — covers ``collection-lists``. + Parallel CRUD shape on collection-list types. -Remaining specialism Protocols (brand-rights, content-standards, -property-lists, collection-lists) are added in subsequent -breadth-sprint PRs. +The breadth sprint is now COMPLETE — every spec specialism slug +except ``governance-aware-seller`` has REQUIRED_METHODS coverage +and a Protocol class. ``governance-aware-seller`` stays unenforced +by design (it's a SELLER composition claim, not a wire-implementor +claim — see :class:`CampaignGovernancePlatform` docstring). """ from __future__ import annotations from adcp.decisioning.specialisms.audience import AudiencePlatform +from adcp.decisioning.specialisms.brand_rights import BrandRightsPlatform +from adcp.decisioning.specialisms.content_standards import ContentStandardsPlatform from adcp.decisioning.specialisms.creative import CreativeBuilderPlatform from adcp.decisioning.specialisms.creative_ad_server import CreativeAdServerPlatform from adcp.decisioning.specialisms.governance import CampaignGovernancePlatform +from adcp.decisioning.specialisms.lists import ( + CollectionListsPlatform, + PropertyListsPlatform, +) from adcp.decisioning.specialisms.sales import SalesPlatform from adcp.decisioning.specialisms.signals import SignalsPlatform __all__ = [ "AudiencePlatform", + "BrandRightsPlatform", "CampaignGovernancePlatform", + "CollectionListsPlatform", + "ContentStandardsPlatform", "CreativeAdServerPlatform", "CreativeBuilderPlatform", + "PropertyListsPlatform", "SalesPlatform", "SignalsPlatform", ] diff --git a/src/adcp/decisioning/specialisms/brand_rights.py b/src/adcp/decisioning/specialisms/brand_rights.py new file mode 100644 index 000000000..37640a844 --- /dev/null +++ b/src/adcp/decisioning/specialisms/brand_rights.py @@ -0,0 +1,147 @@ +"""BrandRightsPlatform Protocol — covers ``brand-rights``. + +Brand-rights agents handle identity discovery + licensing for branded +inventory. Adopters: IP holders (sports leagues, movie studios), CTV +brand-rights desks, and brand-licensing marketplaces. + +The slug mirrors ``schemas/cache/enums/specialism.json``. + +Required methods (3, all sync): + +* :meth:`get_brand_identity` — sync read; brand catalog + identity record +* :meth:`get_rights` — sync read; rights matching a brand + use query +* :meth:`acquire_rights` — buyer commits to an offering; 3-arm + discriminated success union (acquired / pending / rejected). Async + outcomes for the ``pending`` arm flow via the buyer-supplied + ``push_notification_config`` webhook (NOT a polling tool — the spec + doesn't define one for this surface; do NOT reach for ``tasks_get``). + +Mirrors the JS-side ``BrandRightsPlatform`` interface at +``src/lib/server/decisioning/specialisms/brand-rights.ts``. + +Two other surfaces in this domain (``update_rights``, +``creative_approval``) are spec-published but not yet in +``AdcpToolMap``; adopters wire them via the merge seam (custom +handler) until they land in v6.1. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync + from adcp.types import ( + AcquireRightsAcquiredResponse, + AcquireRightsPendingResponse, + AcquireRightsRejectedResponse, + AcquireRightsRequest, + GetBrandIdentityRequest, + GetBrandIdentitySuccessResponse, + GetRightsRequest, + GetRightsSuccessResponse, + ) + + +#: Per-platform metadata generic. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + + +@runtime_checkable +class BrandRightsPlatform(Protocol, Generic[TMeta]): + """Brand identity discovery + rights licensing. + + 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 + REQUEST rejection (``REFERENCE_NOT_FOUND``, ``INVALID_REQUEST``, + ``BUDGET_TOO_LOW``). For spec-defined GRANT rejection (rights + unavailable in jurisdiction, talent dispute pending) return the + :class:`AcquireRightsRejectedResponse` arm so the buyer sees the + structured wire response with ``reason`` + ``suggestions``. + """ + + def get_brand_identity( + self, + req: GetBrandIdentityRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetBrandIdentitySuccessResponse]: + """Read brand identity record — ``brand_id``, ``house``, + localized ``names``, optional logos / industries / + ``keller_type``. Sync; no async ceremony. + + :raises adcp.decisioning.AdcpError: ``code='REFERENCE_NOT_FOUND'`` + when the brand reference doesn't resolve to an identity + the platform tracks. + """ + ... + + def get_rights( + self, + req: GetRightsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetRightsSuccessResponse]: + """List rights matching a brand + use query. + + Sync read; framework wraps the response in the wire envelope. + Returning an empty ``rights`` array is valid (= "no rights + available for the requested terms"); throw ``AdcpError`` only + for buyer-fixable rejection (e.g., unsupported jurisdiction). + + Note: the wire field is ``rights``, NOT ``offerings``. + Adopters who named their internal model ``offerings`` + translate at this seam. + """ + ... + + def acquire_rights( + self, + req: AcquireRightsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ + AcquireRightsAcquiredResponse | AcquireRightsPendingResponse | AcquireRightsRejectedResponse + ]: + """Acquire rights — buyer commits to an offering. + + Three discriminated wire arms: + + * :class:`AcquireRightsAcquiredResponse` — rights granted + immediately. Carries ``rights_id``, ``status: 'acquired'``, + ``brand_id``, ``terms``, ``generation_credentials`` + (scoped per-LLM-provider keys), and ``rights_constraint`` + so the buyer can plumb the grant directly into creative + generation. + * :class:`AcquireRightsPendingResponse` — clearance pending + counter-signature, legal review, or rights-holder approval. + Carries ``rights_id``, ``status: 'pending_approval'``, + ``brand_id``, plus optional ``detail`` and + ``estimated_response_time``. **Async delivery is + webhook-only** — the buyer's ``push_notification_config.url`` + receives the eventual ``Acquired`` or ``Rejected`` outcome. + The spec does NOT define a polling tool for + ``acquire_rights``; do not reach for ``tasks_get`` here. + * :class:`AcquireRightsRejectedResponse` — terminal rejection. + Carries ``rights_id``, ``status: 'rejected'``, ``brand_id``, + ``reason``, and optional ``suggestions[]`` for buyer + remediation. + + Pre-flight (catalog availability, agency authorization) MUST + run sync regardless of arm — invalid requests reject before + allocating any state. + + :raises adcp.decisioning.AdcpError: only for buyer-fixable + REQUEST rejection (``INVALID_REQUEST``, + ``BUDGET_TOO_LOW``). For GRANT rejection return the + :class:`AcquireRightsRejectedResponse` arm — that's the + structured business outcome path. + """ + ... + + +__all__ = ["BrandRightsPlatform"] diff --git a/src/adcp/decisioning/specialisms/content_standards.py b/src/adcp/decisioning/specialisms/content_standards.py new file mode 100644 index 000000000..3eee695ce --- /dev/null +++ b/src/adcp/decisioning/specialisms/content_standards.py @@ -0,0 +1,180 @@ +"""ContentStandardsPlatform Protocol — covers ``content-standards``. + +Content standards enforcement: brand safety policies, content adjacency +rules, and per-creative compliance verification. + +The slug mirrors ``schemas/cache/enums/specialism.json``. + +Two adopter shapes: + +* **Standalone content-standards agent** (Innovid-style): runs apart + from a sales agent; buyers call ``list_content_standards`` / + ``get_content_standards`` / ``validate_content_delivery`` directly. +* **Composed within a seller** (governance overlay): seller imports + content-standards into its agent surface, calls ``calibrate_content`` + internally during creative review, and surfaces violations through + ``sync_creatives`` review state. + +Both shapes use the same interface; difference is whether the +platform field is populated alongside ``sales`` or as the only +specialism. + +Required methods (6, all sync): + +* :meth:`list_content_standards` — discover published standards +* :meth:`get_content_standards` — read a single standard by id +* :meth:`create_content_standards` — create a new standard + (idempotent on buyer's ``idempotency_key``) +* :meth:`update_content_standards` — patch an existing standard +* :meth:`calibrate_content` — calibrate content against published + standards +* :meth:`validate_content_delivery` — post-flight conformance check + +Optional (analyzer reads — adopters without analyzer pipelines omit): + +* :meth:`get_media_buy_artifacts` — content artifacts produced during + flight (creative proofs, ad-server tags, completed log captures) +* :meth:`get_creative_features` — per-creative analyzed features + (object detection, scene classification, transcript) + +Mirrors the JS-side ``ContentStandardsPlatform`` interface at +``src/lib/server/decisioning/specialisms/content-standards.ts``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync + from adcp.types import ( + CalibrateContentRequest, + CalibrateContentResponse, + CreateContentStandardsRequest, + CreateContentStandardsResponse, + GetContentStandardsRequest, + GetContentStandardsResponse, + GetCreativeFeaturesRequest, + GetCreativeFeaturesResponse, + GetMediaBuyArtifactsRequest, + GetMediaBuyArtifactsResponse, + ListContentStandardsRequest, + ListContentStandardsResponse, + UpdateContentStandardsRequest, + UpdateContentStandardsResponse, + ValidateContentDeliveryRequest, + ValidateContentDeliveryResponse, + ) + + +#: Per-platform metadata generic. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + + +@runtime_checkable +class ContentStandardsPlatform(Protocol, Generic[TMeta]): + """Content standards CRUD + calibration + delivery validation. + + 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 (``REFERENCE_NOT_FOUND``, ``INVALID_REQUEST``, + ``POLICY_VIOLATION`` for buyer rights issues, etc.). + """ + + def list_content_standards( + self, + req: ListContentStandardsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ListContentStandardsResponse]: + """Discover content standards published by this agent.""" + ... + + def get_content_standards( + self, + req: GetContentStandardsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetContentStandardsResponse]: + """Read a single content standard by id.""" + ... + + def create_content_standards( + self, + req: CreateContentStandardsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[CreateContentStandardsResponse]: + """Create a new content standard. + + Adopter validates the policy schema and returns the persisted + record. Idempotent on the buyer's ``idempotency_key``. + """ + ... + + def update_content_standards( + self, + req: UpdateContentStandardsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[UpdateContentStandardsResponse]: + """Update an existing content standard.""" + ... + + def calibrate_content( + self, + req: CalibrateContentRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[CalibrateContentResponse]: + """Calibrate content against the published standards. + + Returns the standard's current calibration profile + any + flags raised against the submitted content. + """ + ... + + def validate_content_delivery( + self, + req: ValidateContentDeliveryRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ValidateContentDeliveryResponse]: + """Validate that a delivered media-buy / creative meets the + buyer's declared content-standards. + + Sellers call this post-flight to confirm adjacency and policy + conformance before issuing a + ``validate_content_delivery_artifact`` to a governance agent. + """ + ... + + # ---- Optional analyzer reads ---- + + def get_media_buy_artifacts( + self, + req: GetMediaBuyArtifactsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetMediaBuyArtifactsResponse]: + """Read content artifacts produced during a media buy's flight. + + Optional — adopters who don't expose artifact archival omit. + Required by governance receivers running adjacency validation. + """ + ... + + def get_creative_features( + self, + req: GetCreativeFeaturesRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetCreativeFeaturesResponse]: + """Read per-creative analyzed features (object detection, + scene classification, transcript) extracted during calibration. + + Optional — adopters without analyzer pipelines omit. + """ + ... + + +__all__ = ["ContentStandardsPlatform"] diff --git a/src/adcp/decisioning/specialisms/lists.py b/src/adcp/decisioning/specialisms/lists.py new file mode 100644 index 000000000..742ad8a2b --- /dev/null +++ b/src/adcp/decisioning/specialisms/lists.py @@ -0,0 +1,193 @@ +"""PropertyListsPlatform + CollectionListsPlatform — list-publishing +specialisms. + +Two distinct specialisms with parallel CRUD shapes, both publishing +authorized lists with token-issuance semantics: + +* **``property-lists``** — agent publishes/maintains authorized + property lists (which sellers can sell what for which advertisers; + buyer-side authorization graphs). Sellers FETCH and validate + against these. +* **``collection-lists``** — agent publishes/maintains authorized + collection lists (program/show-level brand safety via + IMDb / Gracenote / EIDR ids). Sellers FETCH and apply for + inventory filtering. + +Both have CRUD on the same shape — create, update, get, list, delete +— just on different list types. They could fold into one interface +if your adopter implements both; today they're separated to match +the spec specialism-per-list-type shape. + +Token-issuance semantics: ``create_*`` returns a one-time +``fetch_token`` sellers store in their secret manager; ``delete_*`` +revokes the token. Tokens are scoped per-seller for revocation; +compromise-driven revocation MUST also trigger the delete path. + +Mirrors the JS-side ``PropertyListsPlatform`` and +``CollectionListsPlatform`` interfaces at +``src/lib/server/decisioning/specialisms/lists.ts``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync + from adcp.types import ( + CreateCollectionListRequest, + CreateCollectionListResponse, + CreatePropertyListRequest, + CreatePropertyListResponse, + DeleteCollectionListRequest, + DeleteCollectionListResponse, + DeletePropertyListRequest, + DeletePropertyListResponse, + GetCollectionListRequest, + GetCollectionListResponse, + GetPropertyListRequest, + GetPropertyListResponse, + ListCollectionListsRequest, + ListCollectionListsResponse, + ListPropertyListsRequest, + ListPropertyListsResponse, + UpdateCollectionListRequest, + UpdateCollectionListResponse, + UpdatePropertyListRequest, + UpdatePropertyListResponse, + ) + + +#: Per-platform metadata generic. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + + +@runtime_checkable +class PropertyListsPlatform(Protocol, Generic[TMeta]): + """Property-list CRUD with fetch-token issuance semantics. + + 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 (``REFERENCE_NOT_FOUND``, ``POLICY_VIOLATION``). + """ + + def create_property_list( + self, + req: CreatePropertyListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[CreatePropertyListResponse]: + """Create a property list. + + Returns a ``fetch_token`` the buyer stores in their secret + manager. Token is scoped to this ``list_id``; MUST NOT be + reused across lists. + """ + ... + + def update_property_list( + self, + req: UpdatePropertyListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[UpdatePropertyListResponse]: + """Patch an existing property list.""" + ... + + def get_property_list( + self, + req: GetPropertyListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetPropertyListResponse]: + """Read a property list by id. + + Sellers call this with the ``fetch_token`` from + :meth:`create_property_list`. + """ + ... + + def list_property_lists( + self, + req: ListPropertyListsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ListPropertyListsResponse]: + """Discover property lists the caller is authorized to read.""" + ... + + def delete_property_list( + self, + req: DeletePropertyListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[DeletePropertyListResponse]: + """Delete a property list. + + MUST revoke the ``fetch_token`` immediately and signal cache + invalidation to sellers (reduced ``cache_valid_until`` or a + list-changed webhook). Compromise-driven revocation MUST + also trigger this path. + """ + ... + + +@runtime_checkable +class CollectionListsPlatform(Protocol, Generic[TMeta]): + """Collection-list CRUD with fetch-token issuance semantics. + + Parallel shape to :class:`PropertyListsPlatform`; covers + program-level brand-safety lists (shows, series, podcasts) keyed + by IMDb / Gracenote / EIDR ids. + + Same security model: ``create_*`` issues a per-seller + ``fetch_token``, ``delete_*`` revokes it; compromise-driven + revocation MUST trigger delete. + """ + + def create_collection_list( + self, + req: CreateCollectionListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[CreateCollectionListResponse]: + """Create a collection list. Returns a per-seller-scoped + ``fetch_token``.""" + ... + + def update_collection_list( + self, + req: UpdateCollectionListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[UpdateCollectionListResponse]: + """Patch an existing collection list.""" + ... + + def get_collection_list( + self, + req: GetCollectionListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetCollectionListResponse]: + """Read a collection list by id.""" + ... + + def list_collection_lists( + self, + req: ListCollectionListsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ListCollectionListsResponse]: + """Discover collection lists the caller is authorized to read.""" + ... + + def delete_collection_list( + self, + req: DeleteCollectionListRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[DeleteCollectionListResponse]: + """Delete a collection list. Revokes the ``fetch_token`` and + signals cache invalidation.""" + ... + + +__all__ = ["CollectionListsPlatform", "PropertyListsPlatform"] diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 0c32f2e2a..36da9e6e7 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -288,29 +288,36 @@ 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. ``brand-rights``) emits an "unenforced specialism" + """Spec-recognized specialism that the v6.0 framework doesn't + enforce method coverage for emits an "unenforced specialism" UserWarning — distinct from the "novel" warning, since it's a real claim, just not method-checked. - Use ``brand-rights`` here because ``signal-*`` / ``audience-sync`` - got method-coverage rules in Batch 1, ``creative-*`` got - coverage in Batch 2, and ``governance-spend-authority`` / - ``governance-delivery-monitor`` got coverage in Batch 3. - Brand-rights, content-standards, property-lists, - collection-lists, and ``governance-aware-seller`` (a SELLER - claim, not a governance-AGENT slug — distinct from the two - governance-AGENT slugs covered by CampaignGovernancePlatform) - are still pending until subsequent breadth-sprint batches.""" + After breadth-sprint Batch 4, ``governance-aware-seller`` is the + ONLY spec specialism slug staying unenforced — by design, since + it's a SELLER composition claim (a sales-* archetype that + integrates with a governance agent via sync_governance + + check_governance), NOT a wire-implementor claim. Adopters claim + it to signal "this seller composes with governance" without + implementing CampaignGovernancePlatform themselves. + + Note: ``governance-aware-seller`` is also in + GOVERNANCE_SPECIALISMS, so a platform claiming it without + ``governance_aware=True`` ALSO trips the security gate. This + test sets ``governance_aware=True`` so we hit the unenforced + warning path cleanly, isolated from the security gate.""" class _UnenforcedSpecPlatform(DecisioningPlatform): - capabilities = DecisioningCapabilities(specialisms=["brand-rights"]) + capabilities = DecisioningCapabilities( + specialisms=["governance-aware-seller"], + governance_aware=True, + ) 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 "brand-rights" in str(w.message)] + matched = [w for w in caught if "governance-aware-seller" 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 102592d8c..094bb6c6b 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -23,11 +23,15 @@ from adcp.decisioning import ( AudiencePlatform, + BrandRightsPlatform, CampaignGovernancePlatform, + CollectionListsPlatform, + ContentStandardsPlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, DecisioningCapabilities, DecisioningPlatform, + PropertyListsPlatform, SalesPlatform, SignalsPlatform, SingletonAccounts, @@ -42,10 +46,14 @@ def test_specialism_protocols_are_publicly_exported() -> None: - """All six Protocol classes (Batches 0–3) are on + """All ten Protocol classes (Batches 0–4) are on ``adcp.decisioning.__all__`` so adopters import from the canonical public surface, not the internal ``adcp.decisioning.specialisms.*`` - modules.""" + modules. + + Breadth sprint complete: every spec specialism slug except + ``governance-aware-seller`` now has a Protocol class + + REQUIRED_METHODS coverage.""" import adcp.decisioning as dx assert "SalesPlatform" in dx.__all__ @@ -54,11 +62,19 @@ def test_specialism_protocols_are_publicly_exported() -> None: assert "CreativeBuilderPlatform" in dx.__all__ assert "CreativeAdServerPlatform" in dx.__all__ assert "CampaignGovernancePlatform" in dx.__all__ + assert "BrandRightsPlatform" in dx.__all__ + assert "ContentStandardsPlatform" in dx.__all__ + assert "PropertyListsPlatform" in dx.__all__ + assert "CollectionListsPlatform" in dx.__all__ assert dx.SignalsPlatform is SignalsPlatform assert dx.AudiencePlatform is AudiencePlatform assert dx.CreativeBuilderPlatform is CreativeBuilderPlatform assert dx.CreativeAdServerPlatform is CreativeAdServerPlatform assert dx.CampaignGovernancePlatform is CampaignGovernancePlatform + assert dx.BrandRightsPlatform is BrandRightsPlatform + assert dx.ContentStandardsPlatform is ContentStandardsPlatform + assert dx.PropertyListsPlatform is PropertyListsPlatform + assert dx.CollectionListsPlatform is CollectionListsPlatform # ---- SignalsPlatform ---- @@ -696,3 +712,304 @@ def test_governance_aware_seller_is_not_a_governance_agent_protocol() -> None: assert "governance-aware-seller" not in REQUIRED_METHODS_PER_SPECIALISM assert "governance-spend-authority" in REQUIRED_METHODS_PER_SPECIALISM assert "governance-delivery-monitor" in REQUIRED_METHODS_PER_SPECIALISM + + +# ---- BrandRightsPlatform ---- + + +def test_brand_rights_runtime_checkable() -> None: + """A class with the three brand-rights methods passes + ``isinstance`` against :class:`BrandRightsPlatform`.""" + + class _BrandRightsImpl: + def get_brand_identity(self, req, ctx): + return {} + + def get_rights(self, req, ctx): + return {"rights": []} + + def acquire_rights(self, req, ctx): + return {} + + assert isinstance(_BrandRightsImpl(), BrandRightsPlatform) + + +def test_validate_platform_enforces_brand_rights_methods() -> None: + """A platform claiming ``brand-rights`` without all three + required methods fails fast at server boot.""" + + class _PartialBrandRightsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["brand-rights"]) + accounts = SingletonAccounts(account_id="hello") + + # Missing get_brand_identity + acquire_rights. + def get_rights(self, req, ctx): + return {} + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialBrandRightsPlatform()) + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "get_brand_identity" in missing_methods + assert "acquire_rights" in missing_methods + + +def test_brand_rights_required_methods_pinned() -> None: + """Contract test — ``brand-rights`` requires the three sync wire + tools per ``schemas/cache/brand/*``.""" + assert REQUIRED_METHODS_PER_SPECIALISM["brand-rights"] == { + "get_brand_identity", + "get_rights", + "acquire_rights", + } + + +# ---- ContentStandardsPlatform ---- + + +def test_content_standards_runtime_checkable_full() -> None: + """A class with all 8 content-standards methods (6 required + 2 + optional analyzer reads) passes the strict structural match.""" + + class _ContentStandardsImpl: + def list_content_standards(self, req, ctx): + return {} + + def get_content_standards(self, req, ctx): + return {} + + def create_content_standards(self, req, ctx): + return {} + + def update_content_standards(self, req, ctx): + return {} + + def calibrate_content(self, req, ctx): + return {} + + def validate_content_delivery(self, req, ctx): + return {} + + def get_media_buy_artifacts(self, req, ctx): + return {} + + def get_creative_features(self, req, ctx): + return {} + + assert isinstance(_ContentStandardsImpl(), ContentStandardsPlatform) + + +def test_validate_platform_enforces_content_standards_required_methods() -> None: + """A platform claiming ``content-standards`` without all six + required methods fails fast. Analyzer reads + (``get_media_buy_artifacts``, ``get_creative_features``) are + optional and don't gate server boot.""" + + class _PartialContentStandardsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["content-standards"]) + accounts = SingletonAccounts(account_id="hello") + + # Missing 4 of 6 required methods. + def list_content_standards(self, req, ctx): + return {} + + def get_content_standards(self, req, ctx): + return {} + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialContentStandardsPlatform()) + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "create_content_standards" in missing_methods + assert "update_content_standards" in missing_methods + assert "calibrate_content" in missing_methods + assert "validate_content_delivery" in missing_methods + + +def test_validate_platform_passes_content_standards_minimal() -> None: + """Minimal compliant ``content-standards`` adopter — implements + only the 6 required methods, no analyzer reads. Validates.""" + + class _MinimalContentStandardsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["content-standards"]) + accounts = SingletonAccounts(account_id="hello") + + def list_content_standards(self, req, ctx): + return {} + + def get_content_standards(self, req, ctx): + return {} + + def create_content_standards(self, req, ctx): + return {} + + def update_content_standards(self, req, ctx): + return {} + + def calibrate_content(self, req, ctx): + return {} + + def validate_content_delivery(self, req, ctx): + return {} + + validate_platform(_MinimalContentStandardsPlatform()) + + +def test_content_standards_required_methods_pinned() -> None: + """Contract test — ``content-standards`` requires 6 methods. + Analyzer reads are optional.""" + expected = { + "list_content_standards", + "get_content_standards", + "create_content_standards", + "update_content_standards", + "calibrate_content", + "validate_content_delivery", + } + assert REQUIRED_METHODS_PER_SPECIALISM["content-standards"] == expected + + +# ---- PropertyListsPlatform / CollectionListsPlatform ---- + + +def test_property_lists_runtime_checkable() -> None: + """A class with the 5 property-list CRUD methods passes the + structural match.""" + + class _PropertyListsImpl: + def create_property_list(self, req, ctx): + return {} + + def update_property_list(self, req, ctx): + return {} + + def get_property_list(self, req, ctx): + return {} + + def list_property_lists(self, req, ctx): + return {} + + def delete_property_list(self, req, ctx): + return {} + + assert isinstance(_PropertyListsImpl(), PropertyListsPlatform) + + +def test_collection_lists_runtime_checkable() -> None: + """A class with the 5 collection-list CRUD methods passes.""" + + class _CollectionListsImpl: + def create_collection_list(self, req, ctx): + return {} + + def update_collection_list(self, req, ctx): + return {} + + def get_collection_list(self, req, ctx): + return {} + + def list_collection_lists(self, req, ctx): + return {} + + def delete_collection_list(self, req, ctx): + return {} + + assert isinstance(_CollectionListsImpl(), CollectionListsPlatform) + + +def test_validate_platform_enforces_property_lists_methods() -> None: + """``property-lists`` requires all 5 CRUD methods (no optional).""" + + class _PartialPropertyListsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["property-lists"]) + accounts = SingletonAccounts(account_id="hello") + + # Missing delete_property_list — security-critical revocation + # path. The required-methods gate catches this at server boot + # so an adopter can't ship a list-publishing surface without + # the revocation primitive. + def create_property_list(self, req, ctx): + return {} + + def update_property_list(self, req, ctx): + return {} + + def get_property_list(self, req, ctx): + return {} + + def list_property_lists(self, req, ctx): + return {} + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialPropertyListsPlatform()) + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "delete_property_list" in missing_methods + + +def test_validate_platform_enforces_collection_lists_methods() -> None: + """``collection-lists`` mirrors ``property-lists`` — same 5-method + CRUD shape on collection-list types.""" + + class _MinimalCollectionListsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["collection-lists"]) + accounts = SingletonAccounts(account_id="hello") + + def create_collection_list(self, req, ctx): + return {} + + def update_collection_list(self, req, ctx): + return {} + + def get_collection_list(self, req, ctx): + return {} + + def list_collection_lists(self, req, ctx): + return {} + + def delete_collection_list(self, req, ctx): + return {} + + validate_platform(_MinimalCollectionListsPlatform()) + + +def test_lists_required_methods_pinned() -> None: + """Contract test — both list specialisms require their respective + 5-method CRUD set. Drift here surfaces as a visible failure + since the Protocol surfaces should track together.""" + assert REQUIRED_METHODS_PER_SPECIALISM["property-lists"] == { + "create_property_list", + "update_property_list", + "get_property_list", + "list_property_lists", + "delete_property_list", + } + assert REQUIRED_METHODS_PER_SPECIALISM["collection-lists"] == { + "create_collection_list", + "update_collection_list", + "get_collection_list", + "list_collection_lists", + "delete_collection_list", + } + + +# ---- Breadth-sprint completeness pin ---- + + +def test_every_spec_slug_except_governance_aware_seller_is_enforced() -> None: + """Breadth sprint complete: every spec specialism slug except + ``governance-aware-seller`` has a REQUIRED_METHODS_PER_SPECIALISM + entry. ``governance-aware-seller`` stays unenforced by design — + it's a SELLER composition claim (sales-* archetype that + integrates with a governance agent), NOT a wire-implementor + claim.""" + from adcp.decisioning.dispatch import SPEC_SPECIALISM_ENUM + + enforced = set(REQUIRED_METHODS_PER_SPECIALISM.keys()) + spec = set(SPEC_SPECIALISM_ENUM) + # ``signed-requests`` is deprecated per spec (moved to universal + # storyboards); not a Protocol-implementor claim. + unenforced = spec - enforced + assert unenforced == {"governance-aware-seller", "signed-requests"}, ( + f"Unexpected unenforced spec slugs: {unenforced}. After the " + "breadth sprint, only ``governance-aware-seller`` (SELLER " + "composition claim) and ``signed-requests`` (deprecated, " + "moved to universal storyboards) should be unenforced." + )