From b0ab5b1f46f2a80d1ea0234e0b644b1c45bdb005 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 00:14:44 +0000 Subject: [PATCH 1/2] feat!: wire the list_proposals and list_collections tasks Serve the experimental AdCP 3.3 reads from a DecisioningPlatform and close the gaps around them. - OpenOpportunitiesPlatform / CollectionCatalogPlatform hooks. PlatformHandler serves list_proposals when media_buy.lifecycle_tools lists it or open_opportunities.list_proposals is true, and list_collections when experimental_features lists media_buy.collection_catalog. Undeclared methods are not advertised; a declaration without a method fails validate_platform. list_collections resolves without an account. Both refuse a caller below 3.3 with UNSUPPORTED_FEATURE. - The dispatcher answers a task the caller's release does not define with UNSUPPORTED_FEATURE (details task, adcp_version) instead of VERSION_UNSUPPORTED, as the spec requires for list_collections. - The response-envelope helper treats both tasks like list_products: the 3.3 prereleases before beta.3 close their roots without status, so the bundled beta.1 / beta.2 list_collections examples now validate. - proposal.created: WebhookSender.prepare_proposal_created / send_proposal_created, schema-validated, notification_id bound to proposal_id. Agent-level notification configs classify the 3.3 account-anchored types, with a drift guard against the enum. - MCP tool stubs carry the 3.3 request fields; capability exports OpenOpportunities and LaterProposals; media-buy default trust scope covers both reads. BREAKING CHANGE: a server asked for a task its caller's release does not define now fails with UNSUPPORTED_FEATURE instead of VERSION_UNSUPPORTED. validate_platform fails when a platform declares list_proposals or media_buy.collection_catalog without the method. Agent-level notification registrations of proposal.created, proposal.offered, collection_card.*, collection_catalog.bulk_change or creative.staged_update_changed require all_authorized_accounts true. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- MIGRATION_v9_to_v10.md | 3 + docs/adcp-3.3-sdk-inventory.md | 6 +- docs/cross-version-conversion.md | 7 +- docs/proposal-surfaces.md | 22 ++ src/adcp/_notification_envelope.py | 3 + src/adcp/decisioning/__init__.py | 4 + src/adcp/decisioning/capabilities.py | 4 + src/adcp/decisioning/dispatch.py | 8 + src/adcp/decisioning/experimental_reads.py | 101 +++++ src/adcp/decisioning/handler.py | 44 +++ src/adcp/decisioning/platform_router.py | 4 + src/adcp/decisioning/specialisms/__init__.py | 9 +- src/adcp/decisioning/specialisms/sales.py | 49 +++ src/adcp/decisioning/trust_grants.py | 2 + src/adcp/server/base.py | 11 +- src/adcp/server/mcp_tools.py | 41 ++- src/adcp/server/principal.py | 11 +- src/adcp/types/capabilities.py | 4 + src/adcp/validation/_response_envelope.py | 8 +- src/adcp/webhook_sender.py | 49 +++ tests/test_cross_version_conversion.py | 4 +- tests/test_decisioning_handler_shims.py | 3 + tests/test_list_proposals_collections.py | 368 +++++++++++++++++++ tests/test_notification_preparation.py | 9 + 25 files changed, 754 insertions(+), 24 deletions(-) create mode 100644 src/adcp/decisioning/experimental_reads.py create mode 100644 tests/test_list_proposals_collections.py diff --git a/AGENTS.md b/AGENTS.md index 2c63b68b8b..c552274c42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,8 @@ Override these in your `ADCPHandler` subclass. Unimplemented methods return `not | `sync_principal` | protocol | SyncPrincipalRequest | Synchronize authenticated principal configuration | | `get_products` | media_buy | GetProductsRequest | Return ad products matching a brief | | `list_products` | media_buy | ListProductsRequest | List products with the compact lifecycle | -| `list_collections` | media_buy | ListCollectionsRequest | List collection cards (AdCP 3.3, experimental) | -| `list_proposals` | media_buy | ListProposalsRequest | List open opportunities and proposals (AdCP 3.3, experimental) | +| `list_collections` | media_buy | ListCollectionsRequest | List collection cards (AdCP 3.3, experimental `media_buy.collection_catalog`) | +| `list_proposals` | media_buy | ListProposalsRequest | List proposal snapshots and their dispositions (AdCP 3.3, experimental `media_buy.open_opportunities`) | | `request_proposals` | media_buy | RequestProposalsRequest | Request seller proposals | | `refine_proposals` | media_buy | RefineProposalsRequest | Refine seller proposals | | `decline_proposals` | media_buy | DeclineProposalsRequest | Decline seller proposals | diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index a4f374f860..00c771564b 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -15,6 +15,9 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk | AdCP 3.3.0-beta.2 adds separate-account outcomes, seller offers and measurement provenance | `RequestAccountRequest.allow_separate_account` (default `false`) lets a change request resolve as `created` (a new account carrying the change) or `matched` (an account the caller already holds), and the new correctable error code `ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT` reports a change the seller would take only on a new account. Experimental seller offers add `proposal_initiator`, `offer_reason` on `list_proposals` rows and the `proposal.offered` notification (`ProposalOfferedWebhook`); experimental measurement provenance adds `CollectionMechanism`, `counting_mechanisms` and `vendor_metric_provenance[]` (`VendorMetricProvenance`). The reporting-subscription fields settled after beta.1 (`standard_dimensions`, `unmapped_dimensions`, `inactive_intervals`, `prior_versions[].configuration`, per-offering `offering_id`, `previous_timezone`) are now generated. `principal_kind` and `expected_principal_kind` are deprecated. `NotificationConfig.product_payload_view` loses its schema default: it is `None` when unset, and a product subscription's dump no longer adds `product_payload_view: "legacy"` (absent means legacy); read `config.product_payload_view or "legacy"`. These are generated types, imported from their domain packages (for example `adcp.types.domains.core.ProposalOfferedWebhook`); no task was added. | | AdCP 3.3.0-beta.3 composes the protocol envelope on every task response | adcontextprotocol/adcp#8547: every response root composes `core/protocol-envelope.json`, which composes `core/version-envelope.json`, so generated responses inherit `ProtocolEnvelope` alone (no separate `AdcpVersionEnvelope` base) and the local copies of envelope fields are gone. Submitted, working and input-required bodies compose `core/task-submitted.json` / `task-working.json` / `task-input-required.json`: a submitted arm still pins `status` to `submitted` and requires `task_id`. `task_id` (non-empty) and the task-level `message` (at most 2,000 characters) each have one definition (`core/task-id.json`, `core/task-message.json`); fields that refer to them stay typed `str` with those constraints, so no caller has to wrap a value. `TaskId`, `TaskMessage`, `TaskSubmitted`, `TaskWorking` and `TaskInputRequired` are generated. Consequences for handlers: `list_products` and `decline_proposals` now carry `status` (the server stamps `completed`; the 3.3 → 3.2 conversion drops it for 3.2 callers, as a recorded loss, and the 3.2 → 3.3 conversion supplies it). Whether a release carries `status` on those two tools is read from that release's own response schema (`uses_task_status_envelope(tool, version)` asks whether it composes `core/protocol-envelope.json`), not from its version string, so a 3.2.x release that later composes the envelope needs no SDK change; under a release that does not, the typed model's defaulted `completed` is not sent (a closed 3.2 `list_products` root refuses it) and nothing is stripped for any other tool; an absent `replayed` reads as `false` and `replayed: false` is valid; and on governance responses `status` is the task status, so a `check_governance` result names its decision in `verdict` and `report_plan_outcome` in `outcome_state`, never in `status`. `create_mcp_webhook_payload` gives a `result` without `status` the webhook's task status, and replaces a model result's defaulted envelope status with it, so a failed task's result never reads `completed`. | | AdCP 3.3.0-beta.3 adds live creative updates, account-request money terms and reporting-subscription fields | Experimental `creative.live_updates` (`LiveUpdatePolicy`, `CreativeStagedUpdate`, `creative.staged_update_changed` and `CreativeStagedUpdateChangedWebhook`, error code `CREATIVE_LIVE_UPDATE_UNSUPPORTED` with `CreativeLiveUpdateUnsupportedDetails`). `request_account` outcomes carry `account` money terms on `matched` / `created`, `effective_at` on an `updated` money change, and `not_carried_over`. Reporting subscriptions gain `destination_overrides[]`, the paged readback (`prior_versions_count`, `accounts_with_exceptions_count`, `accounts_unauthorized_count`, `get_principal` `reporting_subscription_page`), `authorization.regranted_at`, `flight_history[]` and `cancellation_effective_at` on media buys, `available_from` / `withdrawn_at` on offerings and `stub` on billing obligations. `comply_test_controller` adds the `reporting_subscription_probe` scenario: override `TestControllerStore.reporting_subscription_probe` to serve it. Generated renames: `PriorVersion` is `ReportingSubscriptionPriorVersion`, `AccountsWithException` is `ReportingSubscriptionAccountExceptions`, and `AccountsUnauthorizedItem` is `ReportingSubscriptionUnauthorizedAccount`. | +| `list_proposals` and `list_collections` are served from declarations | The experimental 3.3 reads were already on `ADCPClient`, `ADCPHandler`, the adapters and the CLI. `PlatformHandler` now serves them: implement `list_proposals` (`OpenOpportunitiesPlatform`; declare it in `media_buy.lifecycle_tools` or `media_buy.open_opportunities.list_proposals`) or `list_collections` (`CollectionCatalogPlatform`; declare `media_buy.collection_catalog` and `media_buy.collection_cards` in `experimental_features`). A declaration with no method behind it now fails `validate_platform` at boot, and a method with no declaration is not advertised. `adcp.decisioning.capabilities` exports `OpenOpportunities` and `LaterProposals`. | +| A task the caller's release does not define fails with `UNSUPPORTED_FEATURE` | A server asked for a task its caller's negotiated release lacks (`list_proposals` or `list_collections` from a 3.2 buyer, `list_products` from a 3.0 buyer) used to answer `VERSION_UNSUPPORTED`; it now answers `UNSUPPORTED_FEATURE` with `details: {"task", "adcp_version"}`, as the spec requires for `list_collections`. The caller's version is supported, so re-pinning is not the fix. Buyers matching on `VERSION_UNSUPPORTED` for this case should match `UNSUPPORTED_FEATURE`. The SDK client already refused such a call before transport with `UNSUPPORTED_FEATURE`. | +| Agent-level notification configs classify the 3.3 account-anchored types | `sync_agent_notification_configs` / `sync_principal` now require `all_authorized_accounts: true` for `proposal.created`, `proposal.offered`, `collection_card.updated`, `collection_card.removed`, `collection_catalog.bulk_change` and `creative.staged_update_changed`, like the other account-anchored types; a registration without it is rejected. `WebhookSender.prepare_proposal_created` / `send_proposal_created` build the schema-validated `proposal.created` payload. | | The reporting-subscription surface is served from 3.3.0-beta.3 on, unprojected | The beta.1 / beta.2 field projections are gone, with the preview module that patched the fields onto the models: `project_offering`, `project_reporting_delivery`, `project_subscription_state`, `project_subscription_coverage` and `serves_settled_subscription_fields` are removed from `adcp.reporting.subscriptions`, and `ReportingSubscriptionEngine.subscription_coverage()` no longer takes `adcp_version`. One predicate, `adcp.reporting.subscriptions.serves_subscriptions(adcp_version)` (from `SUBSCRIPTIONS_MIN_ADCP_VERSION`, `3.3.0-beta.3`), gates the whole surface: capabilities, `get_reporting_status` coverage (including `status: "inactive"` with `deactivated_at`) and the principal readback. A caller served an earlier release gets none of it. The extensible `reporting-dimension` vocabulary accepts unknown values on selectors, offerings and the capability block. | | Reporting-subscription registration follows the 3.3.0-beta.3 rules | A caller may hold several pacing or analytics subscriptions per account and purpose (the lowest `subscription_id` owes a shared offering; the others record a package-level `overridden` exception), while billing stays one per account at a scope level and must use `match: exact` with `dimensions`. Membership edits (`scope.account_ids`, `exclude_account_ids`) no longer need a new `subscription_version`, and `client.reporting.subscribe` no longer bumps the version for them. `destination_overrides` must name existing destinations. The readback lists 16 prior versions and 1,000 accounts inline with `*_count` totals; page the rest with `get_principal` `reporting_subscription_page`. A partial grant refuses an unlisted reporting task with `SCOPE_INSUFFICIENT`. `AccountReady` gains `resolution`, `billing`, `billing_entity`, `payment_terms`, `effective_at` and `not_carried_over`. | | A server that restricts `supported_versions` runs on its newest listed release | Any server (`create_mcp_server`, `create_a2a_server`, `serve`, `MCPToolSet`, `ADCPAgentExecutor`, `create_tool_caller`, `PlatformHandler`, `create_adcp_server_from_platform`) with `supported_versions=["3.2"]` and no `adcp_version` runs its handlers on 3.2, instead of raising because the SDK default is outside the list. Pass `adcp_version` explicitly to choose another listed release. | diff --git a/docs/adcp-3.3-sdk-inventory.md b/docs/adcp-3.3-sdk-inventory.md index 7fd908226a..7c02a22d47 100644 --- a/docs/adcp-3.3-sdk-inventory.md +++ b/docs/adcp-3.3-sdk-inventory.md @@ -35,10 +35,10 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | Task | Schemas | Notes | SDK status | | --- | --- | --- | --- | -| `list_collections` | `media-buy/list-collections-request.json`, `-response.json` | Experimental collection cards: filters (`platform`, `genres_include`, `talent`), `cache_scope`, `outcome`. Lifecycle tool under `media_buy.collection_lists`. | types generated; client method, server tool registration, A2A skill, MCP tool list: (3), #1645 | -| `list_proposals` | `media-buy/list-proposals-request.json`, `-response.json` | Experimental `media_buy.open_opportunities`: `Opportunity`, `Proposal`, `Origin`, `CloseReason`, `Status`; filters by `media_buy_id`, `opportunity_id`, `proposal_id`. Added to `LifecycleTool` and to `account-change.json` `Task`. | types generated; client/server surface: (3), #1645 | +| `list_collections` | `media-buy/list-collections-request.json`, `-response.json` | Experimental collection cards (`media_buy.collection_catalog`, which requires `media_buy.collection_cards`): selectors, filters (`kinds`, `genres_include`, `talent`, ...), `if_collections_version`, `cache_scope`, `outcome` `listed` / `unchanged`. `account` optional; never provisions; a seller MUST refuse a caller below 3.3 with `UNSUPPORTED_FEATURE`. | done (#1645): client, multi-agent client, CLI, MCP/A2A adapters and tool list; `CollectionCatalogPlatform` hook on `PlatformHandler`, advertised and boot-validated from `experimental_features`; the dispatcher refuses a caller whose release lacks the task with `UNSUPPORTED_FEATURE` | +| `list_proposals` | `media-buy/list-proposals-request.json`, `-response.json` | Experimental `media_buy.open_opportunities`: snapshots beside `disposition`, `origin`, `later_reason` / `offer_reason`, plus `opportunities[]`; filters `opportunity_ids`, `media_buy_ids`, `proposal_ids`, `dispositions`. Added to `LifecycleTool` and to `account-change.json` `Task`. | done (#1645): client surface as above; `OpenOpportunitiesPlatform` hook, advertised and boot-validated from `lifecycle_tools` or `open_opportunities.list_proposals`. Serving it from a `ProposalStore` is deferred: the store keeps legacy `get_products` proposals, not canonical snapshots with dispositions, and has no account listing | | `get_property_list` over MCP | existing schema | Property list references resolve through AdCP tasks at `agent_url`, `auth_token` as Bearer, every page fetched and validated before a cached snapshot is replaced. | done: `property_lists` resolves over MCP with the list-scoped Bearer token and fetches every page before replacing a snapshot (verified by 3) | -| `proposal.created` webhook | `core/proposal-created-webhook.json`; `NotificationType` / `EventType` `proposal_created` | Seller-initiated proposals under `open_opportunities`. | types generated; webhook dispatch and `notification_configs` event validation: (3), #1645 | +| `proposal.created` webhook | `core/proposal-created-webhook.json`; `NotificationType` / `EventType` `proposal_created` | Seller-initiated proposals under `open_opportunities`. | done (#1645): `WebhookSender.prepare_proposal_created` / `send_proposal_created` (schema-validated, `notification_id` bound to `proposal_id`); agent-level registrations treat it, `proposal.offered`, `collection_card.*`, `collection_catalog.bulk_change` and `creative.staged_update_changed` as account-anchored | | `refine_proposals` submitted response | `core/async_response_refs/media_buy/refine_proposals_async_response_submitted.json` | New async `submitted` envelope for refinement with `expected_confirmation_at`. | types generated; async response dispatch table: (3), #1640 | ## 3. Changed tasks and request/response fields diff --git a/docs/cross-version-conversion.md b/docs/cross-version-conversion.md index c5133a2c29..86d7b63eea 100644 --- a/docs/cross-version-conversion.md +++ b/docs/cross-version-conversion.md @@ -34,7 +34,12 @@ that answer against the application schema (under the configured response mode) and converts it to the buyer's version: up (a 3.2 server answering a `3.3-beta.3` buyer), unchanged (a same-release buyer), or down (a 3.1 or 3.0 buyer). A conversion the target schema refuses fails as a structured -`VERSION_UNSUPPORTED` error rather than reaching the peer invalid. +`VERSION_UNSUPPORTED` error rather than reaching the peer invalid. A task the +buyer's release does not define at all (`list_proposals` or `list_collections` +from a 3.2 buyer) fails before the handler as `UNSUPPORTED_FEATURE` with +`details: {"task", "adcp_version"}`: the buyer's version is supported, the task +is not. `PlatformHandler` applies the same refusal to those two 3.3 reads when +the handler itself runs on an older release. So a production seller can keep its handler code on the stable 3.2 contract while the SDK's default is a pre-release: diff --git a/docs/proposal-surfaces.md b/docs/proposal-surfaces.md index 4c967f525e..a810b6bd50 100644 --- a/docs/proposal-surfaces.md +++ b/docs/proposal-surfaces.md @@ -152,6 +152,28 @@ Framework-managed `TaskHandoff` completions pass through the same validator. the registry directly would bypass response verification; use `TaskHandoff` or validate in an adopter-owned registry wrapper before exposing a custom flow. +## Reading proposals back (`list_proposals`, AdCP 3.3) + +`list_proposals` is the experimental `media_buy.open_opportunities` read: each +snapshot byte-for-byte as issued, beside its seller-tracked `disposition` +(`available`, `accepted`, `declined`, `expired`, `withdrawn`), plus the state of +the opportunities those proposals belong to. It is synchronous and never mutates +state, and it is the repair read for the `proposal.created` notification. + +On a `DecisioningPlatform`, implement `list_proposals(req, ctx)` +(`OpenOpportunitiesPlatform`) and declare it with `media_buy.lifecycle_tools` +containing `list_proposals` or +`media_buy=MediaBuy(open_opportunities=OpenOpportunities(list_proposals=True))`, +and list `media_buy.open_opportunities` in `experimental_features`. The framework +advertises the tool only for that declaration, fails boot when the declaration +has no method behind it, and refuses a caller below 3.3 with +`UNSUPPORTED_FEATURE`. Announce a later proposal with +`WebhookSender.prepare_proposal_created(...)` once it is readable. + +The framework does not yet serve `list_proposals` from a `ProposalStore`: the +store keeps the `get_products` proposal payloads it hydrates recipes from, not +canonical snapshots with dispositions, and it cannot list by account. + ## Also see - `proposals/product-architecture.md` — design rationale for the diff --git a/src/adcp/_notification_envelope.py b/src/adcp/_notification_envelope.py index b762de9a4c..f142495a4a 100644 --- a/src/adcp/_notification_envelope.py +++ b/src/adcp/_notification_envelope.py @@ -10,6 +10,7 @@ "account.change_recorded": "core/account-change-recorded-webhook.json", "capabilities.changed": "core/capabilities-changed-webhook.json", "principal.changed": "core/principal-changed-webhook.json", + "proposal.created": "core/proposal-created-webhook.json", "creative.status_changed": "creative/creative-status-changed-webhook.json", "creative.assignment_changed": "creative/creative-assignment-changed-webhook.json", "creative.purged": "creative/creative-purged-webhook.json", @@ -90,6 +91,8 @@ def validate_notification_body( error = next(validator.iter_errors(schema_payload), None) if error is not None: raise ValueError(f"notification envelope failed schema validation ({error.validator})") + if kind == "proposal.created" and payload["notification_id"] != payload["proposal_id"]: + raise ValueError("notification_id must equal proposal_id") if kind.startswith(("product.", "signal.")) or kind == "wholesale_feed.bulk_change": event = payload["event"] if payload["notification_id"] != event["event_id"]: diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 7071ae7d81..d180ad27a5 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -215,11 +215,13 @@ def create_media_buy( AudiencePlatform, BrandRightsPlatform, CampaignGovernancePlatform, + CollectionCatalogPlatform, CollectionListsPlatform, CompactMediaBuyPlatform, ContentStandardsPlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, + OpenOpportunitiesPlatform, OwnedSignalsPlatform, PropertyListsPlatform, SalesPlatform, @@ -406,6 +408,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "CampaignGovernancePlatform", "CREATIVE_ASSET_TRANSITIONS", "CollectionList", + "CollectionCatalogPlatform", "CollectionListsPlatform", "CompactMediaBuyPlatform", "ContentStandardsPlatform", @@ -442,6 +445,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "NoAuth", "OAuthCredential", "OBSERVED_MEDIA_BUY_TRANSITIONS", + "OpenOpportunitiesPlatform", "OwnedSignalsPlatform", "PermissionDeniedError", "PgProposalStore", diff --git a/src/adcp/decisioning/capabilities.py b/src/adcp/decisioning/capabilities.py index 588c414a82..4bbee67d5a 100644 --- a/src/adcp/decisioning/capabilities.py +++ b/src/adcp/decisioning/capabilities.py @@ -72,12 +72,14 @@ class HelloSeller(DecisioningPlatform): Identity, KeyOrigins, KeywordTargets, + LaterProposals, LifecycleTool, MatchingLatencyHours, Measurement, Metric, Modalities, NegativeKeywords, + OpenOpportunities, Portfolio, RequestSigning, SiCapabilities, @@ -147,6 +149,7 @@ class HelloSeller(DecisioningPlatform): "Identity", "KeyOrigins", "KeywordTargets", + "LaterProposals", "LifecycleTool", "MatchingLatencyHours", "MediaBuy", @@ -154,6 +157,7 @@ class HelloSeller(DecisioningPlatform): "Metric", "Modalities", "NegativeKeywords", + "OpenOpportunities", "Portfolio", "Preview", "PreviewRenderingOrigin", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index fd628926a4..43ebbcffa8 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -968,6 +968,14 @@ def validate_platform( method_name = entry.value if hasattr(entry, "value") else str(entry) if not _has_overridden_method(platform, method_name): missing.append(("media_buy.lifecycle_tools", method_name)) + # AdCP 3.3 experimental reads are served only when declared; a declaration + # needs the same-named platform method behind it. + from adcp.decisioning.experimental_reads import declared_experimental_reads + + reported = {method for _, method in missing} + for declaration, method_name in declared_experimental_reads(platform.capabilities): + if method_name not in reported and not _has_overridden_method(platform, method_name): + missing.append((declaration, method_name)) if unknown: # Three buckets: diff --git a/src/adcp/decisioning/experimental_reads.py b/src/adcp/decisioning/experimental_reads.py new file mode 100644 index 0000000000..5a76f71b31 --- /dev/null +++ b/src/adcp/decisioning/experimental_reads.py @@ -0,0 +1,101 @@ +"""Declaration gates for the AdCP 3.3 experimental read tasks. + +``list_proposals`` (``media_buy.open_opportunities``) and ``list_collections`` +(``media_buy.collection_catalog``) are synchronous, side-effect-free reads +added in AdCP 3.3. Neither exists in an earlier release, and a seller serves +each one only when its capabilities declare it: + +* ``list_proposals``: ``media_buy.lifecycle_tools`` contains + ``list_proposals``, or ``media_buy.open_opportunities.list_proposals`` is + ``true``. Sellers list ``media_buy.open_opportunities`` in + ``experimental_features``. +* ``list_collections``: ``experimental_features`` contains + ``media_buy.collection_catalog`` (the capabilities schema requires + ``media_buy.collection_cards`` beside it). + +:class:`~adcp.decisioning.handler.PlatformHandler` advertises a tool only for +such a declaration backed by a platform method, and +:func:`~adcp.decisioning.dispatch.validate_platform` fails boot when a +declaration has no method behind it. The spec forbids serving +``list_collections`` to a caller below 3.3 (``UNSUPPORTED_FEATURE``); +:func:`refuse_caller_below_release` applies the same rule to both tasks for +servers that run their handlers on an older release, where no conversion +step refuses the call first. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from adcp.decisioning.types import AdcpError + +if TYPE_CHECKING: + from adcp.decisioning.platform import DecisioningCapabilities + +#: Experimental feature id that declares ``list_collections``. +COLLECTION_CATALOG_FEATURE = "media_buy.collection_catalog" + +#: The first release that defines either task. +FIRST_RELEASE = "3.3.0-beta.0" + +#: The experimental read tools; each platform method has the tool's name. +EXPERIMENTAL_READ_TOOLS: frozenset[str] = frozenset({"list_proposals", "list_collections"}) + + +def _value(entry: Any) -> str: + raw = getattr(entry, "value", entry) + root = getattr(raw, "root", raw) + return str(root) + + +def declares_list_proposals(capabilities: DecisioningCapabilities) -> bool: + """Whether the capabilities declare ``list_proposals``.""" + media_buy = capabilities.media_buy + if media_buy is None: + return False + lifecycle = getattr(media_buy, "lifecycle_tools", None) or [] + if any(_value(entry) == "list_proposals" for entry in lifecycle): + return True + open_opportunities = getattr(media_buy, "open_opportunities", None) + return getattr(open_opportunities, "list_proposals", None) is True + + +def declares_collection_catalog(capabilities: DecisioningCapabilities) -> bool: + """Whether the capabilities declare ``list_collections``.""" + features = capabilities.experimental_features or [] + return any(_value(entry) == COLLECTION_CATALOG_FEATURE for entry in features) + + +def declared_experimental_reads(capabilities: DecisioningCapabilities) -> list[tuple[str, str]]: + """``(declaration, method)`` pairs for each experimental read declared.""" + declared: list[tuple[str, str]] = [] + if declares_list_proposals(capabilities): + declared.append(("media_buy.open_opportunities.list_proposals", "list_proposals")) + if declares_collection_catalog(capabilities): + declared.append( + (f"experimental_features[{COLLECTION_CATALOG_FEATURE}]", "list_collections") + ) + return declared + + +def refuse_caller_below_release(tool_name: str, version: str | None) -> None: + """Fail a 3.3-only read for a caller whose release predates it. + + ``version`` is the caller's resolved wire release; ``None`` (no + dispatcher, or an unversioned in-process call) is served. + """ + if version is None: + return + from adcp._version import is_adcp_version_at_least + + try: + served = is_adcp_version_at_least(version, FIRST_RELEASE) + except ValueError: + served = False + if not served: + raise AdcpError( + "UNSUPPORTED_FEATURE", + message=f"{tool_name} requires AdCP 3.3 or later; the request negotiated {version}", + recovery="correctable", + details={"task": tool_name, "adcp_version": version}, + ) diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index d34edc19b5..7b6f0ec46f 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -66,6 +66,11 @@ _invoke_platform_method, ) from adcp.decisioning.domain_lists import bound_capability_domain_lists +from adcp.decisioning.experimental_reads import ( + EXPERIMENTAL_READ_TOOLS, + declared_experimental_reads, + refuse_caller_below_release, +) from adcp.decisioning.implementation_config import ProductConfigStore from adcp.decisioning.measurement_terms import check_advertised_measurement_terms from adcp.decisioning.negotiation import ( @@ -201,6 +206,8 @@ class _PublicAccount(_DecisioningAccount[Any]): ListAccountsResponse, ListCollectionListsRequest, ListCollectionListsResponse, + ListCollectionsRequest, + ListCollectionsResponse, ListContentStandardsRequest, ListContentStandardsResponse, ListCreativesRequest, @@ -209,6 +216,8 @@ class _PublicAccount(_DecisioningAccount[Any]): ListProductsResponse, ListPropertyListsRequest, ListPropertyListsResponse, + ListProposalsRequest, + ListProposalsResponse, ListTasksRequest, ProvidePerformanceFeedbackRequest, ProvidePerformanceFeedbackResponse, @@ -363,6 +372,8 @@ def _is_adcp_32_or_newer(version: str | None) -> bool: "buy_products", "accept_proposal", "control_media_buy", + # AdCP 3.3 adds list_proposals to the lifecycle_tools enum. + "list_proposals", } ) #: Account roster surface unioned into every sales-* claim. Per the @@ -1318,6 +1329,7 @@ class PlatformHandler(ADCPHandler[ToolContext]): | set(_CONTENT_STANDARDS_ADVERTISED_TOOLS) | set(_PROPERTY_LISTS_ADVERTISED_TOOLS) | set(_COLLECTION_LISTS_ADVERTISED_TOOLS) + | set(EXPERIMENTAL_READ_TOOLS) | { "get_reporting_status", "sync_reporting_status", @@ -1380,6 +1392,9 @@ def advertised_tools_for_instance(self) -> frozenset[str]: self._platform, tool_name ): serving.add(tool_name) + for _declaration, method_name in declared_experimental_reads(self._platform.capabilities): + if _implements_optional_method(self._platform, method_name): + serving.add(method_name) if not serving: # Preserve the existing class-universe fallback for novel specialisms. serving.update(self.advertised_tools) @@ -1725,6 +1740,7 @@ async def _resolve_account( "request_proposals", "refine_proposals", "decline_proposals", + "list_collections", }: return _PublicAccount(id="") raise AdcpError( @@ -2496,6 +2512,34 @@ async def control_media_buy( # type: ignore[override] await self._invoke_compact_lifecycle("control_media_buy", params, context), ) + async def list_proposals( # type: ignore[override] + self, params: ListProposalsRequest, context: ToolContext | None = None + ) -> ListProposalsResponse: + """AdCP 3.3 experimental proposal read; the spec makes it synchronous.""" + refuse_caller_below_release( + "list_proposals", context.resolved_adcp_version if context is not None else None + ) + return cast( + "ListProposalsResponse", + await self._invoke_compact_lifecycle( + "list_proposals", params, context, allow_workflow_handoff=False + ), + ) + + async def list_collections( # type: ignore[override] + self, params: ListCollectionsRequest, context: ToolContext | None = None + ) -> ListCollectionsResponse: + """AdCP 3.3 experimental collection-card read; ``account`` is optional.""" + refuse_caller_below_release( + "list_collections", context.resolved_adcp_version if context is not None else None + ) + return cast( + "ListCollectionsResponse", + await self._invoke_compact_lifecycle( + "list_collections", params, context, allow_workflow_handoff=False + ), + ) + async def get_task_status( # type: ignore[override] self, params: GetTaskStatusRequest, diff --git a/src/adcp/decisioning/platform_router.py b/src/adcp/decisioning/platform_router.py index f650a9c7df..c126da73eb 100644 --- a/src/adcp/decisioning/platform_router.py +++ b/src/adcp/decisioning/platform_router.py @@ -110,11 +110,13 @@ AudiencePlatform, BrandRightsPlatform, CampaignGovernancePlatform, + CollectionCatalogPlatform, CollectionListsPlatform, CompactMediaBuyPlatform, ContentStandardsPlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, + OpenOpportunitiesPlatform, OwnedSignalsPlatform, PropertyListsPlatform, SalesPlatform, @@ -169,6 +171,8 @@ async def _run_sync_delegate(method: Any, *args: Any, **kwargs: Any) -> Any: _KNOWN_SPECIALISM_PROTOCOLS: tuple[type, ...] = ( SalesPlatform, CompactMediaBuyPlatform, + OpenOpportunitiesPlatform, + CollectionCatalogPlatform, OwnedSignalsPlatform, SignalsPlatform, AudiencePlatform, diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index a9aefc5fff..f599ad08c7 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -73,18 +73,25 @@ CollectionListsPlatform, PropertyListsPlatform, ) -from adcp.decisioning.specialisms.sales import CompactMediaBuyPlatform, SalesPlatform +from adcp.decisioning.specialisms.sales import ( + CollectionCatalogPlatform, + CompactMediaBuyPlatform, + OpenOpportunitiesPlatform, + SalesPlatform, +) from adcp.decisioning.specialisms.signals import OwnedSignalsPlatform, SignalsPlatform __all__ = [ "AudiencePlatform", "BrandRightsPlatform", "CampaignGovernancePlatform", + "CollectionCatalogPlatform", "CollectionListsPlatform", "CompactMediaBuyPlatform", "ContentStandardsPlatform", "CreativeAdServerPlatform", "CreativeBuilderPlatform", + "OpenOpportunitiesPlatform", "OwnedSignalsPlatform", "PropertyListsPlatform", "SalesPlatform", diff --git a/src/adcp/decisioning/specialisms/sales.py b/src/adcp/decisioning/specialisms/sales.py index 562a453fa2..5a5cf02f6f 100644 --- a/src/adcp/decisioning/specialisms/sales.py +++ b/src/adcp/decisioning/specialisms/sales.py @@ -82,10 +82,14 @@ GetMediaBuysResponse, GetProductsRequest, GetProductsResponse, + ListCollectionsRequest, + ListCollectionsResponse, ListCreativesRequest, ListCreativesResponse, ListProductsRequest, ListProductsResponse, + ListProposalsRequest, + ListProposalsResponse, ProvidePerformanceFeedbackRequest, ProvidePerformanceFeedbackResponse, RefineProposalsRequest, @@ -152,6 +156,51 @@ def control_media_buy( ) -> SalesResult[ControlMediaBuyResponse]: ... +@runtime_checkable +class OpenOpportunitiesPlatform(Protocol, Generic[TMeta]): + """Optional AdCP 3.3 ``list_proposals`` read (experimental). + + ``list_proposals`` returns each stored proposal snapshot byte-for-byte as + issued, beside its seller-tracked ``disposition``, plus the current state + of the opportunities those proposals belong to. It completes synchronously + and never mutates proposal or opportunity state, so return the response + directly; there is no task handoff. + + Declare it with ``media_buy.lifecycle_tools`` containing + ``list_proposals`` or ``media_buy.open_opportunities.list_proposals=True``, + and list ``media_buy.open_opportunities`` in ``experimental_features``. + The framework advertises the tool only for such a declaration, refuses a + caller below AdCP 3.3 with ``UNSUPPORTED_FEATURE``, and fails boot when + the declaration names a method the platform does not implement. + """ + + def list_proposals( + self, req: ListProposalsRequest, ctx: RequestContext[TMeta] + ) -> MaybeAsync[ListProposalsResponse]: ... + + +@runtime_checkable +class CollectionCatalogPlatform(Protocol, Generic[TMeta]): + """Optional AdCP 3.3 ``list_collections`` read (experimental). + + Returns the seller's collection cards (``core/response-collection.json`` + copies) by key or with filters, never products. ``account`` is optional: + without it the response is ``cache_scope: public``. The call is side + effect free and never provisions an account. + + Declare it by listing ``media_buy.collection_catalog`` (and + ``media_buy.collection_cards``, which the capabilities schema requires + alongside it) in ``experimental_features``. The framework advertises the + tool only for that declaration, refuses a caller below AdCP 3.3 with + ``UNSUPPORTED_FEATURE`` as the spec requires, and fails boot when the + declaration has no ``list_collections`` method behind it. + """ + + def list_collections( + self, req: ListCollectionsRequest, ctx: RequestContext[TMeta] + ) -> MaybeAsync[ListCollectionsResponse]: ... + + @runtime_checkable class SalesPlatform(Protocol, Generic[TMeta]): """Unified hybrid interface for every ``sales-*`` specialism. diff --git a/src/adcp/decisioning/trust_grants.py b/src/adcp/decisioning/trust_grants.py index 6c5d67400c..1ed0dd242e 100644 --- a/src/adcp/decisioning/trust_grants.py +++ b/src/adcp/decisioning/trust_grants.py @@ -37,9 +37,11 @@ class TrustGrantClaim: "sync_accounts", "get_products", "list_products", + "list_collections", "request_proposals", "refine_proposals", "decline_proposals", + "list_proposals", "buy_products", "accept_proposal", "create_media_buy", diff --git a/src/adcp/server/base.py b/src/adcp/server/base.py index ca05faaeef..6263809abc 100644 --- a/src/adcp/server/base.py +++ b/src/adcp/server/base.py @@ -526,18 +526,21 @@ async def get_media_buys( async def list_collections( self, params: ListCollectionsRequest | dict[str, Any], context: TContext | None = None ) -> Any: - """List collection cards (AdCP 3.3, experimental ``media_buy.collection_lists``). + """List collection cards (AdCP 3.3, experimental ``media_buy.collection_catalog``). - Override this to serve collection cards. + Override this to serve collection cards. The spec forbids serving the + task to a caller below 3.3; the dispatcher refuses such a caller with + ``UNSUPPORTED_FEATURE`` before the handler runs. """ return self._not_supported("list_collections") async def list_proposals( self, params: ListProposalsRequest | dict[str, Any], context: TContext | None = None ) -> Any: - """List open opportunities and proposals (AdCP 3.3, experimental). + """List proposal snapshots and their dispositions (AdCP 3.3, experimental). - Override this to serve ``media_buy.open_opportunities``. + Override this to serve ``media_buy.open_opportunities``. The task is + synchronous and never mutates proposal or opportunity state. """ return self._not_supported("list_proposals") diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index b6fb619542..d8f60d595a 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -490,30 +490,35 @@ def _widen_media_buy_output_schema_for_legacy_statuses( }, { "name": "list_collections", - "description": "List collection cards a seller publishes (AdCP 3.3, experimental media_buy.collection_lists). Filter by platform, genres or talent.", + "description": "List a seller's collection cards (shows, creators, podcasts) by key or with filters, without products (AdCP 3.3, experimental media_buy.collection_catalog).", "annotations": _RO, "inputSchema": { "type": "object", "properties": { "account": {"type": "object"}, + "collections": {"type": "array", "items": {"type": "object"}}, "filters": {"type": "object"}, "pagination": {"type": "object"}, + "if_collections_version": {"type": "string"}, }, }, }, { "name": "list_proposals", - "description": "List open opportunities and the proposals under them (AdCP 3.3, experimental media_buy.open_opportunities). Filter by media_buy_id, opportunity_id or proposal_id.", + "description": "List the seller's current proposals and their lifecycle state for an account or opportunity (AdCP 3.3, experimental media_buy.open_opportunities).", "annotations": _RO, "inputSchema": { "type": "object", "properties": { "account": {"type": "object"}, - "media_buy_id": {"type": "string"}, - "opportunity_id": {"type": "string"}, - "proposal_id": {"type": "string"}, - "pagination": {"type": "object"}, + "opportunity_ids": {"type": "array", "items": {"type": "string"}}, + "media_buy_ids": {"type": "array", "items": {"type": "string"}}, + "proposal_ids": {"type": "array", "items": {"type": "string"}}, + "dispositions": {"type": "array", "items": {"type": "string"}}, + "cursor": {"type": "string"}, + "max_results": {"type": "integer"}, }, + "required": ["account"], }, }, { @@ -3509,7 +3514,7 @@ def create_tool_caller( detect_wire_version, ) from adcp.validation.schema_errors import build_adcp_validation_error_payload - from adcp.validation.schema_loader import Direction + from adcp.validation.schema_loader import Direction, get_portable_schema from adcp.validation.schema_validator import ( format_issues, validate_request, @@ -3589,6 +3594,28 @@ def convert_version_payload( loss_log_level=logging.DEBUG, ) except ADCPFeatureUnsupportedError as exc: + if ( + direction == "request" + and not isinstance(exc, ADCPVersionConversionError) + and get_portable_schema(method_name, "request", version=source_version) is None + ): + # The caller's release does not define this task (list_proposals + # or list_collections for a 3.2 caller). Its version is + # supported; the task is not, which the spec reports as + # UNSUPPORTED_FEATURE (list_collections requires it). + raise ADCPTaskError( + operation=method_name, + errors=[ + Error( + code="UNSUPPORTED_FEATURE", + message=( + f"{method_name} is not defined in AdCP {source_version}; " + "negotiate a release that defines it" + ), + details={"task": method_name, "adcp_version": source_version}, + ) + ], + ) from exc details = None if isinstance(exc, ADCPVersionConversionError): details = { diff --git a/src/adcp/server/principal.py b/src/adcp/server/principal.py index e52579b73b..4ae94c8e0f 100644 --- a/src/adcp/server/principal.py +++ b/src/adcp/server/principal.py @@ -326,6 +326,13 @@ def __init__(self, code: str, message: str) -> None: "reporting.ledger_changed", "reporting.delivery_ready", "reporting.status_changed", + # AdCP 3.3 experimental account-anchored types. + "creative.staged_update_changed", + "collection_card.updated", + "collection_card.removed", + "collection_catalog.bulk_change", + "proposal.created", + "proposal.offered", } ) _SECRET_COORDINATE_RE = re.compile( @@ -740,9 +747,7 @@ async def _sync_principal( action = ( "would_clear" if changed and cleared - else "would_update" - if changed - else "would_be_unchanged" + else "would_update" if changed else "would_be_unchanged" ) return SyncPrincipalResponse.model_validate( { diff --git a/src/adcp/types/capabilities.py b/src/adcp/types/capabilities.py index dfca1b03b4..b021702132 100644 --- a/src/adcp/types/capabilities.py +++ b/src/adcp/types/capabilities.py @@ -67,12 +67,14 @@ Identity, KeyOrigins, KeywordTargets, + LaterProposals, LifecycleTool, MatchingLatencyHours, Measurement, Metric, Modalities, NegativeKeywords, + OpenOpportunities, Portfolio, RequestSigning, Signals, @@ -268,6 +270,7 @@ class MediaBuy(_MediaBuy): "Identity", "KeyOrigins", "KeywordTargets", + "LaterProposals", "LifecycleTool", "LegacyPostalCodeSystem", "MatchingLatencyHours", @@ -275,6 +278,7 @@ class MediaBuy(_MediaBuy): "Metric", "Modalities", "NegativeKeywords", + "OpenOpportunities", "Portfolio", "RequestSigning", "SiCapabilities", diff --git a/src/adcp/validation/_response_envelope.py b/src/adcp/validation/_response_envelope.py index 58c999a331..0f85f2c315 100644 --- a/src/adcp/validation/_response_envelope.py +++ b/src/adcp/validation/_response_envelope.py @@ -3,8 +3,10 @@ Through AdCP 3.2, ``list_products`` forbids ``status`` outright and the synchronous ``decline_proposals`` arm permits it only on its submitted arm, so grafting ``completed`` onto either makes the result fail the pinned response -schema. From 3.3.0-beta.3 every task response and async status payload -composes ``core/protocol-envelope.json`` (adcontextprotocol/adcp#8547), whose +schema. The 3.3 prereleases before beta.3 add ``list_proposals`` and +``list_collections`` with closed roots that forbid it too. From 3.3.0-beta.3 +every task response and async status payload composes +``core/protocol-envelope.json`` (adcontextprotocol/adcp#8547), whose ``status`` is required, so the exception ends. The served release's own response schema decides, not its version string, so a @@ -21,6 +23,8 @@ { "list_products", "decline_proposals", + "list_proposals", + "list_collections", } ) diff --git a/src/adcp/webhook_sender.py b/src/adcp/webhook_sender.py index cbc6432d97..2e3f68d8bb 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -1094,6 +1094,55 @@ async def send_principal_changed( experimental_agent_identity=experimental_agent_identity, ) + @staticmethod + def prepare_proposal_created( + *, + url: str, + payload: Mapping[str, Any] | BaseModel, + idempotency_key: str | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> PreparedWebhook: + """Prepare a schema-validated ``proposal.created`` notification. + + AdCP 3.3, experimental ``media_buy.open_opportunities``: fire it after a + later proposal is readable through ``list_proposals``. The payload + carries identifiers only. The type, fire timestamp and delivery key + are filled when absent, and ``notification_id`` defaults to + ``proposal_id``, which the spec requires it to equal. + """ + value = ( + payload.model_dump(mode="json", exclude_none=True) + if isinstance(payload, BaseModel) + else dict(payload) + ) + if "proposal_id" in value: + value.setdefault("notification_id", value["proposal_id"]) + return WebhookSender._prepare_notification( + url=url, + notification_type="proposal.created", + payload=value, + idempotency_key=idempotency_key, + extra_headers=extra_headers, + ) + + async def send_proposal_created( + self, + *, + url: str, + payload: Mapping[str, Any] | BaseModel, + idempotency_key: str | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> WebhookDeliveryResult: + """Prepare and send a ``proposal.created`` notification in one attempt.""" + return await self.send_prepared( + self.prepare_proposal_created( + url=url, + payload=payload, + idempotency_key=idempotency_key, + extra_headers=extra_headers, + ) + ) + @staticmethod def prepare_creative_status_changed( *, diff --git a/tests/test_cross_version_conversion.py b/tests/test_cross_version_conversion.py index 7f56081c44..6838ca7fad 100644 --- a/tests/test_cross_version_conversion.py +++ b/tests/test_cross_version_conversion.py @@ -607,7 +607,9 @@ async def test_server_rejects_new_task_on_old_contract_with_structured_error() - caller = create_tool_caller(ADCPHandler(), "list_products") with pytest.raises(ADCPTaskError) as exc: await caller({"adcp_version": "3.0"}) - assert exc.value.errors[0].code == "VERSION_UNSUPPORTED" + # The caller's release is supported; the task is not defined in it. + assert exc.value.errors[0].code == "UNSUPPORTED_FEATURE" + assert exc.value.errors[0].details == {"task": "list_products", "adcp_version": "3.0"} @pytest.mark.parametrize("selected", [None, ("3.0", "3.1")]) diff --git a/tests/test_decisioning_handler_shims.py b/tests/test_decisioning_handler_shims.py index ce4edbdf57..8047dda521 100644 --- a/tests/test_decisioning_handler_shims.py +++ b/tests/test_decisioning_handler_shims.py @@ -91,6 +91,9 @@ def test_advertised_tools_covers_every_specialism_wire_tool() -> None: "buy_products", "accept_proposal", "control_media_buy", + # AdCP 3.3 experimental reads (advertised only when declared) + "list_proposals", + "list_collections", # Account roster (unioned into every sales-* claim) "sync_accounts", "list_accounts", diff --git a/tests/test_list_proposals_collections.py b/tests/test_list_proposals_collections.py new file mode 100644 index 0000000000..ccc513a6bb --- /dev/null +++ b/tests/test_list_proposals_collections.py @@ -0,0 +1,368 @@ +"""AdCP 3.3 experimental reads: ``list_proposals`` and ``list_collections``. + +Both tasks are new in 3.3 with no older equivalent. A seller serves each only +when its capabilities declare it, never to a caller below 3.3, and the buyer +refuses to send either to a 3.2 peer. +""" + +from __future__ import annotations + +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from adcp import ADCPClient +from adcp.compat.versions import convert_payload +from adcp.decisioning import ( + CollectionCatalogPlatform, + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + OpenOpportunitiesPlatform, + SingletonAccounts, +) +from adcp.decisioning.capabilities import LifecycleTool, MediaBuy, OpenOpportunities +from adcp.decisioning.dispatch import validate_platform +from adcp.decisioning.experimental_reads import refuse_caller_below_release +from adcp.decisioning.handler import PlatformHandler +from adcp.decisioning.types import AdcpError +from adcp.exceptions import ADCPFeatureUnsupportedError, ADCPTaskError +from adcp.server import ADCPHandler, ToolContext +from adcp.server.mcp_tools import create_tool_caller +from adcp.server.principal import _ACCOUNT_NOTIFICATION_TYPES, _MEDIA_BUY_NOTIFICATION_TYPES +from adcp.server.responses import capabilities_response +from adcp.types import ListCollectionsRequest, ListProposalsRequest +from adcp.types.core import AgentConfig, Protocol, TaskStatus +from adcp.validation import validate_response +from adcp.validation._response_envelope import uses_task_status_envelope +from adcp.webhooks import WebhookSender + +SCHEMAS = Path(__file__).resolve().parents[1] / "schemas" / "cache" +PIN = "3.3-beta.3" +AT = "2026-10-23T09:14:05Z" + +PROPOSALS: dict[str, Any] = {"outcome": "listed", "proposals": [], "opportunities": []} +COLLECTIONS: dict[str, Any] = { + "outcome": "listed", + "collections": [], + "collections_version": "cc_1", + "cache_scope": "public", +} + + +class _Sales(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + accounts = SingletonAccounts(account_id="reads-account") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb-1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + +class _Reads(_Sales): + """Implements both reads but declares neither.""" + + def __init__(self) -> None: + self.seen: list[tuple[str, Any]] = [] + + def list_proposals(self, req, ctx): + self.seen.append(("list_proposals", ctx.account.id)) + return dict(PROPOSALS) + + def list_collections(self, req, ctx): + self.seen.append(("list_collections", ctx.account.id)) + return dict(COLLECTIONS) + + +class _LifecycleDeclared(_Reads): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + media_buy=MediaBuy(lifecycle_tools=[LifecycleTool.list_proposals]), + experimental_features=["media_buy.open_opportunities"], + ) + + +class _OpenOpportunitiesDeclared(_Reads): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + media_buy=MediaBuy(open_opportunities=OpenOpportunities(list_proposals=True)), + experimental_features=["media_buy.open_opportunities"], + ) + + +class _CatalogDeclared(_Reads): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + experimental_features=["media_buy.collection_catalog", "media_buy.collection_cards"], + ) + + +def _handler(platform: DecisioningPlatform, executor: ThreadPoolExecutor) -> PlatformHandler: + return PlatformHandler(platform, executor=executor, registry=InMemoryTaskRegistry()) + + +def test_platforms_satisfy_the_new_protocols() -> None: + assert isinstance(_Reads(), OpenOpportunitiesPlatform) + assert isinstance(_Reads(), CollectionCatalogPlatform) + assert not isinstance(_Sales(), OpenOpportunitiesPlatform) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + (_Reads, set()), + (_LifecycleDeclared, {"list_proposals"}), + (_OpenOpportunitiesDeclared, {"list_proposals"}), + (_CatalogDeclared, {"list_collections"}), + ], +) +def test_advertised_only_when_declared_and_implemented( + platform: type[DecisioningPlatform], expected: set[str] +) -> None: + with ThreadPoolExecutor(max_workers=1) as executor: + advertised = _handler(platform(), executor).get_advertised_tools() + assert advertised & {"list_proposals", "list_collections"} == expected + + +@pytest.mark.parametrize( + ("capabilities", "method"), + [ + ( + DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + media_buy=MediaBuy(lifecycle_tools=[LifecycleTool.list_proposals]), + ), + "list_proposals", + ), + ( + DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + media_buy=MediaBuy(open_opportunities=OpenOpportunities(list_proposals=True)), + ), + "list_proposals", + ), + ( + DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + experimental_features=[ + "media_buy.collection_catalog", + "media_buy.collection_cards", + ], + ), + "list_collections", + ), + ], +) +def test_boot_fails_for_a_declaration_without_a_method( + capabilities: DecisioningCapabilities, method: str +) -> None: + class _Declared(_Sales): + pass + + _Declared.capabilities = capabilities + with pytest.raises(AdcpError) as caught: + validate_platform(_Declared()) + missing = [item["method"] for item in caught.value.details["missing"]] + assert missing.count(method) == 1 + + +@pytest.mark.asyncio +async def test_platform_handler_dispatches_with_resolved_account() -> None: + platform = _LifecycleDeclared() + with ThreadPoolExecutor(max_workers=1) as executor: + handler = _handler(platform, executor) + response = await handler.list_proposals( + ListProposalsRequest.model_validate({"account": {"account_id": "reads-account"}}), + ToolContext(resolved_adcp_version=PIN), + ) + assert response == PROPOSALS + [(name, account_id)] = platform.seen + assert name == "list_proposals" + assert account_id.startswith("reads-account") + + +@pytest.mark.asyncio +async def test_list_collections_serves_without_an_account() -> None: + platform = _CatalogDeclared() + with ThreadPoolExecutor(max_workers=1) as executor: + handler = _handler(platform, executor) + response = await handler.list_collections( + ListCollectionsRequest.model_validate({}), ToolContext(resolved_adcp_version=PIN) + ) + assert response == COLLECTIONS + assert [name for name, _ in platform.seen] == ["list_collections"] + + +@pytest.mark.parametrize("tool", ["list_proposals", "list_collections"]) +@pytest.mark.parametrize("version", ["3.2", "3.2.7", "3.1", "not-a-version"]) +def test_platform_handler_refuses_a_caller_below_3_3(tool: str, version: str) -> None: + with pytest.raises(AdcpError) as caught: + refuse_caller_below_release(tool, version) + assert caught.value.code == "UNSUPPORTED_FEATURE" + assert caught.value.details == {"task": tool, "adcp_version": version} + + +@pytest.mark.parametrize("version", [None, "3.3-beta.0", PIN, "3.3", "3.4"]) +def test_platform_handler_serves_3_3_and_later(version: str | None) -> None: + refuse_caller_below_release("list_collections", version) + + +@pytest.mark.parametrize("tool", ["list_proposals", "list_collections"]) +def test_converter_has_no_older_equivalent(tool: str) -> None: + with pytest.raises(ADCPFeatureUnsupportedError, match="unavailable in AdCP 3.2"): + convert_payload( + tool, + {"account": {"account_id": "a"}}, + direction="request", + source_version="3.2", + target_version=PIN, + ) + + +class _Seller(ADCPHandler[Any]): + async def list_proposals(self, params, context=None): + return dict(PROPOSALS) + + async def list_collections(self, params, context=None): + return dict(COLLECTIONS) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool", ["list_proposals", "list_collections"]) +async def test_server_refuses_a_3_2_caller_with_unsupported_feature(tool: str) -> None: + caller = create_tool_caller(_Seller(), tool) + with pytest.raises(ADCPTaskError) as caught: + await caller({"adcp_version": "3.2", "account": {"account_id": "a"}}) + error = caught.value.errors[0] + assert error.code == "UNSUPPORTED_FEATURE" + assert error.details == {"task": tool, "adcp_version": "3.2"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool", ["list_proposals", "list_collections"]) +async def test_server_serves_a_3_3_caller(tool: str) -> None: + caller = create_tool_caller(_Seller(), tool) + response = await caller({"adcp_version": PIN, "account": {"account_id": "a"}}) + assert response["status"] == "completed" + assert validate_response(tool, response, version=PIN).valid + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", [Protocol.MCP, Protocol.A2A]) +@pytest.mark.parametrize("tool", ["list_proposals", "list_collections"]) +async def test_client_refuses_a_3_2_peer_before_transport(protocol: Protocol, tool: str) -> None: + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example", protocol=protocol), + server_version="3.2", + adcp_version="3.2", + account_policy="off", + ) + wire = AsyncMock() + if protocol is Protocol.MCP: + client.adapter._call_mcp_tool_wire = wire + else: + client.adapter._call_a2a_tool_wire = wire + if tool == "list_proposals": + result = await client.list_proposals( + ListProposalsRequest.model_validate({"account": {"account_id": "a"}}) + ) + else: + result = await client.list_collections(ListCollectionsRequest.model_validate({})) + assert result.status == TaskStatus.FAILED + assert result.adcp_error["code"] == "UNSUPPORTED_FEATURE" + assert result.adcp_error["details"]["wire_response_received"] is False + wire.assert_not_called() + + +def test_a_3_2_capabilities_caller_never_sees_list_proposals_declared() -> None: + declaration = capabilities_response(["media_buy"], adcp_version=PIN) + declaration["adcp"]["idempotency"] = {"supported": False} + declaration["media_buy"] = { + "lifecycle_tools": ["list_products", "list_proposals"], + "open_opportunities": {"list_proposals": True}, + } + converted = convert_payload( + "get_adcp_capabilities", + declaration, + direction="sync", + source_version=PIN, + target_version="3.2", + ) + assert converted.payload["media_buy"] == {"lifecycle_tools": ["list_products"]} + assert {loss.pointer for loss in converted.losses} == { + "/media_buy/lifecycle_tools/1", + "/media_buy/open_opportunities", + } + + +@pytest.mark.parametrize("tool", ["list_proposals", "list_collections"]) +def test_status_envelope_follows_each_prerelease_schema(tool: str) -> None: + # beta.0 through beta.2 close the root without status; beta.3 composes the + # protocol envelope, which requires it. + for version in ("3.3-beta.0", "3.3-beta.1", "3.3-beta.2"): + assert uses_task_status_envelope(tool, version) is False + assert uses_task_status_envelope(tool, PIN) is True + assert uses_task_status_envelope(tool, None) is True + + +@pytest.mark.parametrize("bundle", ["3.3.0-beta.1", "3.3.0-beta.2", "3.3.0-beta.3"]) +def test_bundled_list_collections_examples_validate(bundle: str) -> None: + schema = json.loads( + (SCHEMAS / bundle / "media-buy" / "list-collections-response.json").read_text() + ) + version = bundle.replace("3.3.0-", "3.3-") + for example in schema["examples"]: + outcome = validate_response("list_collections", example["data"], version=version) + assert outcome.valid, outcome.issues + + +def test_proposal_created_preparation_binds_notification_id_to_proposal_id() -> None: + payload = { + "subscriber_id": "buyer-proposals", + "account_id": "acc_1", + "proposal_id": "prop_1", + "opportunity_id": "opp_1", + "later_reason": "event_package", + "expires_at": AT, + } + prepared = WebhookSender.prepare_proposal_created( + url="https://buyer.example/hook", payload=payload + ) + body = json.loads(prepared.body) + assert body["notification_type"] == "proposal.created" + assert body["notification_id"] == "prop_1" + assert body["idempotency_key"] == prepared.idempotency_key + + with pytest.raises(ValueError, match="notification_id must equal proposal_id"): + WebhookSender.prepare_proposal_created( + url="https://buyer.example/hook", payload={**payload, "notification_id": "other"} + ) + with pytest.raises(ValueError, match="schema validation"): + # Identifiers only: commercial terms or prose never ride on it. + WebhookSender.prepare_proposal_created( + url="https://buyer.example/hook", payload={**payload, "terms": "cheap"} + ) + + +def test_every_account_anchored_notification_type_requires_the_acknowledgment() -> None: + """Drift guard: the agent-level surface classifies every enum value.""" + enum = json.loads((SCHEMAS / "3.3.0-beta.3" / "enums" / "notification-type.json").read_text()) + caller_anchored = {"capabilities.changed", "principal.changed", "account_request.changed"} + assert set(enum["enum"]) - caller_anchored - _MEDIA_BUY_NOTIFICATION_TYPES == set( + _ACCOUNT_NOTIFICATION_TYPES + ) diff --git a/tests/test_notification_preparation.py b/tests/test_notification_preparation.py index e6ebe48b09..6546093f15 100644 --- a/tests/test_notification_preparation.py +++ b/tests/test_notification_preparation.py @@ -75,6 +75,14 @@ def notification_payload(kind: str) -> dict[str, Any]: "changed_indicator_types": ["pacing_risk"], "observed_at": AT, }, + "proposal_created": { + "notification_id": "prop_1", + "account_id": "acct_1", + "proposal_id": "prop_1", + "opportunity_id": "opp_1", + "later_reason": "event_package", + "expires_at": AT, + }, } return {**common, **fields[kind]} @@ -88,6 +96,7 @@ def notification_payload(kind: str) -> dict[str, Any]: "creative_assignment_changed", "creative_purged", "indicators_changed", + "proposal_created", ] From da5ae15d66966c6aeb992a5b1c644a1854370f62 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Nov 2026 00:17:30 +0000 Subject: [PATCH 2/2] docs(client): name the capabilities that gate list_proposals and list_collections Co-Authored-By: Claude Opus 5.5 --- src/adcp/client.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/adcp/client.py b/src/adcp/client.py index 74753adcb8..a257e161d1 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -2614,7 +2614,11 @@ async def list_collections( account_policy: AccountPolicy | None = None, options: TaskOptions | None = None, ) -> TaskResult[ListCollectionsResponse]: - """List collection cards (AdCP 3.3, experimental ``media_buy.collection_lists``).""" + """List collection cards (AdCP 3.3, experimental ``media_buy.collection_catalog``). + + A peer negotiated below 3.3 has no such task: the call fails with + ``UNSUPPORTED_FEATURE`` before anything is sent. + """ return cast( TaskResult[ListCollectionsResponse], await self._execute_typed_task("list_collections", request, ListCollectionsResponse), @@ -2628,7 +2632,12 @@ async def list_proposals( account_policy: AccountPolicy | None = None, options: TaskOptions | None = None, ) -> TaskResult[ListProposalsResponse]: - """List open opportunities and proposals (AdCP 3.3, experimental).""" + """List proposal snapshots and their dispositions (AdCP 3.3, experimental). + + Requires a seller advertising ``media_buy.open_opportunities``. A peer + negotiated below 3.3 has no such task: the call fails with + ``UNSUPPORTED_FEATURE`` before anything is sent. + """ return cast( TaskResult[ListProposalsResponse], await self._execute_typed_task("list_proposals", request, ListProposalsResponse), @@ -7484,7 +7493,7 @@ async def list_collections( async def list_proposals( self, request: ListProposalsRequest ) -> list[TaskResult[ListProposalsResponse]]: - """List open opportunities and proposals across all agents.""" + """List proposal snapshots and their dispositions across all agents.""" return await _gather_agent_results( *(agent.list_proposals(request) for agent in self.agents.values())