Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/adcp/decisioning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,13 @@ def create_media_buy(
)
from adcp.decisioning.specialisms import (
AudiencePlatform,
BrandRightsPlatform,
CampaignGovernancePlatform,
CollectionListsPlatform,
ContentStandardsPlatform,
CreativeAdServerPlatform,
CreativeBuilderPlatform,
PropertyListsPlatform,
SalesPlatform,
SignalsPlatform,
)
Expand Down Expand Up @@ -111,8 +115,11 @@ def create_media_buy(
"AdcpError",
"AudiencePlatform",
"AuthInfo",
"BrandRightsPlatform",
"CampaignGovernancePlatform",
"CollectionList",
"CollectionListsPlatform",
"ContentStandardsPlatform",
"CreativeAdServerPlatform",
"CreativeBuilderPlatform",
"DecisioningCapabilities",
Expand All @@ -128,6 +135,7 @@ def create_media_buy(
"Proposal",
"PropertyList",
"PropertyListReference",
"PropertyListsPlatform",
"RequestContext",
"ResourceResolver",
"SalesPlatform",
Expand Down
50 changes: 50 additions & 0 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
),
}


Expand Down
29 changes: 26 additions & 3 deletions src/adcp/decisioning/specialisms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
147 changes: 147 additions & 0 deletions src/adcp/decisioning/specialisms/brand_rights.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading