diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 62faf2186..50a3cab22 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -78,6 +78,7 @@ def create_media_buy( ) from adcp.decisioning.specialisms import ( AudiencePlatform, + CampaignGovernancePlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, SalesPlatform, @@ -110,6 +111,7 @@ def create_media_buy( "AdcpError", "AudiencePlatform", "AuthInfo", + "CampaignGovernancePlatform", "CollectionList", "CreativeAdServerPlatform", "CreativeBuilderPlatform", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index ea97df4d4..c0577a43e 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -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", + } + ), } diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index 48e9861c4..25ec30f04 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -30,10 +30,19 @@ 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 @@ -41,11 +50,13 @@ 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", diff --git a/src/adcp/decisioning/specialisms/governance.py b/src/adcp/decisioning/specialisms/governance.py new file mode 100644 index 000000000..c45601b94 --- /dev/null +++ b/src/adcp/decisioning/specialisms/governance.py @@ -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"] diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 37fecb2b0..0c32f2e2a 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -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") @@ -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()) @@ -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"]) diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 9f25b1098..102592d8c 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -23,6 +23,7 @@ from adcp.decisioning import ( AudiencePlatform, + CampaignGovernancePlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, DecisioningCapabilities, @@ -41,7 +42,7 @@ def test_specialism_protocols_are_publicly_exported() -> None: - """All five Protocol classes (Batches 0–2) are on + """All six Protocol classes (Batches 0–3) are on ``adcp.decisioning.__all__`` so adopters import from the canonical public surface, not the internal ``adcp.decisioning.specialisms.*`` modules.""" @@ -52,10 +53,12 @@ def test_specialism_protocols_are_publicly_exported() -> None: assert "AudiencePlatform" in dx.__all__ assert "CreativeBuilderPlatform" in dx.__all__ assert "CreativeAdServerPlatform" in dx.__all__ + assert "CampaignGovernancePlatform" 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 # ---- SignalsPlatform ---- @@ -553,3 +556,143 @@ def test_creative_ad_server_distinct_from_builder() -> None: "list_creatives", "get_creative_delivery", } + + +# ---- CampaignGovernancePlatform ---- + + +def test_campaign_governance_runtime_checkable_full() -> None: + """A class with all four governance methods passes + ``isinstance`` against :class:`CampaignGovernancePlatform`.""" + + class _GovernanceImpl: + def check_governance(self, req, ctx): + return {"status": "approved"} + + def sync_plans(self, req, ctx): + return {"plans": []} + + def report_plan_outcome(self, req, ctx): + return {} + + def get_plan_audit_logs(self, req, ctx): + return {"logs": []} + + assert isinstance(_GovernanceImpl(), CampaignGovernancePlatform) + + +def _make_complete_governance_platform_class(governance_aware: bool): + """Helper: build a governance platform with all four required + methods + the given ``governance_aware`` flag.""" + + class _CompleteGovernancePlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["governance-spend-authority"], + governance_aware=governance_aware, + ) + accounts = SingletonAccounts(account_id="hello") + + def check_governance(self, req, ctx): + return {} + + def sync_plans(self, req, ctx): + return {} + + def report_plan_outcome(self, req, ctx): + return {} + + def get_plan_audit_logs(self, req, ctx): + return {} + + return _CompleteGovernancePlatform + + +def test_validate_platform_enforces_governance_spend_authority_methods() -> None: + """A platform claiming ``governance-spend-authority`` without + implementing the four required methods fails fast at server boot. + Use ``governance_aware=True`` to isolate the required-method gate + from the governance-aware security gate.""" + + class _PartialGovernancePlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["governance-spend-authority"], + governance_aware=True, + ) + accounts = SingletonAccounts(account_id="hello") + + # Implements only check_governance + sync_plans; + # missing report_plan_outcome + get_plan_audit_logs. + def check_governance(self, req, ctx): + return {} + + def sync_plans(self, req, ctx): + return {} + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialGovernancePlatform()) + assert exc_info.value.code == "INVALID_REQUEST" + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "report_plan_outcome" in missing_methods + assert "get_plan_audit_logs" in missing_methods + + +def test_validate_platform_passes_for_complete_governance_platform() -> None: + """Happy path — fully-implemented governance platform with + ``governance_aware=True`` passes both gates.""" + validate_platform(_make_complete_governance_platform_class(governance_aware=True)()) + + +def test_governance_security_gate_independent_of_required_methods() -> None: + """SECURITY REGRESSION GUARD: A platform with all four governance + methods present but ``governance_aware=False`` STILL fails server + boot. Required-method enforcement and governance-aware enforcement + are independent gates; both fire. + + Without this invariant, an adopter who happens to satisfy the + method coverage could silently skip the governance security gate + — which is the exact regression the foundation's + ``validate_platform`` was designed to prevent. This test pins + that the addition of required-method coverage in Batch 3 doesn't + accidentally short-circuit the security gate.""" + with pytest.raises(AdcpError) as exc_info: + validate_platform(_make_complete_governance_platform_class(governance_aware=False)()) + msg = str(exc_info.value).lower() + assert "governance" in msg + # Verify the failure is the governance-aware gate, NOT a + # required-methods complaint (the methods ARE all implemented). + assert "governance_aware" in str(exc_info.value) + + +def test_governance_specialisms_share_method_set() -> None: + """Both governance-AGENT specialisms gate on the same four + methods. Drift in REQUIRED_METHODS_PER_SPECIALISM here surfaces + as a visible test failure — they share the + CampaignGovernancePlatform Protocol surface.""" + expected = { + "check_governance", + "sync_plans", + "report_plan_outcome", + "get_plan_audit_logs", + } + assert REQUIRED_METHODS_PER_SPECIALISM["governance-spend-authority"] == expected + assert REQUIRED_METHODS_PER_SPECIALISM["governance-delivery-monitor"] == expected + + +def test_governance_aware_seller_is_not_a_governance_agent_protocol() -> None: + """``governance-aware-seller`` names a SELLER claim — a sales-* + archetype that composes with a governance agent. It does NOT + implement CampaignGovernancePlatform; it integrates WITH a + platform that does. The slug stays unenforced in + REQUIRED_METHODS_PER_SPECIALISM (no method-coverage rule) until + sync_governance handler shim wiring lands for sales adopters. + + Pins the architectural distinction: governance-aware-seller is + NOT in the REQUIRED_METHODS map; the other two governance-* slugs + ARE. All three remain in GOVERNANCE_SPECIALISMS for the + foundation's governance-aware security gate.""" + from adcp.decisioning import GOVERNANCE_SPECIALISMS + + assert "governance-aware-seller" in GOVERNANCE_SPECIALISMS + 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