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
2 changes: 2 additions & 0 deletions src/adcp/decisioning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def create_media_buy(
)
from adcp.decisioning.specialisms import (
AudiencePlatform,
CampaignGovernancePlatform,
CreativeAdServerPlatform,
CreativeBuilderPlatform,
SalesPlatform,
Expand Down Expand Up @@ -110,6 +111,7 @@ def create_media_buy(
"AdcpError",
"AudiencePlatform",
"AuthInfo",
"CampaignGovernancePlatform",
"CollectionList",
"CreativeAdServerPlatform",
"CreativeBuilderPlatform",
Expand Down
29 changes: 29 additions & 0 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,35 @@
"get_creative_delivery",
}
),
# Governance-AGENT specialisms — both share the unified
# ``CampaignGovernancePlatform`` Protocol. The spec's third
# governance slug, ``governance-aware-seller``, names a SELLER
# claim (sales-* archetype that composes with a governance agent
# via sync_governance + check_governance) — it does NOT
# implement CampaignGovernancePlatform. Stays unenforced until
# sync_governance handler shim wiring lands for sales adopters.
#
# SECURITY GATE: claiming any governance-* slug also requires
# ``capabilities.governance_aware=True`` — enforced independently
# by ``validate_platform`` against ``GOVERNANCE_SPECIALISMS``.
# Required-method coverage and governance-aware are independent
# gates; both fire.
"governance-spend-authority": frozenset(
{
"check_governance",
"sync_plans",
"report_plan_outcome",
"get_plan_audit_logs",
}
),
"governance-delivery-monitor": frozenset(
{
"check_governance",
"sync_plans",
"report_plan_outcome",
"get_plan_audit_logs",
}
),
}


Expand Down
17 changes: 14 additions & 3 deletions src/adcp/decisioning/specialisms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,33 @@
Stateful library + per-creative pricing + tag generation. Required
``build_creative``, ``preview_creative``, ``list_creatives``,
``get_creative_delivery``; optional ``sync_creatives``.
* :class:`CampaignGovernancePlatform` — covers
``governance-spend-authority`` + ``governance-delivery-monitor``.
Required ``check_governance``, ``sync_plans``,
``report_plan_outcome``, ``get_plan_audit_logs``. NOTE: a third
governance slug, ``governance-aware-seller``, names a SELLER claim
(sales-* archetype that composes with a governance agent) — it
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.

Remaining specialism Protocols (governance-*, brand-rights,
content-standards, property-lists, collection-lists) are added in
subsequent breadth-sprint PRs.
Remaining specialism Protocols (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.governance import CampaignGovernancePlatform
from adcp.decisioning.specialisms.sales import SalesPlatform
from adcp.decisioning.specialisms.signals import SignalsPlatform

__all__ = [
"AudiencePlatform",
"CampaignGovernancePlatform",
"CreativeAdServerPlatform",
"CreativeBuilderPlatform",
"SalesPlatform",
Expand Down
178 changes: 178 additions & 0 deletions src/adcp/decisioning/specialisms/governance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""CampaignGovernancePlatform Protocol — covers ``governance-spend-authority``
and ``governance-delivery-monitor``.

A governance agent making runtime decisions for advertiser campaigns
implements the methods on this Protocol. Today's spec splits the
governance-AGENT role across two specialism slugs differing only by
capability (gating spend vs monitoring delivery); both share the
same Protocol surface. When ``adcontextprotocol/adcp#3329`` lands and
the spec consolidates to a single ``campaign-governance`` slug, the
underlying type stays unchanged — only the slug map updates.

Mirrors the JS-side ``CampaignGovernancePlatform`` interface at
``src/lib/server/decisioning/specialisms/campaign-governance.ts``.

**Distinct from ``governance-aware-seller``.** That third
``governance-*`` slug names a SELLER claim — a sales-* archetype
that composes with a buyer's governance agent (calls
``check_governance``, accepts ``sync_governance``, propagates
approvals/conditions/denials). It does NOT implement
``CampaignGovernancePlatform`` itself; it integrates WITH a platform
that does. The framework's required-method coverage for
``governance-aware-seller`` is therefore unenforced — the slug
remains a "spec-recognized but unenforced" claim until/unless
sync_governance handler shim wiring lands for sales adopters.

**Security gate (foundation).** Adopters claiming any of the three
``governance-*`` slugs MUST set
``DecisioningCapabilities.governance_aware=True`` AND wire a custom
:class:`adcp.decisioning.StateReader` that returns real
:data:`adcp.decisioning.GovernanceContextJWS` values. The
foundation's :func:`adcp.decisioning.dispatch.validate_platform`
fails-fast at server boot if any governance-* slug is claimed
without ``governance_aware=True``. Required-method enforcement
(this PR) AND governance-aware enforcement (foundation) are
INDEPENDENT gates; both fire independently. A platform passing the
required-method gate but with ``governance_aware=False`` still
fails server boot — silent governance-gate skipping is a security
regression the framework refuses to ship.

Required methods (every governance-AGENT specialism):

* :meth:`check_governance` — runtime decision (approved / denied /
conditions). Sync.
* :meth:`sync_plans` — plan CRUD; buyers push their plans into the
agent so it can maintain spend authority + delivery context.
* :meth:`report_plan_outcome` — outcome reporting from sellers
(impressions delivered, spend incurred, transitions).
* :meth:`get_plan_audit_logs` — chronological audit log read.

Async story: every method is sync at the wire level — none of the
governance response schemas declare a ``Submitted`` arm. Slow
approval pipelines (operator review) return current state (e.g.,
``status: 'pending'``) and emit ``ctx.publish_status_change(
resource_type='plan', ...)`` when the human decision lands.
"""

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 (
CheckGovernanceRequest,
CheckGovernanceResponse,
GetPlanAuditLogsRequest,
GetPlanAuditLogsResponse,
ReportPlanOutcomeRequest,
ReportPlanOutcomeResponse,
SyncPlansRequest,
SyncPlansResponse,
)


#: Per-platform metadata generic.
TMeta = TypeVar("TMeta", default=dict[str, Any])


@runtime_checkable
class CampaignGovernancePlatform(Protocol, Generic[TMeta]):
"""Runtime governance decisioning for advertiser campaigns.

A decision API: the agent inspects a proposed action (or running
delivery) and returns ``approved``, ``denied``, or ``conditions``
(approved-if). Status changes (plan moving from
``pending_approval`` → ``active`` → ``closed``) flow via
``ctx.publish_status_change(resource_type='plan', ...)``.

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 (``PLAN_NOT_FOUND``, ``INVALID_REQUEST``, etc.). Use
the :meth:`check_governance` response ``status: 'denied'`` for
governance decisions that ARE the answer (the plan exists and
the agent is rejecting the action) — that's a legitimate
business outcome, not an error.
"""

def check_governance(
self,
req: CheckGovernanceRequest,
ctx: RequestContext[TMeta],
) -> MaybeAsync[CheckGovernanceResponse]:
"""Runtime governance decision.

Buyer (or seller, on the seller's behalf) sends a proposed
action; the agent inspects it against the plan and returns
approved / denied / conditions.

The ``phase`` field discriminates the context:

* ``'intent'`` — pre-action; agent decides whether the
proposed action is permitted at all.
* ``'delivery'`` — running campaign with actuals; agent
decides whether to allow further spend / new packages.
* ``'reconciliation'`` — post-flight; agent confirms the
campaign's outcome matches what was approved.

The agent's logic varies by phase.

:raises adcp.decisioning.AdcpError: for buyer-fixable
rejection (``PLAN_NOT_FOUND``, ``INVALID_REQUEST``).
``status: 'denied'`` on the response is the
governance-decision-as-answer path — not an error.
"""
...

def sync_plans(
self,
req: SyncPlansRequest,
ctx: RequestContext[TMeta],
) -> MaybeAsync[SyncPlansResponse]:
"""Plan CRUD with delta upsert semantics.

Buyers sync their campaign plans into the governance agent so
the agent can maintain spend authority + delivery context.
The agent tracks plan state across the campaign lifecycle
(pending_approval → active → closed); transitions are emitted
via ``ctx.publish_status_change(resource_type='plan', ...)``.
"""
...

def report_plan_outcome(
self,
req: ReportPlanOutcomeRequest,
ctx: RequestContext[TMeta],
) -> MaybeAsync[ReportPlanOutcomeResponse]:
"""Outcome reporting from sellers.

Sellers report what actually happened (impressions delivered,
spend incurred, status transitions) so the agent can
calibrate future decisions. Typically called at terminal
plan states or at agreed reconciliation cadences.
"""
...

def get_plan_audit_logs(
self,
req: GetPlanAuditLogsRequest,
ctx: RequestContext[TMeta],
) -> MaybeAsync[GetPlanAuditLogsResponse]:
"""Audit log read.

Returns the chronological history of governance decisions +
outcome reports for a plan. Buyers and operators use this to
reconstruct who approved what + when, what conditions were
attached, and what the seller reported.
"""
...


__all__ = ["CampaignGovernancePlatform"]
40 changes: 28 additions & 12 deletions tests/test_decisioning_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,17 @@ class _TypoPlatform(DecisioningPlatform):
def test_validate_platform_governance_aware_required_for_governance_specialism() -> None:
"""A platform claiming a governance-* specialism without setting
capabilities.governance_aware=True fails fast — silent gate
skipping is a security regression. (D15 round-4)"""
skipping is a security regression. (D15 round-4)

Use ``governance-aware-seller`` because it's in
GOVERNANCE_SPECIALISMS but NOT in REQUIRED_METHODS_PER_SPECIALISM
— isolates the governance-aware security gate from the
required-method gate (the latter is exercised in
``test_decisioning_specialisms.py``)."""

class _GovernanceWithoutOptInPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(
specialisms=["governance-spend-authority"],
specialisms=["governance-aware-seller"],
governance_aware=False,
)
accounts = SingletonAccounts(account_id="hello")
Expand All @@ -192,19 +198,25 @@ def test_validate_platform_governance_aware_optin_passes() -> None:
"""Platform with governance_aware=True passes validation. (The
real Stage-3 wiring will additionally require a custom
StateReader; that check is per-request, not boot-time, since the
StateReader is supplied by serve()/dispatch.)"""
StateReader is supplied by serve()/dispatch.)

Use ``governance-aware-seller`` to keep this test isolated from
required-method coverage (which is what
``test_decisioning_specialisms.py`` covers for the two
governance-AGENT slugs that DO have method-coverage rules)."""

class _GovernanceOptInPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(
specialisms=["governance-spend-authority"],
specialisms=["governance-aware-seller"],
governance_aware=True,
)
accounts = SingletonAccounts(account_id="hello")

# Note: governance-spend-authority isn't in
# REQUIRED_METHODS_PER_SPECIALISM yet (v6.0 ships only sales-*),
# so it'll emit an "unknown specialism" UserWarning. That's fine
# — the governance_aware flag is what we're testing here.
# ``governance-aware-seller`` is unenforced in
# REQUIRED_METHODS_PER_SPECIALISM (it's a SELLER claim, not a
# governance-AGENT slug — see governance.py module docstring),
# so it'll emit a "spec-recognized but unenforced" UserWarning.
# That's fine — the governance_aware flag is what we're testing.
with warnings.catch_warnings(record=True):
warnings.simplefilter("always", UserWarning)
validate_platform(_GovernanceOptInPlatform())
Expand Down Expand Up @@ -282,10 +294,14 @@ def test_validate_platform_warns_on_unenforced_spec_specialism() -> None:
real claim, just not method-checked.

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."""
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."""

class _UnenforcedSpecPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["brand-rights"])
Expand Down
Loading
Loading